From 4fb39b90c9861f990152ee0fd931b4af0c7398c6 Mon Sep 17 00:00:00 2001 From: ymrl Date: Fri, 31 Jul 2026 09:38:48 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=8D=E6=AD=A3=E3=81=AA=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=83=96=E3=83=AB=E3=81=AE=E3=83=9E=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E3=82=A2=E3=83=83=E3=83=97=E3=81=A7=E3=81=AE=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=81=A8=E3=83=8F=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 行のないテーブルや、セルのない行だけのテーブルで解析時に例外が出ていた - rowspan/colspan/aria-rowspan/aria-colspanの不正な値がNaNや負数のまま セルのサイズになり、ヘッダー探索のArray(size)がRangeErrorを投げていた - aria-rowindex/aria-colindexの不正な値が座標をNaNにしていた - aria-rowspan/aria-colspanに上限がなく、極端な値でヘッダー探索が 数秒間ブロックしていた - rowCountを全行から求めるようにし、末尾のセルのない行や最終行を 越えるrowspanを正しく扱うようにした Co-Authored-By: Claude Opus 5 --- .changeset/table-parse-invalid-values.md | 12 +++ packages/table/src/index.test.ts | 96 +++++++++++++++++++++ packages/table/src/index.ts | 103 +++++++++++++++-------- 3 files changed, 174 insertions(+), 37 deletions(-) create mode 100644 .changeset/table-parse-invalid-values.md diff --git a/.changeset/table-parse-invalid-values.md b/.changeset/table-parse-invalid-values.md new file mode 100644 index 0000000..5eec621 --- /dev/null +++ b/.changeset/table-parse-invalid-values.md @@ -0,0 +1,12 @@ +--- +"@a11y-visualizer/table": patch +"@a11y-visualizer/browser-extension": patch +--- + +Fix crashes and hangs when analyzing tables with unusual or invalid markup: + +- Tables with no rows, or whose rows contain no cells, no longer throw while being parsed. +- Invalid `rowspan` / `colspan` / `aria-rowspan` / `aria-colspan` values (non-numeric or negative) now fall back to the default of 1, instead of producing `NaN` sizes that made header lookup throw. +- Invalid `aria-rowindex` / `aria-colindex` values now fall back to the position implied by document order, instead of producing `NaN` coordinates. +- `aria-rowspan` / `aria-colspan` are clamped to the same limits as their HTML counterparts, so an extreme value no longer blocks the page for several seconds during header lookup. +- The reported row count now accounts for every row, so a trailing row without cells no longer reports the table as empty, and a `rowspan` reaching past the last row extends the row count as the HTML table model requires. diff --git a/packages/table/src/index.test.ts b/packages/table/src/index.test.ts index df3bb6e..44f74b5 100644 --- a/packages/table/src/index.test.ts +++ b/packages/table/src/index.test.ts @@ -37,6 +37,102 @@ describe("Table", () => { expect(colCount).toBe(2); }); + test("table with no rows", () => { + const table = document.createElement("table"); + const result = new Table(table); + const { cells, rowCount, colCount } = result; + expect(cells).toHaveLength(0); + expect(rowCount).toBe(0); + expect(colCount).toBe(0); + }); + + test("table whose rows have no cells", () => { + const table = document.createElement("table"); + table.innerHTML = ``; + const result = new Table(table); + const { cells, rowCount, colCount } = result; + expect(cells).toEqual([[], []]); + expect(rowCount).toBe(0); + expect(colCount).toBe(0); + }); + + test("table whose last row has no cells", () => { + const table = document.createElement("table"); + table.innerHTML = ` + 0-00-1 + + `; + const result = new Table(table); + const { rowCount, colCount } = result; + expect(rowCount).toBe(1); + expect(colCount).toBe(2); + }); + + test("invalid rowspan/colspan values fall back to 1", () => { + const table = document.createElement("table"); + table.innerHTML = ` + hh2 + ab + `; + const result = new Table(table); + const cell = result.cells[1][0]; + expect(cell.sizeX).toBe(1); + expect(cell.sizeY).toBe(1); + expect(result.rowCount).toBe(2); + expect(result.colCount).toBe(2); + // NaN/負のサイズだとArray(size)がRangeErrorを投げていた + expect(result.getColHeaderElements(cell)).toEqual([ + table.querySelector("#h"), + ]); + expect(() => result.getRowHeaderElements(cell)).not.toThrow(); + }); + + test("invalid aria-colindex falls back to positional order", () => { + const div = document.createElement("div"); + div.setAttribute("role", "grid"); + div.innerHTML = ` +
+
h
+
b
+
+ `; + const result = new Table(div); + expect(result.cells[0][0].positionX).toBe(0); + expect(result.cells[0][1].positionX).toBe(1); + expect(result.colCount).toBe(2); + expect(() => result.getRowHeaderElements(result.cells[0][1])).not.toThrow(); + }); + + test("aria-rowspan is clamped so header lookup stays bounded", () => { + const div = document.createElement("div"); + div.setAttribute("role", "grid"); + div.innerHTML = ` +
h
+
a
+ `; + const result = new Table(div); + expect(result.cells[1][0].sizeY).toBe(65534); + }); + + test("getCell returns null for an element outside the table", () => { + const table = document.createElement("table"); + table.innerHTML = `a`; + const result = new Table(table); + expect(result.getCell(document.createElement("td"))).toBeNull(); + }); + + test("rowspan extending past the last row", () => { + const table = document.createElement("table"); + table.innerHTML = ` + 0-00-1 + 1-1 + `; + const result = new Table(table); + const { rowCount, colCount } = result; + expect(rowCount).toBe(3); + expect(colCount).toBe(2); + }); + test("table has thead, tbody, tfoot", () => { const table = document.createElement("table"); table.innerHTML = ` diff --git a/packages/table/src/index.ts b/packages/table/src/index.ts index b288478..b527a87 100644 --- a/packages/table/src/index.ts +++ b/packages/table/src/index.ts @@ -13,6 +13,41 @@ type ColGroup = { }; type Scope = "row" | "col" | "rowgroup" | "colgroup" | "auto" | "none"; + +/** colspanの上限(HTML仕様) */ +const MAX_COL_SPAN = 1000; +/** rowspanの上限(HTML仕様) */ +const MAX_ROW_SPAN = 65534; + +/** + * colspan/rowspan(およびaria-colspan/aria-rowspan)の属性値を解釈する + * + * 数値として解釈できない値や負の値は、HTML仕様に準じて既定値の1として扱う。 + * NaNや負の値がそのままセルのサイズになると、ヘッダー探索の`Array(size)`が + * RangeErrorを投げるため、ここで正規化しておく必要がある。 + * aria-*にも上限を適用するのは、極端に大きな値でヘッダー探索が + * 長時間ブロックするのを防ぐため + */ +const parseSpanAttribute = (value: string | null, max: number): number => { + if (!value) return 1; + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return 1; + return Math.min(parsed, max); +}; + +/** + * aria-colindex/aria-rowindexの属性値を0始まりの位置に変換する + * + * 1始まりの整数として解釈できない値はnullを返す。呼び出し側では + * 属性がない場合と同様に、並び順からの位置の計算にフォールバックする + */ +const parseIndexAttribute = (value: string | null): number | null => { + if (!value) return null; + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return null; + return parsed - 1; +}; + type Cell = { element: Element; sizeX: number; @@ -57,29 +92,25 @@ export class Table { const cellElements = getCellElements(row); const prevRow = rows.length > 0 ? rows[rows.length - 1] : null; const prevFirstCell = prevRow ? prevRow[0] : null; - const ariaRowIndex = row.getAttribute("aria-rowindex"); - const rowPositionY = ariaRowIndex - ? parseInt(ariaRowIndex, 10) - 1 - : prevFirstCell - ? prevFirstCell.positionY + 1 - : rowIndex; + const rowIndexPosition = parseIndexAttribute( + row.getAttribute("aria-rowindex"), + ); + const rowPositionY = + rowIndexPosition ?? + (prevFirstCell ? prevFirstCell.positionY + 1 : rowIndex); rows.push( cellElements.reduce((cells, cell) => { const cellTagName = cell.tagName.toLowerCase(); const cellRole = getKnownRole(cell); - const ariaRowIndex = row.getAttribute("aria-rowindex"); - const ariaColIndex = cell.getAttribute("aria-colindex"); - const positionY = ariaRowIndex - ? parseInt(ariaRowIndex, 10) - 1 - : rowPositionY; + const colIndexPosition = parseIndexAttribute( + cell.getAttribute("aria-colindex"), + ); + const positionY = rowIndexPosition ?? rowPositionY; const leftCell = cells.length > 0 ? cells[cells.length - 1] : null; - let positionX = ariaColIndex - ? parseInt(ariaColIndex, 10) - 1 - : leftCell - ? leftCell.positionX + leftCell.sizeX - : 0; - // let positionX: number = dx; - if (!ariaColIndex && rowIndex > 0) { + let positionX = + colIndexPosition ?? + (leftCell ? leftCell.positionX + leftCell.sizeX : 0); + if (colIndexPosition === null && rowIndex > 0) { let dy = rowIndex - 1; while (dy >= 0) { for (let i = 0; i < rows[dy].length; i++) { @@ -102,20 +133,14 @@ export class Table { } } const isNativeTag = ["th", "td"].includes(cellTagName); - const ariaColSpan = !isNativeTag && cell.getAttribute("aria-colspan"); - const ariaRowSpan = !isNativeTag && cell.getAttribute("aria-rowspan"); - const nativeColSpan = isNativeTag && cell.getAttribute("colspan"); - const nativeRowSpan = isNativeTag && cell.getAttribute("rowspan"); - const sizeX = ariaColSpan - ? parseInt(ariaColSpan, 10) - : nativeColSpan - ? Math.min(parseInt(nativeColSpan, 10), 1000) - : 1; - const sizeY = ariaRowSpan - ? parseInt(ariaRowSpan, 10) - : nativeRowSpan - ? Math.min(parseInt(nativeRowSpan, 10), 65534) - : 1; + const sizeX = parseSpanAttribute( + cell.getAttribute(isNativeTag ? "colspan" : "aria-colspan"), + MAX_COL_SPAN, + ); + const sizeY = parseSpanAttribute( + cell.getAttribute(isNativeTag ? "rowspan" : "aria-rowspan"), + MAX_ROW_SPAN, + ); const scopeAttr = cell.getAttribute("scope")?.toLowerCase(); const headerScope: Scope = cellTagName === "th" @@ -145,8 +170,12 @@ export class Table { const ariaColCount = table.getAttribute("aria-colcount"); const rowCount = ariaRowCount ? parseInt(ariaRowCount, 10) - : cells[cells.length - 1].reduce( - (prev, cell) => Math.max(prev, cell.positionY + cell.sizeY), + : cells.reduce( + (prev, row) => + row.reduce( + (prev, cell) => Math.max(prev, cell.positionY + cell.sizeY), + prev, + ), 0, ); const colCount = ariaColCount @@ -170,7 +199,7 @@ export class Table { prevGroup && (prevGroup.element?.contains(row) || (!prevGroup.element && !rowGroupElements[0]) || - (!prevGroup.element && !groupElements[0].contains(row))) + (!prevGroup.element && !groupElements[0]?.contains(row))) ) { prevGroup.sizeY += 1; return prev; @@ -237,10 +266,10 @@ export class Table { * @returns セル情報、テーブル内に見つからない場合はnull */ getCell = (el: Element): Cell | null => { - const cell = this.cells + const found = this.cells .map((row) => row.find((cell) => cell.element === el)) .filter((cell): cell is Cell => !!cell); - return cell ? cell[0] : null; + return found[0] ?? null; }; /**