Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Unreleased

- Fix `continued` text with `align: 'center'` or `align: 'right'` aligning each segment on its own, so the segments of a line overlapped. A line made of `continued` segments is now aligned as a whole, the way the same text given in a single call is, and its links move with it. Fixes #240 and #774
- Fix `doc.table()` parsing a font passed as binary data (e.g. registered with `registerFont(name, uint8Array)`) again for every cell, when it restores the font after measuring and drawing the cell, which made tables with such fonts slower. A font loaded from the same `Uint8Array` is now reused without parsing it again
- Add `lineBreak: 'whitespace'` to `doc.text()`, which breaks a line only at whitespace, a soft hyphen or a zero-width space. The Unicode line breaking rules the wrapper follows also allow a break after a hyphen or a slash, so `design-system` or `https://github.com/foliojs/pdfkit` could be cut at a line end, and a text extractor that joins the lines, such as a résumé parser, reads two words
- Add an `objectStreams` option, which packs the objects that are not streams into compressed object streams and writes a cross-reference stream instead of the xref table, making documents with many such objects, e.g. tagged ones, much smaller. It requires a `pdfVersion` of `1.5` or higher, and cannot be used with encryption or PDF/A-1. Fixes #1810
Expand Down
3 changes: 3 additions & 0 deletions docs/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ The options given to the first `text` call are also retained for subsequent call
`continued` one, but of course you can override them. In the following example, the `width`
option from the first `text` call is retained by the second call.

With `align: 'center'` or `align: 'right'`, a line made of several `continued` segments is
aligned as a whole, like the same text given in a single call.

