Skip to content

Dynamic Page Margin#2922

Open
ihsansfd wants to merge 13 commits intobpampuch:masterfrom
ihsansfd:f/dynamic-page-margin
Open

Dynamic Page Margin#2922
ihsansfd wants to merge 13 commits intobpampuch:masterfrom
ihsansfd:f/dynamic-page-margin

Conversation

@ihsansfd
Copy link
Copy Markdown

@ihsansfd ihsansfd commented Mar 8, 2026

This is an approach to solve the problem when we want to use footer/header in one page only (first page only for header, and last page only for footer). Currently, we have to add page margin (top for header, or bottom for footer) and it will be applied to every page, though we only want for one page only.

With this approach, for footer for example, we will want to conditionally add page margin bottom if last page only. Code:

pageMargins: (function(currentPage, pageCount, pageSize) {
      return {
        left: 20,
        top: 20,
        right: 20,
        bottom: (currentPage === pageCount) ?  100 : 20
      };
    })

This is not very easy, because we need pageCount, but pageCount only available once everything is rendered. And adding page margin requires rerendering because it can change the layout. So my approach is to follow page break functionality. In the first pass, the pageCount would be 0, after that pageCount would be filled, and the dynamic page margin condition would execute. If we found that there's more page (due to the page margin that push down the content), then we do additional rerendering until the loop stops itself (pagesCount === result.pages.length).

Actually it can cause infinite loop. For that to happen, the margin function must create a paradox where:
Assuming N pages → produces M pages
Assuming M pages → produces N pages back again
Forever bouncing between N and M.

This can be introduced if we're using odd/even condition, for example. If odd, do large margin, if even, do small margin. That is why I limit the iteration until 10 to avoid this.

Another approach, we can further restrict by only adding parameter "isFirstPage" and "isLastPage". But this is the approach I choose to give flexibility, since technically user should know what are they doing and we can document this the doc.

Let me know what do you think 🙏 .

Image below is my testing: left is using the dynamic page margin, and I successfully applied the page margin bottom for last page only.
Screenshot 2026-03-08 at 17 21 01

@ihsansfd
Copy link
Copy Markdown
Author

ihsansfd commented Mar 8, 2026

@liborm85 could you help to review this PR? thanks

@ihsansfd ihsansfd changed the title Dyanamic Page Margin Dynamic Page Margin Mar 8, 2026
@ihsansfd
Copy link
Copy Markdown
Author

Hi @liborm85 , will this PR be reviewed anytime soon (I will work on any feedback)? If not, I might consider to fork the library for now because I need this faeture for my project.

@liborm85
Copy link
Copy Markdown
Collaborator

I tried it with text, and it works correctly there. But there’s a problem with tables, try tables example with:

	pageMargins: function(currentPage, pageCount, pageSize) {
		return {
			left: (currentPage % 2 === 0) ? 100 : 20,
			top: 20,
			right: (currentPage % 2 === 0) ? 20 : 100,
			bottom: 20
		};
	},

and see pdf document pages.

@liborm85
Copy link
Copy Markdown
Collaborator

I added example to PR 9a0df24 and problem is on page 8 in table with headerRows:
image

@ihsansfd
Copy link
Copy Markdown
Author

yes thank you @liborm85 , i'll try to spare my time for this pr.

Acknowledged the current issue with tables, that's why I didn't nudge you yet 🙏

@ihsansfd ihsansfd force-pushed the f/dynamic-page-margin branch from d911bad to 87c4c35 Compare April 5, 2026 06:17
@ihsansfd
Copy link
Copy Markdown
Author

ihsansfd commented Apr 5, 2026

Hi @liborm85 , I fixed all the issues. Sorry I need to reset hard some of the commits to avoid making the commits unclean, since I did a lot of changes. But i keep your example file and suggestions.

Also I added log warn and examples on the paradox that would cause infinite loop when applying margin in a non divergent way (prevented by the max loop guard).

Here's the regression tests I use to verify my work:

