Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/table-parse-invalid-values.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions packages/table/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<tr></tr><tr></tr>`;
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 = `
<tr><td>0-0</td><td>0-1</td></tr>
<tr></tr>
`;
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 = `
<tr><th scope="col" id="h">h</th><th scope="col">h2</th></tr>
<tr><td rowspan="abc" colspan="-5">a</td><td>b</td></tr>
`;
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 = `
<div role="row">
<div role="columnheader" aria-colindex="abc" id="h">h</div>
<div role="gridcell" aria-colindex="0">b</div>
</div>
`;
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 = `
<div role="row"><div role="columnheader">h</div></div>
<div role="row"><div role="gridcell" aria-rowspan="99999999">a</div></div>
`;
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 = `<tr><td>a</td></tr>`;
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 = `
<tr><td rowspan="3">0-0</td><td>0-1</td></tr>
<tr><td>1-1</td></tr>
`;
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 = `
Expand Down
103 changes: 66 additions & 37 deletions packages/table/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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++) {
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
};

/**
Expand Down
Loading