diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6b9729..8c66669d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/text.md b/docs/text.md index dd8b17e9..26a41c2c 100644 --- a/docs/text.md +++ b/docs/text.md @@ -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, diff --git a/lib/document.js b/lib/document.js index 09a12442..10674def 100644 --- a/lib/document.js +++ b/lib/document.js @@ -147,6 +147,8 @@ class PDFDocument extends Readable { } addPage(options) { + this._flushPendingLine(); + const documentOptions = this.options; // end the current page if needed @@ -186,6 +188,7 @@ class PDFDocument extends Readable { } continueOnNewPage(options) { + this._flushPendingLine(); const pageMarkings = this.endPageMarkings(this.page); this.addPage(options ?? this.page._options); @@ -200,6 +203,8 @@ class PDFDocument extends Readable { } switchToPage(n) { + this._flushPendingLine(); + let page; if (!(page = this._pageBuffer[n - this._pageBufferStart])) { throw new Error( @@ -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; @@ -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; } diff --git a/lib/mixins/text.js b/lib/mixins/text.js index 70062e13..6dc949a6 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -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) { @@ -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 = {}) { @@ -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) { @@ -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': @@ -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 diff --git a/tests/unit/text.spec.js b/tests/unit/text.spec.js index acdbde3a..cd988444 100644 --- a/tests/unit/text.spec.js +++ b/tests/unit/text.spec.js @@ -1,3 +1,4 @@ +import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; import PDFDocument from '../../lib/document'; import { logData } from './helpers'; @@ -321,6 +322,174 @@ Q }); }); + describe('continued text with center or right alignment', () => { + // where a PDF reader puts each line of text and each link, page by page + async function layout(doc) { + const chunks = []; + doc.on('data', (chunk) => chunks.push(chunk)); + const ended = new Promise((resolve) => doc.on('end', resolve)); + doc.end(); + await ended; + + const round = (n) => Math.round(n * 1000) / 1000; + const pdf = await getDocument({ + data: new Uint8Array(Buffer.concat(chunks)), + verbosity: 0, + }).promise; + const pages = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const { items } = await page.getTextContent(); + const annotations = await page.getAnnotations(); + + // the reader may split a line into runs in different ways, so + // compare whole lines + const lines = new Map(); + for (const item of items.filter((item) => item.str.trim())) { + const y = round(item.transform[5]); + lines.set(y, [...(lines.get(y) || []), item]); + } + pages.push({ + lines: [...lines].map(([y, runs]) => { + runs.sort((a, b) => a.transform[4] - b.transform[4]); + const last = runs[runs.length - 1]; + return { + text: runs + .map((run) => run.str) + .join(' ') + .replace(/\s+/g, ' ') + .trim(), + y, + x: round(runs[0].transform[4]), + end: round(last.transform[4] + last.width), + }; + }), + links: annotations.map((annotation) => annotation.rect.map(round)), + }); + } + return pages; + } + + // Courier has no kerning, so continued text ends up exactly where the + // same text written at once does + const courier = () => new PDFDocument().font('Courier'); + + test.each(['center', 'right'])( + 'aligns the line as a whole (%s)', + async (align) => { + const single = courier().text('Hello big world', 50, 50, { + width: 400, + align, + }); + const continued = courier() + .text('Hello ', 50, 50, { width: 400, align, continued: true }) + .text('big ', { continued: true }) + .text('world'); + + expect(await layout(continued)).toEqual(await layout(single)); + }, + ); + + test.each(['center', 'right'])( + 'aligns the line that wrapped text completes (%s)', + async (align) => { + const text = 'and then text that wraps onto a second and a third line'; + const single = courier().text(`Lead in, ${text}`, 50, 50, { + width: 200, + align, + }); + const continued = courier() + .text('Lead in, ', 50, 50, { width: 200, align, continued: true }) + .text(text); + + expect(await layout(continued)).toEqual(await layout(single)); + }, + ); + + test('keeps a line on its page when the text goes on to the next page', async () => { + const text = + 'and the rest of the text does not fit, so it goes on to the next page'; + const single = courier().text(`Near the bottom, ${text}`, 50, 700, { + width: 200, + align: 'center', + }); + const continued = courier() + .text('Near the bottom, ', 50, 700, { + width: 200, + align: 'center', + continued: true, + }) + .text(text); + + const pages = await layout(continued); + expect(pages).toHaveLength(2); + expect(pages).toEqual(await layout(single)); + }); + + test('writes a line that is still continued when the document ends', async () => { + const single = courier().text('Hello world', 50, 50, { + width: 400, + align: 'right', + }); + const continued = courier() + .text('Hello ', 50, 50, { width: 400, align: 'right', continued: true }) + .text('world', { continued: true }); + + expect(await layout(continued)).toEqual(await layout(single)); + }); + + test('moves links with the text', async () => { + const link = 'http://www.example.com'; + const single = courier().text('Hello world', 50, 50, { + width: 400, + align: 'center', + link, + }); + const continued = courier() + .text('Hello ', 50, 50, { + width: 400, + align: 'center', + continued: true, + link, + }) + .text('world'); + + const [ + { + links: [whole], + }, + ] = await layout(single); + const [{ links }] = await layout(continued); + + // a link per fragment, together covering the same span + expect(links).toHaveLength(2); + expect(links[0][0]).toBe(whole[0]); + expect(links[0][2]).toBe(links[1][0]); + expect(links[1][2]).toBe(whole[2]); + }); + + test('centers a line of fragments in different sizes', async () => { + const continued = courier() + .fontSize(10) + .text('Name: ', 50, 50, { + width: 400, + align: 'center', + continued: true, + }) + .fontSize(16) + .text('Jane Doe', { continued: true }) + .fontSize(10) + .text(' (editor)'); + + // fragments in different sizes sit on different baselines + const [{ lines }] = await layout(continued); + const start = Math.min(...lines.map((line) => line.x)); + const end = Math.max(...lines.map((line) => line.end)); + + expect(start - 50).toBeCloseTo(450 - end); + }); + }); + describe('text with structure parent links', () => { beforeEach(() => { document = new PDFDocument({