diff --git a/CHANGELOG.md b/CHANGELOG.md index d115edc3..5904aa1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Update linebreak to 1.1 - Fix measuring text when OpenType features are passed in to .text() - Add support for dynamic sizing +- Add table generation ### [v0.15.2] - 2024-12-15 diff --git a/docs/generate.js b/docs/generate.js index 67e66a6e..c6c2d77d 100644 --- a/docs/generate.js +++ b/docs/generate.js @@ -328,5 +328,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/guide.pdf b/docs/guide.pdf index b2c2c584..a91a1bf3 100644 Binary files a/docs/guide.pdf and b/docs/guide.pdf differ diff --git a/docs/table.md b/docs/table.md new file mode 100644 index 00000000..3b618f7d --- /dev/null +++ b/docs/table.md @@ -0,0 +1,61 @@ +# Tables in PDFKit + +## The basics + +PDFKit makes adding tables to documents quite simple, and includes many options +to customize the display of the output. + +Basic tables can be defined without configuration: + + doc.table() + .row(['Column 1', 'Column 2', 'Column 3']) + .row(['A', 'B', 'C']) + +Here is the output: + +![0]() + +Tables and their cells can be configured with custom styles and fonts and colors + + doc.table({ rows: 2, height: 100, width: 200, defaultCell: { + textColor: 'blue', + align: 'center' + }}) + .row(['Column 1', 'Column 2', 'Column 3'], {backgroundColor: 'pink'}) + .row(['A', {value: 'B', fontSize: 10}, 'C']) + +Here is the output: + +![1]() + +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. + +## Table options + +- `cols` - Number of columns you wish to divide the table into, allowing the width of a cell to be calculated +- `rows` - Number of rows you wish to divide the table into, allowing the height of a cell to be calculated +- `cellHeight` - Height of a cell, If not provided it will compute it based on `height` / `rows` (If neither `rows` nor `cellHeight` is provided, the default of `2em` is used) +- `cellWidth` - Width of a cell, If not provided it will compute it based on `width` / `cols` (If neither cols nor cellWidth is provided, the default of `25%` of the table width is used) +- `x` - Optional positioning of the table +- `y` - Optional positioning of the table +- `width` - The width of the table, undefined for page content width +- `height` - The height of the table, undefined for remaining page content height +- `border` - The thickness of the tables border (Default is 0, so no table border, as the cells render it) +- `borderColor` - The border color of the table +- `defaultCell` - Any config you wish to apply to all cells + +## Cell options + +This extends any of the [text options](text.html#Text-Styling) + +- `colspan` - How many columns this cell covers, follows the same logic as HTML `colspan` +- `rowspan` - How many rows this cell covers, follows the same logic as HTML `rowspan` +- `value` - The text value, will be cast to a string (Note that `null` and `undefined` are not rendered but the cell is still outlined) +- `padding` - The padding for the cell (default `0.25em`) +- `border` - The border for the cell (default `1pt`) +- `borderColor` - The border colors for the cell +- `backgroundColor` - The color of the cell +- `textColor` - The color of the text +- `textStroke` - The text stroke (default `0pt`) +- `textStrokeColor` - The text stroke color 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..6a189d64 --- /dev/null +++ b/lib/mixins/table.js @@ -0,0 +1,7 @@ +import { PDFTable } from "../table"; + +export default { + table(opts = {}) { + return new PDFTable(this, opts); + }, +}; diff --git a/lib/page.js b/lib/page.js index 35f6fc5a..2906bf2e 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.js b/lib/table.js new file mode 100644 index 00000000..fecbd6f6 --- /dev/null +++ b/lib/table.js @@ -0,0 +1,430 @@ +import { normalizeSides } from "./utils"; + +/** + * @typedef {{ + colspan ?: number; + rowspan ?: number; + value ?: any; + padding ?: SideDefinition; + border ?: SideDefinition; + borderColor ?: SideDefinition; + backgroundColor ?: PDFColor; + textColor ?: PDFColor; + textStroke ?: Wideness; + textStrokeColor ?: PDFColor; + align ?: 'center' | { x? : 'left' | 'center' | 'right' | 'justify'; y? : 'top' | 'center' | 'bottom' }; + font ?: string; + fontFamily ?: string; + fontSize ?: Size; + x ?: Size; + y ?: Size; + debug ?: boolean; + }} Cell + */ + +/** + * @typedef {{ + cols ?: number; + rows ?: number; + cellHeight ?: Size; + cellWidth ?: Size; + x ?: Size | undefined; + y ?: Size | undefined; + width ?: Size | undefined; + height ?: Size | undefined; + border ?: SideDefinition; + borderColor ?: SideDefinition; + defaultCell ?: Cell + }} TableOpts + */ + +export class PDFTable { + /** + * + * @param {PDFDocument} document + * @param {TableOpts} opts + */ + constructor(document, opts) { + this.document = document; + this.opts = Object.freeze(opts); + // Normalise + this.x = document.sizeToPoint(opts.x, document.x); + this.y = document.sizeToPoint(opts.y, document.y); + this.width = document.sizeToPoint( + opts.width, + document.page.contentWidth - this.x, + ); + this.height = document.sizeToPoint( + opts.height, + document.page.contentHeight - this.y, + ); + + this.cols = opts.cols; + if (opts.cols !== undefined && opts.cols <= 0) { + throw new Error("cols must be greater than 0"); + } + this.cellWidth = document.sizeToPoint( + opts.cellWidth, + opts.cols ? this.width / opts.cols : this.width / 4, + ); + this.cellHeight = document.sizeToPoint( + opts.cellHeight, + opts.rows ? this.height / opts.rows : "2em", + ); + + if (opts.width === undefined && opts.cols !== undefined) { + this.width = this.cellWidth * opts.cols; + } + + this.border = normalizeSides( + opts.border, + 0, + document.sizeToPoint.bind(document), + ); + this.borderColor = normalizeSides(opts.borderColor); + + this.currCellX = 0; + this.currCellY = 0; + this.cellClaim = new Set(); + } + + _initCellWidth(cols) { + if (this.cols === undefined) { + this.cols = cols; + if (this.opts.cellWidth === undefined) { + this.cellWidth = this.width / cols; + } + if (this.opts.width === undefined) this.width = this.cellWidth * cols; + } + } + + /** + * Draws a row of cells to the table + * + * @example + * ``` + * doc.table() + * .row(['A', 'B', 'C']) + * .row(['D', 'E', 'F']) + * ``` + * would render a 3x2 table + * + * | A | B | C | + * | --- | --- | --- | + * | D | E | F | + * + * @param cells - The cells to render + * @param defaultCell - Any config you wish to apply to all cells in this row + */ + row(cells, defaultCell = {}) { + // If you haven't provided any 'cols' indication, + // then we will use the first non-empty row to infer it (assuming it's an array) else it will use the default + if (Array.isArray(cells)) { + const colspan = cells.reduce((acc, cell) => { + const cellColspan = + cell === null || cell === undefined || typeof cell !== "object" + ? undefined + : cell.colspan; + + return ( + acc + + Math.max( + 1, + Math.floor( + cellColspan ?? + defaultCell.colspan ?? + this.opts.defaultCell?.colspan ?? + 1, + ), + ) + ); + }, 0); + if (colspan > 0) this._initCellWidth(colspan); + } + + const startY = this.currCellY; + this.currCellX = 0; + + let maxY = this.y; + + for (let cell of cells) { + if (cell === null || cell === undefined || typeof cell !== "object") { + cell = { value: cell }; + } + cell = { + rowspan: 1, + colspan: 1, + ...this.opts.defaultCell, + ...defaultCell, + ...cell, + }; + + // spanning can only be integer + cell.rowspan = Math.max(1, Math.floor(cell.rowspan)); + cell.colspan = Math.max(1, Math.floor(cell.colspan)); + + // Find first available cell + while (this.cellClaim.has(`${this.currCellX},${this.currCellY}`)) { + this.currCellX++; + if (this.cols && this.currCellX >= this.cols) { + this.currCellX = 0; + this.currCellY++; + } + } + + maxY = Math.max(maxY, this._renderCell(cell)); + + // Claim any spanning cells + for (let i = 0; i < cell.colspan; i++) { + for (let j = 0; j < cell.rowspan; j++) { + if (i !== 0 || j !== 0) + this.cellClaim.add(`${this.currCellX + i},${this.currCellY + j}`); + } + } + // Move to next cell + this.currCellX++; + } + + this.currCellY++; + + // Draw borders + this._renderBorder( + this.border, + this.borderColor, + this.x, + this.y + startY * this.cellHeight, + this.width, + maxY - (this.y + startY * this.cellHeight), + { top: startY === 0, right: true, bottom: false, left: true }, + ); + + // Move cursor to the bottom left of the row + this.document.x = this.x; + this.document.y = maxY; + this.document.moveTo(this.document.x, this.document.y); + + return this; + } + + /** + * Indicates to the table that it is finished + * + * so that it can do any cleanup such as drawing the bottom border + * + * Not strictly required to call but may leave your table in an undesirable state + * + * @returns the document + */ + end() { + // Draw bottom border + this._renderBorder( + this.border, + this.borderColor, + this.x, + this.document.y, + this.width, + 0, + { + top: false, + right: false, + bottom: true, + left: false, + }, + ); + + return this.document; + } + + _renderCell({ + border, + borderColor, + padding, + align, + fontSize, + textStroke, + textColor, + textStrokeColor, + backgroundColor, + value, + colspan, + rowspan, + font, + fontFamily, + debug, + x, + y, + ...cell + }) { + // Set font temporarily + const rollbackFont = this.document._fontSource; + const rollbackFontSize = this.document._fontSize; + const rollbackFontFamily = this.document._fontFamily; + if (font) this.document.font(font, fontFamily); + if (fontSize) this.document.fontSize(fontSize); + + // Normalize options + border = normalizeSides( + border, + 1, + this.document.sizeToPoint.bind(this.document), + ); + borderColor = normalizeSides(borderColor, undefined); + padding = normalizeSides( + padding, + "0.25em", + this.document.sizeToPoint.bind(this.document), + ); + align = + align === undefined || typeof align === "string" + ? { x: align, y: align } + : align; + textStroke = this.document.sizeToPoint(textStroke); + + // Default alignment + if (align.x === undefined) align.x = "left"; + if (align.y === undefined) align.y = "center"; + + if (typeof value === "boolean") value = value ? "\u2713" : "\u2715"; + if (value !== null && value !== undefined) value = String(value); + + // Render the cell borders + const rectHeight = this.cellHeight * rowspan; + const rectWidth = this.cellWidth * colspan; + const posX = this.document.sizeToPoint( + x, + this.x + this.currCellX * this.cellWidth, + ); + const posY = this.document.sizeToPoint( + y, + this.y + this.currCellY * this.cellHeight, + ); + + if (backgroundColor !== undefined) { + this.document + .save() + .rect(posX, posY, rectWidth, rectHeight) + .fill(backgroundColor) + .restore(); + } + this._renderBorder(border, borderColor, posX, posY, rectWidth, rectHeight); + + // Render text + + // Compute bounds of text + const textRectWidth = rectWidth - padding.left - padding.right; + const textRectHeight = rectHeight - padding.top - padding.bottom; + + const textOptions = { + align: align.x, + ellipsis: true, + lineBreak: false, + stroke: textStroke > 0, + fill: true, + ...cell, + width: textRectWidth, + height: textRectHeight, + }; + + // Compute actual position of text based on alignment + const textHeight = this.document.heightOfString(value ?? "", textOptions); + const yOffset = + (textRectHeight - textHeight) * + (align.y === "bottom" ? 1 : align.y === "center" ? 0.5 : 0); + + const textPosX = posX + padding.left; + const textPosY = posY + padding.top; + + // Debug viewer + if (debug) { + this.document + .save() + .dash(1, { space: 1 }) + .lineWidth(1) + .strokeOpacity(0.3); + // Debug text bounds + if (value?.length) + this.document + .rect(textPosX, textPosY + yOffset, textRectWidth, textHeight) + .stroke("red"); + // Debug text allocated space + this.document + .rect(textPosX, textPosY, textRectWidth, textRectHeight) + .stroke("blue"); + this.document.restore(); + } + + if (value?.length) { + this.document.save(); + if (textColor !== undefined) this.document.fillColor(textColor); + if (textStroke > 0) this.document.lineWidth(textStroke); + if (textStrokeColor !== undefined) + this.document.strokeColor(textStrokeColor); + this.document.text(value, textPosX, textPosY + yOffset, textOptions); + this.document.restore(); + } + if (font || fontSize) { + this.document.font(rollbackFont, rollbackFontFamily, rollbackFontSize); + } + + // Return bottom Y position of cell + return posY + rectHeight; + } + + _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); + if (borderColor.top) this.document.strokeColor(borderColor.top); + this.document.stroke().restore(); + } + } else { + // Top + if (computedBorder.top > 0) { + this.document + .save() + .lineWidth(computedBorder.top) + .polygon([x, y], [x + width, y]); + if (borderColor.top) this.document.strokeColor(borderColor.top); + this.document.stroke().restore(); + } + // Right + if (computedBorder.right > 0) { + this.document + .save() + .lineWidth(computedBorder.right) + .polygon([x + width, y], [x + width, y + height]); + if (borderColor.right) this.document.strokeColor(borderColor.right); + this.document.stroke().restore(); + } + // Bottom + if (computedBorder.bottom > 0) { + this.document + .save() + .lineWidth(computedBorder.bottom) + .polygon([x + width, y + height], [x, y + height]); + if (borderColor.bottom) this.document.strokeColor(borderColor.bottom); + this.document.stroke().restore(); + } + // Left + if (computedBorder.left > 0) { + this.document + .save() + .lineWidth(computedBorder.left) + .polygon([x, y + height], [x, y]); + if (borderColor.left) this.document.strokeColor(borderColor.left); + this.document.stroke().restore(); + } + } + } +} diff --git a/tests/unit/table.spec.js b/tests/unit/table.spec.js new file mode 100644 index 00000000..9beb833b --- /dev/null +++ b/tests/unit/table.spec.js @@ -0,0 +1,17 @@ +import PDFDocument from "../../lib/document"; +import { PDFTable } from "../../lib/table"; + +describe("table", () => { + test("created", () => { + const document = new PDFDocument(); + const table = document.table(); + + expect(table).toBeInstanceOf(PDFTable); + }); + test("row", () => { + const document = new PDFDocument(); + const table = document.table(); + table.row(["A", "B", "C"]); + expect(table.cols).toBe(3); + }); +}); diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-coloring-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-coloring-1-snap.png new file mode 100644 index 00000000..e347da28 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-coloring-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..8b65529c 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-skip-borders-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-skip-borders-1-snap.png new file mode 100644 index 00000000..d7166622 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-skip-borders-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-spanning-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-spanning-1-snap.png new file mode 100644 index 00000000..a7798ab1 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-spanning-1-snap.png differ diff --git a/tests/visual/__image_snapshots__/table-spec-js-table-with-font-changes-1-snap.png b/tests/visual/__image_snapshots__/table-spec-js-table-with-font-changes-1-snap.png new file mode 100644 index 00000000..62530ca2 Binary files /dev/null and b/tests/visual/__image_snapshots__/table-spec-js-table-with-font-changes-1-snap.png differ diff --git a/tests/visual/table.spec.js b/tests/visual/table.spec.js new file mode 100644 index 00000000..f7a64301 --- /dev/null +++ b/tests/visual/table.spec.js @@ -0,0 +1,62 @@ +import { runDocTest } from "./helpers"; + +describe("table", function () { + test("simple table", function () { + return runDocTest(function (doc) { + doc.font("tests/fonts/Roboto-Italic.ttf"); + doc + .table() + .row(["Column 1", "Column 2", "Column 3"]) + .row(["One value goes here", "Another one here", "OK?"]); + }); + }); + + test("with font changes", function () { + return runDocTest(function (doc) { + doc.font("tests/fonts/Roboto-Italic.ttf"); + doc + .table() + .row(["Column 1", "Column 2", "Column 3"], { font: "tests/fonts/Roboto-Medium.ttf" }) + .row(["One value goes here", "Another one here", "OK?"], { + font: "tests/fonts/Roboto-Regular.ttf", + }); + doc.text("Italic text"); + }); + }); + + test("spanning", function () { + return runDocTest(function (doc) { + doc.font("tests/fonts/Roboto-Italic.ttf"); + doc + .table() + .row([ + { value: "colspan: 2", colspan: 2 }, + { value: "rowspan: 2", rowspan: 2 }, + ]) + .row(["Col 1", { value: "Col 2" }]) + .row([{ value: "colspan: 3\nrowspan: 3", colspan: 3, rowspan: 3 }]); + }); + }); + + test("coloring", function () { + return runDocTest(function (doc) { + doc.font("tests/fonts/Roboto-Italic.ttf"); + doc + .table() + .row([{ value: "Header 1" }, { value: "Header 2" }], { + backgroundColor: "lightgrey", + }) + .row(["Col 1", "Col 2"]); + }); + }); + + test("skip borders", function () { + return runDocTest(function (doc) { + doc.font("tests/fonts/Roboto-Italic.ttf"); + doc + .table({ border: 1 }) + .row([{ value: "Column 1" }, { value: "Column 2" }], { border: false }) + .end(); + }); + }); +});