From e4d85dfd44dba732bb4af30b08a6bbf55f57b49e Mon Sep 17 00:00:00 2001 From: Likheet Shetty <66800864+Likheet@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:56:28 +1100 Subject: [PATCH] Align continued text with center or right alignment as one line With continued: true each segment was aligned on its own, in the space the previous segment left, so the segments of a center or right aligned line overlapped (#240, #774). Such a line is now held back until it is complete. Its segments are drawn left aligned as usual, but into the held line instead of the page, and once the line ends they are moved into place together, the way a line of a single text call is aligned. Links, goTo and destinations of the held line are added after the move. --- CHANGELOG.md | 1 + docs/text.md | 3 + lib/document.js | 15 ++- lib/mixins/text.js | 196 ++++++++++++++++++++++++++++++++-------- tests/unit/text.spec.js | 169 ++++++++++++++++++++++++++++++++++ 5 files changed, 346 insertions(+), 38 deletions(-) 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({