Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/vector_graphics/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.2.1

* Fixes `text-anchor` on `<text>` with multiple `<tspan>` 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.
Expand Down
116 changes: 98 additions & 18 deletions packages/vector_graphics/lib/src/listener.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -294,6 +308,7 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
PictureInfo toPicture() {
assert(!_done);
_done = true;
_flushPendingTextChunk();
try {
return PictureInfo._(_recorder.endRecording(), _size);
} finally {
Expand Down Expand Up @@ -652,6 +667,15 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener {
@override
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. an `x` or `y` on a
// <text> or <tspan>). 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
// <text> elements) likewise starts a fresh chunk.
if (position.reset || position.x != null || position.y != null) {
_flushPendingTextChunk();
}
if (position.reset) {
_accumulatedTextPositionX = 0;
_textPositionY = 0;
Expand Down Expand Up @@ -685,9 +709,28 @@ 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) {
// 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;
Comment thread
walley892 marked this conversation as resolved.
Outdated

Paragraph buildParagraph(int paintId) {
final Paint paint = _paints[paintId];
if (patternId != null) {
paint.shader = _patterns[patternId]!.shader;
Expand All @@ -707,37 +750,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;
}

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),
);
}
Comment thread
walley892 marked this conversation as resolved.

if (_textTransform != null) {
_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) {
Expand Down Expand Up @@ -885,6 +951,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
Expand Down
2 changes: 1 addition & 1 deletion packages/vector_graphics/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions packages/vector_graphics/test/listener_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -133,6 +136,62 @@ void main() {
expect((drawParagraph1.positionalArguments[1] as Offset).dx, 58);
});

test('Text anchor middle centers the entire chunk across tspans', () async {
// SVG: <text x="100" y="50" text-anchor="middle">
// <tspan>ABCDEFG</tspan><tspan>ABCDEFG</tspan>
// </text>
// 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);
// The parser emits a TextPosition for every <tspan>, 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(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;
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);
Expand Down
5 changes: 5 additions & 0 deletions packages/vector_graphics_compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
`<tspan>` elements that have no whitespace between them in the source.
Previously `<tspan>A</tspan><tspan>B</tspan>` was emitted as `"A"` +
`" B"`, producing a visible gap; it now emits `"A"` + `"B"` to match
every browser.

## 1.2.0

Expand Down
29 changes: 21 additions & 8 deletions packages/vector_graphics_compiler/lib/src/svg/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</tspan>` 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 `<tspan>A</tspan><tspan>B</tspan>`
// 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 &&
Expand All @@ -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;
}

Expand Down
40 changes: 40 additions & 0 deletions packages/vector_graphics_compiler/test/parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 </tspan> and <tspan>.
// That caused `<tspan>A</tspan><tspan>B</tspan>` to render as "A B"
// (a visible gap), instead of "AB" as every browser does.
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">'
'<text x="100" y="50" '
// ignore: missing_whitespace_between_adjacent_strings
'text-anchor="middle"><tspan>ABCDEFG</tspan><tspan>HIJKLMN</tspan>'
// ignore: missing_whitespace_between_adjacent_strings
'</text></svg>';

final VectorInstructions instructions = parseWithoutOptimizers(svg);

expect(instructions.text.map((TextConfig t) => t.text), <String>[
'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 </tspan> and <tspan>, that whitespace must be
// preserved as a single space prepended to the second tspan.
const svg =
// ignore: missing_whitespace_between_adjacent_strings
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">'
// ignore: missing_whitespace_between_adjacent_strings
'<text x="0" y="40"><tspan>A</tspan> <tspan>B</tspan></text></svg>';

final VectorInstructions instructions = parseWithoutOptimizers(svg);

expect(instructions.text.map((TextConfig t) => t.text), <String>[
'A',
' B',
]);
});

test('stroke-opacity', () {
const strokeOpacitySvg = '''
<svg viewBox="0 0 10 10" fill="none">
Expand Down