doc.fillColor('green')
.text(lorem.slice(0, 500), {
width: 465,
Expand Down
15 changes: 14 additions & 1 deletion lib/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ class PDFDocument extends Readable {
}

addPage(options) {
this._flushPendingLine();

const documentOptions = this.options;

// end the current page if needed
Expand Down Expand Up @@ -186,6 +188,7 @@ class PDFDocument extends Readable {
}

continueOnNewPage(options) {
this._flushPendingLine();
const pageMarkings = this.endPageMarkings(this.page);

this.addPage(options ?? this.page._options);
Expand All @@ -200,6 +203,8 @@ class PDFDocument extends Readable {
}

switchToPage(n) {
this._flushPendingLine();

let page;
if (!(page = this._pageBuffer[n - this._pageBufferStart])) {
throw new Error(
Expand All @@ -213,6 +218,8 @@ class PDFDocument extends Readable {
}

flushPages() {
this._flushPendingLine();

// this local variable exists so we're future-proof against
// reentrant calls to flushPages.
const pages = this._pageBuffer;
Expand Down Expand Up @@ -282,7 +289,13 @@ class PDFDocument extends Readable {
}

addContent(data) {
this.page.write(data);
// while a line of continued text is held back, everything drawn after it
// waits with it so that the order of the content stays the same
if (this._pendingLine) {
this._pendingLine.content.push(data);
} else {
this.page.write(data);
}
return this;
}

Expand Down
196 changes: 159 additions & 37 deletions lib/mixins/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ function formatListLabel(n, listType) {
export default {
initText() {
this._line = this._line.bind(this);
this._textLine = this._textLine.bind(this);
// Current coordinates
this.x = 0;
this.y = 0;
this._lineGap = 0;
// a center or right aligned line of continued text, held back until the
// rest of it is known
this._pendingLine = null;
this._continuesLine = false;
},

lineGap(_lineGap) {
Expand Down Expand Up @@ -107,7 +112,15 @@ export default {
},

text(text, x, y, options) {
return this._text(text, x, y, options, this._line);
// the first line of this text carries on a held back line
this._continuesLine = this._pendingLine != null;
this._text(text, x, y, options, this._textLine);

// a held back line is complete once a text does not continue it
if (!this._wrapper) {
this._flushPendingLine();
}
return this;
},

widthOfString(string, options = {}) {
Expand Down Expand Up @@ -462,7 +475,115 @@ export default {
}
},

_fragment(text, x, y, options) {
_textLine(text, options = {}, wrapper) {
// a held back line is complete once another line starts
if (
this._pendingLine &&
(!this._continuesLine || !wrapper || this.y !== this._pendingLine.y)
) {
this._flushPendingLine();
}
this._continuesLine = false;

// A center or right aligned line made of continued text can only be
// aligned once all of it is known, so each line of such text is held back
// until it is complete.
const align = options.align;
if (
wrapper &&
(this._pendingLine ||
(options.continued && (align === 'center' || align === 'right')))
) {
this._holdFragment(text, options);
const lineGap = options.lineGap || this._lineGap || 0;
this.y += this.currentLineHeight(true) + lineGap;
} else {
this._line(text, options, wrapper);
}
},

_holdFragment(text, options) {
text = `${text}`.replace(/\n/g, '');
let line = this._pendingLine;
if (!line) {
line = this._pendingLine = {
align: options.align,
x: this.x,
y: this.y,
lineWidth: options.lineWidth,
end: this.x,
content: [],
annotations: [],
};
}

// draw the fragment left aligned, it moves with the line once that is complete
const content = line.content;
line.content = [];
this._fragment(text, this.x, this.y, { ...options, align: 'left' }, line);
content.push(line.content);
line.content = content;

if (text.length) {
line.end = this.x + this._alignedWidth(text, line.align, options);
}
},

_flushPendingLine() {
const line = this._pendingLine;
if (!line) {
return;
}
this._pendingLine = null;

// align the line as a whole, the way a line of a single text is aligned
const width = line.end - line.x;
const dx =
line.align === 'right'
? line.lineWidth - width
: line.lineWidth / 2 - width / 2;

for (const item of line.content) {
if (!Array.isArray(item)) {
this.addContent(item);
} else if (dx) {
this.addContent('q');
this.addContent(`1 0 0 1 ${number(dx)} 0 cm`);
item.forEach((data) => this.addContent(data));
this.addContent('Q');
} else {
item.forEach((data) => this.addContent(data));
}
}
line.annotations.forEach((annotate) => annotate(dx));
},

// the width a center or right aligned line of text is aligned by
_alignedWidth(text, align, options) {
const wordSpacing = options.wordSpacing || 0;
const characterSpacing = options.characterSpacing || 0;
const horizontalScaling = options.horizontalScaling || 100;

if (align === 'right') {
let textWidth = this.widthOfString(text.replace(/\s+$/, ''), options);
if (wordSpacing) {
const gaps = text.trim().split(/\s+/).length - 1;
textWidth += (wordSpacing * gaps * horizontalScaling) / 100;
}
return textWidth;
}

// the wrapper's textWidth also counts the spacing after the last
// word and the word spacing a second time
return (
options.textWidth -
((characterSpacing + wordSpacing * options.wordCount) *
horizontalScaling) /
100
);
},

_fragment(text, x, y, options, heldLine) {
let dy, encoded, i, positions, textWidth, words;
text = `${text}`.replace(/\n/g, '');
if (text.length === 0) {
Expand All @@ -479,23 +600,13 @@ export default {
if (options.width) {
switch (align) {
case 'right':
textWidth = this.widthOfString(text.replace(/\s+$/, ''), options);
if (wordSpacing) {
const gaps = text.trim().split(/\s+/).length - 1;
textWidth += (wordSpacing * gaps * horizontalScaling) / 100;
}
x += options.lineWidth - textWidth;
x += options.lineWidth - this._alignedWidth(text, align, options);
break;

case 'center':
// the wrapper's textWidth also counts the spacing after the last
// word and the word spacing a second time
textWidth =
options.textWidth -
((characterSpacing + wordSpacing * options.wordCount) *
horizontalScaling) /
100;
x += options.lineWidth / 2 - textWidth / 2;
x +=
options.lineWidth / 2 -
this._alignedWidth(text, align, options) / 2;
break;

case 'justify':
Expand Down Expand Up @@ -558,28 +669,39 @@ export default {
characterSpacing * (text.length - 1);

// create link annotations if the link option is given
if (options.link != null) {
const linkOptions = {};
if (
this._currentStructureElement &&
this._currentStructureElement.dictionary.data.S === 'Link'
) {
linkOptions.structParent = this._currentStructureElement;
const linkOptions = {};
if (
options.link != null &&
this._currentStructureElement &&
this._currentStructureElement.dictionary.data.S === 'Link'
) {
linkOptions.structParent = this._currentStructureElement;
}
const lineHeight = this.currentLineHeight();
const annotate = (x, y) => {
if (options.link != null) {
this.link(x, y, renderedWidth, lineHeight, options.link, linkOptions);
}
if (options.goTo != null) {
this.goTo(x, y, renderedWidth, lineHeight, options.goTo);
}
this.link(
x,
y,
renderedWidth,
this.currentLineHeight(),
options.link,
linkOptions,
);
}
if (options.goTo != null) {
this.goTo(x, y, renderedWidth, this.currentLineHeight(), options.goTo);
}
if (options.destination != null) {
this.addNamedDestination(options.destination, 'XYZ', x, y, null);
if (options.destination != null) {
this.addNamedDestination(options.destination, 'XYZ', x, y, null);
}
};

// on a held back line they are added once the line has moved into place
if (heldLine) {
const ctm = this._ctm.slice();
const [startX, startY] = [x, y];
heldLine.annotations.push((dx) => {
const current = this._ctm;
this._ctm = ctm;
annotate(startX + dx, startY);
this._ctm = current;
});
} else {
annotate(x, y);
}

// create underline
Expand Down
Loading
Loading