Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions src/ElementWriter.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,16 +331,17 @@ class ElementWriter extends EventEmitter {
return false;
}

let xOffset = useBlockXOffset ? (block.xOffset || 0) : ctx.x;
let yOffset = useBlockYOffset ? (block.yOffset || 0) : ctx.y;

block.items.forEach(item => {
switch (item.type) {
case 'line':
var l = item.item.clone();

if (l._node) {
l._node.positions[0].pageNumber = ctx.page + 1;
}
l.x = (l.x || 0) + (useBlockXOffset ? (block.xOffset || 0) : ctx.x);
l.y = (l.y || 0) + (useBlockYOffset ? (block.yOffset || 0) : ctx.y);
updateNodePageNumber(l, ctx.page + 1);
l.x = (l.x || 0) + xOffset;
l.y = (l.y || 0) + yOffset;

page.items.push({
type: 'line',
Expand All @@ -351,7 +352,9 @@ class ElementWriter extends EventEmitter {
case 'vector':
var v = pack(item.item);

offsetVector(v, useBlockXOffset ? (block.xOffset || 0) : ctx.x, useBlockYOffset ? (block.yOffset || 0) : ctx.y);
updateNodePageNumber(v, ctx.page + 1);

offsetVector(v, xOffset, yOffset);
if (v._isFillColorFromUnbreakable) {
// If the item is a fillColor from an unbreakable block
// We have to add it at the beginning of the items body array of the page
Expand All @@ -371,14 +374,17 @@ class ElementWriter extends EventEmitter {

case 'image':
case 'svg':
case 'attachment':
case 'beginClip':
case 'endClip':
case 'beginVerticalAlignment':
case 'endVerticalAlignment':
var img = pack(item.item);

img.x = (img.x || 0) + (useBlockXOffset ? (block.xOffset || 0) : ctx.x);
img.y = (img.y || 0) + (useBlockYOffset ? (block.yOffset || 0) : ctx.y);
updateNodePageNumber(img, ctx.page + 1);

img.x = (img.x || 0) + xOffset;
img.y = (img.y || 0) + yOffset;

page.items.push({
type: item.type,
Expand Down Expand Up @@ -438,4 +444,10 @@ function addPageItem(page, item, index) {
}
}

function updateNodePageNumber(item, pageNumber) {
if (item._node && item._node.positions.length > 0) {
item._node.positions[0].pageNumber = pageNumber;
}
}

export default ElementWriter;
20 changes: 18 additions & 2 deletions src/LayoutBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class LayoutBuilder {
this.tableLayouts = {};
this.nestedLevel = 0;
this.verticalAlignmentItemStack = [];
this._suppressLinearNodeList = false;
}

registerTableLayouts(tableLayouts) {
Expand Down Expand Up @@ -259,7 +260,9 @@ class LayoutBuilder {
let sizes = sizeFunction(this.writer.context().getCurrentPage().pageSize, this.writer.context().getCurrentPage().pageMargins);
this.writer.beginUnbreakableBlock(sizes.width, sizes.height);
node = this.docPreprocessor.preprocessBlock(node);
this._suppressLinearNodeList = true;
this.processNode(this.docMeasure.measureBlock(node));
this._suppressLinearNodeList = false;
this.writer.commitUnbreakableBlock(sizes.x, sizes.y);
}
}
Expand Down Expand Up @@ -479,7 +482,9 @@ class LayoutBuilder {
}
};

this.linearNodeList.push(node);
if (!this._suppressLinearNodeList) {
this.linearNodeList.push(node);
}
decorateNode(node);

if (this.writer.context().getCurrentPage() !== null) {
Expand Down Expand Up @@ -1234,7 +1239,7 @@ class LayoutBuilder {
// leafs (texts)
processLeaf(node) {
let line = this.buildNextLine(node);
if (line && (node.tocItem || node.id)) {
if (line) {
line._node = node;
}
let currentHeight = (line) ? line.getHeight() : 0;
Expand Down Expand Up @@ -1406,29 +1411,40 @@ class LayoutBuilder {
}

// images
// _node references allow addFragment to update page numbers when unbreakable blocks move pages.
// For canvas/qr, _node is set on the first vector since individual vectors are the page items.
processImage(node) {
let position = this.writer.addImage(node);
node.positions.push(position);
node._node = node;
}

processCanvas(node) {
let positions = this.writer.addCanvas(node);
addAll(node.positions, positions);
if (node.canvas.length > 0) {
node.canvas[0]._node = node;
}
}

processSVG(node) {
let position = this.writer.addSVG(node);
node.positions.push(position);
node._node = node;
}

processQr(node) {
let position = this.writer.addQr(node);
node.positions.push(position);
if (node._canvas && node._canvas.length > 0) {
node._canvas[0]._node = node;
}
}

processAttachment(node) {
let position = this.writer.addAttachment(node);
node.positions.push(position);
node._node = node;
}
}

Expand Down
167 changes: 167 additions & 0 deletions tests/unit/LayoutBuilder.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,152 @@ describe('LayoutBuilder', function () {

});

// Fills most of page 1 so a 60px-tall unbreakable block won't fit and moves to page 2
function fillerAndHeadingWithUnbreakable(stackContent, blockId) {
var fillerBreaks = new Array(57).join("\n");
return [
{ text: 'Filler' + fillerBreaks, id: 'filler' },
{ text: 'Heading', id: 'heading', headlineLevel: 1 },
{
stack: stackContent,
unbreakable: true,
id: blockId
}
];
}

function getPageBreakBeforeCalls(spy) {
return Array.from({ length: spy.callCount }, function (_, i) {
return spy.getCall(i);
});
}

function findCallById(spy, id) {
var calls = getPageBreakBeforeCalls(spy);
var call = calls.find(function (call) { return call.args[0].id === id; });
assert(call, id + ' should be passed to pageBreakBefore');
return call;
}

it('should update page numbers when unbreakable block moves to next page', function () {
// Content without id/tocItem to ensure _node is set on all lines
docStructure = fillerAndHeadingWithUnbreakable([
{ text: 'Line 1' },
{ text: 'Line 2' },
{ text: 'Line 3' },
{ text: 'Line 4' },
{ text: 'Line 5' }
], 'unbreakable-block');

pageBreakBeforeFunction = sinon.spy();

builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

var headingCall = findCallById(pageBreakBeforeFunction, 'heading');

// The unbreakable block moved to page 2, so the heading on page 1
// should have no following nodes on the same page
var following = headingCall.args[1].getFollowingNodesOnPage();
assert.equal(following.length, 0,
'heading should have no following nodes on page 1, got ' + following.length);
});

it('should allow pageBreakBefore to fix orphaned heading before unbreakable block', function () {
// Content without id/tocItem — the fix ensures _node is set on all
// lines, not just those with id or tocItem
docStructure = fillerAndHeadingWithUnbreakable([
{ text: 'Line 1' },
{ text: 'Line 2' },
{ text: 'Line 3' },
{ text: 'Line 4' },
{ text: 'Line 5' }
], 'unbreakable-block');

// Callback that moves orphaned headings: if a heading has no following
// content on the same page, insert a page break before it
pageBreakBeforeFunction = function (nodeInfo, followingInfo) {
if (nodeInfo.headlineLevel && followingInfo.getFollowingNodesOnPage().length === 0) {
return true;
}
return false;
};

var pages = builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

// The heading should have moved to page 2 along with the unbreakable block
assert.equal(pages.length, 2, 'should have 2 pages');

var page2Lines = pages[1].items
.filter(function (item) { return item.type === 'line'; })
.map(function (item) {
return item.item.inlines.map(function (inline) { return inline.text; }).join('');
});
assert.ok(page2Lines.indexOf('Heading') > -1, 'heading should be on page 2, got lines: ' + JSON.stringify(page2Lines));
assert.ok(page2Lines.indexOf('Line 1') > -1, 'unbreakable content should be on page 2');
});

it('should update page numbers for canvas in unbreakable block', function () {
docStructure = fillerAndHeadingWithUnbreakable([
{ canvas: [{ type: 'rect', x: 0, y: 0, w: 100, h: 60 }], id: 'canvas-node' }
], 'unbreakable-canvas-block');

pageBreakBeforeFunction = sinon.spy();

builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

var canvasCall = findCallById(pageBreakBeforeFunction, 'canvas-node');
assert.deepEqual(canvasCall.args[0].pageNumbers, [2], 'canvas should be on page 2, got: ' + JSON.stringify(canvasCall.args[0].pageNumbers));
});

it('should update page numbers for qr in unbreakable block', function () {
docStructure = fillerAndHeadingWithUnbreakable([
{ qr: 'test', fit: 60, id: 'qr-node' }
], 'unbreakable-qr-block');

pageBreakBeforeFunction = sinon.spy();

builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

var qrCall = findCallById(pageBreakBeforeFunction, 'qr-node');
assert.deepEqual(qrCall.args[0].pageNumbers, [2], 'qr should be on page 2, got: ' + JSON.stringify(qrCall.args[0].pageNumbers));
});

it('should render attachment inside unbreakable block on same page', function () {
docStructure = [
{
stack: [
{ attachment: 'test.pdf', width: 10, height: 18, id: 'att-same-page' }
],
unbreakable: true,
id: 'unbreakable-att-same'
}
];

pageBreakBeforeFunction = sinon.spy();

var pages = builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

var page1Items = pages[0].items.map(function (item) { return item.type; });
assert.ok(page1Items.indexOf('attachment') > -1, 'attachment should be rendered on page 1, got items: ' + JSON.stringify(page1Items));
});

it('should update page numbers for attachment in unbreakable block', function () {
docStructure = fillerAndHeadingWithUnbreakable([
{ attachment: 'test.pdf', width: 10, height: 60, id: 'att-node' }
], 'unbreakable-att-block');

pageBreakBeforeFunction = sinon.spy();

var pages = builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

// Verify the attachment item is actually present on page 2
var page2Items = pages[1].items.map(function (item) { return item.type; });
assert.ok(page2Items.indexOf('attachment') > -1, 'attachment should be rendered on page 2, got items: ' + JSON.stringify(page2Items));

var attCall = findCallById(pageBreakBeforeFunction, 'att-node');
assert.deepEqual(attCall.args[0].pageNumbers, [2], 'attachment should be on page 2, got: ' + JSON.stringify(attCall.args[0].pageNumbers));
});

it('should provide all page numbers of the node', function () {
var eightyLineBreaks = new Array(80).join("\n");
docStructure = [
Expand All @@ -2076,6 +2222,27 @@ describe('LayoutBuilder', function () {
assert.deepEqual(pageBreakBeforeFunction.getCall(2).args[0].pageNumbers, [1, 2]);
assert.deepEqual(pageBreakBeforeFunction.getCall(3).args[0].pageNumbers, [2]);
});

it('should not include header/footer nodes in pageBreakBefore calls', function () {
docStructure = [
{ text: 'Body text', id: 'body' }
];
header = function () {
return { text: 'Header text', id: 'header-text' };
};
footer = function () {
return { text: 'Footer text', id: 'footer-text' };
};

pageBreakBeforeFunction = sinon.spy();

builder.layoutDocument(docStructure, pdfDocument, styleDictionary, defaultStyle, background, header, footer, watermark, pageBreakBeforeFunction);

var calls = getPageBreakBeforeCalls(pageBreakBeforeFunction);
var ids = calls.map(function (call) { return call.args[0].id; });
assert.ok(ids.indexOf('header-text') === -1, 'header nodes should not appear in pageBreakBefore, got ids: ' + JSON.stringify(ids));
assert.ok(ids.indexOf('footer-text') === -1, 'footer nodes should not appear in pageBreakBefore, got ids: ' + JSON.stringify(ids));
});
});

describe('table of content', function () {
Expand Down
Loading