From e1198aefe4435e63629b1203425a2fec785c722b Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Fri, 10 Jul 2026 06:21:19 +0000 Subject: [PATCH] fix(text): reset continued state when an explicit position is given (#1641) A `text` call that passes an explicit x or y coordinate and is not itself `continued` now discards any wrapping state left behind by a previous `continued: true` run, so the coordinate is honored as absolute instead of being offset by the previous run's inline position. Rich-text sequences (whose continuing segments pass no coordinates) are unaffected. Adds regression tests and documents the forward-looking nature of the `continued` flag. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + docs/text.md | 8 +++++++- lib/mixins/text.js | 13 +++++++++++++ tests/unit/text.spec.js | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8155b4e35..db9c7aaf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Unreleased +- Reset `continued` wrapping state when a `text` call is given an explicit x/y position, so the coordinate is treated as absolute instead of being offset by a previous continued run (#1641) - [BREAKING CHANGE] Restrict AcroForm options to documented mappings and explicit escape hatches. - [BREAKING CHANGE] Stop automatically uppercasing annotation option keys. - Do not mutate options passed to `doc.annotate()` and its convenience methods (link, note, strike, lineAnnotation, rectAnnotation, ellipseAnnotation, textAnnotation, fileAnnotation) diff --git a/docs/text.md b/docs/text.md index 411505ae9..c04f158ff 100644 --- a/docs/text.md +++ b/docs/text.md @@ -106,7 +106,7 @@ below. * `strike` - whether to strike out the text * `oblique` - whether to slant the text (angle in degrees or `true`) * `baseline` - the vertical alignment of the text with respect to its insertion point (values as [canvas textBaseline](https://www.w3schools.com/tags/canvas_textbaseline.asp)) -* `continued` - whether the text segment will be followed immediately by another segment. Useful for changing styling in the middle of a paragraph. +* `continued` - whether the text segment will be followed immediately by another segment. Useful for changing styling in the middle of a paragraph. Note that this is a *forward-looking* flag set on the current segment to make the **next** `text` call continue from where this one left off — the continuing call itself does not need the flag. See [Rich Text](#rich-text) below. * `features` - an array of [OpenType feature tags](https://www.microsoft.com/typography/otspec/featuretags.htm) to apply. Can also be provided as an object with features as keys and boolean values. If not provided, a set of defaults is used. To deactivate default font features, you have to explicitly set them to false (`{ liga: false }`). When providing an empty array the default font features will still be used. Additionally, the fill and stroke color and opacity methods described in the @@ -173,6 +173,12 @@ Here is the output: ![4]() +Because `continued` is forward-looking, the wrapping state stays active until a `text` call +*without* `continued: true` consumes it. To end a run explicitly — for example the last +iteration of a loop — set `continued: false` on the final segment. Alternatively, giving the +next `text` call an explicit `x` or `y` position starts it fresh: such a call ignores the +leftover state and draws at the absolute coordinates you pass, rather than continuing inline. + To cancel a link in rich text set the `link` option to `null`. doc.fillColor('red') diff --git a/lib/mixins/text.js b/lib/mixins/text.js index f83d8ba7a..886146ea7 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -53,6 +53,19 @@ export default { }, _text(text, x, y, options, lineCallback) { + // A call that explicitly positions itself (an x or y is given) and is not + // itself a continued segment should start fresh, discarding any wrapping + // state left behind by a previous `continued: true` run. Otherwise the + // passed coordinate would be offset by the previous run's inline position + // instead of being treated as absolute (see issue #1641). + const rawOptions = x && typeof x === 'object' ? x : options || {}; + const hasExplicitPosition = + (x != null && typeof x !== 'object') || y != null; + if (hasExplicitPosition && !rawOptions.continued) { + this._wrapper = null; + this._textOptions = null; + } + options = this._initOptions(x, y, options); // Convert text to a string diff --git a/tests/unit/text.spec.js b/tests/unit/text.spec.js index 69cdb3b4d..f2ad43f0e 100644 --- a/tests/unit/text.spec.js +++ b/tests/unit/text.spec.js @@ -201,6 +201,46 @@ Q expect(docData).toContainText({ text: 'text with null x' }); }); + + // Capture the absolute x each fragment is actually drawn at. + const spyFragmentX = () => { + const positions = []; + const original = document._fragment.bind(document); + document._fragment = (text, x, y, options) => { + if (text && String(text).trim()) { + positions.push({ text: String(text).trim(), x }); + } + return original(text, x, y, options); + }; + return positions; + }; + + test('explicit position resets a previous continued run (#1641)', () => { + const positions = spyFragmentX(); + + ['AAA ', 'BBB ', 'CCC '].forEach((t) => + document.text(t, { continued: true }), + ); + // A non-continued call with an explicit x should be absolute, not offset + // by the inline position left behind by the continued run above. + document.text('FRESH', 400); + document.end(); + + const fresh = positions.find((p) => p.text === 'FRESH'); + expect(fresh.x).toBe(400); + }); + + test('continued run without coordinates still flows inline', () => { + const positions = spyFragmentX(); + + document.text('AAA', { continued: true }); + document.text('BBB'); + document.end(); + + const a = positions.find((p) => p.text === 'AAA'); + const b = positions.find((p) => p.text === 'BBB'); + expect(b.x).toBeGreaterThan(a.x); + }); }); describe('text with structure parent links', () => {