diff --git a/CHANGELOG.md b/CHANGELOG.md index 438fb666..dc25a482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Fix precision rounding issues in LineWrapper - Add support for dynamic sizing - Add support for rotatable text +- 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..7956e82d --- /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", + { content: "nothing interesting here", textColor: "grey" }, + { content: "nothing interesting here", textColor: "grey" }, + { content: "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, content: "Header with Colspan = 2" }, "Header 3"], + ["Header 1", "Header 2", "Header 3"], + ["Sample value 1", "Sample value 2", "Sample value 3"], + [ + { + rowSpan: 3, + content: "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, + content: "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", content: "border:\n[true, false, false, false]" }, + { border: false, backgroundColor: "#ddd", content: "border:\nfalse" }, + { border: true, backgroundColor: "#eee", content: "border:\ntrue" }, + ], + [ + { rowSpan: 3, border: true, backgroundColor: "#eef", content: "rowSpan: 3\n\nborder:\ntrue" }, + { border: undefined, backgroundColor: "#eee", content: "border:\nundefined (default)" }, + { border: [false, false, false, true], backgroundColor: "#ddd", content: "border:\n[false, false, false, true]" }, + ], + [ + { colSpan: 2, border: true, backgroundColor: "#efe", content: "colSpan: 2\n\nborder:\ntrue" }, + ], + [ + { border: 0, backgroundColor: "#eee", content: "border:\n0 (same as false)" }, + { border: [false, true, true, false], backgroundColor: "#ddd", content: "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", + content: + "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 + +- `content` - The value, will be cast to a string (boolean is converted to `Y/N`, and `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 content (default `{x: 'left', y: 'top'}`) +- `textStroke` - The text stroke (default `0`) +- `textStrokeColor` - Sets the text stroke color of the cells content (default `black`) +- `textColor` - Sets the text color of the cells content (default `black`) +- `type` - Sets the cell type (for accessibility) (default `TD`) +- `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 6afab490..20cb55af 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 { @@ -378,6 +379,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..e8fadac0 --- /dev/null +++ b/lib/mixins/table.js @@ -0,0 +1,11 @@ +import PDFTable from '../table/index'; + +export default { + /** + * @param {Table} [opts] + * @returns {PDFTable} + */ + table(opts) { + return new PDFTable(this, opts); + }, +}; diff --git a/lib/page.js b/lib/page.js index dcea632d..a2f99f46 100644 --- a/lib/page.js +++ b/lib/page.js @@ -149,6 +149,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/index.js b/lib/table/index.js new file mode 100644 index 00000000..6fe7b089 --- /dev/null +++ b/lib/table/index.js @@ -0,0 +1,103 @@ +import { normalizeRow, normalizeTable } from './normalize'; +import { applyHeights, measure } from './size'; +import { renderRow } from './render'; + +class PDFTable { + /** + * @param {PDFDocument} document + * @param {Table} [opts] + */ + constructor(document, opts = {}) { + this.document = document; + this.opts = Object.freeze(opts); + + normalizeTable.call(this); + + this._currRowIndex = 0; + this._ended = false; + + // Render cells if present + if (opts.data) { + if (Symbol.asyncIterator in opts.data) { + return (async () => { + for await (const row of opts.data) { + if (Symbol.asyncIterator in row) await this.rowAsync(row); + else this.row(row); + } + return this.end(); + })(); + } else { + 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} + */ + row(row, lastRow = false) { + if (this._ended) { + 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 + row = normalizeRow.call(this, row, this._currRowIndex); + row = measure.call(this, row, this._currRowIndex); + const { newPage, toRender } = applyHeights.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(); + } else { + this._currRowIndex++; + return this; + } + } + + /** + * Render a new row in the table + * + * @param {AsyncIterable} row - The cells to render + * @param {boolean} lastRow - Whether this row is the last row + * @returns {this} + */ + async rowAsync(row, lastRow = false) { + return this.row(await Array.fromAsync(row), lastRow); + } + + /** + * 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; + return this.document; + } +} + +export default PDFTable; diff --git a/lib/table/normalize.js b/lib/table/normalize.js new file mode 100644 index 00000000..b3287c06 --- /dev/null +++ b/lib/table/normalize.js @@ -0,0 +1,194 @@ +import { deepMerge, definedProps, normalizeSides } from '../utils'; +import { + normalizedColumnStyle, + normalizedDefaultStyle, + normalizedRowStyle, +} from './style'; +import { COLUMN_FIELDS, ROW_FIELDS } from './types'; + +/** + * Normalize a table + * + * @this PDFTable + * @memberOf PDFTable + * @private + */ +export function normalizeTable() { + // Normalize config + this._position = { + x: this.document.sizeToPoint(this.opts.position?.x, this.document.x), + y: this.document.sizeToPoint(this.opts.position?.y, this.document.y), + }; + this._maxWidth = this.document.sizeToPoint( + this.opts.maxWidth, + this.document.page.width - + this.document.page.margins.right - + this._position.x, + ); + + const { defaultStyle, defaultColStyle, defaultRowStyle } = + normalizedDefaultStyle(this.opts.defaultStyle); + this._defaultStyle = defaultStyle; + + let colStyle; + if (this.opts.columnStyles) { + if (Array.isArray(this.opts.columnStyles)) { + colStyle = (i) => this.opts.columnStyles[i]; + } else if (typeof this.opts.columnStyles === 'function') { + colStyle = this.opts.columnStyles; + } else if (typeof this.opts.columnStyles === 'object') { + colStyle = () => this.opts.columnStyles; + } + } + if (!colStyle) colStyle = () => ({}); + this._colStyle = normalizedColumnStyle.bind(this, defaultColStyle, colStyle); + + let rowStyle; + if (this.opts.rowStyles) { + if (Array.isArray(this.opts.rowStyles)) { + rowStyle = (i) => this.opts.rowStyles[i]; + } else if (typeof this.opts.rowStyles === 'function') { + rowStyle = this.opts.rowStyles; + } else if (typeof this.opts.rowStyles === 'object') { + rowStyle = () => this.opts.rowStyles; + } + } + if (!rowStyle) rowStyle = () => ({}); + this._rowStyle = normalizedRowStyle.bind(this, defaultRowStyle, rowStyle); +} + +/** + * Convert content into a string + * - booleans get converted into 'Y'/'N' + * - null and undefined are preserved (as they will be ignored) + * - everything else is run through `String()` + * + * @param {*} content + * @returns {string} + * @private + */ +export function normalizeContent(content) { + // Parse out content + if (typeof content === 'boolean') { + content = content ? 'Y' : 'N'; + } else if (content !== null && content !== undefined) { + content = String(content); + } + return content; +} + +/** + * 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); + + // Initialize cell context + const rollbackFont = this.document._fontSource; + const rollbackFontSize = this.document._fontSize; + const rollbackFontFamily = this.document._fontFamily; + if (font.src) this.document.font(font.src, font.family); + if (font.size) this.document.fontSize(font.size); + + // Refetch rowStyle to reflect font changes + rowStyle = this._rowStyle(rowIndex); + + COLUMN_FIELDS.forEach((f) => delete colStyle[f]); + ROW_FIELDS.forEach((f) => delete rowStyle[f]); + + 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, { + rowIndex, + colIndex, + font, + }); + + // Normalize config + config.content = normalizeContent(config.content); + config.rowSpan = config.rowSpan ?? 1; + config.colSpan = config.colSpan ?? 1; + config.padding = normalizeSides(config.padding, '0.25em', (x) => + this.document.sizeToPoint(x, '0.25em'), + ); + config.border = normalizeSides(config.border, 1, (x) => + this.document.sizeToPoint(x, 1), + ); + config.borderColor = normalizeSides( + config.borderColor, + 'black', + (x) => x ?? 'black', + ); + config.align = + config.align === undefined || typeof config.align === 'string' + ? { x: config.align, y: config.align } + : config.align; + config.align.x = config.align.x ?? 'left'; + config.align.y = config.align.y ?? 'top'; + 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 ?? {}; + + if (this.opts.debug !== undefined) config.debug = this.opts.debug; + + this.document.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 || cell === undefined || typeof cell !== 'object') { + cell = { content: cell }; + } + cell = definedProps(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..67ba4b48 --- /dev/null +++ b/lib/table/render.js @@ -0,0 +1,228 @@ +/** + * Render a cell + * + * @this PDFTable + * @memberOf PDFTable + * @param {SizedNormalizedTableCellStyle[]} row + * @param {number} rowIndex + * @private + */ +export function renderRow(row, rowIndex) { + const rowStruct = this.document.struct('TR'); + row.forEach((cell) => renderCell.call(this, cell, rowStruct)); + this._struct.add(rowStruct); + + 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 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(() => { + // 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) + .fill(cell.backgroundColor) + .restore(); + } + + // 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, + 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 content + if (cell.content) renderCellContent.call(this, cell); + }); +} + +/** + * @this PDFTable + * @memberOf PDFTable + * @param cell + */ +function renderCellContent(cell) { + // Configure fonts + 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); + + const x = cell.contentX; + const y = cell.contentY; + const Ah = cell.contentAllocatedHeight; + const Aw = cell.contentAllocatedWidth; + const Cw = cell.contentWidth; + const Ch = cell.contentHeight; + const Ox = -cell.contentOffsetX; + const Oy = -cell.contentOffsetY; + + 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) { + this.document.save(); + this.document.dash(1, { space: 1 }).lineWidth(1).strokeOpacity(0.3); + + // Debug actual contents bounds + if (cell.content) { + this.document + .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 content bounds + this.document.rect(x, y, Aw, Ah).stroke('orange'); + + this.document.restore(); + } + + // Create content mask to cut off any overflowing text + // Mask cuts off at the padding not the actual cell, this is intentional! + this.document.save().rect(x, y, Aw, Ah).clip(); + + this.document.fillColor(cell.textColor).strokeColor(cell.textStrokeColor); + if (cell.textStroke > 0) this.document.lineWidth(cell.textStroke); + + // Render the text + this.document.text(cell.content, x + dx, y + dy, cell.textOptions); + + // Cleanup + this.document.restore(); + this.document.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) { + const computedBorder = Object.fromEntries( + Object.entries(border).map(([k, v]) => [k, mask && !mask[k] ? 0 : v]), + ); + + if ( + [computedBorder.right, computedBorder.bottom, computedBorder.left].every( + (val) => val === computedBorder.top, + ) + ) { + if (computedBorder.top > 0) { + this.document + .save() + .lineWidth(computedBorder.top) + .rect(x, y, width, height) + .stroke(borderColor.top) + .restore(); + } + } else { + // Top + if (computedBorder.top > 0) { + this.document + .save() + .lineWidth(computedBorder.top) + .polygon([x, y], [x + width, y]) + .stroke(borderColor.top) + .restore(); + } + // Right + if (computedBorder.right > 0) { + this.document + .save() + .lineWidth(computedBorder.right) + .polygon([x + width, y], [x + width, y + height]) + .stroke(borderColor.right) + .restore(); + } + // Bottom + if (computedBorder.bottom > 0) { + this.document + .save() + .lineWidth(computedBorder.bottom) + .polygon([x + width, y + height], [x, y + height]) + .stroke(borderColor.bottom) + .restore(); + } + // Left + if (computedBorder.left > 0) { + this.document + .save() + .lineWidth(computedBorder.left) + .polygon([x, y + height], [x, y]) + .stroke(borderColor.left) + .restore(); + } + } +} diff --git a/lib/table/size.js b/lib/table/size.js new file mode 100644 index 00000000..50970c67 --- /dev/null +++ b/lib/table/size.js @@ -0,0 +1,235 @@ +/** + * Compute the widths of the columns, ensuring to distribute the star widths + * + * @this PDFTable + * @memberOf PDFTable + * @param {NormalizedTableCellStyle[]} row + * @param {number} rowIndex + * + * @returns {PartialSizedNormalizedTableCellStyle[]} + * @private + */ +export function measure(row, rowIndex) { + if (rowIndex === 0) { + ensureColumnWidths.call( + this, + row.reduce((a, cell) => a + cell.colSpan, 0), + ); + } + + return row.map((cell) => { + let cellWidth = 0; + + // Traverse all the columns of the cell + for (let i = 0; i < cell.colSpan; i++) { + cellWidth += this._columnWidths[cell.colIndex + i]; + } + + const contentAllocatedWidth = + cellWidth - cell.padding.left - cell.padding.right; + + let rotation = Math.cos(cell.textOptions.rotation ?? 0); + + const textOptions = { + align: cell.align.x === "justify" ? cell.align.x : undefined, + ellipsis: true, + stroke: cell.textStroke > 0, + fill: true, + // What is the resultant width after the rotation, + // If perfect vertical then we treat this as unbounded + // since we are trying to calculate the height + width: rotation === 0 ? undefined : Math.abs(contentAllocatedWidth / rotation), + ...cell.textOptions, + }; + + let contentWidth = 0; + let contentHeight = 0; + let contentOffsetX = 0; + let contentOffsetY = 0; + if (cell.content) { + // Set content + 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); + + ({ + width: contentWidth, + height: contentHeight, + x: contentOffsetX, + y: contentOffsetY, + } = this.document.boundsOfString( + cell.content, + 0, + 0, + textOptions, + )); + + this.document.font(rollbackFont, rollbackFontFamily, rollbackFontSize); + } + + return { + ...cell, + width: cellWidth, + contentAllocatedWidth, + contentWidth, + contentHeight, + contentOffsetX, + contentOffsetY, + textOptions, + x: this._columnXPos[cell.colIndex], + contentX: this._columnXPos[cell.colIndex] + cell.padding.left, + }; + }); +} + +/** + * Compute the widths of the columns, ensuring to distribute the star widths + * + * @this PDFTable + * @memberOf PDFTable + * @param {number} numCols + * @private + */ +function ensureColumnWidths(numCols) { + /** @type number[] **/ + this._columnWidths = []; + + // 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--; + }); + } + + this._columnXPos = this._columnWidths.map((_, i, a) => + a.slice(0, i).reduce((a, b) => a + b, this._position.x), + ); +} + +/** + * Compute the widths of the columns, ensuring to distribute the star widths + * + * @this PDFTable + * @memberOf PDFTable + * @param {PartialSizedNormalizedTableCellStyle[]} row + * @param {number} rowIndex + * @returns {{newPage: boolean, toRender: SizedNormalizedTableCellStyle[]}} + * @private + */ +export function applyHeights(row, rowIndex) { + if (rowIndex === 0) { + this._rowHeights = []; + this._rowYPos = [this._position.y]; + /** @type {Set} **/ + this._rowBuffer = new Set(); + } + + // 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]; + } + + // Find any finishing cells + const toRender = []; + this._rowBuffer.forEach((cell) => { + if (cell.rowIndex + cell.rowSpan - 1 === rowIndex) { + toRender.push(cell); + this._rowBuffer.delete(cell); + } + }); + + const rowStyle = this._rowStyle(rowIndex); + let rowHeight = rowStyle.height; + + if (rowHeight === 'auto') { + // Compute remaining height on cells + rowHeight = toRender.reduce((acc, cell) => { + let minHeight = + cell.contentHeight + 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; + } + + return { + newPage, + toRender: toRender.map((cell) => { + let height = 0; + for (let i = 0; i < cell.rowSpan; i++) { + height += this._rowHeights[cell.rowIndex + i]; + } + + const contentAllocatedHeight = + height - cell.padding.top - cell.padding.bottom; + return { + ...cell, + textOptions: { ...cell.textOptions, height: contentAllocatedHeight }, + height, + y: this._rowYPos[cell.rowIndex], + contentY: this._rowYPos[cell.rowIndex] + cell.padding.top, + contentAllocatedHeight, + // Clamp the content height to the cell (in situations where content will overflow) + contentHeight: Math.min(contentAllocatedHeight, cell.contentHeight), + }; + }), + }; +} diff --git a/lib/table/style.js b/lib/table/style.js new file mode 100644 index 00000000..9c8a8b57 --- /dev/null +++ b/lib/table/style.js @@ -0,0 +1,170 @@ +import { deepMerge, definedProps, normalizeSides } from '../utils'; +import { COLUMN_FIELDS, ROW_FIELDS } from './types'; + +/** + * 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 = { content: defaultStyle }; + defaultStyle = definedProps(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 = + defaultStyle.align === undefined || typeof defaultStyle.align === 'string' + ? { x: defaultStyle.align, y: defaultStyle.align } + : defaultStyle.align; + + COLUMN_FIELDS.forEach((f) => delete defaultStyle[f]); + ROW_FIELDS.forEach((f) => delete defaultStyle[f]); + + 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 || + rowStyle === undefined || + typeof rowStyle !== 'object' + ) { + rowStyle = { height: rowStyle }; + } + // Normalize + rowStyle.padding = normalizeSides(rowStyle.padding); + rowStyle.border = normalizeSides(rowStyle.border); + rowStyle.borderColor = normalizeSides(rowStyle.borderColor); + rowStyle.align = + rowStyle.align === undefined || typeof rowStyle.align === 'string' + ? { x: rowStyle.align, y: rowStyle.align } + : rowStyle.align; + + // Merge defaults + rowStyle = deepMerge(defaultRowStyle, rowStyle); + + if ( + rowStyle.height === null || + rowStyle.height === undefined || + rowStyle.height === 'auto' + ) { + rowStyle.height = 'auto'; + } else { + rowStyle.height = this.document.sizeToPoint( + rowStyle.height, + 0, + this.document.page, + this.document.page.contentHeight, + ); + } + rowStyle.minHeight = this.document.sizeToPoint( + rowStyle.minHeight, + 0, + this.document.page, + this.document.page.contentHeight, + ); + rowStyle.maxHeight = this.document.sizeToPoint( + rowStyle.maxHeight, + 0, + this.document.page, + this.document.page.contentHeight, + ); + + return definedProps(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 || + colStyle === undefined || + typeof colStyle !== 'object' + ) { + colStyle = { width: colStyle }; + } + // Normalize + colStyle.padding = normalizeSides(colStyle.padding); + colStyle.border = normalizeSides(colStyle.border); + colStyle.borderColor = normalizeSides(colStyle.borderColor); + colStyle.align = + colStyle.align === undefined || typeof colStyle.align === 'string' + ? { x: colStyle.align, y: colStyle.align } + : colStyle.align; + + // Merge defaults + colStyle = deepMerge(defaultColStyle, colStyle); + + if ( + colStyle.width === null || + colStyle.width === undefined || + 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 definedProps(colStyle); +} diff --git a/lib/table/types.js b/lib/table/types.js new file mode 100644 index 00000000..451bd5be --- /dev/null +++ b/lib/table/types.js @@ -0,0 +1,278 @@ +/** + * @template T + * @typedef {function(number): T} Dynamic + */ + +/** + * The value of the contents of a cell + * @typedef {string | number | boolean | null | undefined} TableCellContent + */ + +/** @typedef {Object} TableCellStyle + * + * @property {TableCellContent} [content] + * The content 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 content + * + * 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 content + * + * Defaults to `{x: 'left', y: 'top'}` + * @property {Size} [textStroke] + * Sets the text stroke width of the cells content + * + * Defaults to `0` + * @property {PDFColor} [textStrokeColor] + * Sets the text stroke color of the cells content + * + * Defaults to `black` + * @property {PDFColor} [textColor] + * Sets the text color of the cells content + * + * 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 {boolean} [debug] + * Whether to show the debug lines for the cell + * + * Defaults to `false` + */ +/** @typedef {TableCellContent | 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 content. + * + * `%` 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 {TableCellContent | 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} content + * @property {Font} font + * @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} PartialSizedNormalizedTableCellStyle + * + * @extends {NormalizedTableCellStyle} + * + * @property {number} x + * @property {number} contentX + * @property {number} width + * @property {number} contentWidth + * @property {number} contentAllocatedWidth + * @property {number} contentHeight + */ + +/** + * @typedef {Object} SizedNormalizedTableCellStyle + * + * @extends {PartialSizedNormalizedTableCellStyle} + * + * @property {number} y + * @property {number} contentY + * @property {number} height + * @property {number} contentAllocatedHeight + */ + +/** + * @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 content is higher. + * + * Defaults to `*`. + * @property {DefaultTableCell} [defaultStyle] + * Defaults to apply to every cell + * @property {Iterable> | AsyncIterable | AsyncIterable>} [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 {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']; diff --git a/lib/utils.js b/lib/utils.js index c243ebb4..628ac5bb 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -11,6 +11,41 @@ export function PDFNumber(n) { * @typedef {number | `${number}` | `${number}${'em' | 'in' | 'px' | 'cm' | 'mm' | 'pc' | 'ex' | 'ch' | 'rem' | 'vw' | 'vmin' | 'vmax' | '%' | 'pt'}`} Size */ +/** + * @typedef {Array | string | Array} PDFColor + */ + +/** @typedef {string | Buffer | Uint8Array | ArrayBuffer} PDFFontSource */ + +/** + * @template {Size} [T=Size] + * @typedef {Object} Position + * @property {T} [x] + * The x coordinate + * + * Defaults to the current document position `doc.x` + * @property {T} [y] + * The y coordinate + * + * Defaults to the current document position `doc.y` + */ + +/** + * @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 * @@ -96,3 +131,76 @@ export const CM_TO_IN = 1 / 2.54; // 1CM = 1/2.54 IN export const PX_TO_IN = 1 / 96; // 1 PX = 1/96 IN export const IN_TO_PT = 72; // 1 IN = 72 PT export const PC_TO_PT = 12; // 1 PC = 12 PT + +/** + * @template T + * @param {T} obj + * @returns {T} + */ +export function definedProps(obj) { + return Object.fromEntries( + Object.entries(obj) + .map(([k, v]) => { + if ( + v !== undefined && + v !== null && + typeof v === "object" && + !Array.isArray(v) + ) + return [k, definedProps(v)]; + return [k, v]; + }) + .filter(([, v]) => v !== undefined), + ); +} + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +function isObject(item) { + return item && typeof item === "object" && !Array.isArray(item); +} + +/** + * Deep merge two objects. + * + * @note Modifies target + * + * @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; +} + +/** + * Create a deep copy of a value + * + * @template T + * @param {T} a + * @returns {T} + */ +export function deepClone(a) { + if (a === undefined || a === null || typeof a !== "object") return a; + return JSON.parse(JSON.stringify(a)); +} 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..16c5b446 --- /dev/null +++ b/tests/unit/table.spec.js @@ -0,0 +1,16 @@ +import PDFDocument from "../../lib/document"; +import PDFTable from "../../lib/table"; + +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); + }); +}); diff --git a/tests/unit/utils.spec.js b/tests/unit/utils.spec.js index 9f0fc717..28a4e35c 100644 --- a/tests/unit/utils.spec.js +++ b/tests/unit/utils.spec.js @@ -1,6 +1,11 @@ -import { normalizeSides } from "../../lib/utils"; +import { + deepClone, + deepMerge, + definedProps, + 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 +19,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 +38,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({ @@ -60,3 +59,46 @@ describe("normalizeSides", () => { }); }); }); + +describe('definedProps', () => { + test.each([ + [{}, {}], + [{ a: 'hi' }, { a: 'hi' }], + [{ a: undefined }, {}], + [{ a: undefined, b: 1 }, { b: 1 }], + [{ a: { b: undefined } }, { a: {} }], + [{ a: { b: { c: undefined } } }, { a: { b: {} } }], + ])('%o -> %o', (obj, expected) => { + expect(definedProps(obj)).toEqual(expected); + }); +}); + +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); + }); +}); + +describe('deepClone', () => { + test.each([ + [1], + [true], + ['hello'], + [{ a: 'hello' }], + [{ a: { b: 'hello' } }], + ])('%s', (a) => { + expect(deepClone(a)).toEqual(a); + }); +}); diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-async-iterables-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-async-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-async-iterables-1-snap.png differ 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-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/__image_snapshots__/table-spec-js-table-sync-iterables-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-sync-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-sync-iterables-1-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..de8951d7 --- /dev/null +++ b/tests/visual/table.spec.js @@ -0,0 +1,365 @@ +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', + { content: 'nothing interesting here', textColor: 'grey' }, + { content: 'nothing interesting here', textColor: 'grey' }, + { content: '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, content: 'Header with Colspan = 2' }, 'Header 3'], + ['Header 1', 'Header 2', 'Header 3'], + ['Sample value 1', 'Sample value 2', 'Sample value 3'], + [ + { + rowSpan: 3, + content: + '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, + content: + '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', + content: 'border:\n[true, false, false, false]', + }, + { + border: false, + backgroundColor: '#ddd', + content: 'border:\nfalse', + }, + { + border: true, + backgroundColor: '#eee', + content: 'border:\ntrue', + }, + ], + [ + { + rowSpan: 3, + border: true, + backgroundColor: '#eef', + content: 'rowSpan: 3\n\nborder:\ntrue', + }, + { + border: undefined, + backgroundColor: '#eee', + content: 'border:\nundefined (default)', + }, + { + border: [false, false, false, true], + backgroundColor: '#ddd', + content: 'border:\n[false, false, false, true]', + }, + ], + [ + { + colSpan: 2, + border: true, + backgroundColor: '#efe', + content: 'colSpan: 2\n\nborder:\ntrue', + }, + ], + [ + { + border: 0, + backgroundColor: '#eee', + content: 'border:\n0 (same as false)', + }, + { + border: [false, true, true, false], + backgroundColor: '#ddd', + content: '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', + content: + 'rowSpan: 3\ncolSpan: 3\n\nborder:\n[true, true, true, true]', + }, + ], + ['row 2'], + ['row 3'], + ], + }); + }); + }); + + test('sync 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('async iterables', function () { + return runDocTest(async function (doc) { + doc.font('tests/fonts/Roboto-Italic.ttf'); + + const asyncIterator = (async function* () { + yield ['1', '2']; + yield ['3', '4']; + })(); + + await doc.table({ data: asyncIterator }); + }); + }); + + 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')}`), + ], + }); + }); + }); +});