diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c275639..72c60d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/text.md b/docs/text.md index 52c7a8ed..dd8b17e9 100644 --- a/docs/text.md +++ b/docs/text.md @@ -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 @@ -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) diff --git a/lib/line_wrapper.js b/lib/line_wrapper.js index ee2f26bf..15257a90 100644 --- a/lib/line_wrapper.js +++ b/lib/line_wrapper.js @@ -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) { @@ -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; @@ -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, @@ -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 diff --git a/tests/unit/line_wrapper.spec.js b/tests/unit/line_wrapper.spec.js index 11c83dba..17c90afc 100644 --- a/tests/unit/line_wrapper.spec.js +++ b/tests/unit/line_wrapper.spec.js @@ -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); + }); + }); + }); }); diff --git a/tests/unit/text.spec.js b/tests/unit/text.spec.js index cd5df975..acdbde3a 100644 --- a/tests/unit/text.spec.js +++ b/tests/unit/text.spec.js @@ -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', () => {