Skip to content
Merged
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

- 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
- Fix wrapped text with `align: 'center'` sitting left of the middle of the line when `characterSpacing` or `wordSpacing` is set, and with `align: 'right'` running past the right edge by the `wordSpacing` of each gap between words
- Fix `boundsOfString()` with `lineBreak: false` leaving out `wordSpacing`, so its width now matches the rendered text and its underline
Expand Down
6 changes: 5 additions & 1 deletion docs/text.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ doing that for long pieces of text. PDFKit can also automatically wrap text
into multiple columns.

The text will automatically wrap unless you set the `lineBreak` option to `false`.
A line may break wherever the Unicode line breaking rules allow one, which
includes after a hyphen or a slash inside a word. Set `lineBreak` to
`'whitespace'` to break only at whitespace, a soft hyphen or a zero-width space,
so that a word such as `design-system` or a URL moves to the next line whole.
By default it will wrap to the page margin, but the `width` option allows
you to set a different width the text should be wrapped to.
If you set the `height` option, the text will be clipped to the number of
Expand Down Expand Up @@ -83,7 +87,7 @@ PDFKit has many options for controlling the look of text added to PDF
documents, which can be passed to the `text` method. They are enumerated
below.

* `lineBreak` - set to `false` to disable line wrapping all together
* `lineBreak` - set to `false` to disable line wrapping all together, or to `'whitespace'` to break lines only at whitespace, a soft hyphen or a zero-width space, never after a hyphen or a slash inside a word
* `width` - the width that text should be wrapped to (by default, the page width minus the left and right margin)
* `height` - the maximum height that text should be clipped to
* `rotation` - the rotation of the text in degrees (by default 0)
Expand Down
19 changes: 19 additions & 0 deletions lib/line_wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { PDFNumber } from './utils';

const SOFT_HYPHEN = '\u00AD';
const HYPHEN = '-';
// lineBreak 'whitespace' keeps the breaks after whitespace and after the two
// characters whose only purpose is to allow a break: a soft hyphen and a
// zero-width space.
const WHITESPACE_BREAK = /[\s\u00AD\u200B]/;

const isWhitespaceBreak = (text, bk) =>
bk.required ||
bk.position >= text.length ||
WHITESPACE_BREAK.test(text[bk.position - 1]);

class LineWrapper extends EventEmitter {
constructor(document, options) {
Expand All @@ -30,6 +39,7 @@ class LineWrapper extends EventEmitter {
this.startY = this.document.y;
this.column = 1;
this.ellipsis = options.ellipsis;
this.breakAtWhitespaceOnly = options.lineBreak === 'whitespace';
this.continuedX = 0;
this.indentApplied = false;
this.features = options.features;
Expand Down Expand Up @@ -130,6 +140,12 @@ class LineWrapper extends EventEmitter {
const wordWidths = Object.create(null);

while ((bk = breaker.nextBreak())) {
// The Unicode rules also allow a break after a hyphen or a slash, which
// cuts "design-system" or "https://github.com/x" at a line end; a text
// extractor that joins the lines then reads two words.
if (this.breakAtWhitespaceOnly && !isWhitespaceBreak(text, bk)) {
continue;
}
var shouldContinue;
let word = text.slice(
(last != null ? last.position : undefined) || 0,
Expand Down Expand Up @@ -219,6 +235,9 @@ class LineWrapper extends EventEmitter {
if (options.ellipsis != null) {
this.ellipsis = options.ellipsis;
}
if (options.lineBreak != null) {
this.breakAtWhitespaceOnly = options.lineBreak === 'whitespace';
}

// make sure we're actually on the page
// and that the first line of is never by
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/line_wrapper.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,94 @@ describe('LineWrapper', () => {
expect(line).toEqual({ x: 15, lineWidth: 285 });
});
});

describe('lineBreak whitespace', () => {
// each line arrives with its trailing space or newline, which these
// tests are not about
const wrapLines = (text, options) => {
const wrapper = new LineWrapper(document, options);
const lines = [];
wrapper.on('line', (buffer) => {
lines.push(buffer.trimEnd());
document.y += document.currentLineHeight(true);
});
wrapper.wrap(text, options);
return lines;
};
// one point too narrow for the text to fit on one line
const widthJustUnder = (text) => document.widthOfString(text) - 1;

test('by default the Unicode rules break after a hyphen and after a slash', () => {
expect(
wrapLines('a design-system', {
width: widthJustUnder('a design-system'),
}),
).toEqual(['a design-', 'system']);
expect(
wrapLines('see https://github.com/foliojs/pdfkit', {
width: widthJustUnder('see https://github.com/foliojs/pdfkit'),
}),
).toEqual(['see https://github.com/foliojs/', 'pdfkit']);
});

test('moves a hyphenated word to the next line whole', () => {
expect(
wrapLines('a design-system', {
width: widthJustUnder('a design-system'),
lineBreak: 'whitespace',
}),
).toEqual(['a', 'design-system']);
});

test('moves a URL to the next line whole', () => {
expect(
wrapLines('see https://github.com/foliojs/pdfkit', {
width: widthJustUnder('see https://github.com/foliojs/pdfkit'),
lineBreak: 'whitespace',
}),
).toEqual(['see', 'https://github.com/foliojs/pdfkit']);
});

test('still breaks at a soft hyphen and shows the hyphen', () => {
expect(
wrapLines('a computer\u00ADscreen', {
width: widthJustUnder('a computerscreen'),
lineBreak: 'whitespace',
}),
).toEqual(['a computer-', 'screen']);
});

test('still breaks at a zero-width space', () => {
expect(
wrapLines('a computer\u200Bscreen', {
width: widthJustUnder('a computerscreen'),
lineBreak: 'whitespace',
}),
// the zero-width space stays at the end of the line, as a space does
).toEqual(['a computer\u200B', 'screen']);
});

test('still breaks a paragraph at a newline', () => {
expect(
wrapLines('design-system\nnext', {
width: 300,
lineBreak: 'whitespace',
}),
).toEqual(['design-system', 'next']);
});

test('still splits a word wider than the line where the line ends', () => {
const url = `https://example.com/${'x'.repeat(40)}`;
const lines = wrapLines(`see ${url}`, {
width: 120,
lineBreak: 'whitespace',
});

expect(lines.length).toBeGreaterThan(2);
expect(lines.join('')).toBe(`see ${url}`);
lines.forEach((line) => {
expect(document.widthOfString(line)).toBeLessThanOrEqual(120);
});
});
});
});
30 changes: 30 additions & 0 deletions tests/unit/text.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,36 @@ Q
),
]);
});
test('with lineBreak whitespace a hyphenated word moves to the next line whole', () => {
const lines = [];
const line = document._line;
document._line = function (text, options, wrapper) {
lines.push(text);
return line.call(this, text, options, wrapper);
};
const width = document.widthOfString('a design-system') - 1;

document.text('a design-system', { width, lineBreak: 'whitespace' });

expect(lines).toEqual(['a ', 'design-system']);
});

test('a continued segment can switch to lineBreak whitespace', () => {
const lines = [];
const line = document._line;
document._line = function (text, options, wrapper) {
lines.push(text);
return line.call(this, text, options, wrapper);
};
const width = document.widthOfString('a design-system') - 1;

document.text('a ', { width, continued: true });
document.text('design-system', { lineBreak: 'whitespace' });

// a continued segment whose first word does not fit emits an empty
// line to move down before the word
expect(lines.filter(Boolean)).toEqual(['a ', 'design-system']);
});
});

describe('text with structure parent links', () => {
Expand Down
Loading