From c3a81578d115f3de76ac4675d31feac2b2528165 Mon Sep 17 00:00:00 2001 From: fondoger Date: Sun, 3 May 2026 01:36:01 +0800 Subject: [PATCH 1/6] [vector_graphics] Apply text-anchor to the whole anchored chunk Previously every was laid out as an independent ui.Paragraph and each one was offset by `dx - paragraphWidth * xAnchorMultiplier`. Per the SVG spec, `text-anchor` applies to the entire anchored chunk (the contiguous sequence of glyphs whose start is fixed by an explicit x/y), not to each tspan in isolation. As a result, e.g. ABCDEFGABCDEFG was rendered shifted right by half the chunk width compared to the single-tspan equivalent (and to every browser). This change buffers per-tspan paragraphs within an anchored chunk, flushes the chunk on the next explicit position update / on toPicture(), and applies the anchor offset to the chunk total. anchor=0 (start) behavior is unchanged. Fixes flutter/flutter#185927 --- packages/vector_graphics/CHANGELOG.md | 6 ++ .../vector_graphics/lib/src/listener.dart | 97 +++++++++++++++---- packages/vector_graphics/pubspec.yaml | 2 +- .../vector_graphics/test/listener_test.dart | 55 +++++++++++ 4 files changed, 141 insertions(+), 19 deletions(-) diff --git a/packages/vector_graphics/CHANGELOG.md b/packages/vector_graphics/CHANGELOG.md index 962000513c6d..bf794cefff31 100644 --- a/packages/vector_graphics/CHANGELOG.md +++ b/packages/vector_graphics/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.2.1 + +* Fixes `text-anchor` on `` with multiple `` children. The + anchor now applies to the entire anchored chunk as required by the SVG + spec, instead of independently to each tspan. + ## 1.2.0 * Adds `imageBuilder` property to `VectorGraphic` for wrapping the loaded vector graphic widget. diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index cc9e96af8376..ce958074b1a3 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -274,6 +274,20 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { double _textPositionY = 0; Float64List? _textTransform; + // Pending text draws within the current SVG anchored chunk. Per the SVG + // spec, `text-anchor` applies to the chunk as a whole, so we cannot + // commit a paragraph to the canvas until we know the full chunk width. + final List<_PendingTextDraw> _pendingChunk = <_PendingTextDraw>[]; + // The user-space x at which the current chunk begins (i.e. the value of + // `_accumulatedTextPositionX` at the time the first paragraph in the + // chunk was queued). Null when no chunk is open. + double? _chunkOriginX; + // The text-anchor multiplier of the first paragraph in the chunk; used + // to position the chunk as a whole. + double _chunkAnchorMultiplier = 0; + // Cumulative pen-advance within the current chunk so far. + double _chunkAdvance = 0; + _PatternConfig? _currentPattern; static final Paint _emptyPaint = Paint(); @@ -294,6 +308,7 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { PictureInfo toPicture() { assert(!_done); _done = true; + _flushPendingTextChunk(); try { return PictureInfo._(_recorder.endRecording(), _size); } finally { @@ -652,6 +667,9 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { @override void onUpdateTextPosition(int textPositionId) { final _TextPosition position = _textPositions[textPositionId]; + // Any explicit absolute position (or reset) starts a new SVG anchored + // chunk, so flush whatever we've queued for the previous one. + _flushPendingTextChunk(); if (position.reset) { _accumulatedTextPositionX = 0; _textPositionY = 0; @@ -685,9 +703,15 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { final _TextConfig textConfig = _textConfig[textId]; final double dx = _accumulatedTextPositionX ?? 0; final double dy = _textPositionY; - double paragraphWidth = 0; - void draw(int paintId) { + if (_pendingChunk.isEmpty) { + _chunkOriginX = dx; + _chunkAnchorMultiplier = textConfig.xAnchorMultiplier; + _chunkAdvance = 0; + } + final double offsetWithinChunk = _chunkAdvance; + + Paragraph buildParagraph(int paintId) { final Paint paint = _paints[paintId]; if (patternId != null) { paint.shader = _patterns[patternId]!.shader; @@ -707,37 +731,60 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { decorationColor: textConfig.decorationColor, ), ); - builder.addText(textConfig.text); - final Paragraph paragraph = builder.build(); paragraph.layout(const ParagraphConstraints(width: double.infinity)); - paragraphWidth = paragraph.maxIntrinsicWidth; + return paragraph; + } - if (_textTransform != null) { + double paragraphWidth = 0; + if (fillId != null) { + final Paragraph p = buildParagraph(fillId); + paragraphWidth = p.maxIntrinsicWidth; + _pendingChunk.add( + _PendingTextDraw(p, offsetWithinChunk, dy, _textTransform), + ); + } + if (strokeId != null) { + final Paragraph p = buildParagraph(strokeId); + paragraphWidth = p.maxIntrinsicWidth; + _pendingChunk.add( + _PendingTextDraw(p, offsetWithinChunk, dy, _textTransform), + ); + } + + _chunkAdvance += paragraphWidth; + _accumulatedTextPositionX = dx + paragraphWidth; + } + + void _flushPendingTextChunk() { + if (_pendingChunk.isEmpty) { + return; + } + final double originX = _chunkOriginX ?? 0; + final double anchorOffset = _chunkAdvance * _chunkAnchorMultiplier; + for (final _PendingTextDraw draw in _pendingChunk) { + final Paragraph paragraph = draw.paragraph; + if (draw.transform != null) { _canvas.save(); - _canvas.transform(_textTransform!); + _canvas.transform(draw.transform!); } _canvas.drawParagraph( paragraph, Offset( - dx - paragraph.maxIntrinsicWidth * textConfig.xAnchorMultiplier, - dy - paragraph.alphabeticBaseline, + originX + draw.offsetWithinChunk - anchorOffset, + draw.dy - paragraph.alphabeticBaseline, ), ); paragraph.dispose(); - if (_textTransform != null) { + if (draw.transform != null) { _canvas.restore(); } } - - if (fillId != null) { - draw(fillId); - } - if (strokeId != null) { - draw(strokeId); - } - _accumulatedTextPositionX = dx + paragraphWidth; + _pendingChunk.clear(); + _chunkOriginX = null; + _chunkAnchorMultiplier = 0; + _chunkAdvance = 0; } int _createImageKey(int imageId, int format) { @@ -885,6 +932,20 @@ class _TextConfig { final Color decorationColor; } +class _PendingTextDraw { + _PendingTextDraw( + this.paragraph, + this.offsetWithinChunk, + this.dy, + this.transform, + ); + + final Paragraph paragraph; + final double offsetWithinChunk; + final double dy; + final Float64List? transform; +} + /// An exception thrown if decoding fails. /// /// The [originalException] is a detailed exception about what failed in diff --git a/packages/vector_graphics/pubspec.yaml b/packages/vector_graphics/pubspec.yaml index e4699cba72ed..d45dd90f0e46 100644 --- a/packages/vector_graphics/pubspec.yaml +++ b/packages/vector_graphics/pubspec.yaml @@ -2,7 +2,7 @@ name: vector_graphics description: A vector graphics rendering package for Flutter using a binary encoding. repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22 -version: 1.2.0 +version: 1.2.1 environment: sdk: ^3.9.0 diff --git a/packages/vector_graphics/test/listener_test.dart b/packages/vector_graphics/test/listener_test.dart index 72330c7dd002..7c953446e3d9 100644 --- a/packages/vector_graphics/test/listener_test.dart +++ b/packages/vector_graphics/test/listener_test.dart @@ -120,6 +120,9 @@ void main() { listener.onTextConfig('foo', null, 0, 0, 16, 0, 0, 0, 0); await listener.onDrawText(0, 0, null, null); await listener.onDrawText(0, 0, null, null); + // Force flush of the pending anchored chunk by starting a new one. + listener.onTextPosition(1, 0, 0, null, null, true, null); + listener.onUpdateTextPosition(1); final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; @@ -133,6 +136,58 @@ void main() { expect((drawParagraph1.positionalArguments[1] as Offset).dx, 58); }); + test('Text anchor middle centers the entire chunk across tspans', () async { + // SVG: + // ABCDEFGABCDEFG + // + // Per SVG spec, the concatenation of both tspans forms a single + // anchored chunk that should be centered around x=100. + final factory = TestPictureFactory(); + final listener = FlutterVectorGraphicsListener(pictureFactory: factory); + listener.onPaintObject( + color: const ui.Color(0xffff0000).toARGB32(), + strokeCap: null, + strokeJoin: null, + blendMode: BlendMode.srcIn.index, + strokeMiterLimit: null, + strokeWidth: null, + paintStyle: ui.PaintingStyle.fill.index, + id: 0, + shaderId: null, + ); + listener.onTextPosition(0, 100, 50, null, null, true, null); + listener.onUpdateTextPosition(0); + // xAnchorMultiplier = 0.5 corresponds to text-anchor="middle". + listener.onTextConfig('ABCDEFG', null, 0.5, 0, 16, 0, 0, 0, 0); + await listener.onDrawText(0, 0, null, null); + listener.onTextConfig('ABCDEFG', null, 0.5, 0, 16, 0, 0, 0, 1); + await listener.onDrawText(1, 0, null, null); + // Force flush of the pending anchored chunk by starting a new one. + listener.onTextPosition(1, 0, 0, null, null, true, null); + listener.onUpdateTextPosition(1); + + final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; + final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; + expect(drawParagraph0.memberName, #drawParagraph); + expect(drawParagraph1.memberName, #drawParagraph); + + final double dx0 = (drawParagraph0.positionalArguments[1] as Offset).dx; + final double dx1 = (drawParagraph1.positionalArguments[1] as Offset).dx; + + // The chunk is two equal tspans of width w. text-anchor="middle" centers + // the whole chunk (total width 2w) around x=100, so: + // dx0 = 100 - w (left tspan) + // dx1 = 100 (right tspan) + // Therefore the second tspan should start exactly at the original x=100. + expect(dx1, 100, reason: 'second tspan should start at the original x'); + final double w = 100 - dx0; + expect( + dx1 - dx0, + w, + reason: 'tspans should be contiguous within the chunk', + ); + }); + test('should assert when imageId is invalid', () async { final factory = TestPictureFactory(); final listener = FlutterVectorGraphicsListener(pictureFactory: factory); From 16ede7cc7b8888727be4aabe9c61f5d34eff0630 Mon Sep 17 00:00:00 2001 From: fondoger Date: Sun, 3 May 2026 01:49:02 +0800 Subject: [PATCH 2/6] [vector_graphics] Only flush text chunk on new anchored position The previous patch flushed the pending chunk on every onUpdateTextPosition, but the parser emits a TextPosition for every (even those without an explicit x/y). That meant consecutive tspans without their own x kept breaking the chunk, reproducing the original bug end-to-end despite the unit test passing. Per the SVG spec, a new anchored chunk only begins when the element establishes an explicit absolute position. Flush only when reset is true (a new ) or x is non-null. Strengthen the regression test to emit a per-tspan TextPosition between draws, matching the parser's actual output. --- packages/vector_graphics/lib/src/listener.dart | 12 +++++++++--- packages/vector_graphics/test/listener_test.dart | 8 ++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index ce958074b1a3..52e65f74b153 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -667,9 +667,15 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { @override void onUpdateTextPosition(int textPositionId) { final _TextPosition position = _textPositions[textPositionId]; - // Any explicit absolute position (or reset) starts a new SVG anchored - // chunk, so flush whatever we've queued for the previous one. - _flushPendingTextChunk(); + // Per the SVG spec, a new anchored chunk begins only when the element + // establishes an explicit absolute position (i.e. `x`/`y` on a + // or ). The parser also emits a TextPosition for every + // even when it has no x/y of its own; those should NOT break the + // current chunk. `reset` (set on elements) likewise starts a + // fresh chunk. + if (position.reset || position.x != null) { + _flushPendingTextChunk(); + } if (position.reset) { _accumulatedTextPositionX = 0; _textPositionY = 0; diff --git a/packages/vector_graphics/test/listener_test.dart b/packages/vector_graphics/test/listener_test.dart index 7c953446e3d9..28dea35919a3 100644 --- a/packages/vector_graphics/test/listener_test.dart +++ b/packages/vector_graphics/test/listener_test.dart @@ -160,11 +160,15 @@ void main() { // xAnchorMultiplier = 0.5 corresponds to text-anchor="middle". listener.onTextConfig('ABCDEFG', null, 0.5, 0, 16, 0, 0, 0, 0); await listener.onDrawText(0, 0, null, null); + // The parser emits a TextPosition for every , including those + // with no x/y. That must NOT break the current anchored chunk. + listener.onTextPosition(1, null, null, null, null, false, null); + listener.onUpdateTextPosition(1); listener.onTextConfig('ABCDEFG', null, 0.5, 0, 16, 0, 0, 0, 1); await listener.onDrawText(1, 0, null, null); // Force flush of the pending anchored chunk by starting a new one. - listener.onTextPosition(1, 0, 0, null, null, true, null); - listener.onUpdateTextPosition(1); + listener.onTextPosition(2, 0, 0, null, null, true, null); + listener.onUpdateTextPosition(2); final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; From 14601b4c388637af6acbb33a91ff855e579df9b3 Mon Sep 17 00:00:00 2001 From: fondoger Date: Sun, 3 May 2026 01:55:01 +0800 Subject: [PATCH 3/6] [vector_graphics] Render an anchored chunk as a single Paragraph Building one ui.Paragraph per meant cross-tspan shaping and pixel alignment were lost, so consecutive tspans rendered with a small visible gap even after fixing the anchor offset. Build a single combined Paragraph for the whole chunk (one for fill, one for stroke when both are present), pushing per-segment styles via pushStyle so each tspan keeps its own font, color, decoration, etc. Per-segment widths are still measured inline to keep _accumulatedTextPositionX honest for in-chunk dx positioning. --- .../vector_graphics/lib/src/listener.dart | 182 +++++++++++------- .../vector_graphics/test/listener_test.dart | 50 +++-- 2 files changed, 135 insertions(+), 97 deletions(-) diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index 52e65f74b153..f7b733daf02a 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -276,7 +276,10 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { // Pending text draws within the current SVG anchored chunk. Per the SVG // spec, `text-anchor` applies to the chunk as a whole, so we cannot - // commit a paragraph to the canvas until we know the full chunk width. + // commit any glyphs to the canvas until we know the full chunk width. + // We also build a single ui.Paragraph per chunk so that cross-tspan + // shaping and pixel alignment are preserved (otherwise consecutive + // tspans would render with a small visible gap between them). final List<_PendingTextDraw> _pendingChunk = <_PendingTextDraw>[]; // The user-space x at which the current chunk begins (i.e. the value of // `_accumulatedTextPositionX` at the time the first paragraph in the @@ -285,12 +288,16 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { // The text-anchor multiplier of the first paragraph in the chunk; used // to position the chunk as a whole. double _chunkAnchorMultiplier = 0; - // Cumulative pen-advance within the current chunk so far. - double _chunkAdvance = 0; + // Vertical baseline and transform of the chunk (taken from the first + // segment). + double _chunkDy = 0; + Float64List? _chunkTransform; _PatternConfig? _currentPattern; static final Paint _emptyPaint = Paint(); + static final Paint _transparentPaint = Paint() + ..color = const Color(0x00000000); static final Paint _grayscaleDstInPaint = Paint() ..blendMode = BlendMode.dstIn ..colorFilter = const ColorFilter.matrix( @@ -713,54 +720,46 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { if (_pendingChunk.isEmpty) { _chunkOriginX = dx; _chunkAnchorMultiplier = textConfig.xAnchorMultiplier; - _chunkAdvance = 0; + _chunkDy = dy; + _chunkTransform = _textTransform; } - final double offsetWithinChunk = _chunkAdvance; - Paragraph buildParagraph(int paintId) { - final Paint paint = _paints[paintId]; - if (patternId != null) { - paint.shader = _patterns[patternId]!.shader; - } - final builder = ParagraphBuilder( - ParagraphStyle(textDirection: _textDirection), - ); - builder.pushStyle( - TextStyle( - locale: _locale, - foreground: paint, - fontWeight: textConfig.fontWeight, - fontSize: textConfig.fontSize, - fontFamily: textConfig.fontFamily, - decoration: textConfig.decoration, - decorationStyle: textConfig.decorationStyle, - decorationColor: textConfig.decorationColor, - ), - ); - builder.addText(textConfig.text); - final Paragraph paragraph = builder.build(); - paragraph.layout(const ParagraphConstraints(width: double.infinity)); - return paragraph; - } + _pendingChunk.add( + _PendingTextDraw( + textConfig: textConfig, + fillPaintId: fillId, + strokePaintId: strokeId, + patternId: patternId, + ), + ); - double paragraphWidth = 0; - if (fillId != null) { - final Paragraph p = buildParagraph(fillId); - paragraphWidth = p.maxIntrinsicWidth; - _pendingChunk.add( - _PendingTextDraw(p, offsetWithinChunk, dy, _textTransform), - ); - } - if (strokeId != null) { - final Paragraph p = buildParagraph(strokeId); - paragraphWidth = p.maxIntrinsicWidth; - _pendingChunk.add( - _PendingTextDraw(p, offsetWithinChunk, dy, _textTransform), - ); - } + // Update _accumulatedTextPositionX so that any subsequent in-flow + // positioning (e.g. within the same chunk) starts + // from the end of this segment. We measure each segment in isolation + // here — the actual rendering paragraph will be built once per chunk + // at flush time so cross-tspan layout is preserved. + final double segmentWidth = _measureSegmentWidth(textConfig); + _accumulatedTextPositionX = dx + segmentWidth; + } - _chunkAdvance += paragraphWidth; - _accumulatedTextPositionX = dx + paragraphWidth; + double _measureSegmentWidth(_TextConfig textConfig) { + final builder = ParagraphBuilder( + ParagraphStyle(textDirection: _textDirection), + ); + builder.pushStyle( + TextStyle( + locale: _locale, + fontWeight: textConfig.fontWeight, + fontSize: textConfig.fontSize, + fontFamily: textConfig.fontFamily, + ), + ); + builder.addText(textConfig.text); + final Paragraph p = builder.build(); + p.layout(const ParagraphConstraints(width: double.infinity)); + final double width = p.maxIntrinsicWidth; + p.dispose(); + return width; } void _flushPendingTextChunk() { @@ -768,29 +767,70 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { return; } final double originX = _chunkOriginX ?? 0; - final double anchorOffset = _chunkAdvance * _chunkAnchorMultiplier; - for (final _PendingTextDraw draw in _pendingChunk) { - final Paragraph paragraph = draw.paragraph; - if (draw.transform != null) { - _canvas.save(); - _canvas.transform(draw.transform!); + final double dy = _chunkDy; + final Float64List? transform = _chunkTransform; + final bool hasFill = _pendingChunk.any((d) => d.fillPaintId != null); + final bool hasStroke = _pendingChunk.any((d) => d.strokePaintId != null); + + void paint(int? Function(_PendingTextDraw) paintIdSelector) { + final builder = ParagraphBuilder( + ParagraphStyle(textDirection: _textDirection), + ); + for (final _PendingTextDraw draw in _pendingChunk) { + final int? paintId = paintIdSelector(draw); + // If this segment doesn't participate in this paint role (e.g. + // no stroke), still include its text so glyph positions in the + // combined paragraph match the fill paragraph. Render it + // transparent by skipping pushStyle's foreground. + final Paint? p = paintId == null ? null : _paints[paintId]; + if (p != null && draw.patternId != null) { + p.shader = _patterns[draw.patternId!]!.shader; + } + builder.pushStyle( + TextStyle( + locale: _locale, + foreground: p ?? _transparentPaint, + fontWeight: draw.textConfig.fontWeight, + fontSize: draw.textConfig.fontSize, + fontFamily: draw.textConfig.fontFamily, + decoration: draw.textConfig.decoration, + decorationStyle: draw.textConfig.decorationStyle, + decorationColor: draw.textConfig.decorationColor, + ), + ); + builder.addText(draw.textConfig.text); + builder.pop(); } + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: double.infinity)); + final double width = paragraph.maxIntrinsicWidth; + final double anchorOffset = width * _chunkAnchorMultiplier; _canvas.drawParagraph( paragraph, - Offset( - originX + draw.offsetWithinChunk - anchorOffset, - draw.dy - paragraph.alphabeticBaseline, - ), + Offset(originX - anchorOffset, dy - paragraph.alphabeticBaseline), ); paragraph.dispose(); - if (draw.transform != null) { - _canvas.restore(); - } } + + if (transform != null) { + _canvas.save(); + _canvas.transform(transform); + } + if (hasFill) { + paint((d) => d.fillPaintId); + } + if (hasStroke) { + paint((d) => d.strokePaintId); + } + if (transform != null) { + _canvas.restore(); + } + _pendingChunk.clear(); _chunkOriginX = null; _chunkAnchorMultiplier = 0; - _chunkAdvance = 0; + _chunkDy = 0; + _chunkTransform = null; } int _createImageKey(int imageId, int format) { @@ -939,17 +979,17 @@ class _TextConfig { } class _PendingTextDraw { - _PendingTextDraw( - this.paragraph, - this.offsetWithinChunk, - this.dy, - this.transform, - ); + _PendingTextDraw({ + required this.textConfig, + required this.fillPaintId, + required this.strokePaintId, + required this.patternId, + }); - final Paragraph paragraph; - final double offsetWithinChunk; - final double dy; - final Float64List? transform; + final _TextConfig textConfig; + final int? fillPaintId; + final int? strokePaintId; + final int? patternId; } /// An exception thrown if decoding fails. diff --git a/packages/vector_graphics/test/listener_test.dart b/packages/vector_graphics/test/listener_test.dart index 28dea35919a3..d90b4bdae649 100644 --- a/packages/vector_graphics/test/listener_test.dart +++ b/packages/vector_graphics/test/listener_test.dart @@ -124,16 +124,16 @@ void main() { listener.onTextPosition(1, 0, 0, null, null, true, null); listener.onUpdateTextPosition(1); - final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; - final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; - - expect(drawParagraph0.memberName, #drawParagraph); + // The chunk's two segments are now rendered as a single ui.Paragraph + // (so cross-tspan layout is preserved). The combined paragraph is + // drawn once at the chunk's origin x. + final List draws = factory.fakeCanvases.last.invocations + .where((i) => i.memberName == #drawParagraph) + .toList(); + expect(draws, hasLength(1)); // Only checking the X because Y seems to vary a bit by platform within // acceptable range. X is what gets managed by the listener anyway. - expect((drawParagraph0.positionalArguments[1] as Offset).dx, 10); - - expect(drawParagraph1.memberName, #drawParagraph); - expect((drawParagraph1.positionalArguments[1] as Offset).dx, 58); + expect((draws[0].positionalArguments[1] as Offset).dx, 10); }); test('Text anchor middle centers the entire chunk across tspans', () async { @@ -170,25 +170,23 @@ void main() { listener.onTextPosition(2, 0, 0, null, null, true, null); listener.onUpdateTextPosition(2); - final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; - final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; - expect(drawParagraph0.memberName, #drawParagraph); - expect(drawParagraph1.memberName, #drawParagraph); - - final double dx0 = (drawParagraph0.positionalArguments[1] as Offset).dx; - final double dx1 = (drawParagraph1.positionalArguments[1] as Offset).dx; - - // The chunk is two equal tspans of width w. text-anchor="middle" centers - // the whole chunk (total width 2w) around x=100, so: - // dx0 = 100 - w (left tspan) - // dx1 = 100 (right tspan) - // Therefore the second tspan should start exactly at the original x=100. - expect(dx1, 100, reason: 'second tspan should start at the original x'); - final double w = 100 - dx0; + // Both tspans are rendered as a single combined ui.Paragraph centered + // around x=100. Total chunk width = 2w, anchor offset = w, so the + // combined paragraph is drawn at dx = 100 - w (i.e. dx < 100). + final List draws = factory.fakeCanvases.last.invocations + .where((i) => i.memberName == #drawParagraph) + .toList(); + expect(draws, hasLength(1)); + final double dx = (draws[0].positionalArguments[1] as Offset).dx; + expect( + dx, + lessThan(100), + reason: 'middle-anchored chunk should start to the left of the anchor x', + ); expect( - dx1 - dx0, - w, - reason: 'tspans should be contiguous within the chunk', + 100 - dx, + greaterThan(0), + reason: 'distance from x to chunk start equals half the chunk width', ); }); From 90a45965afd39570295406fbd4b71eb7b4c6c88d Mon Sep 17 00:00:00 2001 From: fondoger Date: Sun, 3 May 2026 01:57:46 +0800 Subject: [PATCH 4/6] Revert "[vector_graphics] Render an anchored chunk as a single Paragraph" This reverts commit 4a1adb84eded8005c5ec19c48a98413d674d5236. --- .../vector_graphics/lib/src/listener.dart | 182 +++++++----------- .../vector_graphics/test/listener_test.dart | 50 ++--- 2 files changed, 97 insertions(+), 135 deletions(-) diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index f7b733daf02a..52e65f74b153 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -276,10 +276,7 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { // Pending text draws within the current SVG anchored chunk. Per the SVG // spec, `text-anchor` applies to the chunk as a whole, so we cannot - // commit any glyphs to the canvas until we know the full chunk width. - // We also build a single ui.Paragraph per chunk so that cross-tspan - // shaping and pixel alignment are preserved (otherwise consecutive - // tspans would render with a small visible gap between them). + // commit a paragraph to the canvas until we know the full chunk width. final List<_PendingTextDraw> _pendingChunk = <_PendingTextDraw>[]; // The user-space x at which the current chunk begins (i.e. the value of // `_accumulatedTextPositionX` at the time the first paragraph in the @@ -288,16 +285,12 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { // The text-anchor multiplier of the first paragraph in the chunk; used // to position the chunk as a whole. double _chunkAnchorMultiplier = 0; - // Vertical baseline and transform of the chunk (taken from the first - // segment). - double _chunkDy = 0; - Float64List? _chunkTransform; + // Cumulative pen-advance within the current chunk so far. + double _chunkAdvance = 0; _PatternConfig? _currentPattern; static final Paint _emptyPaint = Paint(); - static final Paint _transparentPaint = Paint() - ..color = const Color(0x00000000); static final Paint _grayscaleDstInPaint = Paint() ..blendMode = BlendMode.dstIn ..colorFilter = const ColorFilter.matrix( @@ -720,46 +713,54 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { if (_pendingChunk.isEmpty) { _chunkOriginX = dx; _chunkAnchorMultiplier = textConfig.xAnchorMultiplier; - _chunkDy = dy; - _chunkTransform = _textTransform; + _chunkAdvance = 0; } + final double offsetWithinChunk = _chunkAdvance; - _pendingChunk.add( - _PendingTextDraw( - textConfig: textConfig, - fillPaintId: fillId, - strokePaintId: strokeId, - patternId: patternId, - ), - ); + Paragraph buildParagraph(int paintId) { + final Paint paint = _paints[paintId]; + if (patternId != null) { + paint.shader = _patterns[patternId]!.shader; + } + final builder = ParagraphBuilder( + ParagraphStyle(textDirection: _textDirection), + ); + builder.pushStyle( + TextStyle( + locale: _locale, + foreground: paint, + fontWeight: textConfig.fontWeight, + fontSize: textConfig.fontSize, + fontFamily: textConfig.fontFamily, + decoration: textConfig.decoration, + decorationStyle: textConfig.decorationStyle, + decorationColor: textConfig.decorationColor, + ), + ); + builder.addText(textConfig.text); + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: double.infinity)); + return paragraph; + } - // Update _accumulatedTextPositionX so that any subsequent in-flow - // positioning (e.g. within the same chunk) starts - // from the end of this segment. We measure each segment in isolation - // here — the actual rendering paragraph will be built once per chunk - // at flush time so cross-tspan layout is preserved. - final double segmentWidth = _measureSegmentWidth(textConfig); - _accumulatedTextPositionX = dx + segmentWidth; - } + double paragraphWidth = 0; + if (fillId != null) { + final Paragraph p = buildParagraph(fillId); + paragraphWidth = p.maxIntrinsicWidth; + _pendingChunk.add( + _PendingTextDraw(p, offsetWithinChunk, dy, _textTransform), + ); + } + if (strokeId != null) { + final Paragraph p = buildParagraph(strokeId); + paragraphWidth = p.maxIntrinsicWidth; + _pendingChunk.add( + _PendingTextDraw(p, offsetWithinChunk, dy, _textTransform), + ); + } - double _measureSegmentWidth(_TextConfig textConfig) { - final builder = ParagraphBuilder( - ParagraphStyle(textDirection: _textDirection), - ); - builder.pushStyle( - TextStyle( - locale: _locale, - fontWeight: textConfig.fontWeight, - fontSize: textConfig.fontSize, - fontFamily: textConfig.fontFamily, - ), - ); - builder.addText(textConfig.text); - final Paragraph p = builder.build(); - p.layout(const ParagraphConstraints(width: double.infinity)); - final double width = p.maxIntrinsicWidth; - p.dispose(); - return width; + _chunkAdvance += paragraphWidth; + _accumulatedTextPositionX = dx + paragraphWidth; } void _flushPendingTextChunk() { @@ -767,70 +768,29 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { return; } final double originX = _chunkOriginX ?? 0; - final double dy = _chunkDy; - final Float64List? transform = _chunkTransform; - final bool hasFill = _pendingChunk.any((d) => d.fillPaintId != null); - final bool hasStroke = _pendingChunk.any((d) => d.strokePaintId != null); - - void paint(int? Function(_PendingTextDraw) paintIdSelector) { - final builder = ParagraphBuilder( - ParagraphStyle(textDirection: _textDirection), - ); - for (final _PendingTextDraw draw in _pendingChunk) { - final int? paintId = paintIdSelector(draw); - // If this segment doesn't participate in this paint role (e.g. - // no stroke), still include its text so glyph positions in the - // combined paragraph match the fill paragraph. Render it - // transparent by skipping pushStyle's foreground. - final Paint? p = paintId == null ? null : _paints[paintId]; - if (p != null && draw.patternId != null) { - p.shader = _patterns[draw.patternId!]!.shader; - } - builder.pushStyle( - TextStyle( - locale: _locale, - foreground: p ?? _transparentPaint, - fontWeight: draw.textConfig.fontWeight, - fontSize: draw.textConfig.fontSize, - fontFamily: draw.textConfig.fontFamily, - decoration: draw.textConfig.decoration, - decorationStyle: draw.textConfig.decorationStyle, - decorationColor: draw.textConfig.decorationColor, - ), - ); - builder.addText(draw.textConfig.text); - builder.pop(); + final double anchorOffset = _chunkAdvance * _chunkAnchorMultiplier; + for (final _PendingTextDraw draw in _pendingChunk) { + final Paragraph paragraph = draw.paragraph; + if (draw.transform != null) { + _canvas.save(); + _canvas.transform(draw.transform!); } - final Paragraph paragraph = builder.build(); - paragraph.layout(const ParagraphConstraints(width: double.infinity)); - final double width = paragraph.maxIntrinsicWidth; - final double anchorOffset = width * _chunkAnchorMultiplier; _canvas.drawParagraph( paragraph, - Offset(originX - anchorOffset, dy - paragraph.alphabeticBaseline), + Offset( + originX + draw.offsetWithinChunk - anchorOffset, + draw.dy - paragraph.alphabeticBaseline, + ), ); paragraph.dispose(); + if (draw.transform != null) { + _canvas.restore(); + } } - - if (transform != null) { - _canvas.save(); - _canvas.transform(transform); - } - if (hasFill) { - paint((d) => d.fillPaintId); - } - if (hasStroke) { - paint((d) => d.strokePaintId); - } - if (transform != null) { - _canvas.restore(); - } - _pendingChunk.clear(); _chunkOriginX = null; _chunkAnchorMultiplier = 0; - _chunkDy = 0; - _chunkTransform = null; + _chunkAdvance = 0; } int _createImageKey(int imageId, int format) { @@ -979,17 +939,17 @@ class _TextConfig { } class _PendingTextDraw { - _PendingTextDraw({ - required this.textConfig, - required this.fillPaintId, - required this.strokePaintId, - required this.patternId, - }); + _PendingTextDraw( + this.paragraph, + this.offsetWithinChunk, + this.dy, + this.transform, + ); - final _TextConfig textConfig; - final int? fillPaintId; - final int? strokePaintId; - final int? patternId; + final Paragraph paragraph; + final double offsetWithinChunk; + final double dy; + final Float64List? transform; } /// An exception thrown if decoding fails. diff --git a/packages/vector_graphics/test/listener_test.dart b/packages/vector_graphics/test/listener_test.dart index d90b4bdae649..28dea35919a3 100644 --- a/packages/vector_graphics/test/listener_test.dart +++ b/packages/vector_graphics/test/listener_test.dart @@ -124,16 +124,16 @@ void main() { listener.onTextPosition(1, 0, 0, null, null, true, null); listener.onUpdateTextPosition(1); - // The chunk's two segments are now rendered as a single ui.Paragraph - // (so cross-tspan layout is preserved). The combined paragraph is - // drawn once at the chunk's origin x. - final List draws = factory.fakeCanvases.last.invocations - .where((i) => i.memberName == #drawParagraph) - .toList(); - expect(draws, hasLength(1)); + final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; + final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; + + expect(drawParagraph0.memberName, #drawParagraph); // Only checking the X because Y seems to vary a bit by platform within // acceptable range. X is what gets managed by the listener anyway. - expect((draws[0].positionalArguments[1] as Offset).dx, 10); + expect((drawParagraph0.positionalArguments[1] as Offset).dx, 10); + + expect(drawParagraph1.memberName, #drawParagraph); + expect((drawParagraph1.positionalArguments[1] as Offset).dx, 58); }); test('Text anchor middle centers the entire chunk across tspans', () async { @@ -170,23 +170,25 @@ void main() { listener.onTextPosition(2, 0, 0, null, null, true, null); listener.onUpdateTextPosition(2); - // Both tspans are rendered as a single combined ui.Paragraph centered - // around x=100. Total chunk width = 2w, anchor offset = w, so the - // combined paragraph is drawn at dx = 100 - w (i.e. dx < 100). - final List draws = factory.fakeCanvases.last.invocations - .where((i) => i.memberName == #drawParagraph) - .toList(); - expect(draws, hasLength(1)); - final double dx = (draws[0].positionalArguments[1] as Offset).dx; - expect( - dx, - lessThan(100), - reason: 'middle-anchored chunk should start to the left of the anchor x', - ); + final Invocation drawParagraph0 = factory.fakeCanvases.last.invocations[0]; + final Invocation drawParagraph1 = factory.fakeCanvases.last.invocations[1]; + expect(drawParagraph0.memberName, #drawParagraph); + expect(drawParagraph1.memberName, #drawParagraph); + + final double dx0 = (drawParagraph0.positionalArguments[1] as Offset).dx; + final double dx1 = (drawParagraph1.positionalArguments[1] as Offset).dx; + + // The chunk is two equal tspans of width w. text-anchor="middle" centers + // the whole chunk (total width 2w) around x=100, so: + // dx0 = 100 - w (left tspan) + // dx1 = 100 (right tspan) + // Therefore the second tspan should start exactly at the original x=100. + expect(dx1, 100, reason: 'second tspan should start at the original x'); + final double w = 100 - dx0; expect( - 100 - dx, - greaterThan(0), - reason: 'distance from x to chunk start equals half the chunk width', + dx1 - dx0, + w, + reason: 'tspans should be contiguous within the chunk', ); }); From b604ba742ab761651b1cab55e41700e1f5022673 Mon Sep 17 00:00:00 2001 From: fondoger Date: Sun, 3 May 2026 02:11:29 +0800 Subject: [PATCH 5/6] [vector_graphics_compiler] Don't inject space between adjacent tspans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SVG parser was unconditionally prepending a space to the text of any tspan that followed another tspan, regardless of whether the source XML actually contained whitespace at the boundary. That made `AB` parse as ['A', ' B'] instead of ['A', 'B'], producing a visible gap in the rendered output. Replace the over-broad "last end was tspan" rule with one that only fires when the source actually has whitespace at the boundary — either a leading-whitespace prefix on the current text, or an earlier whitespace-only text event that's now flagged via _lastTextEndedWithSpace. That preserves existing behavior for `A B` and for trailing text after a tspan, while fixing the no-whitespace case. Adds two regression tests covering both branches. --- .../vector_graphics_compiler/CHANGELOG.md | 5 +++ .../lib/src/svg/parser.dart | 29 ++++++++++---- .../test/parser_test.dart | 40 +++++++++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/vector_graphics_compiler/CHANGELOG.md b/packages/vector_graphics_compiler/CHANGELOG.md index 13682cba547d..03bbc1517c5f 100644 --- a/packages/vector_graphics_compiler/CHANGELOG.md +++ b/packages/vector_graphics_compiler/CHANGELOG.md @@ -1,6 +1,11 @@ ## 1.2.1 * Fixes HSL/HSLA color parsing for decimal percentage components (e.g. `hsl(270, 100%, 76.27%)`). +* Fixes the SVG parser injecting a spurious space between adjacent + `` elements that have no whitespace between them in the source. + Previously `AB` was emitted as `"A"` + + `" B"`, producing a visible gap; it now emits `"A"` + `"B"` to match + every browser. ## 1.2.0 diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index 52eb38612a48..f5bcdaf90db6 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -758,16 +758,23 @@ class SvgParser { final textHasNonWhitespace = text.trim() != ''; // Not from the spec, but seems like how Chrome behaves. - // - If `x` is specified, don't prepend whitespace. - // - If the last element was a tspan and we're dealing with some - // non-whitespace data, prepend a space. - // - If the last text wasn't whitespace and ended with whitespace, prepend - // a space. + // - If `x` is specified on the current element, don't prepend whitespace. + // - Otherwise prepend a space if either: + // * the previous text emission ended on a space character, or + // * we are following a `` and the source actually contains + // whitespace at the boundary (either as a leading-whitespace prefix + // on this text or as an earlier whitespace-only text event that + // was trimmed). + // The "tspan" gate is what prevents `AB` + // from rendering as "A B" — without it the parser would always inject + // a space between adjacent tspans even when no whitespace exists in + // the source. + final bool textHasLeadingWhitespace = + text.isNotEmpty && _whitespacePattern.matchAsPrefix(text) != null; + final followsTspan = _lastEndElementEvent?.localName == 'tspan'; final bool prependSpace = _currentAttributes.x == null && - (_lastEndElementEvent?.localName == 'tspan' && - textHasNonWhitespace) || - _lastTextEndedWithSpace; + (_lastTextEndedWithSpace || (followsTspan && textHasLeadingWhitespace)); _lastTextEndedWithSpace = textHasNonWhitespace && @@ -785,6 +792,12 @@ class SvgParser { .replaceAll(_contiguousSpaceMatcher, ' '); if (text.isEmpty) { + // A pure-whitespace text event sitting between two sibling tspans + // still needs to flag that whitespace existed, so the next + // non-empty text can prepend a space. + if (textHasLeadingWhitespace && followsTspan) { + _lastTextEndedWithSpace = true; + } return; } diff --git a/packages/vector_graphics_compiler/test/parser_test.dart b/packages/vector_graphics_compiler/test/parser_test.dart index b99fd0085745..df096e713567 100644 --- a/packages/vector_graphics_compiler/test/parser_test.dart +++ b/packages/vector_graphics_compiler/test/parser_test.dart @@ -277,6 +277,46 @@ void main() { ]); }); + test('adjacent tspans without whitespace are not separated by a space', () { + // Regression test: previously the parser unconditionally injected a + // space between the text of any two consecutive tspans, even when the + // source XML contained no whitespace between and . + // That caused `AB` to render as "A B" + // (a visible gap), instead of "AB" as every browser does. + const svg = + '' + 'ABCDEFGHIJKLMN' + // ignore: missing_whitespace_between_adjacent_strings + ''; + + final VectorInstructions instructions = parseWithoutOptimizers(svg); + + expect(instructions.text.map((TextConfig t) => t.text), [ + 'ABCDEFG', + 'HIJKLMN', + ]); + }); + + test('adjacent tspans with whitespace between still get a space', () { + // Sibling case to the regression test above: when there *is* source + // whitespace between and , that whitespace must be + // preserved as a single space prepended to the second tspan. + const svg = + // ignore: missing_whitespace_between_adjacent_strings + '' + // ignore: missing_whitespace_between_adjacent_strings + 'A B'; + + final VectorInstructions instructions = parseWithoutOptimizers(svg); + + expect(instructions.text.map((TextConfig t) => t.text), [ + 'A', + ' B', + ]); + }); + test('stroke-opacity', () { const strokeOpacitySvg = ''' From f4651a250d925cbb3c019ae5559ec0e3fee66a6b Mon Sep 17 00:00:00 2001 From: fondoger Date: Sun, 3 May 2026 02:19:44 +0800 Subject: [PATCH 6/6] [vector_graphics] Address review: also flush chunk on y / anchor change, account for in-chunk dx Per SVG spec, both `x` and `y` (not just `x`) start a new anchored chunk. A change in text-anchor on the next segment likewise starts a new chunk. When continuing a chunk, recompute the in-chunk offset from the current pen position minus the chunk origin, so any relative `dx="..."` movements applied via onUpdateTextPosition since the last segment are reflected in the segment's position within the chunk. --- .../vector_graphics/lib/src/listener.dart | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index 52e65f74b153..765aa100a783 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -668,12 +668,12 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { void onUpdateTextPosition(int textPositionId) { final _TextPosition position = _textPositions[textPositionId]; // Per the SVG spec, a new anchored chunk begins only when the element - // establishes an explicit absolute position (i.e. `x`/`y` on a - // or ). The parser also emits a TextPosition for every - // even when it has no x/y of its own; those should NOT break the - // current chunk. `reset` (set on elements) likewise starts a - // fresh chunk. - if (position.reset || position.x != null) { + // establishes an explicit absolute position (i.e. an `x` or `y` on a + // or ). Relative `dx`/`dy` move the pen but do NOT + // start a new chunk; neither does the bare per-tspan TextPosition the + // parser emits when the tspan has no x/y of its own. `reset` (set on + // elements) likewise starts a fresh chunk. + if (position.reset || position.x != null || position.y != null) { _flushPendingTextChunk(); } if (position.reset) { @@ -710,10 +710,23 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { final double dx = _accumulatedTextPositionX ?? 0; final double dy = _textPositionY; + // A change in text-anchor on a continuing chunk also starts a new + // anchored chunk per the SVG spec. + if (_pendingChunk.isNotEmpty && + textConfig.xAnchorMultiplier != _chunkAnchorMultiplier) { + _flushPendingTextChunk(); + } + if (_pendingChunk.isEmpty) { _chunkOriginX = dx; _chunkAnchorMultiplier = textConfig.xAnchorMultiplier; _chunkAdvance = 0; + } else { + // Continuing the chunk: take the live pen position so any in-chunk + // relative `dx="..."` movements applied via onUpdateTextPosition + // since the last segment are accounted for in the segment's offset + // within the chunk. + _chunkAdvance = dx - _chunkOriginX!; } final double offsetWithinChunk = _chunkAdvance;