From 388106c1ab8900d7c72f1da870b07e11022226b1 Mon Sep 17 00:00:00 2001 From: Jake Holland Date: Thu, 30 Jan 2025 11:10:02 +0000 Subject: [PATCH] Enable accessibility in tables --- lib/document.js | 1 + lib/mixins/table.js | 3 + lib/table/accessibility.js | 143 +++++++++++++++++++++++++++++++++++++ lib/table/index.js | 8 +-- lib/table/normalize.js | 11 ++- lib/table/render.js | 41 ++++------- lib/table/types.js | 17 +++++ 7 files changed, 190 insertions(+), 34 deletions(-) create mode 100644 lib/table/accessibility.js diff --git a/lib/document.js b/lib/document.js index 20cb55af..208e776a 100644 --- a/lib/document.js +++ b/lib/document.js @@ -93,6 +93,7 @@ class PDFDocument extends stream.Readable { this.initImages(); this.initOutline(); this.initMarkings(options); + this.initTables(); this.initSubset(options); // Initialize the metadata diff --git a/lib/mixins/table.js b/lib/mixins/table.js index e8fadac0..0d43a37f 100644 --- a/lib/mixins/table.js +++ b/lib/mixins/table.js @@ -1,6 +1,9 @@ import PDFTable from '../table/index'; export default { + initTables() { + this._tableIndex = 0; + }, /** * @param {Table} [opts] * @returns {PDFTable} diff --git a/lib/table/accessibility.js b/lib/table/accessibility.js new file mode 100644 index 00000000..685fd46a --- /dev/null +++ b/lib/table/accessibility.js @@ -0,0 +1,143 @@ +import PDFStructureElement from '../structure_element'; +import PDFDocument from '../document'; + +/** + * Add accessibility to a table + * + * @this PDFTable + * @memberOf PDFTable + * @private + */ +export function accommodateTable() { + if (this.opts.structParent) { + this._tableStruct = this.document.struct('Table'); + this._tableStruct.dictionary.data.ID = this._id; + if (this.opts.structParent instanceof PDFStructureElement) { + this.opts.structParent.add(this._tableStruct); + } else if (this.opts.structParent instanceof PDFDocument) { + this.opts.structParent.addStructure(this._tableStruct); + } + this._headerRowLookup = {}; + this._headerColumnLookup = {}; + } +} + +/** + * Cleanup accessibility on a table + * + * @this PDFTable + * @memberOf PDFTable + * @private + */ +export function accommodateCleanup() { + if (this._tableStruct) this._tableStruct.end(); +} + +/** + * Render a row with all its accessibility features + * + * @this PDFTable + * @memberOf PDFTable + * @param {SizedNormalizedTableCellStyle[]} row + * @param {number} rowIndex + * @param {Function} renderCell + * @private + */ +export function accessibleRow(row, rowIndex, renderCell) { + const rowStruct = this.document.struct('TR'); + rowStruct.dictionary.data.ID = new String(`${this._id}-${rowIndex}`); + this._tableStruct.add(rowStruct); + row.forEach((cell) => renderCell(cell, rowStruct)); + rowStruct.end(); +} + +/** + * Render a cell with all its accessibility features + * + * @this PDFTable + * @memberOf PDFTable + * @param {SizedNormalizedTableCellStyle} cell + * @param {PDFStructureElement} rowStruct + * @param {Function} callback + * @private + */ +export function accessibleCell(cell, rowStruct, callback) { + const cellStruct = this.document.struct(cell.type, { title: cell.title }); + cellStruct.dictionary.data.ID = cell.id; + + rowStruct.add(cellStruct); + + const padding = cell.padding; + const border = cell.border; + const attributes = { + O: 'Table', + Width: cell.width, + Height: cell.height, + Padding: [padding.top, padding.bottom, padding.left, padding.right], + RowSpan: cell.rowSpan > 1 ? cell.rowSpan : undefined, + ColSpan: cell.colSpan > 1 ? cell.colSpan : undefined, + BorderThickness: [border.top, border.bottom, border.left, border.right], + }; + + // Claim row Headers + if (cell.type === 'TH') { + if (cell.scope === 'Row' || cell.scope === 'Both') { + for (let i = 0; i < cell.rowSpan; i++) { + if (!this._headerRowLookup[cell.rowIndex + i]) { + this._headerRowLookup[cell.rowIndex + i] = []; + } + this._headerRowLookup[cell.rowIndex + i].push(cell.id); + } + attributes.Scope = cell.scope; + } + if (cell.scope === 'Column' || cell.scope === 'Both') { + for (let i = 0; i < cell.colSpan; i++) { + if (!this._headerColumnLookup[cell.colIndex + i]) { + this._headerColumnLookup[cell.colIndex + i] = []; + } + this._headerColumnLookup[cell.colIndex + i].push(cell.id); + } + attributes.Scope = cell.scope; + } + } + + // Find any cells which are marked as headers for this cell + const Headers = new Set( + [ + ...Array.from( + { length: cell.colSpan }, + (_, i) => this._headerColumnLookup[cell.colIndex + i], + ).flat(), + ...Array.from( + { length: cell.rowSpan }, + (_, i) => this._headerRowLookup[cell.rowIndex + i], + ).flat(), + ].filter(Boolean), + ); + if (Headers.size) attributes.Headers = Array.from(Headers); + + if (cell.backgroundColor !== undefined) { + attributes.BackgroundColor = this.document._normalizeColor( + cell.backgroundColor, + ); + } + const hasBorder = [border.top, border.bottom, border.left, border.right]; + if (hasBorder.some((x) => x)) { + const borderColor = cell.borderColor; + attributes.BorderColor = [ + hasBorder[0] ? this.document._normalizeColor(borderColor.top) : null, + hasBorder[1] ? this.document._normalizeColor(borderColor.bottom) : null, + hasBorder[2] ? this.document._normalizeColor(borderColor.left) : null, + hasBorder[3] ? this.document._normalizeColor(borderColor.right) : null, + ]; + } + + // Remove any undefined attributes + Object.keys(attributes).forEach( + (key) => attributes[key] === undefined && delete attributes[key], + ); + cellStruct.dictionary.data.A = this.document.ref(attributes); + cellStruct.add(callback); + cellStruct.end(); + cellStruct.dictionary.data.A.end(); +} diff --git a/lib/table/index.js b/lib/table/index.js index 61fccbec..aad43470 100644 --- a/lib/table/index.js +++ b/lib/table/index.js @@ -1,6 +1,7 @@ import { normalizeRow, normalizeTable } from './normalize'; import { measure, ensure } from './size'; import { renderRow } from './render'; +import { accommodateCleanup, accommodateTable } from './accessibility'; class PDFTable { /** @@ -12,6 +13,7 @@ class PDFTable { this.opts = Object.freeze(opts); normalizeTable.call(this); + accommodateTable.call(this); this._currRowIndex = 0; this._ended = false; @@ -35,11 +37,6 @@ class PDFTable { throw new Error(`Table was marked as ended on row ${this._currRowIndex}`); } - if (this._currRowIndex === 0) { - this._struct = this.document.struct('Table'); - this.document.addStructure(this._struct); - } - // Convert the iterable into an array row = Array.from(row); // Transform row @@ -75,6 +72,7 @@ class PDFTable { // Flush any remaining cells while (this._rowBuffer?.size) this.row([]); this._ended = true; + accommodateCleanup.call(this); return this.document; } } diff --git a/lib/table/normalize.js b/lib/table/normalize.js index 1da6fbef..571d160b 100644 --- a/lib/table/normalize.js +++ b/lib/table/normalize.js @@ -15,6 +15,8 @@ import { COLUMN_FIELDS, ROW_FIELDS } from './types'; */ export function normalizeTable() { // Normalize config + let index = this.document._tableIndex++; + this._id = new String(this.opts.id ?? `table-${index}`); this._position = { x: this.document.sizeToPoint(this.opts.position?.x, this.document.x), y: this.document.sizeToPoint(this.opts.position?.y, this.document.y), @@ -137,9 +139,16 @@ export function normalizeCell(cell, rowIndex, colIndex) { config.textStroke = this.document.sizeToPoint(config.textStroke, 0); config.textStrokeColor = config.textStrokeColor ?? 'black'; config.textColor = config.textColor ?? 'black'; - config.type = config.type === 'TH' ? 'TH' : 'TD'; config.textOptions = config.textOptions ?? {}; + // Accessibility settings + config.id = new String(config.id ?? `${this._id}-${rowIndex}-${colIndex}`); + config.type = config.type?.toUpperCase() === 'TH' ? 'TH' : 'TD'; + config.scope = config.scope?.toLowerCase(); + if (config.scope === "row") config.scope = "Row" + else if (config.scope === "both") config.scope = "Both" + else if (config.scope === "column") config.scope = "Column" + if (this.opts.debug !== undefined) config.debug = this.opts.debug; this.document.font(rollbackFont, rollbackFontFamily, rollbackFontSize); diff --git a/lib/table/render.js b/lib/table/render.js index f50da8d0..0923b712 100644 --- a/lib/table/render.js +++ b/lib/table/render.js @@ -1,3 +1,5 @@ +import { accessibleCell, accessibleRow } from './accessibility'; + /** * Render a cell * @@ -8,9 +10,11 @@ * @private */ export function renderRow(row, rowIndex) { - const rowStruct = this.document.struct('TR'); - row.forEach((cell) => renderCell.call(this, cell, rowStruct)); - this._struct.add(rowStruct); + if (this._tableStruct) { + accessibleRow.call(this, row, rowIndex, renderCell.bind(this)); + } else { + row.forEach((cell) => renderCell.call(this, cell)); + } return this._rowYPos[rowIndex] + this._rowHeights[rowIndex]; } @@ -25,23 +29,9 @@ export function renderRow(row, rowIndex) { * @private */ function renderCell(cell, rowStruct) { - const s = this.document.struct(cell.type); - rowStruct.add(s); - s.dictionary.data.Width = cell.width; - s.dictionary.data.Height = cell.height; - s.dictionary.data.Padding = [ - cell.padding.top, - cell.padding.bottom, - cell.padding.left, - cell.padding.right, - ]; - s.dictionary.data.RowSpan = cell.rowSpan; - s.dictionary.data.ColSpan = cell.colSpan; - s.add(() => { + const cellRenderer = () => { // Render cell background if (cell.backgroundColor !== undefined) { - s.dictionary.data.BackgroundColor = - this.document._normalizeColor(cell.backgroundColor) ?? undefined; this.document .save() .rect(cell.x, cell.y, cell.width, cell.height) @@ -50,14 +40,6 @@ function renderCell(cell, rowStruct) { } // Render border - s.dictionary.data.BorderColor = - this.document._normalizeColor(cell.borderColor) ?? undefined; - s.dictionary.data.BorderThickness = [ - cell.border.top, - cell.border.bottom, - cell.border.left, - cell.border.right, - ]; renderBorder.call( this, cell.border, @@ -83,13 +65,16 @@ function renderCell(cell, rowStruct) { // Render text if (cell.text) renderCellText.call(this, cell); - }); + }; + + if (rowStruct) accessibleCell.call(this, cell, rowStruct, cellRenderer); + else cellRenderer(); } /** * @this PDFTable * @memberOf PDFTable - * @param cell + * @param {SizedNormalizedTableCellStyle} cell */ function renderCellText(cell) { // Configure fonts diff --git a/lib/table/types.js b/lib/table/types.js index 61f9b4a2..b36433a8 100644 --- a/lib/table/types.js +++ b/lib/table/types.js @@ -66,6 +66,14 @@ * Same as the options you pass to `doc.text()` * * Will override any defaults set by the cell if set + * @property {string} [title] + * Sets the accessible title for the cell + * @property {'Column' | 'Row' | 'Both'} [scope] + * Sets the accessible scope for the cell + * @property {string} [id] + * Sets the accessible id for the cell + * + * Defaults to `--` * @property {boolean} [debug] * Whether to show the debug lines for the cell * @@ -252,6 +260,15 @@ * * If provided the table will be automatically ended after the last row has been written, * Otherwise it is up to the user to call `table.end()` or `table.row([], true)` + * @property {PDFStructureElement} [structParent] + * The parent structure to mount to + * + * This will cause the entire table to be enclosed in a Table structure + * with TR and TD/TH for cells + * @property {string} [id] + * Sets the accessible id for the table + * + * Defaults to `table-` * @property {boolean} [debug] * Whether to show the debug lines for all the cells *