pdfkit-table
Version:
PdfKit Table. Helps to draw informations in simple tables using pdfkit. #server-side. Generate pdf tables with TypeScript / JavaScript (PDFKIT plugin)
856 lines • 60.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createPdfDocumentWithTables = createPdfDocumentWithTables;
/**
* Builds the PDFDocument subclass extended with `.table()` / `.tables()`,
* using the PDFKit constructor you supply (fork, patched build, or version
* pinned in your own app).
*
* @example
* ```ts
* import PDFKit from 'pdfkit';
* import { createPdfDocumentWithTables } from 'pdfkit-table';
* const PDF = createPdfDocumentWithTables(PDFKit);
* const doc = new PDF({ compress: false });
* ```
*/
function createPdfDocumentWithTables(PdfKitDocument) {
class PDFDocumentWithTables extends PdfKitDocument {
constructor(option) {
super(option);
this.opt = option;
}
logg(..._args) {
// console.log(_args);
}
// -----------------------------------------------------------------------
// addBackground
// -----------------------------------------------------------------------
addBackground({ x, y, width, height }, fillColor, fillOpacity, callback) {
fillColor ||= "grey";
fillOpacity ||= 0.1;
this.save();
this.fill(fillColor)
.fillOpacity(fillOpacity)
.rect(x, y, width, height)
.fill();
this.restore();
typeof callback === "function" && callback(this);
}
// -----------------------------------------------------------------------
// checkPageBreak
// -----------------------------------------------------------------------
/**
* Checks whether the remaining vertical space on the current page is less
* than `minHeight` (default: 10% of the usable page height) and, if so,
* adds a new page — preventing orphaned titles or section headers at the
* very bottom of a page.
*
* @example
* ```ts
* doc.checkPageBreak(); // default: 10% of page height
* doc.checkPageBreak(80); // at least 80pt remaining
* doc.checkPageBreak(0.15); // at least 15% of page height
*
* doc.checkPageBreak().fontSize(11).text("Section title");
* await doc.table({ ... });
* ```
*/
checkPageBreak(minHeight) {
const usableHeight = this.page.height - this.page.margins.top - this.page.margins.bottom;
// If minHeight is a fraction ≤ 1, treat it as a percentage of usable height.
const threshold = minHeight === undefined
? usableHeight * 0.1
: minHeight <= 1
? usableHeight * minHeight
: minHeight;
const remaining = this.page.height - this.page.margins.bottom - this.y;
if (remaining < threshold) {
this.addPage({
layout: this.page.layout,
size: this.page.size,
margins: this.page.margins,
});
}
return this;
}
// -----------------------------------------------------------------------
// table
// -----------------------------------------------------------------------
/**
* Renders a single table.
* Signature is backward-compatible with older releases:
* `table(table, callback)`
* `table(table, options)`
* `table(table, options, callback)`
*/
// pdfkit exposes Mixins.PDFTable.table(); this subclass replaces it at runtime.
// @ts-expect-error — intentionally incompatible with pdfkit's PDFTable mixin signature
table(table, options, callback) {
return new Promise((resolve, reject) => {
try {
// ------------------------------------------------------------------
// Normalize arguments
// ------------------------------------------------------------------
let incomingOpts;
let resolvedCallback;
if (typeof options === "function") {
resolvedCallback = options;
incomingOpts = undefined;
}
else {
incomingOpts = options;
resolvedCallback = callback;
}
const docTable = typeof table === "string"
? JSON.parse(table)
: (table ?? {});
/** Mutable options object used throughout rendering */
const opts = { ...(incomingOpts ?? {}) };
docTable.headers ||= [];
docTable.rows ||= [];
/** Object-shaped rows: `data` wins when defined; otherwise legacy `datas` */
const tableData = docTable.data !== undefined
? docTable.data
: (docTable.datas ?? []);
if (docTable.options)
Object.assign(opts, docTable.options);
// ------------------------------------------------------------------
// Option defaults
// ------------------------------------------------------------------
opts.hideHeader ||= false;
opts.padding ||= 0;
opts.columnsSize ||= [];
opts.addPage ||= false;
opts.absolutePosition ||= false;
opts.minRowHeight ||= 0;
opts.divider ||= {};
opts.divider.header ||= {
disabled: false,
width: undefined,
opacity: undefined,
};
opts.divider.horizontal ||= {
disabled: false,
width: undefined,
opacity: undefined,
};
opts.divider.vertical ||= {
disabled: true,
width: undefined,
opacity: undefined,
};
if (!docTable.headers.length)
throw new Error("Headers not defined. Use options: hideHeader to hide.");
if (opts.useSafelyMarginBottom === undefined)
opts.useSafelyMarginBottom = true;
// keepRowsTogether: true disables all proactive page breaks.
if (opts.keepRowsTogether)
opts.useSafelyMarginBottom = false;
// pageBreakThreshold: fraction of page height below which a row
// triggers a page break.
// Default 0.8 — rows taller than 80 % of the page start in-place
// and let PDFKit handle overflow; only explicit addPage() calls
// (or keepRowsTogether/useSafelyMarginBottom) force a page change.
const pageBreakThreshold = opts.keepRowsTogether
? 0
: Math.min(1, Math.max(0, opts.pageBreakThreshold ?? 0.8));
const title = docTable.title ?? opts.title ?? "";
const subtitle = docTable.subtitle ?? opts.subtitle ?? "";
// ------------------------------------------------------------------
// Layout variables
// ------------------------------------------------------------------
const columnSpacing = opts.columnSpacing || 3;
let columnSizes = [];
let columnPositions = [];
let columnWidth = 0;
const rowDistance = 0.5;
let cellPadding = {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
let tableWidth = 0;
const maxY = this.page.height - this.page.margins.bottom;
let startX = opts.x || this.x || this.page.margins.left;
let startY = opts.y || this.y || this.page.margins.top;
let lastPositionX = 0;
let rowBottomY = 0;
let titleHeight = 0;
this.headerHeight = 0;
let firstLineHeight = 0;
this.datasIndex = 0;
this.rowsIndex = 0;
let lockAddTitles = false;
let lockAddPage = false;
let lockAddHeader = false;
// Safety buffer before the bottom margin.
// Use the document's own bottom margin so rows never start
// if they won't fit with at least that much breathing room.
const safelyMarginBottom = this.page.margins.bottom;
/**
* Original page top margin saved once so we can restore it after
* temporarily raising it to push PDFKit's text-continuation point
* below the repeated header on overflow pages.
*/
const origMarginTop = this.page.margins.top;
if (opts.x === null || opts.x === -1) {
startX = this.page.margins.left;
}
// ------------------------------------------------------------------
// Inner helpers
// ------------------------------------------------------------------
const prepareHeader = () => {
if (opts.prepareHeader) {
opts.prepareHeader.call(this);
return;
}
this.fillColor("black").font("Helvetica-Bold").fontSize(8).fill();
};
const prepareRow = (row, indexColumn, indexRow, rectRow, rectCell) => {
if (opts.prepareRow) {
opts.prepareRow.call(this, row, indexColumn, indexRow, rectRow, rectCell);
return;
}
this.fillColor("black").font("Helvetica").fontSize(8).fill();
};
/**
* Convert a serialised renderer string to a callable function.
*
* `eval()` is avoided because it is blocked by strict CSP policies and
* grants access to the enclosing scope. `new Function()` creates an
* isolated function scope and works in more environments.
*
* @deprecated Pass a real `CellRenderer` function instead of a string.
* String renderers will be removed in a future major version.
*/
const fEval = (str) => {
try {
const factory = new Function("value", "indexColumn", "indexRow", "row", "rectRow", "rectCell", `"use strict"; return (${str})(value, indexColumn, indexRow, row, rectRow, rectCell);`);
return factory;
}
catch {
console.warn("[pdfkit-table] renderer string could not be compiled; falling back to empty string. " +
"Pass a CellRenderer function directly instead of a string.");
return () => "";
}
};
const separationsRow = (type, x, y, width, opacity, color) => {
type ||= "horizontal";
const d = rowDistance * 1.5;
const m = opts.x || this.page.margins.left || 30;
const dividerCfg = opts.divider?.[type] ?? {};
if (dividerCfg.disabled)
return;
opacity = opacity ?? dividerCfg.opacity ?? 0.5;
width = width ?? dividerCfg.width ?? 0.5;
color = color ?? dividerCfg.color ?? "black";
this.moveTo(x, y - d)
.lineTo(x + tableWidth - m, y - d)
.lineWidth(width)
.strokeColor(color)
.opacity(opacity)
.stroke()
.opacity(1);
};
const prepareCellPadding = (p) => {
let arr;
if (Array.isArray(p)) {
switch (p.length) {
case 3:
arr = [p[0], p[1], p[2], p[1]];
break; // top, right, bottom, left=right
case 2:
arr = [p[0], p[1], p[0], p[1]];
break; // top+bottom, right+left
case 1:
arr = Array(4).fill(p[0]);
break;
default:
arr = p;
}
}
else if (typeof p === "number") {
arr = Array(4).fill(p);
}
else if (typeof p === "object" && p !== null) {
const { top = 0, right = 0, bottom = 0, left = 0, } = p;
arr = [top, right, bottom, left];
}
else {
arr = Array(4).fill(0);
}
return {
top: arr[0] >> 0,
right: arr[1] >> 0,
bottom: arr[2] >> 0,
left: arr[3] >> 0,
};
};
/**
* Resolve the effective padding for a cell, respecting explicit `0`.
*
* The plain `||` operator treats `0` as falsy, so `padding: 0` on a
* header column would silently fall through to `opts.padding`.
* This helper uses `!== undefined` so an explicitly-set `0` wins
* over the global option.
*
* Priority: per-column `padding` > `opts.padding` > 0
*/
const resolvePadding = (columnPadding) => columnPadding !== undefined ? columnPadding : (opts.padding ?? 0);
/**
* Apply per-row font/color overrides declared in `row.options`.
*/
const prepareRowOptions = (row) => {
if (typeof row !== "object" ||
row === null ||
!Object.prototype.hasOwnProperty.call(row, "options"))
return;
const { fontFamily, fontSize, color } = row.options ?? {};
fontFamily && this.font(fontFamily);
fontSize && this.fontSize(fontSize);
color && this.fillColor(color);
};
/**
* Draw a colored background behind a row or cell.
*/
const prepareRowBackground = (row, rect) => {
if (typeof row !== "object" || row === null)
return;
const effective = Object.prototype.hasOwnProperty.call(row, "options") &&
typeof row.options === "object" &&
row.options !== null
? row.options
: row;
let fill;
let opac;
if (Object.prototype.hasOwnProperty.call(effective, "columnColor")) {
fill = effective.columnColor;
opac = effective.columnOpacity;
}
else if (Object.prototype.hasOwnProperty.call(effective, "backgroundColor")) {
fill = effective.backgroundColor;
opac = effective.backgroundOpacity;
}
else if (Object.prototype.hasOwnProperty.call(effective, "background") &&
typeof effective.background === "object" &&
effective.background !== null) {
const bg = effective.background;
fill = bg.color;
opac = bg.opacity;
}
fill && this.addBackground(rect, fill, opac);
};
const computeRowHeight = (row, isHeader) => {
let result = isHeader ? 0 : opts.minRowHeight || 0;
const cells = Array.isArray(row)
? row
: docTable.headers.reduce((acc, hdr) => {
const property = typeof hdr === "object" ? hdr.property : undefined;
acc.push(property !== undefined
? row[property]
: undefined);
return acc;
}, []);
cells.forEach((cell, i) => {
let text;
if (typeof cell === "object" && cell !== null) {
text = String(cell.label);
Object.prototype.hasOwnProperty.call(cell, "options") &&
prepareRowOptions(cell);
}
else {
text = String(cell ?? "");
}
text = text.replace("bold:", "").replace("size", "");
const hdr = docTable.headers[i];
const cellp = prepareCellPadding(resolvePadding(typeof hdr === "object" ? hdr.padding : undefined));
const cellHeight = this.heightOfString(text, {
width: columnSizes[i] - (cellp.left + cellp.right),
align: "left",
});
// FIX: include vertical padding in the row height calculation
// for both header and data rows.
result = Math.max(result, cellHeight + cellp.top + cellp.bottom);
});
return result + columnSpacing;
};
// ------------------------------------------------------------------
// Column sizing
// ------------------------------------------------------------------
const calcColumnSizes = () => {
let h = [];
let p = [];
let w = this.page.width -
this.page.margins.right -
(opts.x || this.page.margins.left);
if (opts.width) {
w =
parseInt(String(opts.width), 10) ||
Number(String(opts.width).replace(/[^0-9]/g, "")) >> 0;
}
// ------------------------------------------------------------------
// Flexible column sizing: null | undefined | '*' in columnsSize or
// header.width means "fill the remaining available width equally".
//
// Examples:
// columnsSize: [50, 300, null] → last col fills remainder
// columnsSize: [50, '*', '*', 20, null] → three cols share space
// ------------------------------------------------------------------
/** Returns true when a column-size entry is a flex placeholder. */
const isFlex = (v) => v === null || v === undefined || v === "*";
// Build raw size list from headers (object form) or opts.columnsSize.
// We allow null/undefined/'*' at this stage; they are resolved below.
const rawSizes = (() => {
// Priority 1: object headers with explicit width
const fromHeaders = docTable.headers.map((el) => typeof el === "object" && "width" in el
? el.width
: undefined);
if (fromHeaders.some((v) => v !== undefined))
return fromHeaders;
// Priority 2: opts.columnsSize (may contain flex markers)
if (opts.columnsSize && opts.columnsSize.length > 0)
return opts.columnsSize;
// Priority 3: no sizing info — every column is flex
return docTable.headers.map(() => undefined);
})();
// Count flex slots and sum of fixed widths.
const fixedSum = rawSizes.reduce((acc, v) => acc + (isFlex(v) ? 0 : v), 0);
const flexCount = rawSizes.filter(isFlex).length;
const flexWidth = flexCount > 0 ? Math.max(0, w - fixedSum) / flexCount : 0;
// Resolve final numeric sizes.
h = rawSizes.map((v) => (isFlex(v) ? flexWidth : v));
if (h.length === 0) {
columnWidth = w / docTable.headers.length;
docTable.headers.forEach(() => h.push(columnWidth));
}
// Store the representative columnWidth for callers that use it
// for cells without an explicit header width.
columnWidth =
h.length > 0 ? h.reduce((s, v) => s + v, 0) / h.length : w;
if (opts.rtl) {
// RTL: column positions go right-to-left.
// Mirror of LTR: LTR anchors its left edge at startX (= opts.x ?? margin.left)
// and grows rightward by summing column widths.
// RTL anchors its RIGHT edge at startX + sum(columnWidths) and grows leftward.
// When opts.width is set, it overrides the total column width (same as LTR).
const rtlStartX = opts.x ?? this.page.margins.left;
const totalW = opts.width
? parseInt(String(opts.width), 10) ||
Number(String(opts.width).replace(/[^0-9]/g, "")) >> 0
: h.reduce((s, v) => s + v, 0);
const rightEdge = rtlStartX + totalW;
h.reduce((prev, curr) => {
const colStart = prev - curr;
p.push(colStart >> 0);
return colStart;
}, rightEdge);
if (h.length)
columnSizes = h;
if (p.length)
columnPositions = p;
// tableWidth used by separationsRow — rightEdge as absolute x coordinate
tableWidth = rightEdge;
}
else {
h.reduce((prev, curr) => {
p.push(prev >> 0);
return prev + curr;
}, opts.x || this.page.margins.left);
if (h.length)
columnSizes = h;
if (p.length)
columnPositions = p;
w = p[p.length - 1] + h[h.length - 1];
if (w)
tableWidth = w;
}
};
calcColumnSizes();
// Total rendered width = sum of all column widths.
// Computed after calcColumnSizes() so columnSizes is populated.
const totalColumnsWidth = columnSizes.reduce((s, w) => s + w, 0);
// ------------------------------------------------------------------
// Title helper
// ------------------------------------------------------------------
const createTitle = (data, size, opacity) => {
if (!data)
return;
if (typeof data === "string") {
this.fillColor("black").fontSize(size).opacity(opacity).fill();
this.text(data, startX, startY).opacity(1);
startY = this.y + columnSpacing + 2;
}
else if (typeof data === "object") {
data.fontFamily && this.font(data.fontFamily);
data.label &&
this.fillColor(data.color || "black")
.fontSize(data.fontSize || size)
.text(data.label, startX, startY)
.fill();
startY = this.y + columnSpacing + 2;
}
};
// ------------------------------------------------------------------
// Header rendering (forward-declared so onFirePageAdded can call it)
// ------------------------------------------------------------------
const addHeader = () => {
prepareHeader();
if (this.headerHeight === 0) {
this.headerHeight = computeRowHeight(docTable.headers, true);
}
if (firstLineHeight === 0) {
if (tableData.length > 0)
firstLineHeight = computeRowHeight(tableData[0], true);
if (docTable.rows.length > 0)
firstLineHeight = computeRowHeight(docTable.rows[0], true);
}
titleHeight = !lockAddTitles ? 24.1 : 0;
const calc = startY +
titleHeight +
firstLineHeight +
this.headerHeight +
safelyMarginBottom;
if (firstLineHeight > maxY) {
lockAddPage = true;
}
else if (calc > maxY) {
addNewPage();
return;
}
if (!lockAddTitles) {
createTitle(title, 12, 1);
createTitle(subtitle, 9, 0.7);
if (title || subtitle)
startY += 3;
}
prepareHeader();
lockAddTitles = true;
if (opts.absolutePosition) {
lastPositionX = opts.x || startX || this.x;
startY = opts.y || startY || this.y;
}
else {
lastPositionX = opts.rtl ? columnPositions[0] : startX;
}
if (!opts.hideHeader && docTable.headers.length > 0) {
if (typeof docTable.headers[0] === "string") {
docTable.headers.forEach((header, i) => {
// RTL: use pre-computed column position directly
const cellX = opts.rtl ? columnPositions[i] : lastPositionX;
const rectCell = {
x: cellX,
y: startY - columnSpacing - rowDistance * 2,
width: columnSizes[i],
height: this.headerHeight + columnSpacing,
};
this.addBackground(rectCell);
cellPadding = prepareCellPadding(opts.padding || 0);
this.text(String(header), cellX + cellPadding.left, startY + cellPadding.top, {
width: Number(columnSizes[i]) -
(cellPadding.left + cellPadding.right),
align: opts.rtl ? "right" : "left",
});
if (!opts.rtl)
lastPositionX += columnSizes[i] >> 0;
});
}
else {
docTable.headers.forEach((dataHeader, i) => {
const dh = dataHeader;
let { label, width, renderer, align, headerColor, headerOpacity, headerAlign, padding, } = dh;
width =
((typeof width === "number" ? width : undefined) ||
columnSizes[i]) >> 0;
// RTL: default align to right unless explicitly set
align = headerAlign || align || (opts.rtl ? "right" : "left");
if (renderer && typeof renderer === "string") {
dh.renderer = fEval(renderer);
}
// RTL: use pre-computed column position directly
const cellX = opts.rtl ? columnPositions[i] : lastPositionX;
const rectCell = {
x: cellX,
y: startY - columnSpacing - rowDistance * 2,
width,
height: this.headerHeight + columnSpacing,
};
// headerColor/headerOpacity are the primary header background.
// backgroundColor/background on the header object also apply
// to the header row (as well as to data cells in that column).
this.addBackground(rectCell, headerColor, headerOpacity);
prepareRowBackground(dh, rectCell);
cellPadding = prepareCellPadding(resolvePadding(padding));
this.text(String(label ?? ""), cellX + cellPadding.left, startY + cellPadding.top, {
width: width - (cellPadding.left + cellPadding.right),
align: align,
});
if (!opts.rtl)
lastPositionX += width;
});
}
prepareRowOptions(docTable.headers);
}
if (!opts.hideHeader) {
// Always compute the header's bottom from startY (the current page's
// header top), never from the previous rowBottomY which may belong
// to a different page and would place the divider line in the wrong
// position when addHeader() is called from onFirePageAdded during
// a mid-row overflow.
rowBottomY = startY + computeRowHeight(docTable.headers, true);
separationsRow("header", startX, rowBottomY);
}
else {
rowBottomY = startY;
}
};
// ------------------------------------------------------------------
// Page management
// ------------------------------------------------------------------
/**
* Add a new page with the same layout/margins as the current one.
*/
const addNewPage = () => {
if (lockAddPage)
return;
lockAddPage = true;
this.addPage({
layout: this.page.layout,
size: this.page.size,
margins: this.page.margins,
});
lockAddPage = false;
};
let handlingPageAdded = false;
let pageAddedCount = 0;
let restoreRowStyle = null;
const onFirePageAdded = () => {
if (handlingPageAdded)
return;
handlingPageAdded = true;
pageAddedCount++;
this.page.margins.top = origMarginTop;
startY = origMarginTop;
// Only reset rowBottomY when we are NOT mid-cell (restoreRowStyle
// is null between rows). During a natural overflow of a tall cell,
// restoreRowStyle is set; resetting rowBottomY here would lose the
// row's start position and produce a huge blank gap after the row.
if (restoreRowStyle === null) {
// Normal inter-row page break: reset and draw header.
rowBottomY = 0;
lockAddHeader || addHeader();
}
else {
// Mid-row overflow: draw header but preserve rowBottomY.
// The row-finishing block will anchor it to this.y after text().
lockAddHeader || addHeader();
// Push page.margins.top below the header so PDFKit continues the
// overflowing text beneath it instead of behind it.
// cellPadding is captured via closure from the active cell loop —
// add cellPadding.top so the continued text respects the same
// top padding that was applied on the first page of the cell.
const headerBottom = this.y + columnSpacing * 2 + cellPadding.top;
this.page.margins.top = headerBottom;
this.y = headerBottom;
this.fillColor("black");
restoreRowStyle();
}
handlingPageAdded = false;
};
// Register listener before any rendering begins.
this.on("pageAdded", onFirePageAdded);
if (opts.addPage === true)
addNewPage();
else
addHeader();
// ------------------------------------------------------------------
// Data rows (Table.data / legacy Table.datas)
// ------------------------------------------------------------------
tableData.forEach((row, i) => {
this.datasIndex = i;
const _mr = { x: 0, y: 0, width: 0, height: 0 };
prepareRow(row, 0, i, _mr, _mr);
const rowHeight = computeRowHeight(row, false);
// ----------------------------------------------------------------
// Proactive page-break logic:
//
// The decision is based solely on WHERE THE CURSOR IS NOW,
// not on the total height of the row. A tall row (multi-page
// text) should start on a new page only if the cursor is already
// in the last endOfPageThreshold% of the page — otherwise let
// PDFKit render and break naturally across pages.
//
// Rule:
// • cursor is in the last 10% of usable height → break
// • cursor is in the first 90% → always continue (even for
// tall rows that will overflow — PDFKit handles that)
//
// Rows taller than pageBreakThreshold always flow naturally —
// a proactive break would produce a blank gap before them.
// ----------------------------------------------------------------
const pageContentHeight = maxY - origMarginTop;
const spaceRemaining = maxY - this.y;
const rowFitsInPage = rowHeight <= pageContentHeight * pageBreakThreshold;
// Fraction of page height that defines "near the bottom".
const endOfPageFraction = opts.endOfPageThreshold !== undefined
? Math.min(1, Math.max(0, opts.endOfPageThreshold))
: 0.1;
const endOfPageMinSpace = pageContentHeight * endOfPageFraction;
// Only break proactively when the cursor itself is in the last
// endOfPageThreshold% of the page — the row height is irrelevant
// for this decision. PDFKit handles natural overflow for tall rows.
const cursorNearBottom = spaceRemaining < endOfPageMinSpace;
if (opts.useSafelyMarginBottom &&
!lockAddPage &&
rowFitsInPage &&
cursorNearBottom)
addNewPage();
startY = rowBottomY + columnSpacing + rowDistance;
lockAddPage = false;
const rowStartY = startY;
const rectRow = {
x: startX,
y: rowStartY - columnSpacing - rowDistance * 2,
width: totalColumnsWidth,
height: rowHeight + columnSpacing,
};
prepareRowBackground(row, rectRow);
lastPositionX = opts.rtl ? columnPositions[0] : startX;
let rowHasOverflowed = false;
let maxCellEndY = rowStartY;
// When a tall cell overflows to a new page, subsequent short cells
// must start below the repeated header on the new page instead of
// at rowStartY (which belongs to the previous page). Without this
// fix, Math.max(rowStartY_old_page, this.y_new_page) always returns
// the old-page value, placing the divider line far below the text.
let postOverflowCellStartY = null;
docTable.headers.forEach((dataHeader, index) => {
const hdr = dataHeader;
let { property, width, renderer, align, valign, padding } = hdr;
// Use the pre-computed column size for this index.
// columnWidth is only an average fallback — columnSizes[index]
// has the correct resolved width (including flex columns).
width =
(typeof width === "number" ? width : undefined) ??
columnSizes[index];
// RTL: default text align to right unless explicitly set
align = align || (opts.rtl ? "right" : "left");
cellPadding = prepareCellPadding(resolvePadding(padding));
// RTL: use pre-computed column position directly
const cellX = opts.rtl ? columnPositions[index] : lastPositionX;
const rectCell = {
x: cellX,
y: rowStartY - columnSpacing - rowDistance * 2,
width: width,
height: rowHeight + columnSpacing,
};
prepareRowOptions(row);
prepareRow(row, index, i, rectRow, rectCell);
const cellValue = row[property];
let textStr;
if (typeof cellValue === "object" && cellValue !== null) {
textStr = String(cellValue.label);
// Apply column background from header first, then cell-level
// background on top (cell wins over column default).
prepareRowBackground(docTable.headers[index], rectCell);
if (Object.prototype.hasOwnProperty.call(cellValue, "options")) {
prepareRowOptions(cellValue);
prepareRowBackground(cellValue, rectCell);
}
}
else {
textStr = String(cellValue ?? "");
prepareRowBackground(docTable.headers[index], rectCell);
}
if (textStr.startsWith("bold:")) {
this.font("Helvetica-Bold");
textStr = textStr.replace("bold:", "");
}
if (textStr.startsWith("size")) {
const size = Number(textStr.slice(4, 6).replace(":", "").replace("+", "")) >> 0;
this.fontSize(size < 7 ? 7 : size);
textStr = textStr.replace(`size${size}:`, "");
}
if (typeof renderer === "function") {
textStr = String(renderer(textStr, index, i, row, rectRow, rectCell));
}
// Compute vertical offset respecting both valign and cellPadding.
// Only apply valign when row fits in one page — multi-page rows
// have a rectCell.height that spans pages and would mis-position.
let topTextToAlignVertically = cellPadding.top;
if (rowHeight <= pageContentHeight) {
const usableHeight = rectCell.height - cellPadding.top - cellPadding.bottom;
const heightText = this.heightOfString(textStr, {
width: width - (cellPadding.left + cellPadding.right),
align: align,
});
if (valign === "center") {
topTextToAlignVertically =
cellPadding.top + (usableHeight - heightText) / 2;
}
else if (valign === "bottom") {
topTextToAlignVertically =
cellPadding.top + (usableHeight - heightText);
}
// valign "top" or undefined: keep cellPadding.top
}
restoreRowStyle = () => {
prepareRowOptions(row);
prepareRow(row, index, i, rectRow, rectCell);
};
const pageCountBefore = pageAddedCount;
const cellY = postOverflowCellStartY ?? rowStartY + topTextToAlignVertically;
this.text(textStr, cellX + cellPadding.left, cellY, {
width: width - (cellPadding.left + cellPadding.right),
align: align,
});
this.page.margins.top = origMarginTop;
if (pageAddedCount > pageCountBefore) {
rowHasOverflowed = true;
// rowBottomY was updated by addHeader() inside onFirePageAdded.
// Anchor subsequent cells just below the new-page header and
// reset maxCellEndY so old-page Y values are discarded.
postOverflowCellStartY =
rowBottomY + columnSpacing + rowDistance;
maxCellEndY = this.y;
}
else {
maxCellEndY = Math.max(maxCellEndY, this.y);
}
if (!opts.rtl)
lastPositionX += width;
prepareRowOptions(row);
prepareRow(row, index, i, rectRow, rectCell);
});
restoreRowStyle = null;
this.y = maxCellEndY;
if (rowHasOverflowed) {
rowBottomY = maxCellEndY + columnSpacing + rowDistance * 2;
}
else {
rowBottomY = Math.max(rowStartY + rowHeight, rowBottomY);
if (rowBottomY > this.page.height)
rowBottomY = maxCellEndY + columnSpacing + rowDistance * 2;
}
separationsRow("horizontal", startX, rowBottomY);
if (Object.prototype.hasOwnProperty.call(row, "options")) {
if (Object.prototype.hasOwnProperty.call(row.options, "separation")) {
separationsRow("horizontal", startX, rowBottomY, 1, 1);
}
}
});
// ------------------------------------------------------------------
// Array rows (Table.rows)
// ------------------------------------------------------------------
docTable.rows.forEach((row, i) => {
this.rowsIndex = i;
const _mr2 = { x: 0, y: 0, width: 0, height: 0 };
prepareRow(row, 0, i, _mr2, _mr2);
const rowHeight = computeRowHeight(row, false);
// ----------------------------------------------------------------
// Same page-break logic as the tableData loop above.
// ----------------------------------------------------------------
const pageContentHeight2 = maxY - origMarginTop;
const spaceRemaining2 = maxY - this.y;
const rowFi