pdfmake
Version:
Client/server side PDF printing in pure JavaScript
580 lines (502 loc) • 16.9 kB
JavaScript
import { isString } from './helpers/variableType';
import { EventEmitter } from 'events';
/**
* A store for current x, y positions and available width/height.
* It facilitates column divisions and vertical sync
*/
class DocumentContext extends EventEmitter {
constructor() {
super();
this.pages = [];
this.pageMargins = undefined;
this.x = undefined;
this.availableWidth = undefined;
this.availableHeight = undefined;
this.page = -1;
this.snapshots = [];
this.backgroundLength = [];
}
beginColumnGroup(marginXTopParent, bottomByPage = {}, snakingColumns = false, columnGap = 0, columnWidths = null) {
this.snapshots.push({
x: this.x,
y: this.y,
availableHeight: this.availableHeight,
availableWidth: this.availableWidth,
page: this.page,
bottomByPage: bottomByPage ? bottomByPage : {},
bottomMost: {
x: this.x,
y: this.y,
availableHeight: this.availableHeight,
availableWidth: this.availableWidth,
page: this.page
},
lastColumnWidth: this.lastColumnWidth,
snakingColumns: snakingColumns,
gap: columnGap,
columnWidths: columnWidths
});
this.lastColumnWidth = 0;
if (marginXTopParent) {
this.marginXTopParent = marginXTopParent;
}
}
updateBottomByPage() {
const lastSnapshot = this.snapshots[this.snapshots.length - 1];
if (!lastSnapshot) {
return;
}
const lastPage = this.page;
let previousBottom = -Number.MIN_VALUE;
if (lastSnapshot.bottomByPage && lastSnapshot.bottomByPage[lastPage]) {
previousBottom = lastSnapshot.bottomByPage[lastPage];
}
if (lastSnapshot.bottomByPage) {
lastSnapshot.bottomByPage[lastPage] = Math.max(previousBottom, this.y);
}
}
resetMarginXTopParent() {
this.marginXTopParent = null;
}
/**
* Find the most recent (deepest) snaking column group snapshot.
* @returns {object|null}
*/
getSnakingSnapshot() {
for (let i = this.snapshots.length - 1; i >= 0; i--) {
if (this.snapshots[i].snakingColumns) {
return this.snapshots[i];
}
}
return null;
}
inSnakingColumns() {
return !!this.getSnakingSnapshot();
}
/**
* Check if we're inside a nested non-snaking column group (e.g., a table row)
* within an outer snaking column group. This is used to prevent snaking-specific
* breaks inside table cells — the table's own page break mechanism should handle
* row breaks, and column breaks should happen between rows.
* @returns {boolean}
*/
isInNestedNonSnakingGroup() {
for (let i = this.snapshots.length - 1; i >= 0; i--) {
let snap = this.snapshots[i];
if (snap.snakingColumns) {
return false; // Reached snaking snapshot without finding inner group
}
if (!snap.overflowed) {
return true; // Found non-snaking, non-overflowed inner group
}
}
return false;
}
beginColumn(width, offset, endingCell) {
// Find the correct snapshot for this column group.
// When a snaking column break (moveToNextColumn) occurs during inner column
// processing, overflowed snapshots may sit above this column group's snapshot.
// We need to skip past those to find the one from our beginColumnGroup call.
let saved = this.snapshots[this.snapshots.length - 1];
if (saved && saved.overflowed) {
for (let i = this.snapshots.length - 1; i >= 0; i--) {
if (!this.snapshots[i].overflowed) {
saved = this.snapshots[i];
break;
}
}
}
this.calculateBottomMost(saved, endingCell);
this.page = saved.page;
this.x = this.x + this.lastColumnWidth + (offset || 0);
this.y = saved.y;
this.availableWidth = width; //saved.availableWidth - offset;
this.availableHeight = saved.availableHeight;
this.lastColumnWidth = width;
}
calculateBottomMost(destContext, endingCell) {
if (endingCell) {
this.saveContextInEndingCell(endingCell);
} else {
destContext.bottomMost = bottomMostContext(this, destContext.bottomMost);
}
}
markEnding(endingCell, originalXOffset, discountY) {
this.page = endingCell._columnEndingContext.page;
this.x = endingCell._columnEndingContext.x + originalXOffset;
this.y = endingCell._columnEndingContext.y - discountY;
this.availableWidth = endingCell._columnEndingContext.availableWidth;
this.availableHeight = endingCell._columnEndingContext.availableHeight;
this.lastColumnWidth = endingCell._columnEndingContext.lastColumnWidth;
}
saveContextInEndingCell(endingCell) {
endingCell._columnEndingContext = {
page: this.page,
x: this.x,
y: this.y,
availableHeight: this.availableHeight,
availableWidth: this.availableWidth,
lastColumnWidth: this.lastColumnWidth
};
}
completeColumnGroup(height, endingCell) {
let saved = this.snapshots.pop();
// Track the maximum bottom position across all columns (including overflowed).
// Critical for snaking: content after columns must appear below the tallest column.
let maxBottomY = this.y;
let maxBottomPage = this.page;
let maxBottomAvailableHeight = this.availableHeight;
// Pop overflowed snapshots created by moveToNextColumn (snaking columns).
// Merge their bottomMost values to find the true maximum.
while (saved && saved.overflowed) {
let bm = bottomMostContext(
{
page: maxBottomPage,
y: maxBottomY,
availableHeight: maxBottomAvailableHeight
},
saved.bottomMost || {}
);
maxBottomPage = bm.page;
maxBottomY = bm.y;
maxBottomAvailableHeight = bm.availableHeight;
saved = this.snapshots.pop();
}
if (!saved) {
return {};
}
// Apply the max bottom from all overflowed columns to this base snapshot
if (
maxBottomPage > saved.bottomMost.page ||
(maxBottomPage === saved.bottomMost.page &&
maxBottomY > saved.bottomMost.y)
) {
saved.bottomMost = {
x: saved.x,
y: maxBottomY,
page: maxBottomPage,
availableHeight: maxBottomAvailableHeight,
availableWidth: saved.availableWidth
};
}
this.calculateBottomMost(saved, endingCell);
this.x = saved.x;
let y = saved.bottomMost.y;
if (height) {
if (saved.page === saved.bottomMost.page) {
if ((saved.y + height) > y) {
y = saved.y + height;
}
} else {
y += height;
}
}
this.y = y;
this.page = saved.bottomMost.page;
this.availableWidth = saved.availableWidth;
this.availableHeight = saved.bottomMost.availableHeight;
if (height) {
this.availableHeight -= (y - saved.bottomMost.y);
}
if (height && (saved.bottomMost.y - saved.y < height)) {
this.height = height;
} else {
this.height = saved.bottomMost.y - saved.y;
}
this.lastColumnWidth = saved.lastColumnWidth;
return saved.bottomByPage;
}
/**
* Move to the next column in a column group (snaking columns).
* Creates an overflowed snapshot to track that we've moved to the next column.
* @returns {object} Position info for the new column
*/
moveToNextColumn() {
let prevY = this.y;
let snakingSnapshot = this.getSnakingSnapshot();
if (!snakingSnapshot) {
return { prevY: prevY, y: this.y };
}
// Update snaking snapshot's bottomMost with current position BEFORE resetting.
// This captures where content reached in the current column (overflow point).
this.calculateBottomMost(snakingSnapshot, null);
// Calculate new X position: move right by current column width + gap
let overflowCount = 0;
for (let i = this.snapshots.length - 1; i >= 0; i--) {
if (this.snapshots[i].overflowed) {
overflowCount++;
} else {
break;
}
}
let currentColumnWidth = (snakingSnapshot.columnWidths && snakingSnapshot.columnWidths[overflowCount]) || this.lastColumnWidth || this.availableWidth;
let nextColumnWidth = (snakingSnapshot.columnWidths && snakingSnapshot.columnWidths[overflowCount + 1]) || currentColumnWidth;
this.lastColumnWidth = nextColumnWidth;
let newX = this.x + (currentColumnWidth || 0) + (snakingSnapshot.gap || 0);
let newY = snakingSnapshot.y;
this.snapshots.push({
x: newX,
y: newY,
availableHeight: snakingSnapshot.availableHeight,
availableWidth: nextColumnWidth,
page: this.page,
overflowed: true,
bottomMost: {
x: newX,
y: newY,
availableHeight: snakingSnapshot.availableHeight,
availableWidth: nextColumnWidth,
page: this.page
},
lastColumnWidth: nextColumnWidth,
snakingColumns: true,
gap: snakingSnapshot.gap,
columnWidths: snakingSnapshot.columnWidths
});
this.x = newX;
this.y = newY;
this.availableHeight = snakingSnapshot.availableHeight;
this.availableWidth = nextColumnWidth;
// Sync non-overflowed inner snapshots (e.g. inner column groups for
// product/price rows) with the new snaking column position.
// Without this, inner beginColumn would read stale y/page/x values.
for (let i = this.snapshots.length - 2; i >= 0; i--) {
let snapshot = this.snapshots[i];
if (snapshot.overflowed || snapshot.snakingColumns) {
break; // Stop at first overflowed or snaking snapshot
}
snapshot.x = newX;
snapshot.y = newY;
snapshot.page = this.page;
snapshot.availableHeight = snakingSnapshot.availableHeight;
if (snapshot.bottomMost) {
snapshot.bottomMost.x = newX;
snapshot.bottomMost.y = newY;
snapshot.bottomMost.page = this.page;
snapshot.bottomMost.availableHeight = snakingSnapshot.availableHeight;
}
}
return { prevY: prevY, y: this.y };
}
/**
* Reset snaking column state when moving to a new page.
* Clears overflowed snapshots, resets X to left margin, sets width to first column,
* and syncs all snapshots to new page coordinates.
*/
resetSnakingColumnsForNewPage() {
let snakingSnapshot = this.getSnakingSnapshot();
if (!snakingSnapshot) {
return;
}
let pageTopY = this.pageMargins.top;
let pageInnerHeight = this.getCurrentPage().pageSize.height - this.pageMargins.top - this.pageMargins.bottom;
// When moving to new page, start at first column.
// Reset width to FIRST column width, not last column from previous page.
let firstColumnWidth = snakingSnapshot.columnWidths ? snakingSnapshot.columnWidths[0] : (this.lastColumnWidth || this.availableWidth);
// Clean up overflowed snapshots
while (this.snapshots.length > 1 && this.snapshots[this.snapshots.length - 1].overflowed) {
this.snapshots.pop();
}
// Reset X to start of first column (left margin)
if (this.marginXTopParent) {
this.x = this.pageMargins.left + this.marginXTopParent[0];
} else {
this.x = this.pageMargins.left;
}
this.availableWidth = firstColumnWidth;
this.lastColumnWidth = firstColumnWidth;
// Sync all snapshots to new page state.
// When page break occurs within snaking columns, update ALL snapshots
// (not just snaking column snapshots) to reflect new page coordinates.
// This ensures nested structures (like inner product/price columns)
// don't retain stale values that would cause layout corruption.
for (let i = 0; i < this.snapshots.length; i++) {
let snapshot = this.snapshots[i];
let isSnakingSnapshot = !!snapshot.snakingColumns;
snapshot.x = this.x;
snapshot.y = isSnakingSnapshot ? pageTopY : this.y;
snapshot.availableHeight = isSnakingSnapshot ? pageInnerHeight : this.availableHeight;
snapshot.page = this.page;
if (snapshot.bottomMost) {
snapshot.bottomMost.x = this.x;
snapshot.bottomMost.y = isSnakingSnapshot ? pageTopY : this.y;
snapshot.bottomMost.availableHeight = isSnakingSnapshot ? pageInnerHeight : this.availableHeight;
snapshot.bottomMost.page = this.page;
}
}
}
addMargin(left, right) {
this.x += left;
this.availableWidth -= left + (right || 0);
}
moveDown(offset) {
this.y += offset;
this.availableHeight -= offset;
return this.availableHeight > 0;
}
initializePage() {
this.y = this.pageMargins.top;
this.availableHeight = this.getCurrentPage().pageSize.height - this.pageMargins.top - this.pageMargins.bottom;
const { pageCtx, isSnapshot } = this.pageSnapshot();
pageCtx.availableWidth = this.getCurrentPage().pageSize.width - this.pageMargins.left - this.pageMargins.right;
if (isSnapshot && this.marginXTopParent) {
pageCtx.availableWidth -= this.marginXTopParent[0];
pageCtx.availableWidth -= this.marginXTopParent[1];
}
}
pageSnapshot() {
if (this.snapshots[0]) {
return { pageCtx: this.snapshots[0], isSnapshot: true };
} else {
return { pageCtx: this, isSnapshot: false };
}
}
moveTo(x, y) {
if (x !== undefined && x !== null) {
this.x = x;
this.availableWidth = this.getCurrentPage().pageSize.width - this.x - this.pageMargins.right;
}
if (y !== undefined && y !== null) {
this.y = y;
this.availableHeight = this.getCurrentPage().pageSize.height - this.y - this.pageMargins.bottom;
}
}
moveToRelative(x, y) {
if (x !== undefined && x !== null) {
this.x = this.x + x;
}
if (y !== undefined && y !== null) {
this.y = this.y + y;
}
}
beginDetachedBlock() {
this.snapshots.push({
x: this.x,
y: this.y,
availableHeight: this.availableHeight,
availableWidth: this.availableWidth,
page: this.page,
lastColumnWidth: this.lastColumnWidth
});
}
endDetachedBlock() {
let saved = this.snapshots.pop();
this.x = saved.x;
this.y = saved.y;
this.availableWidth = saved.availableWidth;
this.availableHeight = saved.availableHeight;
this.page = saved.page;
this.lastColumnWidth = saved.lastColumnWidth;
}
moveToNextPage(pageOrientation) {
let nextPageIndex = this.page + 1;
let prevPage = this.page;
let prevY = this.y;
// If we are in a column group
if (this.snapshots.length > 0) {
let lastSnapshot = this.snapshots[this.snapshots.length - 1];
// We have to update prevY accordingly by also taking into consideration
// the 'y' of cells that don't break page
if (lastSnapshot.bottomMost && lastSnapshot.bottomMost.y) {
prevY = Math.max(this.y, lastSnapshot.bottomMost.y);
}
}
let createNewPage = nextPageIndex >= this.pages.length;
if (createNewPage) {
let currentAvailableWidth = this.availableWidth;
let currentPageOrientation = this.getCurrentPage().pageSize.orientation;
let pageSize = getPageSize(this.getCurrentPage(), pageOrientation);
this.addPage(pageSize, null, this.getCurrentPage().customProperties);
if (currentPageOrientation === pageSize.orientation) {
this.availableWidth = currentAvailableWidth;
}
} else {
this.page = nextPageIndex;
this.initializePage();
}
return {
newPageCreated: createNewPage,
prevPage: prevPage,
prevY: prevY,
y: this.y
};
}
addPage(pageSize, pageMargin = null, customProperties = {}) {
if (pageMargin !== null) {
this.pageMargins = pageMargin;
this.x = pageMargin.left;
this.availableWidth = pageSize.width - pageMargin.left - pageMargin.right;
}
let page = { items: [], pageSize: pageSize, pageMargins: this.pageMargins, customProperties: customProperties };
this.pages.push(page);
this.backgroundLength.push(0);
this.page = this.pages.length - 1;
this.initializePage();
this.emit('pageAdded', page);
return page;
}
getCurrentPage() {
if (this.page < 0 || this.page >= this.pages.length) {
return null;
}
return this.pages[this.page];
}
getCurrentPosition() {
let pageSize = this.getCurrentPage().pageSize;
let innerHeight = pageSize.height - this.pageMargins.top - this.pageMargins.bottom;
let innerWidth = pageSize.width - this.pageMargins.left - this.pageMargins.right;
return {
pageNumber: this.page + 1,
pageOrientation: pageSize.orientation,
pageInnerHeight: innerHeight,
pageInnerWidth: innerWidth,
left: this.x,
top: this.y,
verticalRatio: ((this.y - this.pageMargins.top) / innerHeight),
horizontalRatio: ((this.x - this.pageMargins.left) / innerWidth)
};
}
}
function pageOrientation(pageOrientationString, currentPageOrientation) {
if (pageOrientationString === undefined) {
return currentPageOrientation;
} else if (isString(pageOrientationString) && (pageOrientationString.toLowerCase() === 'landscape')) {
return 'landscape';
} else {
return 'portrait';
}
}
const getPageSize = (currentPage, newPageOrientation) => {
newPageOrientation = pageOrientation(newPageOrientation, currentPage.pageSize.orientation);
if (newPageOrientation !== currentPage.pageSize.orientation) {
return {
orientation: newPageOrientation,
width: currentPage.pageSize.height,
height: currentPage.pageSize.width
};
} else {
return {
orientation: currentPage.pageSize.orientation,
width: currentPage.pageSize.width,
height: currentPage.pageSize.height
};
}
};
function bottomMostContext(c1, c2) {
let r;
if (c1.page > c2.page) {
r = c1;
} else if (c2.page > c1.page) {
r = c2;
} else {
r = (c1.y > c2.y) ? c1 : c2;
}
return {
page: r.page,
x: r.x,
y: r.y,
availableHeight: r.availableHeight,
availableWidth: r.availableWidth
};
}
export default DocumentContext;