diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1cbb2e..1e039712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Add support for dynamic sizing - Add support for rotatable text - Fix page cascade options when text overflows +- Add table generation ### [v0.16.0] - 2024-12-29 diff --git a/docs/generate.js b/docs/generate.js index 67e66a6e..a3a1c0ae 100644 --- a/docs/generate.js +++ b/docs/generate.js @@ -207,6 +207,13 @@ class Node { ({ y } = doc); doc.x = doc.y = 0; + // Update the page width for those which rely on the width of the document + var docPageWidth = doc.page.width; + var docPageHeight = doc.page.height; + var docPageMargins = doc.page.margins; + doc.page.width = doc.page.width - x - doc.page.margins.right; + doc.page.margins = { top: 0, left: 0, right: 0, bottom: 0 }; + // run the example code with the document vm.runInNewContext(this.code, { doc, @@ -218,6 +225,9 @@ class Node { doc.restore(); doc.x = x; doc.y = y + this.height; + doc.page.width = docPageWidth; + doc.page.height = docPageHeight; + doc.page.margins = docPageMargins; break; case 'hr': doc.addPage(); @@ -226,6 +236,12 @@ class Node { // loop through subnodes and render them for (let index = 0; index < this.content.length; index++) { const fragment = this.content[index]; + + if (this.type === 'numberlist') { + let node = new Node(['inlinecode', `${index + 1}. `]); + fragment.content.splice(0, 0, node); + } + if (fragment.type === 'text') { // add a new page for each heading, unless it follows another heading if ( @@ -328,5 +344,6 @@ render(doc, 'forms.md'); render(doc, 'destinations.md'); render(doc, 'attachments.md'); render(doc, 'accessibility.md'); +render(doc, 'table.md'); render(doc, 'you_made_it.md'); doc.end(); diff --git a/docs/generate_website.js b/docs/generate_website.js index c0cc9c29..7cbd22de 100644 --- a/docs/generate_website.js +++ b/docs/generate_website.js @@ -24,6 +24,7 @@ const files = [ 'destinations.md', 'attachments.md', 'accessibility.md', + 'table.md', 'you_made_it.md' ]; diff --git a/docs/table.md b/docs/table.md new file mode 100644 index 00000000..81b88c6c --- /dev/null +++ b/docs/table.md @@ -0,0 +1,377 @@ +# Tables in PDFKit + +## The basics + +PDFKit makes adding tables to documents quite simple, and includes many options +to customize the display of the output. + +### A simple table +Basic tables can be defined without configuration: + + doc.table({ + data: [ + ['Column 1', 'Column 2', 'Column 3'], + ['One value goes here', 'Another one here', 'OK?'] + ] + }) + +or the more verbose way + + doc.table() + .row(['Column 1', 'Column 2', 'Column 3']) + .row(['One value goes here', 'Another one here', 'OK?']) + +![1]() + +--- + +### Defining column widths + +Tables allow you to define the widths of columns: + + * `*` - distributes equally, filling the whole available space (default) + * `fixed value` - a fixed width based on the document content + +Example: + + doc.table({ + columnStyles: [100, "*", 200, "*"], + data: [ + ["width=100", "star-sized", "width=200", "star-sized"], + [ + "fixed-width cells have exactly the specified width", + { text: "nothing interesting here", textColor: "grey" }, + { text: "nothing interesting here", textColor: "grey" }, + { text: "nothing interesting here", textColor: "grey" } + ], + ], + }); + +![2]() + +--- + +### Defining row heights + + doc.table({ + rowStyles: [20, 50, 70], + data: [ + ["row 1 with height 20", "column B"], + ["row 2 with height 50", "column B"], + ["row 3 with height 70", "column B"], + ], + }); + +![3]() + +With same height: + + doc.table({ + rowStyles: 40, + data: [ + ["row 1", "column B"], + ["row 2", "column B"], + ["row 3", "column B"], + ], + }); + +![4]() + +--- + +With height from function: + + doc.table({ + rowStyles: (row) => (row + 1) * 25, + data: [ + ["row 1", "column B"], + ["row 2", "column B"], + ["row 3", "column B"], + ], + }); + +![5]() + +--- + +### Column/row spans + +Each cell can set a rowSpan or colSpan + + doc.table({ + columnStyles: [200, "*", "*"], + data: [ + [{ colSpan: 2, text: "Header with Colspan = 2" }, "Header 3"], + ["Header 1", "Header 2", "Header 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + [ + { + rowSpan: 3, + text: "rowspan set to 3\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor", + }, + "Sample value 2", + "Sample value 3", + ], + ["Sample value 2", "Sample value 3"], + ["Sample value 2", "Sample value 3"], + [ + "Sample value 1", + { + colSpan: 2, + rowSpan: 2, + text: "Both:\nrowspan and colspan\ncan be defined at the same time", + }, + ], + ["Sample value 1"], + ], + }) + +![6]() + +--- + +### Styling + +No borders: + + doc.table({ + rowStyles: { border: false }, + data: [ + ["Header 1", "Header 2", "Header 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ], + }) + +![7]() + +Header line only: + + doc.table({ + rowStyles: (i) => { + return i < 1 ? { border: [0, 0, 1, 0] } : { border: false }; + }, + data: [ + ["Header 1", "Header 2", "Header 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ], + }) + +![8]() + +--- + +Light Horizontal lines: + + doc.table({ + rowStyles: (i) => { + return i < 1 + ? { border: [0, 0, 2, 0], borderColor: "black" } + : { border: [0, 0, 1, 0], borderColor: "#aaa" }; + }, + data: [ + ["Header 1", "Header 2", "Header 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ], + }) + +![9]() + +--- + +But you can provide a custom styler as well + + doc.table({ + // Set the style for all cells + defaultStyle: { border: 1, borderColor: "gray" }, + // Set the style for cells based on their column + columnStyles: (i) => { + if (i === 0) return { border: { left: 2 }, borderColor: { left: "black" } }; + if (i === 2) return { border: { right: 2 }, borderColor: { right: "black" } }; + }, + // Set the style for cells based on their row + rowStyles: (i) => { + if (i === 0) return { border: { top: 2 }, borderColor: { top: "black" } }; + if (i === 3) return { border: { bottom: 2 }, borderColor: { bottom: "black" } }; + }, + data: [ + ["Header 1", "Header 2", "Header 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ], + }) + +![10]() + +--- + +Zebra style + + doc.table({ + rowStyles: (i) => { + if (i % 2 === 0) return { backgroundColor: "#ccc" }; + }, + data: [ + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + ], + }) + +![11]() + +--- + +### Optional border + + doc.table({ + data: [ + [ + { border: [true, false, false, false], backgroundColor: "#eee", text: "border:\n[true, false, false, false]" }, + { border: false, backgroundColor: "#ddd", text: "border:\nfalse" }, + { border: true, backgroundColor: "#eee", text: "border:\ntrue" }, + ], + [ + { rowSpan: 3, border: true, backgroundColor: "#eef", text: "rowSpan: 3\n\nborder:\ntrue" }, + { border: undefined, backgroundColor: "#eee", text: "border:\nundefined (default)" }, + { border: [false, false, false, true], backgroundColor: "#ddd", text: "border:\n[false, false, false, true]" }, + ], + [ + { colSpan: 2, border: true, backgroundColor: "#efe", text: "colSpan: 2\n\nborder:\ntrue" }, + ], + [ + { border: 0, backgroundColor: "#eee", text: "border:\n0 (same as false)" }, + { border: [false, true, true, false], backgroundColor: "#ddd", text: "border:\n[false, true, true, false]" }, + ], + ], + }) + +![12]() + +--- + + doc.table({ + defaultStyle: { border: false, width: 60 }, + data: [ + ["", "column 1", "column 2", "column 3"], + [ + "row 1", + { + rowSpan: 3, + colSpan: 3, + border: true, + backgroundColor: "#ccc", + text: "rowSpan: 3\ncolSpan: 3\n\nborder:\n[true, true, true, true]", + }, + ], + ["row 2"], + ["row 3"], + ], + }) + +![13]() + +--- + +When defining multiple styles, the cells follow the precedence: + +1. `defaultStyle` +2. `columnStyles` +3. `rowStyles` +4. `cellStyle` + +so if a table was: + + doc.table({ + defaultStyle: { border: 1 }, + columnStyles: { border: { right: 2 } }, + rowStyles: { border: { bottom: 3 } }, + data: [ + [{ border: { left: 4 } }] + ] + }) + +The resulting cell would have a style of: + + { + border: { + top: 1, // From the default + right: 2, // From the column + bottom: 3, // From the row + left: 4 // From the cell + } + } + + +Internally, PDFKit keeps track of the current X and Y position of table as it +is added to the document. This way, any calls to `text` or `table` will be placed below the table row. + + doc + .text('before') + .table({ + data: [ + ['Column 1', 'Column 2', 'Column 3'], + ['One value goes here', 'Another one here', 'OK?'] + ] + }) + .text('after') + +![16]() + +## Table options + +- `position` - The position of the table (default `{x: doc.x, y: doc.y}`) +- `maxWidth` - The maximum width the table can expand to (defaults to the remaining content width (offset from the tables position)) +- `columnStyles` - Column definitions of the table. (default `auto`) +- `rowStyles` - Row definitions of the table. (default `*`) +- `defaultStyle` - Defaults to apply to every cell +- `data` - The data to render (not required, you can call `.row()`). This can be an iterable (async or sync) +- `debug` - Whether to show the debug lines for all the cells (default `false`) + +## Cell options + +- `text` - The value, will be cast to a string (`null` and `undefined` are not rendered but the cell is still outlined) +- `rowSpan` - How many rows this cell covers, follows the same logic as HTML `rowspan` +- `colSpan` - How many columns this cell covers, follows the same logic as HTML `colspan` +- `padding` - The padding for the cell (default `0.25em`) +- `border` - The border for the cell (default `1pt`) +- `borderColor` - The border colors for the cell (default `black`) +- `font` - Font options for the cell +- `backgroundColor` - Set the background color of the cell +- `align` - The alignment of the cell text (default `{x: 'left', y: 'top'}`) +- `textStroke` - The text stroke (default `0`) +- `textStrokeColor` - Sets the text stroke color of the cells text (default `black`) +- `textColor` - Sets the text color of the cells text (default `black`) +- `type` - Sets the cell type (for accessibility) (default `TD`) +- `textOptions` - Sets any text options you wish to provide (such as rotation) +- `debug` - Whether to show the debug lines for the cell (default `false`) + +## Column options + +Extends the [cell options](#cell-options) above with: + +- `width` - The width of the column (default `*`) +- `minWidth` - The minimum width of the column (default `0`) +- `maxWidth` - The maximum width of the column (default `Infinity`) + +## Row options + +Extends the [cell options](#cell-options) above with: + +- `height` - The height of the row (default `auto`) +- `minHeight` - The minimum height of the row (default `0`) +- `maxHeight` - The maximum height of the row (default `Infinity`) diff --git a/lib/document.js b/lib/document.js index 1a341460..45935eaf 100644 --- a/lib/document.js +++ b/lib/document.js @@ -21,6 +21,7 @@ import AcroFormMixin from './mixins/acroform'; import AttachmentsMixin from './mixins/attachments'; import LineWrapper from './line_wrapper'; import SubsetMixin from './mixins/subsets'; +import TableMixin from './mixins/table'; import MetadataMixin from './mixins/metadata'; class PDFDocument extends stream.Readable { @@ -92,6 +93,7 @@ class PDFDocument extends stream.Readable { this.initImages(); this.initOutline(); this.initMarkings(options); + this.initTables(); this.initSubset(options); // Initialize the metadata @@ -378,6 +380,7 @@ mixin(MarkingsMixin); mixin(AcroFormMixin); mixin(AttachmentsMixin); mixin(SubsetMixin); +mixin(TableMixin); PDFDocument.LineWrapper = LineWrapper; diff --git a/lib/mixins/table.js b/lib/mixins/table.js new file mode 100644 index 00000000..c8e18779 --- /dev/null +++ b/lib/mixins/table.js @@ -0,0 +1,15 @@ +import PDFTable from '../table/index'; + +export default { + initTables() { + this._tableIndex = 0; + }, + /** + * @param {Table} [opts] + * @returns {PDFTable} returns the table object unless `data` is set, + * then it returns the underlying document + */ + table(opts) { + return new PDFTable(this, opts); + }, +}; diff --git a/lib/page.js b/lib/page.js index 7df6609a..060f9699 100644 --- a/lib/page.js +++ b/lib/page.js @@ -150,6 +150,24 @@ class PDFPage { : (data.StructParents = this.document.createStructParentTreeNextKey()); } + /** + * The width of the safe contents of a page + * + * @returns {number} + */ + get contentWidth() { + return this.width - this.margins.left - this.margins.right; + } + + /** + * The height of the safe contents of a page + * + * @returns {number} + */ + get contentHeight() { + return this.height - this.margins.top - this.margins.bottom; + } + maxY() { return this.height - this.margins.bottom; } diff --git a/lib/table/accessibility.js b/lib/table/accessibility.js new file mode 100644 index 00000000..5da0d1b3 --- /dev/null +++ b/lib/table/accessibility.js @@ -0,0 +1,145 @@ +import PDFStructureElement from '../structure_element'; +import PDFDocument from '../document'; + +/** + * Add accessibility to a table + * + * @this PDFTable + * @memberOf PDFTable + * @private + */ +export function accommodateTable() { + const structParent = this.opts.structParent; + if (structParent) { + this._tableStruct = this.document.struct('Table'); + this._tableStruct.dictionary.data.ID = this._id; + if (structParent instanceof PDFStructureElement) { + structParent.add(this._tableStruct); + } else if (structParent instanceof PDFDocument) { + 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 doc = this.document; + + const cellStruct = doc.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); + + const normalizeColor = doc._normalizeColor; + if (cell.backgroundColor != null) { + attributes.BackgroundColor = 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] ? normalizeColor(borderColor.top) : null, + hasBorder[1] ? normalizeColor(borderColor.bottom) : null, + hasBorder[2] ? normalizeColor(borderColor.left) : null, + hasBorder[3] ? normalizeColor(borderColor.right) : null, + ]; + } + + // Remove any undefined attributes + Object.keys(attributes).forEach( + (key) => attributes[key] === undefined && delete attributes[key], + ); + cellStruct.dictionary.data.A = doc.ref(attributes); + cellStruct.add(callback); + cellStruct.end(); + cellStruct.dictionary.data.A.end(); +} diff --git a/lib/table/index.js b/lib/table/index.js new file mode 100644 index 00000000..7193d9b0 --- /dev/null +++ b/lib/table/index.js @@ -0,0 +1,74 @@ +import { normalizeRow, normalizeTable } from './normalize'; +import { measure, ensure } from './size'; +import { renderRow } from './render'; +import { accommodateCleanup, accommodateTable } from './accessibility'; + +class PDFTable { + /** + * @param {PDFDocument} document + * @param {Table} [opts] + */ + constructor(document, opts = {}) { + this.document = document; + this.opts = Object.freeze(opts); + + normalizeTable.call(this); + accommodateTable.call(this); + + this._currRowIndex = 0; + this._ended = false; + + // Render cells if present + if (opts.data) { + for (const row of opts.data) this.row(row); + return this.end(); + } + } + + /** + * Render a new row in the table + * + * @param {Iterable} row - The cells to render + * @param {boolean} lastRow - Whether this row is the last row + * @returns {this} returns the table, unless lastRow is `true` then returns the `PDFDocument` + */ + row(row, lastRow = false) { + if (this._ended) { + throw new Error(`Table was marked as ended on row ${this._currRowIndex}`); + } + + // Convert the iterable into an array + row = Array.from(row); + // Transform row + row = normalizeRow.call(this, row, this._currRowIndex); + if (this._currRowIndex === 0) ensure.call(this, row); + const { newPage, toRender } = measure.call(this, row, this._currRowIndex); + if (newPage) this.document.continueOnNewPage(); + const yPos = renderRow.call(this, toRender, this._currRowIndex); + + // Position document at base of new row + this.document.x = this._position.x; + this.document.y = yPos; + + if (lastRow) return this.end(); + + this._currRowIndex++; + return this; + } + + /** + * Indicates to the table that it is finished, + * allowing the table to flush its cell buffer (which should be empty unless there is rowSpans) + * + * @returns {PDFDocument} the document + */ + end() { + // Flush any remaining cells + while (this._rowBuffer?.size) this.row([]); + this._ended = true; + accommodateCleanup.call(this); + return this.document; + } +} + +export default PDFTable; diff --git a/lib/table/normalize.js b/lib/table/normalize.js new file mode 100644 index 00000000..6ff53492 --- /dev/null +++ b/lib/table/normalize.js @@ -0,0 +1,200 @@ +import { deepMerge, memoize } from './utils'; +import { + normalizeAlignment, + normalizedColumnStyle, + normalizedDefaultStyle, + normalizedRowStyle, +} from './style'; +import { normalizeSides } from '../utils'; + +/** + * Normalize a table + * + * @this PDFTable + * @memberOf PDFTable + * @private + */ +export function normalizeTable() { + const doc = this.document; + const opts = this.opts; + + // Normalize config + let index = doc._tableIndex++; + this._id = new String(opts.id ?? `table-${index}`); + this._position = { + x: doc.sizeToPoint(opts.position?.x, doc.x), + y: doc.sizeToPoint(opts.position?.y, doc.y), + }; + this._maxWidth = doc.sizeToPoint( + opts.maxWidth, + doc.page.width - doc.page.margins.right - this._position.x, + ); + + const { defaultStyle, defaultColStyle, defaultRowStyle } = + normalizedDefaultStyle(opts.defaultStyle); + this._defaultStyle = defaultStyle; + + let colStyle; + if (opts.columnStyles) { + if (Array.isArray(opts.columnStyles)) { + colStyle = (i) => opts.columnStyles[i]; + } else if (typeof opts.columnStyles === 'function') { + // memoize all columns + colStyle = memoize((i) => opts.columnStyles(i), Infinity); + } else if (typeof opts.columnStyles === 'object') { + colStyle = () => opts.columnStyles; + } + } + if (!colStyle) colStyle = () => ({}); + this._colStyle = normalizedColumnStyle.bind(this, defaultColStyle, colStyle); + + let rowStyle; + if (opts.rowStyles) { + if (Array.isArray(opts.rowStyles)) { + rowStyle = (i) => opts.rowStyles[i]; + } else if (typeof opts.rowStyles === 'function') { + // Memoize the row configs in a rolling buffer + rowStyle = memoize((i) => opts.rowStyles(i), 10); + } else if (typeof opts.rowStyles === 'object') { + rowStyle = () => opts.rowStyles; + } + } + if (!rowStyle) rowStyle = () => ({}); + this._rowStyle = normalizedRowStyle.bind(this, defaultRowStyle, rowStyle); +} + +/** + * Convert text into a string + * - null and undefined are preserved (as they will be ignored) + * - everything else is run through `String()` + * + * @param {*} text + * @returns {string} + * @private + */ +export function normalizeText(text) { + // Parse out text + if (text != null) text = `${text}`; + return text; +} + +/** + * Normalize a cell config + * + * @this PDFTable + * @memberOf PDFTable + * @param {TableCellStyle} cell - The cell to mutate + * @param {number} rowIndex - The cells row + * @param {number} colIndex - The cells column + * @returns {NormalizedTableCellStyle} + * @private + */ +export function normalizeCell(cell, rowIndex, colIndex) { + const colStyle = this._colStyle(colIndex); + let rowStyle = this._rowStyle(rowIndex); + + const font = deepMerge({}, colStyle.font, rowStyle.font, cell.font); + const customFont = Object.values(font).filter((v) => v != null).length > 0; + const doc = this.document; + + // Initialize cell context + const rollbackFont = doc._fontSource; + const rollbackFontSize = doc._fontSize; + const rollbackFontFamily = doc._fontFamily; + if (customFont) { + if (font.src) doc.font(font.src, font.family); + if (font.size) doc.fontSize(font.size); + + // Refetch rowStyle to reflect font changes + rowStyle = this._rowStyle(rowIndex); + } + + cell.padding = normalizeSides(cell.padding); + cell.border = normalizeSides(cell.border); + cell.borderColor = normalizeSides(cell.borderColor); + + // Cell takes highest priority, then row, then column, then defaultConfig + const config = deepMerge(this._defaultStyle, colStyle, rowStyle, cell); + config.rowIndex = rowIndex; + config.colIndex = colIndex; + config.font = font ?? {}; + config.customFont = customFont; + + // Normalize config + config.text = normalizeText(config.text); + config.rowSpan = config.rowSpan ?? 1; + config.colSpan = config.colSpan ?? 1; + config.padding = normalizeSides(config.padding, '0.25em', (x) => + doc.sizeToPoint(x, '0.25em'), + ); + config.border = normalizeSides(config.border, 1, (x) => + doc.sizeToPoint(x, 1), + ); + config.borderColor = normalizeSides( + config.borderColor, + 'black', + (x) => x ?? 'black', + ); + config.align = normalizeAlignment(config.align); + config.align.x = config.align.x ?? 'left'; + config.align.y = config.align.y ?? 'top'; + config.textStroke = doc.sizeToPoint(config.textStroke, 0); + config.textStrokeColor = config.textStrokeColor ?? 'black'; + config.textColor = config.textColor ?? 'black'; + config.textOptions = config.textOptions ?? {}; + + // Accessibility settings + config.id = new String(config.id ?? `${this._id}-${rowIndex}-${colIndex}`); + config.type = config.type?.toUpperCase() === 'TH' ? 'TH' : 'TD'; + if (config.scope) { + 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 (typeof this.opts.debug === 'boolean') config.debug = this.opts.debug; + + // Rollback font + if (customFont) doc.font(rollbackFont, rollbackFontFamily, rollbackFontSize); + + return config; +} + +/** + * Normalize a row + * + * @this PDFTable + * @memberOf PDFTable + * @param {TableCell[]} row + * @param {number} rowIndex + * @returns {NormalizedTableCellStyle[]} + * @private + */ +export function normalizeRow(row, rowIndex) { + if (!this._cellClaim) this._cellClaim = new Set(); + + let colIndex = 0; + return row.map((cell) => { + // Ensure TableCell + if (cell == null || typeof cell !== 'object') cell = { text: cell }; + + // Find the starting column of the cell + // Skipping over the claimed cells + while (this._cellClaim.has(`${rowIndex},${colIndex}`)) { + colIndex++; + } + + cell = normalizeCell.call(this, cell, rowIndex, colIndex); + + // Claim any spanning cells + for (let i = 0; i < cell.rowSpan; i++) { + for (let j = 0; j < cell.colSpan; j++) { + this._cellClaim.add(`${rowIndex + i},${colIndex + j}`); + } + } + + colIndex += cell.colSpan; + return cell; + }); +} diff --git a/lib/table/render.js b/lib/table/render.js new file mode 100644 index 00000000..e9a80d8c --- /dev/null +++ b/lib/table/render.js @@ -0,0 +1,222 @@ +import { accessibleCell, accessibleRow } from './accessibility'; + +/** + * Render a cell + * + * @this PDFTable + * @memberOf PDFTable + * @param {SizedNormalizedTableCellStyle[]} row + * @param {number} rowIndex + * @private + */ +export function renderRow(row, rowIndex) { + 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]; +} + +/** + * Render a cell + * + * @this PDFTable + * @memberOf PDFTable + * @param {SizedNormalizedTableCellStyle} cell + * @param {PDFStructureElement} rowStruct + * @private + */ +function renderCell(cell, rowStruct) { + const cellRenderer = () => { + // Render cell background + if (cell.backgroundColor != null) { + this.document + .save() + .rect(cell.x, cell.y, cell.width, cell.height) + .fill(cell.backgroundColor) + .restore(); + } + + // Render border + renderBorder.call( + this, + cell.border, + cell.borderColor, + cell.x, + cell.y, + cell.width, + cell.height, + ); + + // Debug cell borders + if (cell.debug) { + this.document.save(); + this.document.dash(1, { space: 1 }).lineWidth(1).strokeOpacity(0.3); + + // Debug cell bounds + this.document + .rect(cell.x, cell.y, cell.width, cell.height) + .stroke('green'); + + this.document.restore(); + } + + // Render text + if (cell.text) renderCellText.call(this, cell); + }; + + if (rowStruct) accessibleCell.call(this, cell, rowStruct, cellRenderer); + else cellRenderer(); +} + +/** + * @this PDFTable + * @memberOf PDFTable + * @param {SizedNormalizedTableCellStyle} cell + */ +function renderCellText(cell) { + const doc = this.document; + + // Configure fonts + const rollbackFont = doc._fontSource; + const rollbackFontSize = doc._fontSize; + const rollbackFontFamily = doc._fontFamily; + if (cell.customFont) { + if (cell.font.src) doc.font(cell.font.src, cell.font.family); + if (cell.font.size) doc.fontSize(cell.font.size); + } + + const x = cell.textX; + const y = cell.textY; + const Ah = cell.textAllocatedHeight; + const Aw = cell.textAllocatedWidth; + const Cw = cell.textBounds.width; + const Ch = cell.textBounds.height; + const Ox = -cell.textBounds.x; + const Oy = -cell.textBounds.y; + + const PxScale = + cell.align.x === 'right' ? 1 : cell.align.x === 'center' ? 0.5 : 0; + const Px = (Aw - Cw) * PxScale; + const PyScale = + cell.align.y === 'bottom' ? 1 : cell.align.y === 'center' ? 0.5 : 0; + const Py = (Ah - Ch) * PyScale; + + const dx = Px + Ox; + const dy = Py + Oy; + + if (cell.debug) { + doc.save(); + doc.dash(1, { space: 1 }).lineWidth(1).strokeOpacity(0.3); + + // Debug actual text bounds + if (cell.text) { + doc + .moveTo(x + Px, y) + .lineTo(x + Px, y + Ah) + .moveTo(x + Px + Cw, y) + .lineTo(x + Px + Cw, y + Ah) + .stroke('blue') + .moveTo(x, y + Py) + .lineTo(x + Aw, y + Py) + .moveTo(x, y + Py + Ch) + .lineTo(x + Aw, y + Py + Ch) + .stroke('green'); + } + // Debug allocated text bounds + doc.rect(x, y, Aw, Ah).stroke('orange'); + + doc.restore(); + } + + // Create text mask to cut off any overflowing text + // Mask cuts off at the padding not the actual cell, this is intentional! + doc.save().rect(x, y, Aw, Ah).clip(); + + doc.fillColor(cell.textColor).strokeColor(cell.textStrokeColor); + if (cell.textStroke > 0) doc.lineWidth(cell.textStroke); + + // Render the text + doc.text(cell.text, x + dx, y + dy, cell.textOptions); + + // Cleanup + doc.restore(); + if (cell.font) doc.font(rollbackFont, rollbackFontFamily, rollbackFontSize); +} + +/** + * @this PDFTable + * @memberOf PDFTable + * @param {ExpandedSideDefinition} border + * @param {ExpandedSideDefinition} borderColor + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + * @param {number[]} [mask] + * @private + */ +function renderBorder(border, borderColor, x, y, width, height, mask) { + border = Object.fromEntries( + Object.entries(border).map(([k, v]) => [k, mask && !mask[k] ? 0 : v]), + ); + + const doc = this.document; + if ( + [border.right, border.bottom, border.left].every( + (val) => val === border.top, + ) + ) { + if (border.top > 0) { + doc + .save() + .lineWidth(border.top) + .rect(x, y, width, height) + .stroke(borderColor.top) + .restore(); + } + } else { + // Top + if (border.top > 0) { + doc + .save() + .lineWidth(border.top) + .moveTo(x, y) + .lineTo(x + width, y) + .stroke(borderColor.top) + .restore(); + } + // Right + if (border.right > 0) { + doc + .save() + .lineWidth(border.right) + .moveTo(x + width, y) + .lineTo(x + width, y + height) + .stroke(borderColor.right) + .restore(); + } + // Bottom + if (border.bottom > 0) { + doc + .save() + .lineWidth(border.bottom) + .moveTo(x + width, y + height) + .lineTo(x, y + height) + .stroke(borderColor.bottom) + .restore(); + } + // Left + if (border.left > 0) { + doc + .save() + .lineWidth(border.left) + .moveTo(x, y + height) + .lineTo(x, y) + .stroke(borderColor.left) + .restore(); + } + } +} diff --git a/lib/table/size.js b/lib/table/size.js new file mode 100644 index 00000000..435033c7 --- /dev/null +++ b/lib/table/size.js @@ -0,0 +1,412 @@ +import { cosine, sine } from '../utils'; + +/** + * Compute the widths of the columns, ensuring to distribute the star widths + * + * @this PDFTable + * @memberOf PDFTable + * @param {NormalizedTableCellStyle[]} row + * @private + */ +export function ensure(row) { + // Width init + /** @type number[] **/ + this._columnWidths = []; + ensureColumnWidths.call( + this, + row.reduce((a, cell) => a + cell.colSpan, 0), + ); + + // Height init + /** @type number[] **/ + this._rowHeights = []; + /** @type number[] **/ + this._rowYPos = [this._position.y]; + /** @type {Set} **/ + this._rowBuffer = new Set(); +} + +/** + * Compute the widths of the columns, ensuring to distribute the star widths + * + * @this PDFTable + * @memberOf PDFTable + * @param {number} numCols + * @private + */ +function ensureColumnWidths(numCols) { + // Compute the widths + let starColumnIndexes = []; + let starMinAcc = 0; + let unclaimedWidth = this._maxWidth; + + for (let i = 0; i < numCols; i++) { + let col = this._colStyle(i); + if (col.width === '*') { + starColumnIndexes[i] = col; + starMinAcc += col.minWidth; + } else { + unclaimedWidth -= col.width; + this._columnWidths[i] = col.width; + } + } + + let starColCount = starColumnIndexes.reduce((x) => x + 1, 0); + + if (starMinAcc >= unclaimedWidth) { + // case 1 - there's no way to fit all columns within available width + // that's actually pretty bad situation with PDF as we have no horizontal scroll + starColumnIndexes.forEach((cell, i) => { + this._columnWidths[i] = cell.minWidth; + }); + } else if (starColCount > 0) { + // Otherwise we distribute evenly factoring in the cell bounds + starColumnIndexes.forEach((col, i) => { + let starSize = unclaimedWidth / starColCount; + this._columnWidths[i] = Math.max(starSize, col.minWidth); + if (col.maxWidth > 0) { + this._columnWidths[i] = Math.min(this._columnWidths[i], col.maxWidth); + } + unclaimedWidth -= this._columnWidths[i]; + starColCount--; + }); + } + + let tempX = this._position.x; + this._columnXPos = Array.from(this._columnWidths, (v) => { + const t = tempX; + tempX += v; + return t; + }); +} + +/** + * Compute the dimensions of the cells + * + * @this PDFTable + * @memberOf PDFTable + * @param {NormalizedTableCellStyle[]} row + * @param {number} rowIndex + * @returns {{newPage: boolean, toRender: SizedNormalizedTableCellStyle[]}} + * @private + */ +export function measure(row, rowIndex) { + // =================== + // Add cells to buffer + // =================== + row.forEach((cell) => this._rowBuffer.add(cell)); + + if (rowIndex > 0) { + this._rowYPos[rowIndex] = + this._rowYPos[rowIndex - 1] + this._rowHeights[rowIndex - 1]; + } + + const rowStyle = this._rowStyle(rowIndex); + + // ======================================================== + // Find any cells which are to finish rendering on this row + // ======================================================== + /** @type {SizedNormalizedTableCellStyle[]} */ + let toRender = []; + this._rowBuffer.forEach((cell) => { + if (cell.rowIndex + cell.rowSpan - 1 === rowIndex) { + toRender.push(measureCell.call(this, cell, rowStyle.height)); + this._rowBuffer.delete(cell); + } + }); + + // ===================================================== + // Find the shared height for the row based on the cells + // ===================================================== + let rowHeight = rowStyle.height; + if (rowHeight === 'auto') { + // Compute remaining height on cells + rowHeight = toRender.reduce((acc, cell) => { + let minHeight = + cell.textBounds.height + cell.padding.top + cell.padding.bottom; + for (let i = 0; i < cell.rowSpan - 1; i++) { + minHeight -= this._rowHeights[cell.rowIndex + i]; + } + return Math.max(acc, minHeight); + }, 0); + } + + rowHeight = Math.max(rowHeight, rowStyle.minHeight); + if (rowStyle.maxHeight > 0) { + rowHeight = Math.min(rowHeight, rowStyle.maxHeight); + } + this._rowHeights[rowIndex] = rowHeight; + + let newPage = false; + if (rowHeight > this.document.page.contentHeight) { + // We are unable to render this row on a single page, for now we log a warning and disable the newPage + console.warn( + new Error( + `Row ${rowIndex} requested more than the safe page height, row has been clamped`, + ).stack.slice(7), + ); + this._rowHeights[rowIndex] = + this.document.page.maxY() - this._rowYPos[rowIndex]; + } else if (this._rowYPos[rowIndex] + rowHeight >= this.document.page.maxY()) { + // If row is going to go over the safe page height then move it over to new page + this._rowYPos[rowIndex] = this.document.page.margins.top; + newPage = true; + } + + // ===================================================== + // Re-measure the cells using the know known height + // ===================================================== + return { + newPage, + toRender: toRender.map((cell) => measureCell.call(this, cell, rowHeight)), + }; +} + +/** + * Compute the dimensions of the cell and its text + * + * @this PDFTable + * @memberOf PDFTable + * @param {NormalizedTableCellStyle} cell + * @param {number | 'auto'} rowHeight + * @returns {SizedNormalizedTableCellStyle} + * @private + */ +function measureCell(cell, rowHeight) { + // ==================== + // Calculate cell width + // ==================== + let cellWidth = 0; + + // Traverse all the columns of the cell + for (let i = 0; i < cell.colSpan; i++) { + cellWidth += this._columnWidths[cell.colIndex + i]; + } + + // ===================== + // Calculate cell height + // ===================== + let cellHeight = rowHeight; + if (cellHeight === 'auto') { + // The cells height is effectively infinite + // (although we clamp it to the page content size) + cellHeight = this.document.page.contentHeight; + } else { + // Add all the spanning rows heights to the cell + for (let i = 0; i < cell.rowSpan - 1; i++) { + cellHeight += this._rowHeights[cell.rowIndex + i]; + } + } + + // Allocated text space + const textAllocatedWidth = cellWidth - cell.padding.left - cell.padding.right; + + const textAllocatedHeight = + cellHeight - cell.padding.top - cell.padding.bottom; + + // Compute the text bounds + const rotation = cell.textOptions.rotation ?? 0; + const { width: textMaxWidth, height: textMaxHeight } = computeBounds( + rotation, + textAllocatedWidth, + textAllocatedHeight, + ); + + const textOptions = { + // Alignment is handled internally + align: cell.align.x, + ellipsis: true, // Default make overflowing text ellipsis + stroke: cell.textStroke > 0, + fill: true, // To fix the stroke issue + width: textMaxWidth, + height: textMaxHeight, + rotation, + // Allow the user to define any custom fields + ...cell.textOptions, + }; + + // ======================== + // Calculate text height + // ======================== + + // Compute rendered bounds of the text given the constraints of the cell + let textBounds = { x: 0, y: 0, width: 0, height: 0 }; + if (cell.text) { + const rollbackFont = this.document._fontSource; + const rollbackFontSize = this.document._fontSize; + const rollbackFontFamily = this.document._fontFamily; + if (cell.font?.src) this.document.font(cell.font.src, cell.font?.family); + if (cell.font?.size) this.document.fontSize(cell.font.size); + + // We first compute the un-rotated bounds so that we can calculate the width of the text + const unRotatedTextBounds = this.document.boundsOfString(cell.text, 0, 0, { + ...textOptions, + rotation: 0, + }); + textOptions.width = unRotatedTextBounds.width; + textOptions.height = unRotatedTextBounds.height; + + // Then compute the rendered bounds + textBounds = this.document.boundsOfString(cell.text, 0, 0, textOptions); + + this.document.font(rollbackFont, rollbackFontFamily, rollbackFontSize); + } + + return { + ...cell, + textOptions, + x: this._columnXPos[cell.colIndex], + y: this._rowYPos[cell.rowIndex], + textX: this._columnXPos[cell.colIndex] + cell.padding.left, + textY: this._rowYPos[cell.rowIndex] + cell.padding.top, + width: cellWidth, + height: cellHeight, + textAllocatedHeight, + textAllocatedWidth, + textBounds, + }; +} + +/** + * Compute the horizon-locked bounding box of a rect + * + * @param {number} rotation + * @param {number} allocWidth + * @param {number} allocHeight + * + * @returns {{width: number, height: number}} + */ +function computeBounds(rotation, allocWidth, allocHeight) { + let textMaxWidth, textMaxHeight; + + // We use these a lot so pre-compute + const cos = cosine(rotation); + const sin = sine(rotation); + + // <---------------allocWidth----------------> + // A════════════════════F════════════════════B + // ║ ■■ ■ ║ + // ║ ■■ ■ ║ + // ║ ■■ ■ ║ + // ║ ■■ ■ ║ + // ║ ■■ ■ ║ + // ║ ■■ ■ ║ + // ║ ■■░░ ■ ║ + // ║ ■■ ░ ■ ║ + // ║ ■■ Θ ░ ■ ║ + // ║■■ ░ ■ ║ + // E- - - - - - - - - - - - - ■ - - - - - - -║ + // ║■ ■ ║ + // ║■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■ ║ + // ║ ■ ■║ + // ║ ■ ■║ + // ║ ■ G + // ║ ■ ■■║ + // ║ ■ ■■ ║ + // ║ ■ ■ ║ + // ║ ■ ■■ ║ + // ║ ■ ■ ║ + // ║ ■ ■■ ║ + // ║ ■ ■■ ║ + // ║ ■ ■ ║ + // ║ ■ ■■ ║ + // ║ ■ ■ ║ + // ║ ■ ■■ ║ + // ║ ■ ■■ ║ + // D════════════════════H════════════════════C + // + // Given a rectangle ABCD with a fixed side AB of width allocWidth. + // Find the largest (by area) inscribed rectangle EFGH, + // where the angle Θ is equal to rotation (between 0-90 degrees) + // + // From above we can infer + // > AF = EF * cos(Θ) + // > FB = AB - AF + // > FB = FG * sin(Θ) + // Rearrange + // > FG = FB / sin(Θ) + // Substitute + // > FG = (AB - EF*cos(Θ)) / sin(Θ) + // Area of a rectangle + // > A = EF * FG + // Substitute + // > A = EF * (AB - EF*cos(Θ)) / sin(Θ) + // > dA/dEF = (AB - 2*EF*cos(Θ)) / sin(Θ) + // Find peak at dA/dEF = 0 + // > 0 = (AB - 2*EF*cos(Θ)) / sin(Θ) + // > EF = AB / (2*cos(Θ)) + // Substitute + // > FG = (AB - (AB*cos(Θ)) / (2*cos(Θ))) / sin(Θ) + // > FG = AB / (2*sin(Θ)) + // + // Final outcome + // Length EF = AB / (2*cos(Θ)) + // Length FG = AB / (2*sin(Θ)) + if (rotation === 0 || rotation === 180) { + textMaxWidth = allocWidth; + textMaxHeight = allocHeight; + } else if (rotation === 90 || rotation === 270) { + textMaxWidth = allocHeight; + textMaxHeight = allocWidth; + } else if (rotation < 90 || (rotation > 180 && rotation < 270)) { + textMaxWidth = allocWidth / (2 * cos); + textMaxHeight = allocWidth / (2 * sin); + } else { + textMaxHeight = allocWidth / (2 * cos); + textMaxWidth = allocWidth / (2 * sin); + } + + // If The bounding box of the text is beyond the allocHeight + // then we need to clamp it and recompute the bounds + // This time we are computing the sizes based on the outer box ABCD + const EF = sin * textMaxWidth; + const FG = cos * textMaxHeight; + if (EF + FG > allocHeight) { + // > AB = EF * cos(Θ) + FG * sin(Θ) + // > BC = BG + GC + // > BG = FG * cos(Θ) + // > GC = EF * sin(Θ) + // > BC = FG * cos(Θ) + EF * sin(Θ) + // > AB = EF * cos(Θ) + FG * sin(Θ) + // Substitution solve + // > EF = (AB*cos(Θ) - BC*sin(Θ)) / (cos^2(Θ)-sin^2(Θ)) + // > FG = (BC*cos(Θ) - AB*sin(Θ)) / (cos^2(Θ)-sin^2(Θ)) + const denominator = cos * cos - sin * sin; + + if (rotation === 0 || rotation === 180) { + textMaxWidth = allocWidth; + textMaxHeight = allocHeight; + } else if (rotation === 90 || rotation === 270) { + textMaxWidth = allocHeight; + textMaxHeight = allocWidth; + } else if (rotation < 90 || (rotation > 180 && rotation < 270)) { + textMaxWidth = (allocWidth * cos - allocHeight * sin) / denominator; + textMaxHeight = (allocHeight * cos - allocWidth * sin) / denominator; + } else { + textMaxHeight = (allocWidth * cos - allocHeight * sin) / denominator; + textMaxWidth = (allocHeight * cos - allocWidth * sin) / denominator; + } + } + + return { width: Math.abs(textMaxWidth), height: Math.abs(textMaxHeight) }; +} diff --git a/lib/table/style.js b/lib/table/style.js new file mode 100644 index 00000000..74753a57 --- /dev/null +++ b/lib/table/style.js @@ -0,0 +1,148 @@ +import { COLUMN_FIELDS, deepMerge, ROW_FIELDS } from './utils'; +import { normalizeSides } from '../utils'; + +/** + * Normalize the row config + * @note The context here is the cell not the document + * + * @param {DefaultTableCell} [defaultStyleInternal] + * @returns {{ + * defaultStyle: TableCellStyle, + * defaultRowStyle: RowStyle, + * defaultColStyle: ColumnStyle + * }} + * @private + */ +export function normalizedDefaultStyle(defaultStyleInternal) { + let defaultStyle = defaultStyleInternal; + // Force object form + if (typeof defaultStyle !== 'object') defaultStyle = { text: defaultStyle }; + + const defaultRowStyle = Object.fromEntries( + Object.entries(defaultStyle).filter(([k]) => ROW_FIELDS.includes(k)), + ); + const defaultColStyle = Object.fromEntries( + Object.entries(defaultStyle).filter(([k]) => COLUMN_FIELDS.includes(k)), + ); + + defaultStyle.padding = normalizeSides(defaultStyle.padding); + defaultStyle.border = normalizeSides(defaultStyle.border); + defaultStyle.borderColor = normalizeSides(defaultStyle.borderColor); + defaultStyle.align = normalizeAlignment(defaultStyle.align); + + return { defaultStyle, defaultRowStyle, defaultColStyle }; +} + +/** + * Normalize the row config + * + * @note The context here is the cell not the document + * + * @this PDFTable + * @memberOf PDFTable + * @param {RowStyle} defaultRowStyle + * @param {Dynamic} rowStyleInternal + * @param {number} i The target row + * @returns {NormalizedRowStyle} + * @private + */ +export function normalizedRowStyle(defaultRowStyle, rowStyleInternal, i) { + let rowStyle = rowStyleInternal(i); + // Force object form + if (rowStyle == null || typeof rowStyle !== 'object') { + rowStyle = { height: rowStyle }; + } + // Normalize + rowStyle.padding = normalizeSides(rowStyle.padding); + rowStyle.border = normalizeSides(rowStyle.border); + rowStyle.borderColor = normalizeSides(rowStyle.borderColor); + rowStyle.align = normalizeAlignment(rowStyle.align); + + // Merge defaults + rowStyle = deepMerge(defaultRowStyle, rowStyle); + + const document = this.document; + const page = document.page; + const contentHeight = page.contentHeight; + + if (rowStyle.height == null || rowStyle.height === 'auto') { + rowStyle.height = 'auto'; + } else { + rowStyle.height = document.sizeToPoint( + rowStyle.height, + 0, + page, + contentHeight, + ); + } + rowStyle.minHeight = document.sizeToPoint( + rowStyle.minHeight, + 0, + page, + contentHeight, + ); + rowStyle.maxHeight = document.sizeToPoint( + rowStyle.maxHeight, + 0, + page, + contentHeight, + ); + + return rowStyle; +} + +/** + * Normalize the column config + * + * @note The context here is the document not the cell + * + * @param {ColumnStyle} defaultColStyle + * @param {Dynamic} colStyleInternal + * @param {number} i - The target column + * @returns {NormalizedColumnStyle} + * @private + */ +export function normalizedColumnStyle(defaultColStyle, colStyleInternal, i) { + let colStyle = colStyleInternal(i); + // Force object form + if (colStyle == null || typeof colStyle !== 'object') { + colStyle = { width: colStyle }; + } + // Normalize + colStyle.padding = normalizeSides(colStyle.padding); + colStyle.border = normalizeSides(colStyle.border); + colStyle.borderColor = normalizeSides(colStyle.borderColor); + colStyle.align = normalizeAlignment(colStyle.align); + + // Merge defaults + colStyle = deepMerge(defaultColStyle, colStyle); + + if (colStyle.width == null || colStyle.width === '*') { + colStyle.width = '*'; + } else { + colStyle.width = this.document.sizeToPoint( + colStyle.width, + 0, + this.document.page, + this._maxWidth, // Use table width here for percentage scaling + ); + } + colStyle.minWidth = this.document.sizeToPoint( + colStyle.minWidth, + 0, + this.document.page, + this._maxWidth, // Use table width here for percentage scaling + ); + colStyle.maxWidth = this.document.sizeToPoint( + colStyle.maxWidth, + 0, + this.document.page, + this._maxWidth, // Use table width here for percentage scaling + ); + + return colStyle; +} + +export function normalizeAlignment(align) { + return align == null || typeof align === 'string' ? { x: align, y: align } : align; +} diff --git a/lib/table/utils.js b/lib/table/utils.js new file mode 100644 index 00000000..f26f8fe4 --- /dev/null +++ b/lib/table/utils.js @@ -0,0 +1,368 @@ +/** + * @template T + * @typedef {function(number): T} Dynamic + */ + +/** + * @typedef {Object} Font + * @property {PDFFontSource} [src] + * The name of the font + * + * Defaults to the current document font source `doc._fontSrc` + * @property {string} [family] + * The font family of the font + * + * Defaults to the current document font family `doc._fontFamily` + * @property {Size} [size] + * The size of the font + * + * Defaults to the current document font size `doc._fontSize` + */ + +/** + * Measurement of how wide something is, false means 0 and true means 1 + * + * @typedef {Size | boolean} Wideness + */ + +/** + * The value of the text of a cell + * @typedef {string | null | undefined} TableCellText + */ + +/** @typedef {Object} TableCellStyle + * + * @property {TableCellText} [text] + * The text of the table cell + * @property {number} [rowSpan] + * Number of rows the cell spans. + * + * Defaults to `1`. + * @property {number} [colSpan] + * Number of columns the cell spans. + * + * Defaults to `1`. + * @property {SideDefinition} [padding] + * Controls the padding of the cell text + * + * Defaults to `0.25em` + * @property {SideDefinition} [border] + * Controls the thickness of the cells borders. + * + * Defaults to `[1, 1, 1, 1]`. + * @property {SideDefinition} [borderColor] + * Color of the border on each side of the cell. + * + * Defaults to the border color defined by the given table layout, or `black` on all sides. + * @property {Font} [font] + * Font options for the cell + * + * Defaults to the documents current font + * @property {PDFColor} [backgroundColor] + * Set the background color of the cell + * + * Defaults to transparent + * @property {'center' | ExpandedAlign} [align] + * Sets the text alignment of the cells text + * + * Defaults to `{x: 'left', y: 'top'}` + * @property {Size} [textStroke] + * Sets the text stroke width of the cells text + * + * Defaults to `0` + * @property {PDFColor} [textStrokeColor] + * Sets the text stroke color of the cells text + * + * Defaults to `black` + * @property {PDFColor} [textColor] + * Sets the text color of the cells text + * + * Defaults to `black` + * @property {'TH' | 'TD'} [type] + * Sets the cell type (for accessibility) + * + * Defaults to `TD` + * @property {Object} [textOptions] + * Sets any advanced text options passed into the cell renderer + * + * 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 + * + * Defaults to `false` + */ +/** @typedef {TableCellText | TableCellStyle} TableCell **/ + +/** + * The width of the column + * + * - `*` distributes equally, filling the whole available space + * - `%` computes the proportion of the max size + * + * Defaults to `*` + * @typedef {Size | '*'} ColumnWidth + */ + +/** + * @typedef {Object} ColumnStyle + * @extends TableCellStyle + * + * @property {ColumnWidth} [width] + * @property {Size} [minWidth] + * The minimum width of the column + * + * Defaults to `0` + * @property {Size} [maxWidth] + * The maximum width of the column + * + * Defaults to `undefined` meaning no max + */ +/** @typedef {ColumnStyle | ColumnWidth} Column **/ + +/** + * @typedef {Object} NormalizedColumnStyle + * @extends ColumnStyle + * + * @property {number | '*'} width + * @property {number} minWidth + * @property {number} maxWidth + */ + +/** + * The height of the row + * + * - A fixed value sets an absolute height for every row. + * - `auto` sets the height based on the text. + * + * `%` values are based on page content height + * + * Defaults to `auto` + * @typedef {Size | 'auto'} RowHeight + */ + +/** + * @typedef {Object} RowStyle + * @extends TableCellStyle + * + * @property {RowHeight} [height] + * @property {Size} [minHeight] + * The minimum height of the row + * + * `%` values are based on page content height + * + * Defaults to `0` + * @property {Size} [maxHeight] + * The maximum height of the row + * + * `%` values are based on page content height + * + * Defaults to `undefined` meaning no max + */ +/** @typedef {RowStyle | RowHeight} Row **/ + +/** + * @typedef {Object} NormalizedRowStyle + * @extends RowStyle + * + * @property {number | 'auto'} height + * @property {number} minHeight + * @property {number} maxHeight + */ + +/** @typedef {'left' | 'center' | 'right' | 'justify'} AlignX **/ +/** @typedef {'top' | 'center' | 'bottom'} AlignY **/ +/** + * @typedef {Object} ExpandedAlign + * @property {AlignX} [x] + * @property {AlignY} [y] + */ + +/** + * @typedef {Object} DefaultTableCellStyle + * + * @extends ColumnStyle + * @extends RowStyle + * @extends TableCellStyle + */ +/** @typedef {TableCellText | DefaultTableCellStyle} DefaultTableCell **/ + +/** + * @typedef {Object} NormalizedDefaultTableCellStyle + * + * @extends NormalizedColumnStyle + * @extends NormalizedRowStyle + * @extends TableCellStyle + */ + +/** + * @typedef {Object} NormalizedTableCellStyle + * + * @extends NormalizedColumnStyle + * @extends NormalizedRowStyle + * @extends TableCellStyle + * + * @property {number} rowIndex + * @property {number} rowSpan + * @property {number} colIndex + * @property {number} colSpan + * + * @property {string} text + * @property {Font} font + * @property {boolean} customFont + * @property {ExpandedSideDefinition} padding + * @property {ExpandedSideDefinition} border + * @property {ExpandedSideDefinition} borderColor + * @property {ExpandedAlign} align + * @property {number} textStroke + * @property {PDFColor} textStrokeColor + * @property {PDFColor} textColor + * @property {number} minWidth + * @property {number} maxWidth + * @property {number} minHeight + * @property {number} maxHeight + * @property {Object} textOptions + */ + +/** + * @typedef {Object} SizedNormalizedTableCellStyle + * + * @extends {NormalizedTableCellStyle} + * + * @property {number} x + * @property {number} y + * @property {number} textX + * @property {number} textY + * @property {number} width + * @property {number} height + * @property {number} textAllocatedWidth + * @property {number} textAllocatedHeight + * @property {{x: number, y: number, width: number, height: number}} textBounds + */ + +/** + * @typedef {Object} Table + * + * @property {Position} [position] + * The position of the table + * + * Defaults to the current document position `{x: doc.x, y: doc.y}` + * @property {Size} [maxWidth] + * The maximum width the table can expand to + * + * Defaults to the remaining content width (offset from the tables position) + * @property {Column | Column[] | Dynamic} [columnStyles] + * Column definitions of the table. + * - A fixed value sets the config for every column + * - Use an array or a callback function to control the column config for each column individually. + * + * Defaults to `auto` + * @property {Row | Row[] | Dynamic} [rowStyles] + * Row definitions of the table. + * - A fixed value sets the config for every column + * - Use an array or a callback function to control the row config of each row individually. + * + * The given values are ignored for rows whose text is higher. + * + * Defaults to `*`. + * @property {DefaultTableCell} [defaultStyle] + * Defaults to apply to every cell + * @property {Iterable>} [data] + * Two-dimensional iterable that defines the table's data. + * + * With the first dimension being the row, and the second being the column + * + * 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 + * + * Defaults to `false` + */ + +/** + * Fields exclusive to row styles + * @type {string[]} + */ +export const ROW_FIELDS = ['height', 'minHeight', 'maxHeight']; +/** + * Fields exclusive to column styles + * @type {string[]} + */ +export const COLUMN_FIELDS = ['width', 'minWidth', 'maxWidth']; + +export function memoize(fn, maxSize) { + const cache = new Map(); + return function (...args) { + const key = args[0]; + if (!cache.has(key)) { + cache.set(key, fn(...args)); + if (cache.size > maxSize) cache.delete(cache.keys().next()); + } + return cache.get(key); + }; +} + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +function isObject(item) { + return item && typeof item === "object" && !Array.isArray(item); +} + +/** + * Deep merge two objects. + * + * @template T + * @param {T} target + * @param sources + * @returns {T} + */ +export function deepMerge(target, ...sources) { + if (!isObject(target)) return target; + target = deepClone(target); + + for (const source of sources) { + if (isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!(key in target)) target[key] = {}; + target[key] = deepMerge(target[key], source[key]); + } else if (source[key] !== undefined) { + target[key] = deepClone(source[key]); + } + } + } + } + + return target; +} + +function deepClone(obj) { + let result = obj; + if (typeof obj == 'object') { + result = Array.isArray(obj) ? [] : {}; + for (const key in obj) result[key] = deepClone(obj[key]); + } + return result; +} diff --git a/lib/utils.js b/lib/utils.js index f1e5bd5d..17b0d538 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -12,11 +12,10 @@ export function PDFNumber(n) { */ /** - * Measurement of how wide something is, false means 0 and true means 1 - * - * @typedef {Size | boolean} Wideness + * @typedef {Array | string | Array} PDFColor */ +/** @typedef {string | Buffer | Uint8Array | ArrayBuffer} PDFFontSource */ /** * Side definitions * - To define all sides, use a single value @@ -52,16 +51,14 @@ export function normalizeSides( transformer = (v) => v, ) { if ( - sides === undefined || - sides === null || - (typeof sides === "object" && Object.keys(sides).length === 0) + sides == null || + (typeof sides === 'object' && Object.keys(sides).length === 0) ) { sides = defaultDefinition; } - if (typeof sides !== "object" || sides === null) { - sides = [sides, sides, sides, sides]; - } - if (Array.isArray(sides)) { + if (sides == null || typeof sides !== 'object') { + sides = { top: sides, right: sides, bottom: sides, left: sides }; + } else if (Array.isArray(sides)) { if (sides.length === 2) { sides = { vertical: sides[0], horizontal: sides[1] }; } else { @@ -74,7 +71,7 @@ export function normalizeSides( } } - if ("vertical" in sides || "horizontal" in sides) { + if ('vertical' in sides || 'horizontal' in sides) { sides = { top: sides.vertical, right: sides.horizontal, @@ -109,7 +106,7 @@ export function cosine(a) { if (a === 90) return 0; if (a === 180) return -1; if (a === 270) return 0; - return Math.cos(a * Math.PI / 180); + return Math.cos((a * Math.PI) / 180); } /** @@ -124,5 +121,5 @@ export function sine(a) { if (a === 90) return 1; if (a === 180) return 0; if (a === 270) return -1; - return Math.sin(a * Math.PI / 180); + return Math.sin((a * Math.PI) / 180); } diff --git a/rollup.config.js b/rollup.config.js index c56d2ee6..c3a8b867 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -47,7 +47,8 @@ export default [ } } ] - ] + ], + comments: false }), copy({ targets: [ @@ -80,7 +81,8 @@ export default [ } } ] - ] + ], + comments: false }) ] }, diff --git a/tests/unit/table.spec.js b/tests/unit/table.spec.js new file mode 100644 index 00000000..681593ad --- /dev/null +++ b/tests/unit/table.spec.js @@ -0,0 +1,37 @@ +import PDFDocument from '../../lib/document'; +import PDFTable from '../../lib/table'; +import { deepMerge } from '../../lib/table/utils'; + +describe('table', () => { + test('created', () => { + const document = new PDFDocument(); + expect(document.table()).toBeInstanceOf(PDFTable); + expect(document.table({ data: [] })).toBe(document); + }); + test('row', () => { + const document = new PDFDocument(); + const table = document.table(); + table.row(['A', 'B', 'C']); + expect(table._columnWidths.length).toBe(3); + }); +}); + +describe('utils', () => { + describe('deepMerge', () => { + test.each([ + [{ a: 'hello' }, { b: 'world' }, { a: 'hello', b: 'world' }], + [{ a: 'hello' }, { a: 'world' }, { a: 'world' }], + [{}, { a: 'hello' }, { a: 'hello' }], + [{ a: 'hello' }, undefined, { a: 'hello' }], + [undefined, undefined, undefined], + [1, 2, 1], + [1, {}, 1], + [{ a: 'hello' }, { a: {} }, { a: 'hello' }], + [{ a: { b: 'hello' } }, { a: { b: 'world' } }, { a: { b: 'world' } }], + ])('%o -> %o', function () { + const opts = Array.from(arguments); + const expected = opts.splice(-1, 1)[0]; + expect(deepMerge(...opts)).toEqual(expected); + }); + }); +}); diff --git a/tests/unit/utils.spec.js b/tests/unit/utils.spec.js index 9f0fc717..e1181cd6 100644 --- a/tests/unit/utils.spec.js +++ b/tests/unit/utils.spec.js @@ -1,6 +1,6 @@ -import { normalizeSides } from "../../lib/utils"; +import { normalizeSides } from '../../lib/utils'; -describe("normalizeSides", () => { +describe('normalizeSides', () => { test.each([ [1, { top: 1, right: 1, bottom: 1, left: 1 }], [[1, 2], { top: 1, right: 2, bottom: 1, left: 2 }], @@ -14,12 +14,12 @@ describe("normalizeSides", () => { { top: 1, right: 2, bottom: 3, left: 4 }, ], [ - { a: "hi" }, + { a: 'hi' }, { top: undefined, right: undefined, bottom: undefined, left: undefined }, ], [ - { vertical: "hi" }, - { top: "hi", right: undefined, bottom: "hi", left: undefined }, + { vertical: 'hi' }, + { top: 'hi', right: undefined, bottom: 'hi', left: undefined }, ], [ { top: undefined }, @@ -33,23 +33,17 @@ describe("normalizeSides", () => { undefined, { top: undefined, right: undefined, bottom: undefined, left: undefined }, ], - [ - true, - { top: true, right: true, bottom: true, left: true }, - ], - [ - false, - { top: false, right: false, bottom: false, left: false }, - ], - ])("%s -> %s", (size, expected) => { + [true, { top: true, right: true, bottom: true, left: true }], + [false, { top: false, right: false, bottom: false, left: false }], + ])('%s -> %s', (size, expected) => { expect(normalizeSides(size)).toEqual(expected); }); - test("with transformer", () => { + test('with transformer', () => { expect( normalizeSides( undefined, - { top: "1", right: "2", bottom: "3", left: "4" }, + { top: '1', right: '2', bottom: '3', left: '4' }, Number, ), ).toEqual({ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-column-row-spans-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-column-row-spans-1-snap.png new file mode 100644 index 00000000..2e17cf8b Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-column-row-spans-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-defining-column-widths-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-defining-column-widths-1-snap.png new file mode 100644 index 00000000..a9f3752a Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-defining-column-widths-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-defining-row-heights-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-defining-row-heights-1-snap.png new file mode 100644 index 00000000..f2dbcea1 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-defining-row-heights-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-iterables-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-iterables-1-snap.png new file mode 100644 index 00000000..44b87048 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-iterables-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-line-flowing-rotated-text-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-line-flowing-rotated-text-1-snap.png new file mode 100644 index 00000000..12a1c4d1 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-line-flowing-rotated-text-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-multi-line-rotated-text-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-multi-line-rotated-text-1-snap.png new file mode 100644 index 00000000..4e18d57f Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-multi-line-rotated-text-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-optional-border-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-optional-border-1-snap.png new file mode 100644 index 00000000..0d69296e Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-optional-border-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-rotated-text-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-rotated-text-1-snap.png new file mode 100644 index 00000000..e5f699ea Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-rotated-text-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-simple-table-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-simple-table-1-snap.png new file mode 100644 index 00000000..87a7537e Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-simple-table-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-styling-tables-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-styling-tables-1-snap.png new file mode 100644 index 00000000..313b6068 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-styling-tables-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-styling-tables-2-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-styling-tables-2-snap.png new file mode 100644 index 00000000..57e09cd6 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-styling-tables-2-snap.png differ diff --git a/tests/visual/helpers.js b/tests/visual/helpers.js index 087484eb..d54edbbd 100644 --- a/tests/visual/helpers.js +++ b/tests/visual/helpers.js @@ -14,23 +14,26 @@ function runDocTest(options, fn) { const doc = new PDFDocument(options); const buffers = []; - fn(doc); - - doc.on('data', buffers.push.bind(buffers)); - doc.on('end', async () => { - try { - const pdfData = Buffer.concat(buffers); - const { systemFonts = false } = options; - const images = await pdf2png(pdfData, { systemFonts }); - for (let image of images) { - expect(image).toMatchImageSnapshot(); + (async () => { + await fn(doc) + })().then(() => { + doc.on('error', (err) => reject(err)) + doc.on('data', buffers.push.bind(buffers)); + doc.on('end', async () => { + try { + const pdfData = Buffer.concat(buffers); + const { systemFonts = false } = options; + const images = await pdf2png(pdfData, { systemFonts }); + for (let image of images) { + expect(image).toMatchImageSnapshot(); + } + resolve(); + } catch (err) { + reject(err) } - resolve(); - } catch (err) { - reject(err) - } - }); - doc.end(); + }); + doc.end(); + }).catch(err => reject(err)); }); } diff --git a/tests/visual/table.spec.js b/tests/visual/table.spec.js new file mode 100644 index 00000000..d9e38cea --- /dev/null +++ b/tests/visual/table.spec.js @@ -0,0 +1,412 @@ +import { runDocTest } from './helpers'; + +describe('table', function () { + test('simple table', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + data: [ + ['Column 1', 'Column 2', 'Column 3'], + ['One value goes here', 'Another one here', 'OK?'], + ], + }); + }); + }); + test('defining column widths', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + columnStyles: [100, '*', 200, '*'], + data: [ + ['width=100', 'star-sized', 'width=200', 'star-sized'], + [ + 'fixed-width cells have exactly the specified width', + { text: 'nothing interesting here', textColor: 'grey' }, + { text: 'nothing interesting here', textColor: 'grey' }, + { text: 'nothing interesting here', textColor: 'grey' }, + ], + ], + }); + }); + }); + test('defining row heights', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + rowStyles: [20, 50, 70], + data: [ + ['row 1 with height 20', 'column B'], + ['row 2 with height 50', 'column B'], + ['row 3 with height 70', 'column B'], + ], + }); + doc.moveDown(); + doc.text('With same height:'); + doc.table({ + rowStyles: 40, + data: [ + ['row 1', 'column B'], + ['row 2', 'column B'], + ['row 3', 'column B'], + ], + }); + doc.moveDown(); + doc.text('With height from function:'); + doc.table({ + rowStyles: (row) => (row + 1) * 25, + data: [ + ['row 1', 'column B'], + ['row 2', 'column B'], + ['row 3', 'column B'], + ], + }); + }); + }); + test('column/row spans', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + columnStyles: [200, '*', '*'], + rowStyles: (i) => { + return i < 2 + ? { + font: { src: 'tests/fonts/Roboto-MediumItalic.ttf' }, + align: { x: 'center' }, + } + : { + textColor: 'grey', + }; + }, + data: [ + [{ colSpan: 2, text: 'Header with Colspan = 2' }, 'Header 3'], + ['Header 1', 'Header 2', 'Header 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + [ + { + rowSpan: 3, + text: 'rowspan set to 3\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor', + }, + 'Sample value 2', + 'Sample value 3', + ], + ['Sample value 2', 'Sample value 3'], + ['Sample value 2', 'Sample value 3'], + [ + 'Sample value 1', + { + colSpan: 2, + rowSpan: 2, + text: 'Both:\nrowspan and colspan\ncan be defined at the same time', + }, + ], + ['Sample value 1'], + ], + }); + }); + }); + test('styling tables', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.text('noBorders:').moveDown(1); + doc + .table({ + rowStyles: { border: false }, + data: [ + ['Header 1', 'Header 2', 'Header 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ], + }) + .moveDown(2) + .text('headerLineOnly:') + .moveDown(1) + .table({ + rowStyles: (i) => { + return i < 1 ? { border: [0, 0, 1, 0] } : { border: false }; + }, + data: [ + ['Header 1', 'Header 2', 'Header 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ], + }) + .moveDown(2) + .text('lightHorizontalLines:') + .moveDown(1) + .table({ + rowStyles: (i) => { + return i < 1 + ? { + border: [0, 0, 2, 0], + borderColor: 'black', + } + : { + border: [0, 0, 1, 0], + borderColor: '#aaa', + }; + }, + data: [ + ['Header 1', 'Header 2', 'Header 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ], + }) + .moveDown(2) + .text('but you can provide a custom styler as well') + .moveDown(1) + .table({ + defaultStyle: { border: 1, borderColor: 'gray' }, + columnStyles: (i) => { + if (i === 0) { + return { border: { left: 2 }, borderColor: { left: 'black' } }; + } + if (i === 2) { + return { border: { right: 2 }, borderColor: { right: 'black' } }; + } + }, + rowStyles: (i) => { + if (i === 0) { + return { border: { top: 2 }, borderColor: { top: 'black' } }; + } + if (i === 3) { + return { + border: { bottom: 2 }, + borderColor: { bottom: 'black' }, + }; + } + }, + data: [ + ['Header 1', 'Header 2', 'Header 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ], + }) + .moveDown(2) + .text('zebra style') + .moveDown(1) + .table({ + rowStyles: (i) => { + if (i % 2 === 0) return { backgroundColor: '#ccc' }; + }, + data: [ + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + ], + }); + }); + }); + + test('optional border', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + data: [ + [ + { + border: [true, false, false, false], + backgroundColor: '#eee', + text: 'border:\n[true, false, false, false]', + }, + { + border: false, + backgroundColor: '#ddd', + text: 'border:\nfalse', + }, + { + border: true, + backgroundColor: '#eee', + text: 'border:\ntrue', + }, + ], + [ + { + rowSpan: 3, + border: true, + backgroundColor: '#eef', + text: 'rowSpan: 3\n\nborder:\ntrue', + }, + { + border: undefined, + backgroundColor: '#eee', + text: 'border:\nundefined (default)', + }, + { + border: [false, false, false, true], + backgroundColor: '#ddd', + text: 'border:\n[false, false, false, true]', + }, + ], + [ + { + colSpan: 2, + border: true, + backgroundColor: '#efe', + text: 'colSpan: 2\n\nborder:\ntrue', + }, + ], + [ + { + border: 0, + backgroundColor: '#eee', + text: 'border:\n0 (same as false)', + }, + { + border: [false, true, true, false], + backgroundColor: '#ddd', + text: 'border:\n[false, true, true, false]', + }, + ], + ], + }); + + doc.moveDown(2); + + doc.table({ + defaultStyle: { border: false, width: 60 }, + data: [ + ['', 'column 1', 'column 2', 'column 3'], + [ + 'row 1', + { + rowSpan: 3, + colSpan: 3, + border: true, + backgroundColor: '#ccc', + text: 'rowSpan: 3\ncolSpan: 3\n\nborder:\n[true, true, true, true]', + }, + ], + ['row 2'], + ['row 3'], + ], + }); + }); + }); + + test('iterables', function () { + return runDocTest(async function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + + const syncIterator = (function* () { + yield ['1', '2']; + yield ['3', '4']; + })(); + + doc.table({ data: syncIterator }); + }); + }); + + test('rotated text', function () { + return runDocTest(function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.fontSize(7); + doc.table({ + debug: true, + defaultStyle: { height: 50, width: 50, padding: 4 }, + columnStyles: (i) => ({ textOptions: { rotation: i * 45 } }), + rowStyles: [ + { align: { x: 'left', y: 'top' } }, + { align: { x: 'left', y: 'center' } }, + { align: { x: 'left', y: 'bottom' } }, + { align: { x: 'center', y: 'top' } }, + { align: { x: 'center', y: 'center' } }, + { align: { x: 'center', y: 'bottom' } }, + { align: { x: 'right', y: 'top' } }, + { align: { x: 'right', y: 'center' } }, + { align: { x: 'right', y: 'bottom' } }, + { align: { x: 'justify', y: 'top' } }, + { align: { x: 'justify', y: 'center' } }, + { align: { x: 'justify', y: 'bottom' } }, + ], + data: [ + Array(9) + .fill(null) + .map((_, i) => `L,T @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `L,C @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `L,B @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `C,T @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `C,C @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `C,B @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `R,T @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `R,C @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `R,B @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `J,T @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `J,C @${(i * 45).toString().padStart(3, '0')}`), + Array(9) + .fill(null) + .map((_, i) => `J,B @${(i * 45).toString().padStart(3, '0')}`), + ], + }); + }); + }); + + test('line flowing rotated text', function () { + return runDocTest( + { layout: 'landscape', margin: 19 }, + async function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + debug: true, + defaultStyle: { align: 'center' }, + columnStyles: [{ width: 200, textOptions: { rotation: 45 } }], + data: [ + [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent laoreet pulvinar velit, at interdum velit ullamcorper at. Sed a velit vulputate, tristique metus eu, hendrerit nisi. Ut vitae nisl in sapien ultricies commodo et consectetur. nibh. Etiam tempor in orci quis viverra. Ut commodo, purus ac elementum ultricies, diam risus ultricies turpis, ac accumsan orci turpis a libero. Curabitur convallis nisi sed nisi elementum sollicitudin. Aenean eget urna luctus, blandit nulla eget, dapibus neque. Aliquam ut arcu erat.', + ], + ], + }); + }, + ); + }); + + test('multi line rotated text', function () { + return runDocTest( + { layout: 'landscape', margin: 19 }, + async function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + doc.table({ + debug: true, + defaultStyle: { align: 'center', width: 200, height: 200 }, + columnStyles: [{ textOptions: { rotation: 90 } }], + data: [ + [ + 'Hello\nWorld', + ], + ], + }); + }, + ); + }); +});