/*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);

if (typeof pdfmake.setUrlAccessPolicy === 'function') {
	pdfmake.setUrlAccessPolicy((url) => {
		// this can be used to restrict allowed domains
		return url.startsWith('https://');
	});
}

if (typeof pdfmake.setLocalAccessPolicy === 'function') {
	pdfmake.setLocalAccessPolicy((path) => {
		// this can be used to restrict access to local file system
		return true;
	});
}

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. ';

function sectionTitle(text, options) {
	var node = {
		text: text,
		style: 'sectionHeader'
	};

	if (options && options.pageBreak) {
		node.pageBreak = options.pageBreak;
	}

	if (options && options.pageOrientation) {
		node.pageOrientation = options.pageOrientation;
	}

	if (options && options.id) {
		node.id = options.id;
	}

	return node;
}

function generateParagraphs(count, repeatsPerParagraph) {
	var items = [];

	for (var i = 1; i <= count; i++) {
		items.push({
			text: 'Paragraph ' + i + ': ' + loremIpsum.repeat(repeatsPerParagraph),
			margin: [0, 0, 0, 8]
		});
	}

	return items;
}

function generateListItems(count) {
	var items = [];

	for (var i = 1; i <= count; i++) {
		items.push({
			text: 'Bullet ' + i + ': ' + loremIpsum.repeat(i % 3 === 0 ? 2 : 1),
			margin: [0, 2, 0, 2]
		});
	}

	items.splice(4, 0, {
		ul: [
			'Nested bullet A: ' + loremIpsum,
			'Nested bullet B: ' + loremIpsum
		],
		margin: [0, 4, 0, 4]
	});

	return items;
}

function generateHeaderTableBody(rowCount, repeatsPerCell) {
	var body = [[
		{ text: 'Header 1', style: 'tableHeader' },
		{ text: 'Header 2', style: 'tableHeader' },
		{ text: 'Header 3', style: 'tableHeader' }
	]];

	for (var i = 1; i <= rowCount; i++) {
		body.push([
			'Row ' + i + ' / Col 1: ' + loremIpsum.repeat(repeatsPerCell),
			'Row ' + i + ' / Col 2: ' + loremIpsum.repeat(repeatsPerCell),
			'Row ' + i + ' / Col 3: ' + loremIpsum.repeat(repeatsPerCell)
		]);
	}

	return body;
}

function generateAdvancedTableBody(rowCount) {
	var body = [
		[
			{ text: 'Group', style: 'tableHeader' },
			{ text: 'Description', style: 'tableHeader' },
			{ text: 'Status', style: 'tableHeader' }
		],
		[
			{ text: 'Behavior', style: 'tableHeader' },
			{ text: 'Row spans, col spans, vertical alignment and repeated headers', colSpan: 2, style: 'tableHeader' },
			''
		]
	];

	for (var i = 1; i <= rowCount; i++) {
		if (i % 5 === 1 && i < rowCount) {
			body.push([
				{ text: 'Row span ' + i, rowSpan: 2, fillColor: '#f5f5f5' },
				{ text: 'Merged detail ' + i + ': ' + loremIpsum.repeat(1), colSpan: 2, fillColor: '#fcfcfc' },
				''
			]);
			body.push([
				'',
				{ text: 'Follow-up ' + (i + 1) + ': ' + loremIpsum.repeat(1), verticalAlignment: 'bottom' },
				{ text: i % 2 === 0 ? 'Open' : 'Closed', alignment: 'center' }
			]);
			i++;
			continue;
		}

		if (i % 4 === 0) {
			body.push([
				{ text: 'Group ' + i },
				{ text: 'Wide summary ' + i + ': ' + loremIpsum.repeat(1), colSpan: 2 },
				''
			]);
			continue;
		}

		body.push([
			{ text: 'Group ' + i },
			{ text: 'Detail ' + i + ': ' + loremIpsum.repeat(1) },
			{ text: i % 2 === 0 ? 'Active' : 'Review', alignment: 'center', verticalAlignment: 'middle' }
		]);
	}

	return body;
}

function generateCards(count) {
	var cards = [];

	for (var i = 1; i <= count; i++) {
		cards.push({
			stack: [
				{ text: 'Card ' + i, style: 'cardTitle' },
				{ text: 'This card is marked unbreakable so it should move as a whole when margins change across pages and columns.', fontSize: 10, margin: [0, 0, 0, 4] },
				{ text: loremIpsum.repeat(1), fontSize: 9, color: '#555' }
			],
			unbreakable: true,
			fillColor: '#f6f6f6',
			margin: [0, 0, 0, 10]
		});
	}

	return cards;
}

function generateReferenceRows(count) {
	var body = [[
		{ text: 'Anchor', style: 'tableHeader' },
		{ text: 'What To Check', style: 'tableHeader' }
	]];

	for (var i = 1; i <= count; i++) {
		body.push([
			'Check ' + i,
			'Final portrait rows keep alternating margins stable while the footer still depends on currentPage === pageCount.'
		]);
	}

	return body;
}

function buildContent() {
	return [
		sectionTitle('Dynamic Page Margins Regression Showcase'),
		{
			text: 'This file collects layout cases that are most sensitive to page-local margin changes. It stays convergent by using currentPage-based margins only. The paradoxical pageCount-based cases live in the dedicated paradox examples.',
			style: 'description'
		},
		{
			table: {
				widths: ['*', 80],
				body: [
					[{ text: 'Feature', style: 'tableHeader' }, { text: 'Page', style: 'tableHeader', alignment: 'right' }],
					['Orientation changes', { pageReference: 'orientation-section', alignment: 'right' }],
					['Back to portrait', { pageReference: 'portrait-section', alignment: 'right' }]
				]
			},
			layout: 'lightHorizontalLines',
			margin: [0, 0, 0, 12]
		}
	].concat(
		generateParagraphs(4, 4),
		[
			sectionTitle('Lists', { pageBreak: 'before' }),
			{
				text: 'Lists use their own marker positioning, so alternating margins are an easy way to catch stale left offsets.',
				style: 'description'
			},
			{ ul: generateListItems(14), margin: [0, 0, 0, 12] },
			{
				ol: [
					'Ordered item 1: ' + loremIpsum,
					'Ordered item 2: ' + loremIpsum,
					'Ordered item 3: ' + loremIpsum.repeat(2),
					'Ordered item 4: ' + loremIpsum
				]
			},

			sectionTitle('Tables', { pageBreak: 'before' }),
			{
				text: 'These sections cover repeated headers, large body rows, row spans, col spans and vertical alignment.',
				style: 'description'
			},
			'Basic table:',
			{
				table: {
					widths: ['*', '*', '*'],
					body: generateHeaderTableBody(6, 2)
				}
			},
			'',
			'Table with headerRows:',
			{
				table: {
					headerRows: 1,
					widths: ['*', '*', '*'],
					body: generateHeaderTableBody(18, 2)
				}
			},
			'',
			'Advanced table features:',
			{
				table: {
					headerRows: 2,
					keepWithHeaderRows: 1,
					dontBreakRows: false,
					widths: [90, '*', 80],
					body: generateAdvancedTableBody(24)
				},
				layout: 'lightHorizontalLines'
			},

			sectionTitle('Snaking Columns', { pageBreak: 'before' }),
			{
				text: 'This minimal snaking example uses alternating left and right page margins. The carried line into the narrow right column should still wrap inside the current page width.',
				style: 'description'
			},
			{
				columns: [
					{ text: loremIpsum.repeat(42), width: 300, fontSize: 10 },
					{ text: '', width: '*' }
				],
				columnGap: 10,
				snakingColumns: true
			},
			{
				text: 'Nested regular columns inside snaking columns',
				style: 'subheader',
				margin: [0, 16, 0, 6]
			},
			{
				columns: [
					{
						stack: [
							{ text: 'Overview', style: 'cardTitle' },
							{
								columns: [
									{ width: '50%', text: 'Left summary\n' + loremIpsum.repeat(1) },
									{ width: '50%', text: 'Right summary\n' + loremIpsum.repeat(1) }
								],
								columnGap: 10,
								margin: [0, 0, 0, 10]
							},
							{ text: Array.from({ length: 70 }, function (_, index) { return 'Nested line ' + (index + 1) + ': ' + loremIpsum; }).join('\n'), fontSize: 9 }
						]
					},
					{ text: '' }
				],
				columnGap: 20,
				snakingColumns: true
			},

			sectionTitle('Unbreakable Blocks', { pageBreak: 'before' }),
			{
				text: 'Each card below is marked unbreakable. When the next page uses a different left or right margin, the card should move intact rather than splitting.',
				style: 'description'
			},
			{
				columns: [
					{ stack: generateCards(18), width: '*' },
					{ text: '', width: '*' }
				],
				columnGap: 20,
				snakingColumns: true
			},

			sectionTitle('SVG And Canvas', { pageBreak: 'before' }),
			{
				text: 'Non-text nodes are measured once, so this section is useful for checking whether they still align after margin changes and column/page breaks.',
				style: 'description'
			},
			{
				columns: [
					{
						stack: [
							{
								svg: '<svg width="180" height="60"><rect width="180" height="60" fill="#2c7fb8" rx="6"/><text x="90" y="36" text-anchor="middle" fill="white" font-size="14">SVG In Column</text></svg>',
								margin: [0, 0, 0, 10]
							},
							{
								canvas: [
									{ type: 'rect', x: 0, y: 0, w: 180, h: 50, color: '#e8eef7' },
									{ type: 'line', x1: 10, y1: 35, x2: 170, y2: 15, lineWidth: 2, lineColor: '#1f77b4' },
									{ type: 'ellipse', x: 70, y: 10, color: '#ff7f0e', r1: 10, r2: 10 }
								],
								margin: [0, 0, 0, 10]
							},
							{ text: loremIpsum.repeat(14), fontSize: 9 }
						]
					},
					{ text: '', width: '*' }
				],
				columnGap: 20,
				snakingColumns: true
			},

			sectionTitle('Absolute And Relative Positioning', { pageBreak: 'before' }),
			{
				text: 'Relative positioning still follows normal flow. Absolute positioning does not, so this section helps show whether expectations about margins and page-local coordinates are realistic.',
				style: 'description'
			},
			{
				canvas: [
					{ type: 'rect', x: 0, y: 0, w: 460, h: 220, lineColor: '#cccccc' }
				],
				margin: [0, 10, 0, 10]
			},
			{
				text: 'Relative-positioned note',
				relativePosition: { x: 20, y: -180 },
				color: '#1f77b4',
				bold: true
			},
			{
				text: 'Absolute-positioned annotation',
				absolutePosition: { x: 220, y: 170 },
				color: '#d62728',
				bold: true
			},
			{
				text: loremIpsum.repeat(8),
				margin: [0, 0, 0, 8]
			},

			sectionTitle('Orientation Changes', { pageBreak: 'before', pageOrientation: 'landscape', id: 'orientation-section' }),
			{
				text: 'This page flips to landscape. Column widths and alternating margins should still line up correctly.',
				style: 'description'
			},
			{
				columns: [
					{ text: loremIpsum.repeat(18), width: '*' },
					{ text: '', width: '*' },
					{ text: '', width: '*' }
				],
				columnGap: 20,
				snakingColumns: true
			},

			sectionTitle('Back To Portrait', { pageBreak: 'before', pageOrientation: 'portrait', id: 'portrait-section' }),
			{
				text: 'This last section returns to portrait and leaves a footer only on the last page, which is a stable currentPage === pageCount use case outside of pageMargins itself.',
				style: 'description'
			},
			{
				table: {
					headerRows: 1,
					keepWithHeaderRows: 1,
					widths: [70, '*'],
					body: generateReferenceRows(35)
				},
				layout: 'lightHorizontalLines'
			}
		]
	);
}

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
		};
	},
	background: function (currentPage, pageSize) {
		return {
			canvas: [
				{
					type: 'line',
					x1: 0,
					y1: 18,
					x2: pageSize.width,
					y2: 18,
					lineWidth: 0.5,
					lineColor: currentPage % 2 === 0 ? '#dddddd' : '#efefef'
				}
			]
		};
	},
	header: function (currentPage, pageCount) {
		return {
			text: 'Page ' + currentPage + ' of ' + pageCount + ' | alternating margins ' + (currentPage % 2 === 0 ? '80 / 40' : '40 / 80'),
			alignment: 'right',
			fontSize: 8,
			color: '#888',
			margin: [40, 10, 40, 0]
		};
	},
	footer: function (currentPage, pageCount) {
		if (currentPage === pageCount) {
			return {
				text: 'Last page footer: stable currentPage === pageCount behavior outside of pageMargins.',
				alignment: 'center',
				fontSize: 8,
				color: '#777',
				italics: true,
				margin: [40, 0, 40, 10]
			};
		}

		return null;
	},
	watermark: {
		text: 'Dynamic Margins',
		color: '#bfbfbf',
		opacity: 0.05,
		bold: true
	},
	content: buildContent(),
	styles: {
		header: {
			fontSize: 18,
			bold: true,
			margin: [0, 0, 0, 10]
		},
		sectionHeader: {
			fontSize: 16,
			bold: true,
			margin: [0, 0, 0, 8]
		},
		subheader: {
			fontSize: 13,
			bold: true
		},
		tableHeader: {
			bold: true,
			fillColor: '#f0f0f0'
		},
		description: {
			fontSize: 10,
			italics: true,
			color: '#666',
			margin: [0, 0, 0, 8]
		},
		cardTitle: {
			fontSize: 12,
			bold: true,
			margin: [0, 0, 0, 4]
		}
	}
};

var now = new Date();

var pdf = pdfmake.createPdf(docDefinition);
pdf.write('pdfs/dynamicPageMargins.pdf').then(() => {
	console.log(new Date() - now);
}, err => {
	console.error(err);
});

Let me know anything else I can help if there is any. Otherwise, thank you 🙏

@ihsansfd
Copy link
Copy Markdown
Author

ihsansfd commented Apr 5, 2026

By the way, seems like there's another issue unrelated to this changes, which is spotted in the example DynamicPageMargins.

When column in a table expanded multiple pages, seems the text in the next column in the table is not written yet until the text in prev column is finished processed. It caused big spaces on top of the column.

i verified this is not related to the changes by checking out master and run the same example, it produces same output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants