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-0 | 0-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 = `
+ | h | h2 |
+ | a | b |
+ `;
+ 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 = `
+
+ `;
+ 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 = `
+
+
+ `;
+ 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-0 | 0-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;
};
/**