diff --git a/CHANGELOG.md b/CHANGELOG.md index ae73f4edd..59b2f29c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ``` - Fixed extra blank page when using headerRows, dontBreakRows and cell pageBreak together - Fixed rendering of an invalid color name - previously it used the last valid color; now it defaults to black +- Added dynamic `pageMargins` ## 0.3.7 - 2026-03-17 diff --git a/examples/dynamicPageMargins.js b/examples/dynamicPageMargins.js new file mode 100644 index 000000000..5737197b6 --- /dev/null +++ b/examples/dynamicPageMargins.js @@ -0,0 +1,68 @@ +/*eslint no-unused-vars: ["error", {"args": "none"}]*/ + +var pdfmake = require('../js/index'); // only during development, otherwise use the following line +//var pdfmake = require('pdfmake'); + +var Roboto = require('../fonts/Roboto'); +pdfmake.addFonts(Roboto); + +pdfmake.setUrlAccessPolicy((url) => { + // this can be used to restrict allowed domains + return url.startsWith('https://'); +}); + +var loremIpsum = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. '; + +var docDefinition = { + // Stable usage: page-local rules based on currentPage do not feed pagination + // back into the callback, so layout converges naturally. + pageMargins: function(currentPage, pageCount, pageSize) { + return { + left: (currentPage % 2 === 0) ? 80 : 40, + top: 40, + right: (currentPage % 2 === 0) ? 40 : 80, + bottom: 40 + }; + }, + content: [ + { text: loremIpsum.repeat(42) }, + '', + 'Table:', + { + table: { + body: [ + [{ text: 'Header 1', style: 'tableHeader' }, { text: 'Header 2', style: 'tableHeader' }, { text: 'Header 3', style: 'tableHeader' }], + [ + loremIpsum.repeat(4), + loremIpsum.repeat(4), + loremIpsum.repeat(4), + ] + ] + } + }, + '', + 'Table width headerRows:', + { + table: { + headerRows: 1, + body: [ + [{ text: 'Header 1', style: 'tableHeader' }, { text: 'Header 2', style: 'tableHeader' }, { text: 'Header 3', style: 'tableHeader' }], + [ + loremIpsum.repeat(6), + loremIpsum.repeat(6), + loremIpsum.repeat(6), + ] + ] + } + } + ] +}; + +var now = new Date(); + +var pdf = pdfmake.createPdf(docDefinition); +pdf.write('pdfs/dynamicPageMargins.pdf').then(() => { + console.log(new Date() - now); +}, err => { + console.error(err); +}); diff --git a/examples/dynamicPageMarginsModuloParadox.js b/examples/dynamicPageMarginsModuloParadox.js new file mode 100644 index 000000000..b9f71319d --- /dev/null +++ b/examples/dynamicPageMarginsModuloParadox.js @@ -0,0 +1,44 @@ +var pdfmake = require('../js/index'); // only during development, otherwise use the following line +//var pdfmake = require('pdfmake'); + +var Roboto = require('../fonts/Roboto'); +pdfmake.addFonts(Roboto); + +var marginCalls = []; +var lines = []; + +for (var i = 0; i < 3; i++) { + lines.push('Line ' + i + ' with some text to fill the page and change pagination.'); +} + +var docDefinition = { + pageSize: 'A7', + content: lines, + pageMargins: function (currentPage, pageCount) { + marginCalls.push({ currentPage: currentPage, pageCount: pageCount }); + + if (!pageCount) { + return { left: 40, top: 40, right: 40, bottom: 40 }; + } + + // An intentionally paradoxical case with pageCount = 1 every page + // satisfies currentPage % pageCount === 0, so the callback can flip the + // document between one-page and two-page layouts across passes. + if (currentPage % pageCount === 0) { + return { left: 40, bottom: 40, right: 40, top: 140 }; + } + + return { left: 40, bottom: 40, right: 40, top: 40 }; + } +}; + +var now = new Date(); + +var pdf = pdfmake.createPdf(docDefinition); +pdf.write('pdfs/dynamicPageMarginsModuloParadox.pdf').then(function () { + console.log(new Date() - now); + console.log('pageMargins callback calls:', marginCalls.length); + console.log(JSON.stringify(marginCalls, null, 2)); +}, function (err) { + console.error(err); +}); diff --git a/examples/dynamicPageMarginsPageCountParadox.js b/examples/dynamicPageMarginsPageCountParadox.js new file mode 100644 index 000000000..4c8485b6d --- /dev/null +++ b/examples/dynamicPageMarginsPageCountParadox.js @@ -0,0 +1,39 @@ +var pdfmake = require('../js/index'); // only during development, otherwise use the following line +//var pdfmake = require('pdfmake'); + +var Roboto = require('../fonts/Roboto'); +pdfmake.addFonts(Roboto); + +var marginCalls = []; +var lines = []; + +for (var i = 0; i < 5; i++) { + lines.push('Line ' + i + ' with some text to fill the page and change pagination.'); +} + +var docDefinition = { + pageSize: 'A7', + content: lines, + pageMargins: function (currentPage, pageCount) { + marginCalls.push({ currentPage: currentPage, pageCount: pageCount }); + + // This is intentionally paradoxical: changing margins based on the assumed + // total page count can change pagination, which changes pageCount again. + if (pageCount % 2 === 1) { + return { left: 40, bottom: 40, right: 40, top: 140 }; + } + + return { left: 40, bottom: 40, right: 40, top: 40 }; + } +}; + +var now = new Date(); + +var pdf = pdfmake.createPdf(docDefinition); +pdf.write('pdfs/dynamicPageMarginsPageCountParadox.pdf').then(function () { + console.log(new Date() - now); + console.log('pageMargins callback calls:', marginCalls.length); + console.log(JSON.stringify(marginCalls, null, 2)); +}, function (err) { + console.error(err); +}); diff --git a/examples/pdfs/dynamicPageMargins.pdf b/examples/pdfs/dynamicPageMargins.pdf new file mode 100644 index 000000000..06e8b75b6 Binary files /dev/null and b/examples/pdfs/dynamicPageMargins.pdf differ diff --git a/examples/pdfs/dynamicPageMarginsModuloParadox.pdf b/examples/pdfs/dynamicPageMarginsModuloParadox.pdf new file mode 100644 index 000000000..b938df127 Binary files /dev/null and b/examples/pdfs/dynamicPageMarginsModuloParadox.pdf differ diff --git a/examples/pdfs/dynamicPageMarginsPageCountParadox.pdf b/examples/pdfs/dynamicPageMarginsPageCountParadox.pdf new file mode 100644 index 000000000..a5207846d Binary files /dev/null and b/examples/pdfs/dynamicPageMarginsPageCountParadox.pdf differ diff --git a/src/DocumentContext.js b/src/DocumentContext.js index ff308ecf2..29c2164da 100644 --- a/src/DocumentContext.js +++ b/src/DocumentContext.js @@ -1,5 +1,6 @@ import { isString } from './helpers/variableType'; import { EventEmitter } from 'events'; +import { normalizePageMargin } from './PageSize'; /** * A store for current x, y positions and available width/height. @@ -10,6 +11,8 @@ class DocumentContext extends EventEmitter { super(); this.pages = []; this.pageMargins = undefined; + this.pageCount = 0; + this.pageMarginFunctionUsed = false; this.x = undefined; this.availableWidth = undefined; this.availableHeight = undefined; @@ -328,8 +331,8 @@ class DocumentContext extends EventEmitter { return; } - let pageTopY = this.pageMargins.top; - let pageInnerHeight = this.getCurrentPage().pageSize.height - this.pageMargins.top - this.pageMargins.bottom; + let pageTopY = this.getCurrentPage().pageMargins.top; + let pageInnerHeight = this.getCurrentPage().pageSize.height - this.getCurrentPage().pageMargins.top - this.getCurrentPage().pageMargins.bottom; // When moving to new page, start at first column. // Reset width to FIRST column width, not last column from previous page. @@ -342,9 +345,9 @@ class DocumentContext extends EventEmitter { // Reset X to start of first column (left margin) if (this.marginXTopParent) { - this.x = this.pageMargins.left + this.marginXTopParent[0]; + this.x = this.getCurrentPage().pageMargins.left + this.marginXTopParent[0]; } else { - this.x = this.pageMargins.left; + this.x = this.getCurrentPage().pageMargins.left; } this.availableWidth = firstColumnWidth; this.lastColumnWidth = firstColumnWidth; @@ -370,6 +373,43 @@ class DocumentContext extends EventEmitter { } } + restoreColumnStateAfterPageBreak(previousColumnState) { + if (!previousColumnState || this.snapshots.length === 0) { + return; + } + + let currentPage = this.getCurrentPage(); + if (!currentPage || !currentPage.pageMargins) { + return; + } + + // Column snapshots store absolute X coordinates from the previous page. + // When margins change per page, we must preserve the offset from the left + // margin instead of reusing the old absolute X. + let previousPageMargins = previousColumnState.pageMargins || { left: 0 }; + let translateX = x => currentPage.pageMargins.left + (x - previousPageMargins.left); + let currentState = { + x: translateX(previousColumnState.x), + y: this.y, + page: this.page, + availableHeight: this.availableHeight, + availableWidth: previousColumnState.availableWidth + }; + + this.x = currentState.x; + this.availableWidth = previousColumnState.availableWidth; + + for (let i = 0; i < this.snapshots.length; i++) { + let snapshot = this.snapshots[i]; + snapshot.x = translateX(snapshot.x); + snapshot.y = this.y; + snapshot.page = this.page; + snapshot.availableHeight = this.availableHeight; + + snapshot.bottomMost = bottomMostContext(currentState, snapshot.bottomMost || currentState); + } + } + addMargin(left, right) { this.x += left; this.availableWidth -= left + (right || 0); @@ -383,10 +423,10 @@ class DocumentContext extends EventEmitter { } initializePage() { - this.y = this.pageMargins.top; - this.availableHeight = this.getCurrentPage().pageSize.height - this.pageMargins.top - this.pageMargins.bottom; + this.y = this.getCurrentPage().pageMargins.top; + this.availableHeight = this.getCurrentPage().pageSize.height - this.getCurrentPage().pageMargins.top - this.getCurrentPage().pageMargins.bottom; const { pageCtx, isSnapshot } = this.pageSnapshot(); - pageCtx.availableWidth = this.getCurrentPage().pageSize.width - this.pageMargins.left - this.pageMargins.right; + pageCtx.availableWidth = this.getCurrentPage().pageSize.width - this.getCurrentPage().pageMargins.left - this.getCurrentPage().pageMargins.right; if (isSnapshot && this.marginXTopParent) { pageCtx.availableWidth -= this.marginXTopParent[0]; pageCtx.availableWidth -= this.marginXTopParent[1]; @@ -404,11 +444,11 @@ class DocumentContext extends EventEmitter { moveTo(x, y) { if (x !== undefined && x !== null) { this.x = x; - this.availableWidth = this.getCurrentPage().pageSize.width - this.x - this.pageMargins.right; + this.availableWidth = this.getCurrentPage().pageSize.width - this.x - this.getCurrentPage().pageMargins.right; } if (y !== undefined && y !== null) { this.y = y; - this.availableHeight = this.getCurrentPage().pageSize.height - this.y - this.pageMargins.bottom; + this.availableHeight = this.getCurrentPage().pageSize.height - this.y - this.getCurrentPage().pageMargins.bottom; } } @@ -485,11 +525,21 @@ class DocumentContext extends EventEmitter { 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 }; + let currentMargin = pageMargin || this.pageMargins; + + if (typeof currentMargin === 'function') { + this.pageMarginFunctionUsed = true; + currentMargin = normalizePageMargin(currentMargin(this.pages.length + 1, this.pageCount, pageSize)); + } + + if (currentMargin !== undefined && currentMargin !== null) { + this.x = currentMargin.left; + this.availableWidth = pageSize.width - currentMargin.left - currentMargin.right; + } + + let page = { items: [], pageSize: pageSize, pageMargins: currentMargin, customProperties: customProperties }; this.pages.push(page); this.backgroundLength.push(0); this.page = this.pages.length - 1; @@ -510,8 +560,8 @@ class DocumentContext extends EventEmitter { 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; + let innerHeight = pageSize.height - this.getCurrentPage().pageMargins.top - this.getCurrentPage().pageMargins.bottom; + let innerWidth = pageSize.width - this.getCurrentPage().pageMargins.left - this.getCurrentPage().pageMargins.right; return { pageNumber: this.page + 1, @@ -520,8 +570,8 @@ class DocumentContext extends EventEmitter { pageInnerWidth: innerWidth, left: this.x, top: this.y, - verticalRatio: ((this.y - this.pageMargins.top) / innerHeight), - horizontalRatio: ((this.x - this.pageMargins.left) / innerWidth) + verticalRatio: ((this.y - this.getCurrentPage().pageMargins.top) / innerHeight), + horizontalRatio: ((this.x - this.getCurrentPage().pageMargins.left) / innerWidth) }; } } diff --git a/src/ElementWriter.js b/src/ElementWriter.js index 197dcd0f9..360e56479 100644 --- a/src/ElementWriter.js +++ b/src/ElementWriter.js @@ -414,6 +414,8 @@ class ElementWriter extends EventEmitter { if (isNumber(contextOrWidth)) { let width = contextOrWidth; contextOrWidth = new DocumentContext(); + contextOrWidth.pageMargins = this.context().pageMargins; + contextOrWidth.pageCount = this.context().pageCount; contextOrWidth.addPage({ width: width, height: height }, { left: 0, right: 0, top: 0, bottom: 0 }); } diff --git a/src/LayoutBuilder.js b/src/LayoutBuilder.js index f71f918ed..87d96db70 100644 --- a/src/LayoutBuilder.js +++ b/src/LayoutBuilder.js @@ -159,10 +159,48 @@ class LayoutBuilder { }); } - let result = this.tryLayoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark); - while (addPageBreaksIfNecessary(result.linearNodeList, result.pages)) { - resetXYs(result); - result = this.tryLayoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark); + function warnAboutPageMarginCycle() { + if (typeof console !== 'undefined' && typeof console.warn === 'function') { + console.warn('Non-convergent dynamic pageMargins detected. Layout may not be rendered as expected semantically.' + + ' Look at the docs on how to apply dynamic page margins correctly to avoid this warning.' + ); + } + } + + const MAX_LAYOUT_PASSES = 10; + let pagesCount = 0; + let layoutPass = 0; + let result = this.tryLayoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pagesCount); + let pageMarginAssumptionOrder = [pagesCount]; + let pageMarginWarned = false; + + while (++layoutPass < MAX_LAYOUT_PASSES) { + if (result.pageMarginFunctionUsed && pagesCount !== result.pages.length) { + let nextPagesCount = result.pages.length; + if (!pageMarginWarned) { + let cycleStartIndex = pageMarginAssumptionOrder.indexOf(nextPagesCount); + if (cycleStartIndex !== -1) { + // A repeated assumed page count means the callback is feeding + // pagination back into itself (for example 1 -> 2 -> 1 -> 2). + warnAboutPageMarginCycle(); + pageMarginWarned = true; + } + } + + pagesCount = nextPagesCount; + pageMarginAssumptionOrder.push(pagesCount); + resetXYs(result); + result = this.tryLayoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pagesCount); + continue; + } + + if (addPageBreaksIfNecessary(result.linearNodeList, result.pages)) { + resetXYs(result); + result = this.tryLayoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pagesCount); + continue; + } + + break; } return result.pages; @@ -176,7 +214,8 @@ class LayoutBuilder { background, header, footer, - watermark + watermark, + pageCount ) { const isNecessaryAddFirstPage = (docStructure) => { @@ -193,7 +232,10 @@ class LayoutBuilder { docStructure = this.docPreprocessor.preprocessDocument(docStructure); docStructure = this.docMeasure.measureDocument(docStructure); - this.writer = new PageElementWriter(new DocumentContext()); + let documentContext = new DocumentContext(); + documentContext.pageMargins = this.pageMargins; + documentContext.pageCount = pageCount; + this.writer = new PageElementWriter(documentContext); this.writer.context().addListener('pageAdded', (page) => { let backgroundGetter = background; @@ -216,7 +258,7 @@ class LayoutBuilder { this.addHeadersAndFooters(header, footer); this.addWatermark(watermark, pdfDocument, defaultStyle); - return { pages: this.writer.context().pages, linearNodeList: this.linearNodeList }; + return { pages: this.writer.context().pages, linearNodeList: this.linearNodeList, pageMarginFunctionUsed: this.writer.context().pageMarginFunctionUsed }; } addBackground(background) { @@ -1025,10 +1067,20 @@ class LayoutBuilder { // If content did not break page, check if we should break by height if (willBreakByHeight && !isUnbreakableRow && pageBreaks.length === 0) { this.writer.context().moveDown(this.writer.context().availableHeight); + let pageBreakData; if (snakingColumns) { this.snakingAwarePageBreak(); } else { - this.writer.moveToNextPage(); + pageBreakData = this.writer.moveToNextPage(); + } + + if (pageBreakData) { + pageBreaks.push({ + prevPage: pageBreakData.prevPage, + prevY: pageBreakData.prevY, + y: this.writer.context().y, + rowIndex: rowIndex + }); } } @@ -1312,6 +1364,17 @@ class LayoutBuilder { } let positions = this.writer.addLine(line); + if (!positions && this.writer.context().inSnakingColumns() && !this.writer.context().isInNestedNonSnakingGroup()) { + this.snakingAwarePageBreak(node.pageOrientation); + + if (line.inlines && line.inlines.length > 0) { + node._inlines.unshift(...line.inlines); + } + + line = this.buildNextLine(node); + continue; + } + node.positions.push(positions); line = this.buildNextLine(node); if (line) { diff --git a/src/PageElementWriter.js b/src/PageElementWriter.js index 8f397045b..006d9347c 100644 --- a/src/PageElementWriter.js +++ b/src/PageElementWriter.js @@ -22,6 +22,12 @@ class PageElementWriter extends ElementWriter { } addLine(line, dontUpdateContextPosition, index) { + // Outer snaking text needs LayoutBuilder to rebuild the line after a column/page + // break so the first carried line wraps to the new column width correctly. + if (this.context().inSnakingColumns() && !this.context().isInNestedNonSnakingGroup()) { + return super.addLine(line, dontUpdateContextPosition, index); + } + return this._fitOnPage(() => super.addLine(line, dontUpdateContextPosition, index)); } @@ -70,6 +76,19 @@ class PageElementWriter extends ElementWriter { } moveToNextPage(pageOrientation) { + const previousPage = this.context().getCurrentPage(); + let previousColumnState = null; + + // Normal column groups (tables, columns) need their X position translated + // to the new page's margins after the page break has been emitted. + if (previousPage && this.context().snapshots.length > 0 && !this.context().getSnakingSnapshot()) { + previousColumnState = { + x: this.context().x, + availableWidth: this.context().availableWidth, + pageMargins: previousPage.pageMargins + }; + } + let nextPage = this.context().moveToNextPage(pageOrientation); // moveToNextPage is called multiple times for table, because is called for each column @@ -78,7 +97,17 @@ class PageElementWriter extends ElementWriter { this.repeatables.forEach(function (rep) { if (rep.insertedOnPages[this.context().page] === undefined) { rep.insertedOnPages[this.context().page] = true; - this.addFragment(rep, true); + let fragment = rep; + + // Table headers are captured with the original page's left margin. + // Rebase them so the repeatable fragment follows the current page margins. + if (rep.pageMarginLeft !== undefined && this.context().getCurrentPage().pageMargins) { + fragment = Object.assign({}, rep, { + xOffset: this.context().getCurrentPage().pageMargins.left - rep.pageMarginLeft + }); + } + + this.addFragment(fragment, true); } else { this.context().moveDown(rep.height); } @@ -89,6 +118,12 @@ class PageElementWriter extends ElementWriter { prevY: nextPage.prevY, y: this.context().y }); + + if (previousColumnState) { + this.context().restoreColumnStateAfterPageBreak(previousColumnState); + } + + return nextPage; } addPage(pageSize, pageOrientation, pageMargin, customProperties = {}) { @@ -149,6 +184,7 @@ class PageElementWriter extends ElementWriter { currentBlockToRepeatable() { let unbreakableContext = this.context(); + let currentPage = unbreakableContext.getCurrentPage(); let rep = { items: [] }; unbreakableContext.pages[0].items.forEach(item => { @@ -156,6 +192,7 @@ class PageElementWriter extends ElementWriter { }); rep.xOffset = this.originalX; + rep.pageMarginLeft = currentPage && currentPage.pageMargins ? currentPage.pageMargins.left : 0; //TODO: vectors can influence height in some situations rep.height = unbreakableContext.y; diff --git a/src/PageSize.js b/src/PageSize.js index f362f3fc8..d069d7fa7 100644 --- a/src/PageSize.js +++ b/src/PageSize.js @@ -37,6 +37,10 @@ export function normalizePageSize(pageSize, pageOrientation) { } export function normalizePageMargin(margin) { + if (typeof margin === 'function') { + return margin; + } + if (isNumber(margin)) { margin = { left: margin, right: margin, top: margin, bottom: margin }; } else if (Array.isArray(margin)) { diff --git a/src/TableProcessor.js b/src/TableProcessor.js index eed19f0d0..37fa5c13e 100644 --- a/src/TableProcessor.js +++ b/src/TableProcessor.js @@ -163,6 +163,10 @@ class TableProcessor { this.rowPaddingTop = this.layout.paddingTop(rowIndex, this.tableNode); this.bottomLineWidth = this.layout.hLineWidth(rowIndex + 1, this.tableNode); this.rowPaddingBottom = this.layout.paddingBottom(rowIndex, this.tableNode); + const currentPage = writer.context().getCurrentPage(); + // Row borders are drawn later in endRow, potentially on a different page. + // Keep the row's offset from the page margin so it can be reanchored safely. + this.rowXOffset = currentPage && currentPage.pageMargins ? writer.context().x - currentPage.pageMargins.left : writer.context().x; this.rowCallback = this.onRowBreak(rowIndex, writer); writer.addListener('pageChanged', this.rowCallback); @@ -406,6 +410,8 @@ class TableProcessor { let endingPage = writer.context().page; let endingY = writer.context().y; + let endingX = writer.context().x; + let endingAvailableWidth = writer.context().availableWidth; let xs = getLineXs(); @@ -459,6 +465,14 @@ class TableProcessor { this.reservedAtBottom = 0; } + const currentPage = writer.context().getCurrentPage(); + if (currentPage && currentPage.pageMargins) { + // Broken rows reuse the same column geometry on the next page, but their + // absolute X must be recalculated from that page's margins. + writer.context().x = currentPage.pageMargins.left + this.rowXOffset; + writer.context().availableWidth = currentPage.pageSize.width - writer.context().x - currentPage.pageMargins.right; + } + // Draw horizontal lines before the vertical lines so they are not overridden if (willBreak && this.layout.hLineWhenBroken !== false) { this.drawHorizontalLine(rowIndex + 1, writer, y2); @@ -572,6 +586,8 @@ class TableProcessor { writer.context().page = endingPage; writer.context().y = endingY; + writer.context().x = endingX; + writer.context().availableWidth = endingAvailableWidth; let row = this.tableNode.table.body[rowIndex]; for (let i = 0, l = row.length; i < l; i++) { diff --git a/tests/integration/dynamic_page_margins.spec.js b/tests/integration/dynamic_page_margins.spec.js new file mode 100644 index 000000000..95c7a99e4 --- /dev/null +++ b/tests/integration/dynamic_page_margins.spec.js @@ -0,0 +1,300 @@ +'use strict'; + +var assert = require('assert'); + +var integrationTestHelper = require('./integrationTestHelper'); + +describe('Integration test: dynamicPageMargins', function () { + + var testHelper; + + beforeEach(function () { + testHelper = new integrationTestHelper(); + }); + + it('applies different bottom margins per page for footer sizing', function () { + var footerBottomMargin = 100; + var dd = { + content: [ + 'First page content', + ], + pageMargins: function (currentPage, pageCount) { + if (currentPage === pageCount) { + return { left: 40, top: 40, right: 40, bottom: footerBottomMargin }; + } + return { left: 40, top: 40, right: 40, bottom: 40 }; + }, + footer: function (currentPage, pageCount) { + if (currentPage === pageCount) { + return { text: 'Footer on last page' }; + } + return null; + } + }; + + var pages = testHelper.renderPages('A6', dd); + + assert.equal(pages.length, 1); + // The last page should have the dynamic bottom margin applied + assert.equal(pages[0].pageMargins.bottom, footerBottomMargin); + }); + + it('only applies large margin on the last page when multiple pages exist', function () { + // Generate enough content for multiple pages on A7 + var lines = []; + for (var i = 0; i < 20; i++) { + lines.push('Line ' + i + ' with some text to fill the page'); + } + + var dd = { + content: lines, + pageMargins: function (currentPage, pageCount) { + if (currentPage === pageCount) { + return { left: 40, top: 40, right: 40, bottom: 120 }; + } + return { left: 40, top: 40, right: 40, bottom: 40 }; + }, + footer: function (currentPage, pageCount) { + if (currentPage === pageCount) { + return { text: 'Footer text' }; + } + return null; + } + }; + + var pages = testHelper.renderPages('A7', dd); + + assert(pages.length >= 2, 'Should have at least 2 pages'); + + // At least one page should have the large bottom margin applied + var hasLargeMargin = pages.some(function (p) { return p.pageMargins.bottom === 120; }); + assert(hasLargeMargin, 'At least one page should have large bottom margin'); + }); + + it('accepts array format from dynamicPageMargins', function () { + var dd = { + content: ['Some content'], + pageMargins: function () { + return { left: 40, top: 40, right: 40, bottom: 150 }; + }, + footer: function () { + return { text: 'Footer' }; + } + }; + + var pages = testHelper.renderPages('A6', dd); + + assert.equal(pages.length, 1); + assert.equal(pages[0].pageMargins.bottom, 150); + assert.equal(pages[0].pageMargins.left, 40); + }); + + it('receives pageSize in pageMargins callback', function () { + var receivedPageSize = null; + var dd = { + content: ['Content'], + pageMargins: function (currentPage, pageCount, pageSize) { + receivedPageSize = pageSize; + return { left: 40, top: 40, right: 40, bottom: 40 }; + }, + footer: function () { + return { text: 'Footer' }; + } + }; + + testHelper.renderPages('A6', dd); + + assert.notEqual(receivedPageSize, null, 'pageSize should be passed to pageMargins'); + assert(receivedPageSize.width > 0, 'pageSize.width should be positive'); + assert(receivedPageSize.height > 0, 'pageSize.height should be positive'); + }); + + it('repositions table headers and continued cell content on each page', function () { + var body = [[{ text: 'H1' }, { text: 'H2' }, { text: 'H3' }]]; + + for (var i = 0; i < 10; i++) { + body.push([ + 'row ' + i + ' aaa bbb ccc ddd eee fff ggg hhh iii', + 'cell ' + i + ' aaa bbb ccc ddd eee fff ggg hhh iii', + 'third ' + i + ' aaa bbb ccc ddd eee fff ggg hhh iii' + ]); + } + + var dd = { + content: [ + { + table: { + headerRows: 1, + widths: ['*', '*', '*'], + body: body + } + } + ], + pageMargins: function (currentPage) { + return { + left: currentPage % 2 === 0 ? 100 : 20, + top: 20, + right: currentPage % 2 === 0 ? 20 : 100, + bottom: 20 + }; + } + }; + + var pages = testHelper.renderPages('A7', dd); + var page1Lines = pages[0].items.filter(function (item) { return item.type === 'line'; }).map(function (item) { return item.item; }); + var page2Lines = pages[1].items.filter(function (item) { return item.type === 'line'; }).map(function (item) { return item.item; }); + var page2Vectors = pages[1].items.filter(function (item) { return item.type === 'vector'; }).map(function (item) { return item.item; }); + var page2BodyVerticalLine = page2Vectors.find(function (item) { + return item.x1 === item.x2 && item.y1 > 40; + }); + + assert(pages.length > 1, 'table should span multiple pages'); + assert.equal(page1Lines[0].x, 25); + assert.equal(page2Lines[0].x, 105); + assert.equal(page2Lines[3].x, 105); + assert(page2BodyVerticalLine, 'page 2 should have a vertical border for the broken row'); + assert.equal(page2BodyVerticalLine.x1, 100.5); + }); + + it('applies the requested right margin to table borders on alternating pages', function () { + var body = [[{ text: 'H1' }, { text: 'H2' }, { text: 'H3' }]]; + + for (var i = 0; i < 40; i++) { + body.push([ + 'row ' + i, + 'cell ' + i, + 'third ' + i + ]); + } + + var dd = { + content: [ + { + table: { + headerRows: 1, + widths: ['33%', '33%', '34%'], + body: body + } + } + ], + pageMargins: function (currentPage) { + return { + left: currentPage % 2 === 0 ? 100 : 20, + top: 20, + right: currentPage % 2 === 0 ? 20 : 100, + bottom: 20 + }; + } + }; + + var pages = testHelper.renderPages('A6', dd); + + function remainingRightSpace(page) { + var vectors = page.items.filter(function (item) { return item.type === 'vector'; }).map(function (item) { return item.item; }); + var maxX = vectors.reduce(function (max, vector) { + return Math.max(max, vector.x2 || vector.x1 || vector.x || 0); + }, 0); + + return page.pageSize.width - maxX; + } + + assert(Math.abs(remainingRightSpace(pages[0]) - (pages[0].pageMargins.right + 0.5)) < 0.01); + assert(Math.abs(remainingRightSpace(pages[1]) - (pages[1].pageMargins.right + 0.5)) < 0.01); + }); + + it('does not draw reversed carry-over borders for fixed-height rows that break across pages', function () { + var filler = []; + for (var i = 0; i < 15; i++) { + filler.push('filler line ' + i + ' lorem ipsum dolor sit amet consectetur'); + } + + var dd = { + content: filler.concat([ + { text: 'Defining row heights', style: 'subheader' }, + { + table: { + heights: [20, 50, 70], + body: [ + ['row 1 with height 20', 'column B'], + ['row 2 with height 50', 'column B'], + ['row 3 with height 70', 'column B'] + ] + } + }, + 'With same height:' + ]), + styles: { + subheader: { + fontSize: 16, + bold: true, + margin: [0, 10, 0, 5] + } + }, + pageMargins: function (currentPage) { + return { + left: currentPage % 2 === 0 ? 100 : 20, + top: 20, + right: currentPage % 2 === 0 ? 20 : 200, + bottom: 20 + }; + } + }; + + var pages = testHelper.renderPages('A6', dd); + var textPage = pages[3]; + var textLine = textPage.items + .filter(function (item) { return item.type === 'line'; }) + .map(function (item) { return item.item; }) + .find(function (line) { return line.inlines.map(function (inline) { return inline.text; }).join('') === 'With same height:'; }); + var reversedVerticalLine = textPage.items + .filter(function (item) { return item.type === 'vector'; }) + .map(function (item) { return item.item; }) + .find(function (item) { return item.type === 'line' && item.x1 === item.x2 && item.y1 > item.y2; }); + + assert.equal(textPage.pageMargins.left, 100); + assert.equal(textLine.x, 100); + assert.equal(reversedVerticalLine, undefined); + }); + + it('warns when pageCount-dependent margins oscillate', function () { + var lines = []; + var calls = []; + var warnings = []; + var originalWarn = console.warn; + + for (var i = 0; i < 5; i++) { + lines.push('Line ' + i + ' with some text to fill the page and change pagination.'); + } + + var dd = { + content: lines, + pageMargins: function (currentPage, pageCount) { + calls.push({ currentPage: currentPage, pageCount: pageCount }); + + if (pageCount % 2 === 1) { + return { left: 40, top: 40, right: 40, bottom: 140 }; + } + + return { left: 40, top: 40, right: 40, bottom: 40 }; + } + }; + + console.warn = function (message) { + warnings.push(message); + }; + + try { + var pages = testHelper.renderPages('A7', dd); + var distinctAssumptions = new Set(calls.map(function (call) { return call.pageCount; })); + + assert(pages.length > 0, 'layout should still complete'); + assert(distinctAssumptions.has(1), 'should try a 1-page assumption'); + assert(distinctAssumptions.has(2), 'should try a 2-page assumption'); + assert(calls.length > 4, 'should continue retrying after warning under the current policy'); + assert.equal(warnings.length, 1, 'should warn exactly once'); + assert(/Non-convergent dynamic pageMargins/.test(warnings[0])); + } finally { + console.warn = originalWarn; + } + }); +}); diff --git a/tests/integration/integrationTestHelper.js b/tests/integration/integrationTestHelper.js index 5ce11dde7..7df52108b 100644 --- a/tests/integration/integrationTestHelper.js +++ b/tests/integration/integrationTestHelper.js @@ -32,7 +32,8 @@ class IntegrationTestHelper { } this.pdfDocument = new PDFDocument(fontDescriptors, docDefinition.images, docDefinition.attachments, { size: [pageSize.width, pageSize.height], compress: false }); - var builder = new LayoutBuilder(pageSize, { left: this.MARGINS.left, right: this.MARGINS.right, top: this.MARGINS.top, bottom: this.MARGINS.bottom }, new SVGMeasure()); + var pageMargins = docDefinition.pageMargins || { left: this.MARGINS.left, right: this.MARGINS.right, top: this.MARGINS.top, bottom: this.MARGINS.bottom }; + var builder = new LayoutBuilder(pageSize, pageMargins, new SVGMeasure()); return builder.layoutDocument( docDefinition.content, diff --git a/tests/integration/snaking_columns.spec.js b/tests/integration/snaking_columns.spec.js index a75223666..aa2e3f344 100644 --- a/tests/integration/snaking_columns.spec.js +++ b/tests/integration/snaking_columns.spec.js @@ -1032,6 +1032,42 @@ describe('Integration test: snaking columns', function () { assert.ok(lineWidth > 150, 'Page 2 separate column should have reset to wide width (>150), found: ' + lineWidth); }); + it('should reflow the first carried line when dynamic margins snake into a narrower column', function () { + var dd = { + pageMargins: function (currentPage) { + return { + left: currentPage % 2 === 0 ? 100 : 20, + top: 20, + right: currentPage % 2 === 0 ? 20 : 100, + bottom: 20 + }; + }, + content: [ + { + columns: [ + { text: 'Wide content ' + 'text '.repeat(2000), width: 300, fontSize: 10 }, + { text: '', width: '*' } + ], + columnGap: 10, + snakingColumns: true + } + ] + }; + + var pages = testHelper.renderPages('A4', dd); + var page1 = pages[0]; + var lines = page1.items.filter(function (item) { return item.type === 'line'; }).map(function (item) { return item.item; }); + var secondColumnLines = lines.filter(function (line) { return line.x > 320; }); + var firstSecondColumnLine = secondColumnLines[0]; + var rightLimit = page1.pageSize.width - page1.pageMargins.right; + + assert.ok(firstSecondColumnLine, 'Page 1 should continue into the second snaking column'); + assert.ok( + firstSecondColumnLine.x + firstSecondColumnLine.getWidth() <= rightLimit + 0.5, + 'The first carried line in the second column should respect the page right margin' + ); + }); + describe('snaking columns nested checks', function () { it('should respect left margin when snaking columns break to next page', function () { diff --git a/tests/unit/PageElementWriter.spec.js b/tests/unit/PageElementWriter.spec.js index d3fe262a4..04db28c23 100644 --- a/tests/unit/PageElementWriter.spec.js +++ b/tests/unit/PageElementWriter.spec.js @@ -421,7 +421,7 @@ describe('PageElementWriter', function () { it('should use existing page', function () { addOneTenthLines(1); - ctx.pages.push({ items: [], pageSize: pageSize }); + ctx.pages.push({ items: [], pageSize: pageSize, pageMargins: MARGINS }); ctx.availableWidth = 'garbage'; ctx.availableHeight = 'garbage';