Skip to content

Commit ade10ca

Browse files
authored
[vector_graphics][vector_graphics_compiler] Fix text-anchor and tspan gap with adjacent <tspan>s (#11637)
## Summary `<text x="..." text-anchor="middle"><tspan>ABCDEFG</tspan><tspan>ABCDEFG</tspan></text>` rendered noticeably differently from the equivalent single-tspan `<tspan>ABCDEFGABCDEFG</tspan>`: the two-tspan version was shifted right past the anchor *and* had a visible space between the two tspans. Two independent root causes, fixed in two separate places. **1. `vector_graphics` — anchor applies per-chunk, not per-tspan.** The listener was independently offsetting each tspan's paragraph by `dx - paragraphWidth * xAnchorMultiplier`. Per the SVG spec, `text-anchor` applies to the entire anchored chunk (the contiguous run of glyphs whose start is established by an explicit `x`/`y`), not to each tspan in isolation. The listener now buffers per-tspan paragraphs within a chunk, flushes on the next explicit position update or on `toPicture()`, and applies the anchor offset to the chunk total. `text-anchor="start"` behavior is unchanged. A new TextPosition with `x` (or `reset=true`) starts a new chunk; bare per-tspan TextPositions emitted by the parser do not. **2. `vector_graphics_compiler` — parser injected a spurious space between adjacent tspans.** `_appendText` had a "previous element was a tspan ⇒ prepend a space" rule that fired regardless of whether the source actually had whitespace at the boundary. So `<tspan>A</tspan><tspan>B</tspan>` parsed to `['A', ' B']` (the visible gap), even though every browser parses it to `'AB'`. Replaced with a rule that only prepends when whitespace truly exists at the boundary — either as a leading-whitespace prefix on the current text, or as an earlier whitespace-only text event we've now flagged. Tests added on both sides: - `vector_graphics/test/listener_test.dart`: `Text anchor middle centers the entire chunk across tspans` — drives the listener with a two-tspan middle-anchored chunk (including the parser's bare per-tspan TextPosition between them) and asserts the second tspan starts at the original x. - `vector_graphics_compiler/test/parser_test.dart`: `adjacent tspans without whitespace are not separated by a space` and `adjacent tspans with whitespace between still get a space` — locks down both branches of the new parser rule. Both packages bumped to `1.2.1` with CHANGELOG entries. Fixes flutter/flutter#185927 ## Before / After | SVG | Before (buggy) | After (this PR) | | --- | --- | --- | | **svg1** (single tspan)<br>`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" fill="none">`<br>&nbsp;&nbsp;`<text x="100" y="50" text-anchor="middle" fill="#FF0000">`<br>&nbsp;&nbsp;&nbsp;&nbsp;`<tspan>ABCDEFGABCDEFG</tspan>`<br>&nbsp;&nbsp;`</text>`<br>`</svg>`<br><br>**svg2** (two tspans)<br>`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" fill="none">`<br>&nbsp;&nbsp;`<text x="100" y="50" text-anchor="middle" fill="#FF0000">`<br>&nbsp;&nbsp;&nbsp;&nbsp;`<tspan>ABCDEFG</tspan><tspan>ABCDEFG</tspan>`<br>&nbsp;&nbsp;`</text>`<br>`</svg>` | <img width="390" height="844" alt="image" src="https://github.com/user-attachments/assets/7ffbe594-d821-4b4c-b759-c5fb61a61339" /> | <img width="780" height="1688" alt="image" src="https://github.com/user-attachments/assets/076b69fb-2bfc-490a-9669-566e45f7e9a2" /> | ## Test plan 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent b9bdd37 commit ade10ca

8 files changed

Lines changed: 224 additions & 28 deletions

File tree

packages/vector_graphics/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 1.2.2
2+
3+
* Fixes `text-anchor` on `<text>` with multiple `<tspan>` children. The
4+
anchor now applies to the entire anchored chunk as required by the SVG
5+
spec, instead of independently to each tspan.
6+
17
## 1.2.1
28

39
* Fixes uncaught `StateError` and `NoSuchMethodError` from `useHtmlRenderObject()` on CanvasKit / iOS Safari so SVG widgets fall back to the HTML render object.

packages/vector_graphics/lib/src/listener.dart

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,20 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
274274
double _textPositionY = 0;
275275
Float64List? _textTransform;
276276

277+
// Pending text draws within the current SVG anchored chunk. Per the SVG
278+
// spec, `text-anchor` applies to the chunk as a whole, so we cannot
279+
// commit a paragraph to the canvas until we know the full chunk width.
280+
final List<_PendingTextDraw> _pendingChunk = <_PendingTextDraw>[];
281+
// The user-space x at which the current chunk begins (i.e. the value of
282+
// `_accumulatedTextPositionX` at the time the first paragraph in the
283+
// chunk was queued). Null when no chunk is open.
284+
double? _chunkOriginX;
285+
// The text-anchor multiplier of the first paragraph in the chunk; used
286+
// to position the chunk as a whole.
287+
double _chunkAnchorMultiplier = 0;
288+
// Cumulative pen-advance within the current chunk so far.
289+
double _chunkAdvance = 0;
290+
277291
_PatternConfig? _currentPattern;
278292

279293
static final Paint _emptyPaint = Paint();
@@ -294,6 +308,7 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
294308
PictureInfo toPicture() {
295309
assert(!_done);
296310
_done = true;
311+
_flushPendingTextChunk();
297312
try {
298313
return PictureInfo._(_recorder.endRecording(), _size);
299314
} finally {
@@ -652,6 +667,15 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
652667
@override
653668
void onUpdateTextPosition(int textPositionId) {
654669
final _TextPosition position = _textPositions[textPositionId];
670+
// Per the SVG spec, a new anchored chunk begins only when the element
671+
// establishes an explicit absolute position (i.e. an `x` or `y` on a
672+
// <text> or <tspan>). Relative `dx`/`dy` move the pen but do NOT
673+
// start a new chunk; neither does the bare per-tspan TextPosition the
674+
// parser emits when the tspan has no x/y of its own. `reset` (set on
675+
// <text> elements) likewise starts a fresh chunk.
676+
if (position.reset || position.x != null || position.y != null) {
677+
_flushPendingTextChunk();
678+
}
655679
if (position.reset) {
656680
_accumulatedTextPositionX = 0;
657681
_textPositionY = 0;
@@ -685,9 +709,26 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
685709
final _TextConfig textConfig = _textConfig[textId];
686710
final double dx = _accumulatedTextPositionX ?? 0;
687711
final double dy = _textPositionY;
688-
double paragraphWidth = 0;
689712

690-
void draw(int paintId) {
713+
// A change in text-anchor on a continuing chunk also starts a new
714+
// anchored chunk per the SVG spec.
715+
if (_pendingChunk.isNotEmpty &&
716+
textConfig.xAnchorMultiplier != _chunkAnchorMultiplier) {
717+
_flushPendingTextChunk();
718+
}
719+
720+
if (_pendingChunk.isEmpty) {
721+
_chunkOriginX = dx;
722+
_chunkAnchorMultiplier = textConfig.xAnchorMultiplier;
723+
_chunkAdvance = 0;
724+
} else {
725+
// Continuing the chunk: take the live pen position so any in-chunk
726+
// relative `dx="..."` movements applied via onUpdateTextPosition
727+
// since the last segment are accounted for in the segment's offset
728+
// within the chunk.
729+
_chunkAdvance = dx - _chunkOriginX!;
730+
}
731+
Paragraph buildParagraph(int paintId) {
691732
final Paint paint = _paints[paintId];
692733
if (patternId != null) {
693734
paint.shader = _patterns[patternId]!.shader;
@@ -707,37 +748,56 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
707748
decorationColor: textConfig.decorationColor,
708749
),
709750
);
710-
711751
builder.addText(textConfig.text);
712-
713752
final Paragraph paragraph = builder.build();
714753
paragraph.layout(const ParagraphConstraints(width: double.infinity));
715-
paragraphWidth = paragraph.maxIntrinsicWidth;
754+
return paragraph;
755+
}
756+
757+
double paragraphWidth = 0;
758+
if (fillId != null) {
759+
final Paragraph p = buildParagraph(fillId);
760+
paragraphWidth = p.maxIntrinsicWidth;
761+
_pendingChunk.add(_PendingTextDraw(p, _chunkAdvance, dy, _textTransform));
762+
}
763+
if (strokeId != null) {
764+
final Paragraph p = buildParagraph(strokeId);
765+
paragraphWidth = p.maxIntrinsicWidth;
766+
_pendingChunk.add(_PendingTextDraw(p, _chunkAdvance, dy, _textTransform));
767+
}
768+
769+
_chunkAdvance += paragraphWidth;
770+
_accumulatedTextPositionX = dx + paragraphWidth;
771+
}
716772

717-
if (_textTransform != null) {
773+
void _flushPendingTextChunk() {
774+
if (_pendingChunk.isEmpty) {
775+
return;
776+
}
777+
final double originX = _chunkOriginX ?? 0;
778+
final double anchorOffset = _chunkAdvance * _chunkAnchorMultiplier;
779+
for (final _PendingTextDraw draw in _pendingChunk) {
780+
final Paragraph paragraph = draw.paragraph;
781+
if (draw.transform != null) {
718782
_canvas.save();
719-
_canvas.transform(_textTransform!);
783+
_canvas.transform(draw.transform!);
720784
}
721785
_canvas.drawParagraph(
722786
paragraph,
723787
Offset(
724-
dx - paragraph.maxIntrinsicWidth * textConfig.xAnchorMultiplier,
725-
dy - paragraph.alphabeticBaseline,
788+
originX + draw.offsetWithinChunk - anchorOffset,
789+
draw.dy - paragraph.alphabeticBaseline,
726790
),
727791
);
728792
paragraph.dispose();
729-
if (_textTransform != null) {
793+
if (draw.transform != null) {
730794
_canvas.restore();
731795
}
732796
}
733-
734-
if (fillId != null) {
735-
draw(fillId);
736-
}
737-
if (strokeId != null) {
738-
draw(strokeId);
739-
}
740-
_accumulatedTextPositionX = dx + paragraphWidth;
797+
_pendingChunk.clear();
798+
_chunkOriginX = null;
799+
_chunkAnchorMultiplier = 0;
800+
_chunkAdvance = 0;
741801
}
742802

743803
int _createImageKey(int imageId, int format) {
@@ -885,6 +945,20 @@ class _TextConfig {
885945
final Color decorationColor;
886946
}
887947

948+
class _PendingTextDraw {
949+
_PendingTextDraw(
950+
this.paragraph,
951+
this.offsetWithinChunk,
952+
this.dy,
953+
this.transform,
954+
);
955+
956+
final Paragraph paragraph;
957+
final double offsetWithinChunk;
958+
final double dy;
959+
final Float64List? transform;
960+
}
961+
888962
/// An exception thrown if decoding fails.
889963
///
890964
/// The [originalException] is a detailed exception about what failed in

packages/vector_graphics/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: vector_graphics
22
description: A vector graphics rendering package for Flutter using a binary encoding.
33
repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22
5-
version: 1.2.1
5+
version: 1.2.2
66

77
environment:
88
sdk: ^3.9.0

packages/vector_graphics/test/listener_test.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ void main() {
120120
listener.onTextConfig('foo', null, 0, 0, 16, 0, 0, 0, 0);
121121
await listener.onDrawText(0, 0, null, null);
122122
await listener.onDrawText(0, 0, null, null);
123+
// Force flush of the pending anchored chunk by starting a new one.
124+
listener.onTextPosition(1, 0, 0, null, null, true, null);
125+
listener.onUpdateTextPosition(1);
123126

124127
final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0];
125128
final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1];
@@ -133,6 +136,62 @@ void main() {
133136
expect((drawParagraph1.positionalArguments[1] as Offset).dx, 58);
134137
});
135138

139+
test('Text anchor middle centers the entire chunk across tspans', () async {
140+
// SVG: <text x="100" y="50" text-anchor="middle">
141+
// <tspan>ABCDEFG</tspan><tspan>ABCDEFG</tspan>
142+
// </text>
143+
// Per SVG spec, the concatenation of both tspans forms a single
144+
// anchored chunk that should be centered around x=100.
145+
final factory = TestPictureFactory();
146+
final listener = FlutterVectorGraphicsListener(pictureFactory: factory);
147+
listener.onPaintObject(
148+
color: const ui.Color(0xffff0000).toARGB32(),
149+
strokeCap: null,
150+
strokeJoin: null,
151+
blendMode: BlendMode.srcIn.index,
152+
strokeMiterLimit: null,
153+
strokeWidth: null,
154+
paintStyle: ui.PaintingStyle.fill.index,
155+
id: 0,
156+
shaderId: null,
157+
);
158+
listener.onTextPosition(0, 100, 50, null, null, true, null);
159+
listener.onUpdateTextPosition(0);
160+
// xAnchorMultiplier = 0.5 corresponds to text-anchor="middle".
161+
listener.onTextConfig('ABCDEFG', null, 0.5, 0, 16, 0, 0, 0, 0);
162+
await listener.onDrawText(0, 0, null, null);
163+
// The parser emits a TextPosition for every <tspan>, including those
164+
// with no x/y. That must NOT break the current anchored chunk.
165+
listener.onTextPosition(1, null, null, null, null, false, null);
166+
listener.onUpdateTextPosition(1);
167+
listener.onTextConfig('ABCDEFG', null, 0.5, 0, 16, 0, 0, 0, 1);
168+
await listener.onDrawText(1, 0, null, null);
169+
// Force flush of the pending anchored chunk by starting a new one.
170+
listener.onTextPosition(2, 0, 0, null, null, true, null);
171+
listener.onUpdateTextPosition(2);
172+
173+
final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0];
174+
final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1];
175+
expect(drawParagraph0.memberName, #drawParagraph);
176+
expect(drawParagraph1.memberName, #drawParagraph);
177+
178+
final double dx0 = (drawParagraph0.positionalArguments[1] as Offset).dx;
179+
final double dx1 = (drawParagraph1.positionalArguments[1] as Offset).dx;
180+
181+
// The chunk is two equal tspans of width w. text-anchor="middle" centers
182+
// the whole chunk (total width 2w) around x=100, so:
183+
// dx0 = 100 - w (left tspan)
184+
// dx1 = 100 (right tspan)
185+
// Therefore the second tspan should start exactly at the original x=100.
186+
expect(dx1, 100, reason: 'second tspan should start at the original x');
187+
final double w = 100 - dx0;
188+
expect(
189+
dx1 - dx0,
190+
w,
191+
reason: 'tspans should be contiguous within the chunk',
192+
);
193+
});
194+
136195
test('should assert when imageId is invalid', () async {
137196
final factory = TestPictureFactory();
138197
final listener = FlutterVectorGraphicsListener(pictureFactory: factory);

packages/vector_graphics_compiler/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 1.2.3
2+
3+
* Fixes the SVG parser injecting a spurious space between adjacent
4+
`<tspan>` elements that have no whitespace between them in the source.
5+
Previously `<tspan>A</tspan><tspan>B</tspan>` was emitted as `"A"` +
6+
`" B"`, producing a visible gap; it now emits `"A"` + `"B"` to match
7+
every browser.
8+
19
## 1.2.2
210

311
* Adds support for modern space-separated HSL and HSLA color syntax.

packages/vector_graphics_compiler/lib/src/svg/parser.dart

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -758,16 +758,23 @@ class SvgParser {
758758
final textHasNonWhitespace = text.trim() != '';
759759

760760
// Not from the spec, but seems like how Chrome behaves.
761-
// - If `x` is specified, don't prepend whitespace.
762-
// - If the last element was a tspan and we're dealing with some
763-
// non-whitespace data, prepend a space.
764-
// - If the last text wasn't whitespace and ended with whitespace, prepend
765-
// a space.
761+
// - If `x` is specified on the current element, don't prepend whitespace.
762+
// - Otherwise prepend a space if either:
763+
// * the previous text emission ended on a space character, or
764+
// * we are following a `</tspan>` and the source actually contains
765+
// whitespace at the boundary (either as a leading-whitespace prefix
766+
// on this text or as an earlier whitespace-only text event that
767+
// was trimmed).
768+
// The "tspan" gate is what prevents `<tspan>A</tspan><tspan>B</tspan>`
769+
// from rendering as "A B" — without it the parser would always inject
770+
// a space between adjacent tspans even when no whitespace exists in
771+
// the source.
772+
final bool textHasLeadingWhitespace =
773+
text.isNotEmpty && _whitespacePattern.matchAsPrefix(text) != null;
774+
final followsTspan = _lastEndElementEvent?.localName == 'tspan';
766775
final bool prependSpace =
767776
_currentAttributes.x == null &&
768-
(_lastEndElementEvent?.localName == 'tspan' &&
769-
textHasNonWhitespace) ||
770-
_lastTextEndedWithSpace;
777+
(_lastTextEndedWithSpace || (followsTspan && textHasLeadingWhitespace));
771778

772779
_lastTextEndedWithSpace =
773780
textHasNonWhitespace &&
@@ -785,6 +792,12 @@ class SvgParser {
785792
.replaceAll(_contiguousSpaceMatcher, ' ');
786793

787794
if (text.isEmpty) {
795+
// A pure-whitespace text event sitting between two sibling tspans
796+
// still needs to flag that whitespace existed, so the next
797+
// non-empty text can prepend a space.
798+
if (textHasLeadingWhitespace && followsTspan) {
799+
_lastTextEndedWithSpace = true;
800+
}
788801
return;
789802
}
790803

packages/vector_graphics_compiler/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: vector_graphics_compiler
22
description: A compiler to convert SVGs to the binary format used by `package:vector_graphics`.
33
repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics_compiler
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22
5-
version: 1.2.2
5+
version: 1.2.3
66

77
executables:
88
vector_graphics_compiler:

packages/vector_graphics_compiler/test/parser_test.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,42 @@ void main() {
277277
]);
278278
});
279279

280+
test('adjacent tspans without whitespace are not separated by a space', () {
281+
// Regression test: previously the parser unconditionally injected a
282+
// space between the text of any two consecutive tspans, even when the
283+
// source XML contained no whitespace between </tspan> and <tspan>.
284+
// That caused `<tspan>A</tspan><tspan>B</tspan>` to render as "A B"
285+
// (a visible gap), instead of "AB" as every browser does.
286+
const svg = '''
287+
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
288+
<text x="100" y="50" text-anchor="middle"><tspan>ABCDEFG</tspan><tspan>HIJKLMN</tspan></text>
289+
</svg>''';
290+
291+
final VectorInstructions instructions = parseWithoutOptimizers(svg);
292+
293+
expect(instructions.text.map((TextConfig t) => t.text), <String>[
294+
'ABCDEFG',
295+
'HIJKLMN',
296+
]);
297+
});
298+
299+
test('adjacent tspans with whitespace between still get a space', () {
300+
// Sibling case to the regression test above: when there *is* source
301+
// whitespace between </tspan> and <tspan>, that whitespace must be
302+
// preserved as a single space prepended to the second tspan.
303+
const svg = '''
304+
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
305+
<text x="0" y="40"><tspan>A</tspan> <tspan>B</tspan></text>
306+
</svg>''';
307+
308+
final VectorInstructions instructions = parseWithoutOptimizers(svg);
309+
310+
expect(instructions.text.map((TextConfig t) => t.text), <String>[
311+
'A',
312+
' B',
313+
]);
314+
});
315+
280316
test('stroke-opacity', () {
281317
const strokeOpacitySvg = '''
282318
<svg viewBox="0 0 10 10" fill="none">

0 commit comments

Comments
 (0)