From 3d710a2211a6fdf836154d0375a2262ba7147eeb Mon Sep 17 00:00:00 2001 From: Edwin Hollen Date: Mon, 15 Jun 2026 18:22:50 +0000 Subject: [PATCH] [various] Support SVG filters (blur, offset, feMerge) and alpha masks This PR introduces support for basic SVG filters and alpha masking to the vector_graphics ecosystem. Key Features: * Added parsing, resolution, and encoding of SVG filters including `feGaussianBlur`, `feOffset`, and `feMerge` in `vector_graphics_compiler`. * Added support for alpha masks (`mask-type=alpha`). * Updated the binary format and `vector_graphics_codec` to support encoding and decoding paint blurs. * Updated `vector_graphics` to render paint blurs using Flutter's `ui.ImageFilter.blur` and alpha masks via `BlendMode.dstIn`. * Enhanced `SourceAlpha` filters to preserve opacity variations in gradient stops by mapping gradient colors to black while maintaining their alpha values. Implementation Details: * **Filter Chaining**: Filters are parsed into a directed acyclic graph (DAG) of primitives. The resolver traces these primitives from the filter outputs back to `SourceGraphic`/`SourceAlpha` to determine the necessary rendering layers. * **Multi-layer Filter Resolution**: Handles multi-layer filters (e.g. from `feMerge`) by duplicating the target rendering nodes for each layer in the AST. * **Text Filter Resolution**: Resolves filters at the `TextPositionNode` level (rather than leaf `TextNode`s) to maintain correct text chunk alignment and cursor positioning. * **Nested Filter Propagation**: Enhanced nested parent nodes (groups), inner path filters, and text position nodes to correctly inherit and propagate the outer `SourceAlpha` state down the tree. * **Blur Scaling**: Scales the blur standard deviations by the accumulated transform's scale factors at both the group and path/text levels to ensure correct rendering when scaled (since the compiler bakes geometry transforms at compile time, the blur sigmas must also be scaled to match). * **Spec Compliance**: * Supports both comma and space-separated `stdDeviation` values. * Defaults omitted `stdDeviation` attributes to `0.0` (as per SVG spec). * Automatically resolves default `in` attributes for `feMergeNode`s (falling back to the previous sibling primitive's result or `SourceGraphic`). * Preserves original fill/stroke opacity when applying `SourceAlpha` filters. * Handles self-closing `` elements. * **Robustness, Performance & Optimizations**: * Added cycle detection during filter tracing to prevent stack overflow on circular filter references. * Skips rendering node recreation for filter layers that do not affect the paint (e.g. plain `SourceGraphic` branches in a merge). * Wrapped current layer state restoration in `try-finally` blocks inside parent node and text position node visitors to guarantee robust recovery in case of exceptions. * Caches blackened gradients in `ResolvingVisitor` to prevent redundant object allocations for shared SVG gradients. * Guarded against setting paint `imageFilter` when blur standard deviations are `0.0` to avoid unnecessary rendering overhead in Flutter. * Defensively clamped blur standard deviations to non-negative values in the rendering listener to prevent crashes when processing malformed SVG files. ## Pre-Review Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] page, which explains my responsibilities. - [x] I read and followed the [relevant style guides] and ran [the auto-formatter]. - [x] I signed the [CLA]. - [x] The title of the PR starts with the name of the package surrounded by square brackets, e.g. `[shared_preferences]` - [x] I linked to at least one issue that this PR fixes in the description above. * *Note: No existing issue, this is a new feature contribution.* - [x] I followed [the version and CHANGELOG instructions], using [semantic versioning] and the [repository CHANGELOG style], or I have commented below to indicate which documented exception this PR falls under[^1]. - [x] I updated/added any relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or I have commented below to indicate which [test exemption] this PR falls under[^1]. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. [Contributor Guide]: https://github.com/flutter/packages/blob/main/CONTRIBUTING.md [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/master/docs/contributing/Tree-hygiene.md [relevant style guides]: https://github.com/flutter/packages/blob/main/CONTRIBUTING.md#style [the auto-formatter]: https://github.com/flutter/packages/blob/main/script/tool/README.md#format-code [CLA]: https://cla.developers.google.com/ [Discord]: https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md [linked to at least one issue that this PR fixes]: https://github.com/flutter/flutter/blob/master/docs/contributing/Tree-hygiene.md#overview [the version and CHANGELOG instructions]: https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#version-and-changelog-updates [semantic versioning]: https://dart.dev/tools/pub/versioning#semantic-versions [repository CHANGELOG style]: https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changelog-style [test exemption]: https://github.com/flutter/flutter/blob/master/docs/contributing/Tree-hygiene.md#tests TAG=agy CONV=27cb188a-9ad9-4434-8918-8ef71bdba17c --- packages/vector_graphics/CHANGELOG.md | 3 +- .../vector_graphics/lib/src/listener.dart | 9 + packages/vector_graphics/pubspec.yaml | 4 +- .../vector_graphics/test/listener_test.dart | 150 +++++ packages/vector_graphics_codec/CHANGELOG.md | 3 +- .../lib/vector_graphics_codec.dart | 23 + packages/vector_graphics_codec/pubspec.yaml | 2 +- .../test/vector_graphics_codec_test.dart | 45 ++ .../vector_graphics_compiler/CHANGELOG.md | 4 + .../lib/src/geometry/matrix.dart | 9 +- .../lib/src/paint.dart | 22 +- .../lib/src/svg/node.dart | 35 +- .../lib/src/svg/parser.dart | 238 +++++++- .../lib/src/svg/resolver.dart | 525 ++++++++++++++++-- .../lib/src/svg/visitor.dart | 6 +- .../lib/vector_graphics_compiler.dart | 6 + .../vector_graphics_compiler/pubspec.yaml | 5 +- .../test/end_to_end_test.dart | 238 ++++++++ .../test/parser_test.dart | 494 ++++++++++++++++ 19 files changed, 1763 insertions(+), 58 deletions(-) diff --git a/packages/vector_graphics/CHANGELOG.md b/packages/vector_graphics/CHANGELOG.md index 4d13b0fe794f..3837164ab4f4 100644 --- a/packages/vector_graphics/CHANGELOG.md +++ b/packages/vector_graphics/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 1.2.3 +* Adds support for SVG filters (blur, offset, feMerge) and mask-type=alpha. * Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. ## 1.2.2 diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index 93b1a5a14e9a..72847b7edeae 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -393,6 +393,15 @@ class FlutterVectorGraphicsListener extends VectorGraphicsCodecListener { _paints.add(paint); } + @override + void onPaintBlur(int paintId, double sigmaX, double sigmaY) { + final double clampedX = sigmaX < 0.0 ? 0.0 : sigmaX; + final double clampedY = sigmaY < 0.0 ? 0.0 : sigmaY; + if (clampedX > 0.0 || clampedY > 0.0) { + _paints[paintId].imageFilter = ImageFilter.blur(sigmaX: clampedX, sigmaY: clampedY); + } + } + @override void onPathClose() { _currentPath!.close(); diff --git a/packages/vector_graphics/pubspec.yaml b/packages/vector_graphics/pubspec.yaml index 8a21c4e2186a..5ae2917219f2 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.2 +version: 1.2.3 environment: sdk: ^3.10.0 @@ -12,7 +12,7 @@ dependencies: flutter: sdk: flutter http: ^1.0.0 - vector_graphics_codec: ^1.1.11+1 + vector_graphics_codec: ^1.1.14 dev_dependencies: flutter_test: diff --git a/packages/vector_graphics/test/listener_test.dart b/packages/vector_graphics/test/listener_test.dart index 16c181d6c7a4..affd20081d85 100644 --- a/packages/vector_graphics/test/listener_test.dart +++ b/packages/vector_graphics/test/listener_test.dart @@ -187,6 +187,156 @@ void main() { await listener.waitForImageDecode(); expect(() => listener.onDrawImage(2, 10, 10, 100, 100, null), throwsAssertionError); }); + + test('Paint blur is applied to paint', () async { + final factory = TestPictureFactory(); + final listener = FlutterVectorGraphicsListener(pictureFactory: factory); + + listener.onPaintObject( + color: const ui.Color(0xff000000).toARGB32(), + strokeCap: null, + strokeJoin: null, + blendMode: BlendMode.srcIn.index, + strokeMiterLimit: null, + strokeWidth: null, + paintStyle: ui.PaintingStyle.fill.index, + id: 0, + shaderId: null, + ); + + listener.onPaintBlur(0, 5.0, 10.0); + + listener.onPathStart(0, 0); + listener.onPathMoveTo(0, 0); + listener.onPathLineTo(10, 10); + listener.onPathFinished(); + + await listener.onDrawPath(0, 0, null); + + final Invocation drawPath = factory.fakeCanvases.single.invocations.single; + expect(drawPath.isMethod, true); + expect(drawPath.memberName, #drawPath); + final paint = drawPath.positionalArguments[1] as ui.Paint; + expect(paint.imageFilter, isNotNull); + expect(paint.imageFilter, ui.ImageFilter.blur(sigmaX: 5.0, sigmaY: 10.0)); + }); + + test('onPaintBlur with 0.0 standard deviations does not set imageFilter', () async { + final TestPictureFactory factory = TestPictureFactory(); + final FlutterVectorGraphicsListener listener = FlutterVectorGraphicsListener( + id: 0, + locale: null, + textDirection: null, + clipViewbox: true, + pictureFactory: factory, + ); + + listener.onPaintObject( + color: const ui.Color(0xff000000).toARGB32(), + strokeCap: null, + strokeJoin: null, + blendMode: BlendMode.srcIn.index, + strokeMiterLimit: null, + strokeWidth: null, + paintStyle: ui.PaintingStyle.fill.index, + id: 0, + shaderId: null, + ); + + listener.onPaintBlur(0, 0.0, 0.0); + + listener.onPathStart(0, 0); + listener.onPathMoveTo(0, 0); + listener.onPathLineTo(10, 10); + listener.onPathFinished(); + + await listener.onDrawPath(0, 0, null); + + final Invocation drawPath = factory.fakeCanvases.single.invocations.single; + expect(drawPath.isMethod, true); + expect(drawPath.memberName, #drawPath); + final paint = drawPath.positionalArguments[1] as ui.Paint; + expect(paint.imageFilter, isNull); + }); + + test('onPaintBlur with negative standard deviations clamps them to 0.0', () async { + final TestPictureFactory factory = TestPictureFactory(); + final FlutterVectorGraphicsListener listener = FlutterVectorGraphicsListener( + id: 0, + locale: null, + textDirection: null, + clipViewbox: true, + pictureFactory: factory, + ); + + listener.onPaintObject( + color: const ui.Color(0xff000000).toARGB32(), + strokeCap: null, + strokeJoin: null, + blendMode: BlendMode.srcIn.index, + strokeMiterLimit: null, + strokeWidth: null, + paintStyle: ui.PaintingStyle.fill.index, + id: 0, + shaderId: null, + ); + + // One negative, one positive -> positive preserved, negative clamped to 0.0 + listener.onPaintBlur(0, -5.0, 10.0); + + listener.onPathStart(0, 0); + listener.onPathMoveTo(0, 0); + listener.onPathLineTo(10, 10); + listener.onPathFinished(); + + await listener.onDrawPath(0, 0, null); + + final Invocation drawPath = factory.fakeCanvases.single.invocations.single; + expect(drawPath.isMethod, true); + expect(drawPath.memberName, #drawPath); + final paint = drawPath.positionalArguments[1] as ui.Paint; + expect(paint.imageFilter, isNotNull); + expect(paint.imageFilter, ui.ImageFilter.blur(sigmaX: 0.0, sigmaY: 10.0)); + }); + + test('onPaintBlur with both negative standard deviations does not set imageFilter', () async { + final TestPictureFactory factory = TestPictureFactory(); + final FlutterVectorGraphicsListener listener = FlutterVectorGraphicsListener( + id: 0, + locale: null, + textDirection: null, + clipViewbox: true, + pictureFactory: factory, + ); + + listener.onPaintObject( + color: const ui.Color(0xff000000).toARGB32(), + strokeCap: null, + strokeJoin: null, + blendMode: BlendMode.srcIn.index, + strokeMiterLimit: null, + strokeWidth: null, + paintStyle: ui.PaintingStyle.fill.index, + id: 0, + shaderId: null, + ); + + // Both negative -> both clamped to 0.0 -> no filter + listener.onPaintBlur(0, -5.0, -10.0); + + listener.onPathStart(0, 0); + listener.onPathMoveTo(0, 0); + listener.onPathLineTo(10, 10); + listener.onPathFinished(); + + await listener.onDrawPath(0, 0, null); + + final Invocation drawPath = factory.fakeCanvases.single.invocations.single; + expect(drawPath.isMethod, true); + expect(drawPath.memberName, #drawPath); + final paint = drawPath.positionalArguments[1] as ui.Paint; + expect(paint.imageFilter, isNull); + }); } class TestPictureFactory implements PictureFactory { diff --git a/packages/vector_graphics_codec/CHANGELOG.md b/packages/vector_graphics_codec/CHANGELOG.md index 2de4252b84c0..c4a5b1d7de39 100644 --- a/packages/vector_graphics_codec/CHANGELOG.md +++ b/packages/vector_graphics_codec/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 1.1.14 +* Adds support for SVG filters (blur, offset, feMerge) and mask-type=alpha. * Updates minimum supported SDK version to Flutter 3.38/Dart 3.10. ## 1.1.13 diff --git a/packages/vector_graphics_codec/lib/vector_graphics_codec.dart b/packages/vector_graphics_codec/lib/vector_graphics_codec.dart index 4b06b710984e..d8c5bdbdbc64 100644 --- a/packages/vector_graphics_codec/lib/vector_graphics_codec.dart +++ b/packages/vector_graphics_codec/lib/vector_graphics_codec.dart @@ -126,6 +126,7 @@ class VectorGraphicsCodec { static const int _textPositionTag = 50; static const int _updateTextPositionTag = 51; static const int _pathTagHalfPrecision = 52; + static const int _paintBlurTag = 53; static const int _version = 1; static const int _magicNumber = 0x00882d62; @@ -230,6 +231,9 @@ class VectorGraphicsCodec { case _updateTextPositionTag: _readUpdateTextPosition(buffer, listener); continue; + case _paintBlurTag: + _readPaintBlur(buffer, listener); + continue; default: throw StateError('Unknown type tag $type'); } @@ -959,6 +963,22 @@ class VectorGraphicsCodec { final Float64List? transform = buffer.getTransform(); listener?.onPatternStart(patternId, x, y, width, height, transform!); } + + /// Write a paint blur command to the buffer. + void writePaintBlur(VectorGraphicsBuffer buffer, int paintId, double sigmaX, double sigmaY) { + buffer._checkPhase(_CurrentSection.paints); + buffer._putUint8(_paintBlurTag); + buffer._putUint16(paintId); + buffer._putFloat32(sigmaX); + buffer._putFloat32(sigmaY); + } + + void _readPaintBlur(_ReadBuffer buffer, VectorGraphicsCodecListener? listener) { + final int paintId = buffer.getUint16(); + final double sigmaX = buffer.getFloat32(); + final double sigmaY = buffer.getFloat32(); + listener?.onPaintBlur(paintId, sigmaX, sigmaY); + } } /// Implement this listener class to support decoding of vector_graphics binary @@ -1028,6 +1048,9 @@ abstract class VectorGraphicsCodecListener { /// Prepare to draw a new mask, until the next [onRestoreLayer] command. void onMask(); + /// A paint blur has been decoded. + void onPaintBlur(int paintId, double sigmaX, double sigmaY) {} + /// A radial gradient shader has been parsed. /// /// [focalX] and [focalY] are either both `null` or `non-null`. diff --git a/packages/vector_graphics_codec/pubspec.yaml b/packages/vector_graphics_codec/pubspec.yaml index 55f594d124ac..3d48876aeed5 100644 --- a/packages/vector_graphics_codec/pubspec.yaml +++ b/packages/vector_graphics_codec/pubspec.yaml @@ -2,7 +2,7 @@ name: vector_graphics_codec description: An encoding library for the binary format used in `package:vector_graphics` repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics_codec issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22 -version: 1.1.13 +version: 1.1.14 environment: sdk: ^3.10.0 diff --git a/packages/vector_graphics_codec/test/vector_graphics_codec_test.dart b/packages/vector_graphics_codec/test/vector_graphics_codec_test.dart index be4ef2de29e7..5a1bd8927811 100644 --- a/packages/vector_graphics_codec/test/vector_graphics_codec_test.dart +++ b/packages/vector_graphics_codec/test/vector_graphics_codec_test.dart @@ -125,6 +125,24 @@ void main() { ]); }); + test('Can encode and decode paint blur', () { + final buffer = VectorGraphicsBuffer(); + final listener = TestListener(); + final int paintId = codec.writeFill(buffer, 23, 0); + codec.writePaintBlur(buffer, paintId, 2.5, 3.5); + final int pathId = codec.writePath( + buffer, + Uint8List.fromList([ControlPointTypes.moveTo, ControlPointTypes.close]), + Float32List.fromList([1, 2]), + 0, + ); + codec.writeDrawPath(buffer, pathId, paintId, null); + + codec.decode(buffer.done(), listener); + + expect(listener.commands, contains(OnPaintBlur(paintId, 2.5, 3.5))); + }); + test('Basic message encode and decode with shaded path', () { final buffer = VectorGraphicsBuffer(); final listener = TestListener(); @@ -875,6 +893,11 @@ class TestListener extends VectorGraphicsCodecListener { ); } + @override + void onPaintBlur(int paintId, double sigmaX, double sigmaY) { + commands.add(OnPaintBlur(paintId, sigmaX, sigmaY)); + } + @override void onPathClose() { commands.add(const OnPathClose()); @@ -1339,6 +1362,28 @@ class OnPaintObject { 'paintStyle: $paintStyle, id: $id, shaderId: $shaderId)'; } +@immutable +class OnPaintBlur { + const OnPaintBlur(this.paintId, this.sigmaX, this.sigmaY); + + final int paintId; + final double sigmaX; + final double sigmaY; + + @override + int get hashCode => Object.hash(paintId, sigmaX, sigmaY); + + @override + bool operator ==(Object other) => + other is OnPaintBlur && + other.paintId == paintId && + other.sigmaX == sigmaX && + other.sigmaY == sigmaY; + + @override + String toString() => 'OnPaintBlur(paintId: $paintId, sigmaX: $sigmaX, sigmaY: $sigmaY)'; +} + @immutable class OnPathClose { const OnPathClose(); diff --git a/packages/vector_graphics_compiler/CHANGELOG.md b/packages/vector_graphics_compiler/CHANGELOG.md index f246e4d0b0db..8af60bcd7eb8 100644 --- a/packages/vector_graphics_compiler/CHANGELOG.md +++ b/packages/vector_graphics_compiler/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.2.6 + +* Adds support for SVG filters (blur, offset, feMerge) and mask-type=alpha. + ## 1.2.5 * Updates allowed version range of `xml` to include up to 7.0.1. diff --git a/packages/vector_graphics_compiler/lib/src/geometry/matrix.dart b/packages/vector_graphics_compiler/lib/src/geometry/matrix.dart index 1214facb32b8..4396b852fe53 100644 --- a/packages/vector_graphics_compiler/lib/src/geometry/matrix.dart +++ b/packages/vector_graphics_compiler/lib/src/geometry/matrix.dart @@ -43,6 +43,12 @@ class AffineMatrix { /// Translations can affect this value, so we have to track it. final double _m4_10; + /// The scale factor along the X axis, including rotation. + double get xScale => math.sqrt(a * a + c * c); + + /// The scale factor along the Y axis, including rotation. + double get yScale => math.sqrt(b * b + d * d); + /// Calculates the scale for a stroke width based on the average of the x- and /// y-axis scales of this matrix. double? scaleStrokeWidth(double? width) { @@ -50,9 +56,6 @@ class AffineMatrix { return width; } - final double xScale = math.sqrt(a * a + c * c); - final double yScale = math.sqrt(b * b + d * d); - return (xScale + yScale) / 2 * width; } diff --git a/packages/vector_graphics_compiler/lib/src/paint.dart b/packages/vector_graphics_compiler/lib/src/paint.dart index 6b27e5646831..fc546baf5ae9 100644 --- a/packages/vector_graphics_compiler/lib/src/paint.dart +++ b/packages/vector_graphics_compiler/lib/src/paint.dart @@ -397,7 +397,7 @@ class Paint { /// Creates a new collection of painting attributes. /// /// See [Paint]. - const Paint({BlendMode? blendMode, this.stroke, this.fill}) + const Paint({BlendMode? blendMode, this.stroke, this.fill, this.filterBlurX, this.filterBlurY}) : blendMode = blendMode ?? BlendMode.srcOver; /// The Porter-Duff algorithm to use when compositing this painting object @@ -418,15 +418,23 @@ class Paint { /// followed by stroke. final Fill? fill; + /// The Gaussian blur sigma X to apply to this paint, if any. + final double? filterBlurX; + + /// The Gaussian blur sigma Y to apply to this paint, if any. + final double? filterBlurY; + @override - int get hashCode => Object.hash(blendMode, stroke, fill); + int get hashCode => Object.hash(blendMode, stroke, fill, filterBlurX, filterBlurY); @override bool operator ==(Object other) { return other is Paint && other.blendMode == blendMode && other.stroke == stroke && - other.fill == fill; + other.fill == fill && + other.filterBlurX == filterBlurX && + other.filterBlurY == filterBlurY; } /// Apply the bounds to the given paint. @@ -443,6 +451,8 @@ class Paint { blendMode: blendMode, stroke: stroke, fill: Fill(color: fill!.color, shader: newShader), + filterBlurX: filterBlurX, + filterBlurY: filterBlurY, ); } @@ -456,6 +466,12 @@ class Paint { if (fill != null) { buffer.write('${leading}fill: $fill'); } + if (filterBlurX != null) { + buffer.write('${leading}filterBlurX: $filterBlurX'); + } + if (filterBlurY != null) { + buffer.write('${leading}filterBlurY: $filterBlurY'); + } buffer.write(')'); return buffer.toString(); } diff --git a/packages/vector_graphics_compiler/lib/src/svg/node.dart b/packages/vector_graphics_compiler/lib/src/svg/node.dart index c6565cfae88d..7a7dbcf36aa9 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/node.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/node.dart @@ -203,15 +203,18 @@ class ParentNode extends AttributedNode { /// Create the paint required to draw a save layer, or `null` if none is /// required. - Paint? createLayerPaint() { + Paint? createLayerPaint({double? filterBlurX, double? filterBlurY}) { final double? fillOpacity = attributes.fill?.opacity; final bool needsLayer = (attributes.blendMode != null) || + (filterBlurX != null || filterBlurY != null) || (fillOpacity != null && fillOpacity != 1.0 && fillOpacity != 0.0); if (needsLayer) { return Paint( blendMode: attributes.blendMode, + filterBlurX: filterBlurX, + filterBlurY: filterBlurY, fill: attributes.fill?.toFill(Rect.largest, transform) ?? Fill(color: Color.opaqueBlack.withOpacity(fillOpacity ?? 1.0)), @@ -405,13 +408,24 @@ class PathNode extends AttributedNode { final Path path; /// Compute the paint used by this Path. - Paint? computePaint(Rect bounds, AffineMatrix transform) { + Paint? computePaint( + Rect bounds, + AffineMatrix transform, { + double? filterBlurX, + double? filterBlurY, + }) { final Stroke? stroke = attributes.stroke?.toStroke(bounds, transform); final Fill? fill = attributes.fill?.toFill(bounds, transform, defaultColor: Color.opaqueBlack); if (fill == null && stroke == null) { return null; } - return Paint(blendMode: attributes.blendMode, fill: fill, stroke: stroke); + return Paint( + blendMode: attributes.blendMode, + fill: fill, + stroke: stroke, + filterBlurX: filterBlurX, + filterBlurY: filterBlurY, + ); } @override @@ -479,13 +493,24 @@ class TextNode extends AttributedNode { final String text; /// Compute the [Paint] that this text node uses. - Paint? computePaint(Rect bounds, AffineMatrix transform) { + Paint? computePaint( + Rect bounds, + AffineMatrix transform, { + double? filterBlurX, + double? filterBlurY, + }) { final Fill? fill = attributes.fill?.toFill(bounds, transform, defaultColor: Color.opaqueBlack); final Stroke? stroke = attributes.stroke?.toStroke(bounds, transform); if (fill == null && stroke == null) { return null; } - return Paint(blendMode: attributes.blendMode, fill: fill, stroke: stroke); + return Paint( + blendMode: attributes.blendMode, + fill: fill, + stroke: stroke, + filterBlurX: filterBlurX, + filterBlurY: filterBlurY, + ); } /// Compute the [TextConfig] that this text node uses. diff --git a/packages/vector_graphics_compiler/lib/src/svg/parser.dart b/packages/vector_graphics_compiler/lib/src/svg/parser.dart index cbf90242b236..4383f09c9dba 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/parser.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/parser.dart @@ -40,6 +40,7 @@ typedef _ParseFunc = void Function(SvgParser parserState, bool warningsAsErrors) typedef _PathFunc = Path? Function(SvgParser parserState); final RegExp _whitespacePattern = RegExp(r'\s'); +final RegExp _stdDeviationDelimiter = RegExp(r'[\s,]+'); const Map _svgElementParsers = { 'svg': _Elements.svg, @@ -55,6 +56,7 @@ const Map _svgElementParsers = { 'image': _Elements.image, 'text': _Elements.textOrTspan, 'tspan': _Elements.textOrTspan, + 'filter': _Elements.filter, }; const Map _svgPathFuncs = { @@ -153,7 +155,191 @@ class _Elements { return; } + static void filter(SvgParser parserState, bool warningsAsErrors) { + if (parserState._currentStartElement?.isSelfClosing ?? false) { + return; + } + final String id = parserState.buildUrlIri(); + + final primitives = {}; + String? lastResult; + var primitiveCount = 0; + + List? currentMergeInputs; + String? currentMergeResult; + + for (final XmlEvent event in parserState._readSubtree()) { + if (event is XmlEndElementEvent) { + if (event.localName == 'feMerge') { + if (currentMergeResult != null && currentMergeInputs != null) { + primitives[currentMergeResult] = _FilterPrimitive( + type: 'merge', + inputs: currentMergeInputs, + ); + lastResult = currentMergeResult; + currentMergeResult = null; + currentMergeInputs = null; + } + } + continue; + } + if (event is XmlStartElementEvent) { + final String localName = event.localName; + + if (localName == 'feGaussianBlur' || localName == 'feOffset' || localName == 'feMerge') { + final String result = + parserState.attribute('result') ?? 'implicit_result_${primitiveCount++}'; + final String defaultInput = lastResult ?? 'SourceGraphic'; + final String input = parserState.attribute('in') ?? defaultInput; + + if (localName == 'feGaussianBlur') { + final String? rawStdDeviation = parserState.attribute('stdDeviation'); + double? parsedX = 0.0; + double? parsedY = 0.0; + if (rawStdDeviation != null) { + final List parts = rawStdDeviation + .trim() + .split(_stdDeviationDelimiter) + .where((String s) => s.isNotEmpty) + .toList(); + if (parts.isNotEmpty) { + parsedX = parseDouble(parts.first, tryParse: true); + parsedY = parts.length > 1 ? parseDouble(parts[1], tryParse: true) : parsedX; + } + } + if (parsedX != null && parsedX >= 0 && parsedY != null && parsedY >= 0) { + primitives[result] = _FilterPrimitive( + type: 'blur', + input: input, + sigmaX: parsedX, + sigmaY: parsedY, + ); + lastResult = result; + } + } else if (localName == 'feOffset') { + final String? rawDx = parserState.attribute('dx'); + final String? rawDy = parserState.attribute('dy'); + final double dx = rawDx != null ? (parseDouble(rawDx, tryParse: true) ?? 0.0) : 0.0; + final double dy = rawDy != null ? (parseDouble(rawDy, tryParse: true) ?? 0.0) : 0.0; + + primitives[result] = _FilterPrimitive(type: 'offset', input: input, dx: dx, dy: dy); + lastResult = result; + } else if (localName == 'feMerge') { + if (event.isSelfClosing) { + primitives[result] = _FilterPrimitive(type: 'merge'); + lastResult = result; + } else { + currentMergeResult = result; + currentMergeInputs = []; + } + } + } else if (localName == 'feMergeNode') { + final String input = parserState.attribute('in') ?? lastResult ?? 'SourceGraphic'; + currentMergeInputs?.add(input); + } + } + } + + final visiting = {}; + List? trace(String input) { + if (input == 'SourceGraphic') { + return const [ + SvgFilterLayer(isSourceAlpha: false, sigmaX: 0, sigmaY: 0, dx: 0, dy: 0), + ]; + } + if (input == 'SourceAlpha') { + return const [ + SvgFilterLayer(isSourceAlpha: true, sigmaX: 0, sigmaY: 0, dx: 0, dy: 0), + ]; + } + if (visiting.contains(input)) { + return null; + } + final _FilterPrimitive? primitive = primitives[input]; + if (primitive == null) { + return null; + } + visiting.add(input); + + if (primitive.type == 'merge') { + final mergedLayers = []; + for (final String mergeInput in primitive.inputs) { + final List? parentLayers = trace(mergeInput); + if (parentLayers == null) { + visiting.remove(input); + return null; + } + mergedLayers.addAll(parentLayers); + } + visiting.remove(input); + return mergedLayers; + } + + final List? parentLayers = trace(primitive.input); + visiting.remove(input); + if (parentLayers == null) { + return null; + } + + final results = []; + for (final SvgFilterLayer parentLayer in parentLayers) { + if (primitive.type == 'blur') { + results.add( + SvgFilterLayer( + isSourceAlpha: parentLayer.isSourceAlpha, + sigmaX: math.sqrt( + parentLayer.sigmaX * parentLayer.sigmaX + primitive.sigmaX * primitive.sigmaX, + ), + sigmaY: math.sqrt( + parentLayer.sigmaY * parentLayer.sigmaY + primitive.sigmaY * primitive.sigmaY, + ), + dx: parentLayer.dx, + dy: parentLayer.dy, + ), + ); + } else if (primitive.type == 'offset') { + results.add( + SvgFilterLayer( + isSourceAlpha: parentLayer.isSourceAlpha, + sigmaX: parentLayer.sigmaX, + sigmaY: parentLayer.sigmaY, + dx: parentLayer.dx + primitive.dx, + dy: parentLayer.dy + primitive.dy, + ), + ); + } else { + results.add(parentLayer); + } + } + return results; + } + + if (lastResult == null) { + return; + } + + final List? layers = trace(lastResult); + if (layers == null || layers.isEmpty) { + return; + } + + if (layers.length == 1 && + !layers.first.isSourceAlpha && + layers.first.sigmaX == 0 && + layers.first.sigmaY == 0 && + layers.first.dx == 0 && + layers.first.dy == 0) { + return; + } + + parserState._definitions.addFilter(id, SvgFilter(layers)); + return; + } + static void pattern(SvgParser parserState, bool warningsAsErrors) { + if (parserState._currentStartElement?.isSelfClosing ?? false) { + return; + } final SvgAttributes attributes = parserState._currentAttributes; final String rawWidth = parserState.attribute('width') ?? ''; final String rawHeight = parserState.attribute('height') ?? ''; @@ -363,6 +549,9 @@ class _Elements { } static void clipPath(SvgParser parserState, bool warningsAsErrors) { + if (parserState._currentStartElement?.isSelfClosing ?? false) { + return; + } final String id = parserState.buildUrlIri(); final pathNodes = []; for (final XmlEvent event in parserState._readSubtree()) { @@ -807,7 +996,7 @@ class SvgParser { _parseTree(); /// Resolve the tree - final resolvingVisitor = ResolvingVisitor(); + final resolvingVisitor = ResolvingVisitor(_definitions.getFilter); final tessellator = Tessellator(); final maskingOptimizer = MaskingOptimizer(); final clippingOptimizer = ClippingOptimizer(); @@ -1588,6 +1777,8 @@ class SvgParser { id: id, ), textAnchorMultiplier: parseTextAnchor(attributeMap['text-anchor']), + filterId: attributeMap['filter'], + maskType: attributeMap['mask-type'], ); } } @@ -1600,6 +1791,7 @@ class _Resolver { final Map _drawables = {}; final Map _shaders = {}; final Map> _clips = >{}; + final Map _filters = {}; int _deferredExpansionCount = 0; bool _sealed = false; @@ -1615,6 +1807,18 @@ class _Resolver { return _drawables[ref]; } + /// Add the filter defined by [ref] to the resolver. + void addFilter(String ref, SvgFilter filter) { + assert(!_sealed); + _filters[ref] = filter; + } + + /// Retrieve the filter defined by [ref], or `null` if it is undefined. + SvgFilter? getFilter(String ref) { + assert(_sealed); + return _filters[ref]; + } + /// Retrieve the clip defined by [ref], or `null` if it is undefined. List getClipPath(String ref) { assert(_sealed); @@ -1769,6 +1973,8 @@ class SvgAttributes { this.dy, this.width, this.height, + this.filterId, + this.maskType, }); /// For use in tests to construct arbitrary attributes. @@ -1798,6 +2004,8 @@ class SvgAttributes { this.dy, this.width, this.height, + this.filterId, + this.maskType, }); /// The empty set of properties. @@ -1953,6 +2161,12 @@ class SvgAttributes { /// The relative y translation. final DoubleOrPercentage? dy; + /// The raw identifier for the filter to apply. + final String? filterId; + + /// The `mask-type` attribute. + final String? maskType; + /// A copy of these attributes after absorbing a saveLayer. /// /// Specifically, this will null out `blendMode` and any opacity related @@ -1983,6 +2197,7 @@ class SvgAttributes { y: y, width: width, height: height, + maskType: maskType, ); } @@ -2028,6 +2243,8 @@ class SvgAttributes { y: y, dx: dx, dy: dy, + filterId: filterId, + maskType: maskType, ); } } @@ -2328,3 +2545,22 @@ class ColorOrNone { @override String toString() => isNone ? '"none"' : (color?.toString() ?? 'null'); } + +class _FilterPrimitive { + _FilterPrimitive({ + required this.type, + this.input = '', + this.inputs = const [], + this.sigmaX = 0.0, + this.sigmaY = 0.0, + this.dx = 0.0, + this.dy = 0.0, + }); + final String type; + final String input; + final List inputs; + final double sigmaX; + final double sigmaY; + final double dx; + final double dy; +} diff --git a/packages/vector_graphics_compiler/lib/src/svg/resolver.dart b/packages/vector_graphics_compiler/lib/src/svg/resolver.dart index 09c96d2c02c4..7133eacef55b 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/resolver.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/resolver.dart @@ -3,12 +3,14 @@ // found in the LICENSE file. import 'dart:typed_data'; +import 'package:meta/meta.dart'; import '../geometry/basic_types.dart'; import '../geometry/matrix.dart'; import '../geometry/path.dart'; import '../geometry/vertices.dart'; import '../image/image_info.dart'; import '../paint.dart'; +import '../util.dart'; import 'constants.dart'; import 'node.dart'; import 'parser.dart'; @@ -18,7 +20,13 @@ import 'visitor.dart'; /// single coordinate space, removing extra attributes, empty nodes, resolving /// references/masks/clips. class ResolvingVisitor extends Visitor { + /// Creates a new [ResolvingVisitor]. + ResolvingVisitor([this._filterResolver]); + + final SvgFilter? Function(String)? _filterResolver; late Rect _bounds; + SvgFilterLayer? _currentLayer; + final Map _blackenedGradients = {}; final Set _activeMasks = {}; final Set _activeDeferred = {}; @@ -56,7 +64,12 @@ class ResolvingVisitor extends Visitor { final AffineMatrix childTransform = maskNode.concatTransform(data); final Node mask = resolvedMask.accept(this, childTransform); - return ResolvedMaskNode(child: child, mask: mask, blendMode: maskNode.blendMode); + return ResolvedMaskNode( + child: child, + mask: mask, + blendMode: maskNode.blendMode, + maskType: resolvedMask.attributes.maskType, + ); } finally { _activeMasks.remove(maskNode.maskId); } @@ -66,11 +79,85 @@ class ResolvingVisitor extends Visitor { Node visitParentNode(ParentNode parentNode, AffineMatrix data) { final AffineMatrix nextTransform = parentNode.concatTransform(data); - final Paint? saveLayerPaint = parentNode.createLayerPaint(); + SvgFilter? filter; + final String? filterId = parentNode.attributes.filterId; + if (filterId != null && _filterResolver != null) { + filter = _filterResolver(filterId); + } + + if (filter != null && filter.layers.isNotEmpty) { + if (filter.layers.length == 1) { + return _resolveParentNodeForLayer(parentNode, nextTransform, filter.layers.first); + } + + final children = []; + for (final SvgFilterLayer layer in filter.layers) { + children.add(_resolveParentNodeForLayer(parentNode, nextTransform, layer)); + } + return ParentNode(SvgAttributes.empty, children: children); + } + + return _resolveParentNodeNoFilter(parentNode, nextTransform); + } + + Node _resolveParentNodeForLayer( + ParentNode parentNode, + AffineMatrix nextTransform, + SvgFilterLayer layer, + ) { + final AffineMatrix layerTransform = (layer.dx == 0 && layer.dy == 0) + ? nextTransform + : nextTransform.translated(layer.dx, layer.dy); + + final bool hasBlur = layer.sigmaX > 0 || layer.sigmaY > 0; + + final SvgFilterLayer? previousLayer = _currentLayer; + _currentLayer = SvgFilterLayer( + isSourceAlpha: layer.isSourceAlpha || (previousLayer?.isSourceAlpha ?? false), + sigmaX: 0, + sigmaY: 0, + dx: 0, + dy: 0, + ); + + try { + final Paint? saveLayerPaint = parentNode.createLayerPaint( + filterBlurX: hasBlur ? layer.sigmaX * nextTransform.xScale : null, + filterBlurY: hasBlur ? layer.sigmaY * nextTransform.yScale : null, + ); - final Node result; + final Node resolved; + if (saveLayerPaint == null) { + resolved = ParentNode( + SvgAttributes.empty, + precalculatedTransform: AffineMatrix.identity, + children: [ + for (final Node child in parentNode.children) + child.applyAttributes(parentNode.attributes).accept(this, layerTransform), + ], + ); + } else { + resolved = SaveLayerNode( + SvgAttributes.empty, + paint: saveLayerPaint, + children: [ + for (final Node child in parentNode.children) + child + .applyAttributes(parentNode.attributes.forSaveLayer()) + .accept(this, layerTransform), + ], + ); + } + return resolved; + } finally { + _currentLayer = previousLayer; + } + } + + Node _resolveParentNodeNoFilter(ParentNode parentNode, AffineMatrix nextTransform) { + final Paint? saveLayerPaint = parentNode.createLayerPaint(); if (saveLayerPaint == null) { - result = ParentNode( + return ParentNode( SvgAttributes.empty, precalculatedTransform: AffineMatrix.identity, children: [ @@ -78,17 +165,15 @@ class ResolvingVisitor extends Visitor { child.applyAttributes(parentNode.attributes).accept(this, nextTransform), ], ); - } else { - result = SaveLayerNode( - SvgAttributes.empty, - paint: saveLayerPaint, - children: [ - for (final Node child in parentNode.children) - child.applyAttributes(parentNode.attributes.forSaveLayer()).accept(this, nextTransform), - ], - ); } - return result; + return SaveLayerNode( + SvgAttributes.empty, + paint: saveLayerPaint, + children: [ + for (final Node child in parentNode.children) + child.applyAttributes(parentNode.attributes.forSaveLayer()).accept(this, nextTransform), + ], + ); } @override @@ -99,40 +184,195 @@ class ResolvingVisitor extends Visitor { .withFillType(pathNode.attributes.fillRule ?? pathNode.path.fillType); final Rect originalBounds = pathNode.path.bounds(); final Rect newBounds = transformedPath.bounds(); + SvgFilter? filter; + final String? filterId = pathNode.attributes.filterId; + if (filterId != null && _filterResolver != null) { + filter = _filterResolver(filterId); + } + + if (filter != null && filter.layers.isNotEmpty) { + final Paint? originalPaint = pathNode.computePaint(originalBounds, transform); + if (originalPaint == null) { + return Node.empty; + } + + if (filter.layers.length == 1) { + return _resolvePathNodeForLayer( + pathNode, + originalPaint, + transform, + transformedPath, + newBounds, + filter.layers.first, + ); + } + + final children = []; + for (final SvgFilterLayer layer in filter.layers) { + children.add( + _resolvePathNodeForLayer( + pathNode, + originalPaint, + transform, + transformedPath, + newBounds, + layer, + ), + ); + } + return ParentNode(SvgAttributes.empty, children: children); + } + + if (_currentLayer != null && + (_currentLayer!.isSourceAlpha || _currentLayer!.sigmaX > 0 || _currentLayer!.sigmaY > 0)) { + final Paint? originalPaint = pathNode.computePaint(originalBounds, transform); + if (originalPaint == null) { + return Node.empty; + } + return _resolvePathNodeForLayer( + pathNode, + originalPaint, + transform, + transformedPath, + newBounds, + _currentLayer!, + ); + } + final Paint? paint = pathNode.computePaint(originalBounds, transform); if (paint != null) { - if (pathNode.attributes.stroke?.dashArray != null) { - final children = []; - final parent = ParentNode(pathNode.attributes, children: children); - if (paint.fill != null) { - children.add( - ResolvedPathNode( - paint: Paint(blendMode: paint.blendMode, fill: paint.fill), - bounds: newBounds, - path: transformedPath, + return _resolvePathNode(pathNode, paint, transformedPath, newBounds); + } + return Node.empty; + } + + Node _resolvePathNodeForLayer( + PathNode pathNode, + Paint originalPaint, + AffineMatrix transform, + Path transformedPath, + Rect newBounds, + SvgFilterLayer layer, + ) { + final Path layerPath = (layer.dx == 0 && layer.dy == 0) + ? transformedPath + : pathNode.path + .transformed(transform.translated(layer.dx, layer.dy)) + .withFillType(pathNode.attributes.fillRule ?? pathNode.path.fillType); + final Rect layerBounds = (layer.dx == 0 && layer.dy == 0) ? newBounds : layerPath.bounds(); + + final Paint paint = _createLayerPaint(originalPaint, layer, transform); + + return _resolvePathNode(pathNode, paint, layerPath, layerBounds); + } + + Node _resolvePathNode(PathNode pathNode, Paint paint, Path transformedPath, Rect newBounds) { + if (pathNode.attributes.stroke?.dashArray != null) { + final children = []; + final parent = ParentNode(pathNode.attributes, children: children); + if (paint.fill != null) { + children.add( + ResolvedPathNode( + paint: Paint( + blendMode: paint.blendMode, + fill: paint.fill, + filterBlurX: paint.filterBlurX, + filterBlurY: paint.filterBlurY, ), - ); - } - if (paint.stroke != null) { - children.add( - ResolvedPathNode( - paint: Paint(blendMode: paint.blendMode, stroke: paint.stroke), - bounds: newBounds, - path: transformedPath.dashed(pathNode.attributes.stroke!.dashArray!), + bounds: newBounds, + path: transformedPath, + ), + ); + } + if (paint.stroke != null) { + children.add( + ResolvedPathNode( + paint: Paint( + blendMode: paint.blendMode, + stroke: paint.stroke, + filterBlurX: paint.filterBlurX, + filterBlurY: paint.filterBlurY, ), - ); - } - return parent; + bounds: newBounds, + path: transformedPath.dashed(pathNode.attributes.stroke!.dashArray!), + ), + ); } - return ResolvedPathNode(paint: paint, bounds: newBounds, path: transformedPath); + return parent; } - return Node.empty; + return ResolvedPathNode(paint: paint, bounds: newBounds, path: transformedPath); } @override Node visitTextPositionNode(TextPositionNode textPositionNode, AffineMatrix data) { final AffineMatrix nextTransform = textPositionNode.concatTransform(data); + SvgFilter? filter; + final String? filterId = textPositionNode.attributes.filterId; + if (filterId != null && _filterResolver != null) { + filter = _filterResolver(filterId); + } + + if (filter != null && filter.layers.isNotEmpty) { + if (filter.layers.length == 1) { + final SvgFilterLayer layer = filter.layers.first; + final SvgFilterLayer? previousLayer = _currentLayer; + _currentLayer = SvgFilterLayer( + isSourceAlpha: layer.isSourceAlpha || (previousLayer?.isSourceAlpha ?? false), + sigmaX: layer.sigmaX, + sigmaY: layer.sigmaY, + dx: 0, + dy: 0, + ); + try { + final AffineMatrix layerData = data.translated(layer.dx, layer.dy); + final AffineMatrix layerNextTransform = textPositionNode.concatTransform(layerData); + + final resolvedChildren = [ + for (final Node child in textPositionNode.children) + child.applyAttributes(textPositionNode.attributes).accept(this, layerNextTransform), + ]; + return ResolvedTextPositionNode( + textPositionNode.computeTextPosition(_bounds, layerData), + resolvedChildren, + ); + } finally { + _currentLayer = previousLayer; + } + } + + final children = []; + final SvgFilterLayer? previousLayer = _currentLayer; + try { + for (final SvgFilterLayer layer in filter.layers) { + _currentLayer = SvgFilterLayer( + isSourceAlpha: layer.isSourceAlpha || (previousLayer?.isSourceAlpha ?? false), + sigmaX: layer.sigmaX, + sigmaY: layer.sigmaY, + dx: 0, + dy: 0, + ); + final AffineMatrix layerData = data.translated(layer.dx, layer.dy); + final AffineMatrix layerNextTransform = textPositionNode.concatTransform(layerData); + + final resolvedChildren = [ + for (final Node child in textPositionNode.children) + child.applyAttributes(textPositionNode.attributes).accept(this, layerNextTransform), + ]; + + children.add( + ResolvedTextPositionNode( + textPositionNode.computeTextPosition(_bounds, layerData), + resolvedChildren, + ), + ); + } + return ParentNode(SvgAttributes.empty, children: children); + } finally { + _currentLayer = previousLayer; + } + } + return ResolvedTextPositionNode(textPositionNode.computeTextPosition(_bounds, data), [ for (final Node child in textPositionNode.children) child.applyAttributes(textPositionNode.attributes).accept(this, nextTransform), @@ -141,15 +381,153 @@ class ResolvingVisitor extends Visitor { @override Node visitTextNode(TextNode textNode, AffineMatrix data) { + if (_currentLayer != null && + (_currentLayer!.isSourceAlpha || _currentLayer!.sigmaX > 0 || _currentLayer!.sigmaY > 0)) { + final Paint? originalPaint = textNode.computePaint(_bounds, data); + if (originalPaint == null) { + return Node.empty; + } + final TextConfig textConfig = textNode.computeTextConfig(_bounds, data); + if (textConfig.text.trim().isEmpty) { + return Node.empty; + } + + final Paint paint = _createLayerPaint(originalPaint, _currentLayer!, data); + + return ResolvedTextNode(textConfig: textConfig, paint: paint); + } + + SvgFilter? filter; + final String? filterId = textNode.attributes.filterId; + if (filterId != null && _filterResolver != null) { + filter = _filterResolver(filterId); + } + + if (filter != null && filter.layers.isNotEmpty) { + final Paint? originalPaint = textNode.computePaint(_bounds, data); + if (originalPaint == null) { + return Node.empty; + } + final TextConfig textConfig = textNode.computeTextConfig(_bounds, data); + if (textConfig.text.trim().isEmpty) { + return Node.empty; + } + + if (filter.layers.length == 1) { + return _resolveTextNodeForLayer( + textNode, + originalPaint, + data, + textConfig, + filter.layers.first, + ); + } + + final children = []; + for (final SvgFilterLayer layer in filter.layers) { + children.add(_resolveTextNodeForLayer(textNode, originalPaint, data, textConfig, layer)); + } + return ParentNode(SvgAttributes.empty, children: children); + } + final Paint? paint = textNode.computePaint(_bounds, data); final TextConfig textConfig = textNode.computeTextConfig(_bounds, data); - if (paint != null && textConfig.text.trim().isNotEmpty) { return ResolvedTextNode(textConfig: textConfig, paint: paint); } return Node.empty; } + Node _resolveTextNodeForLayer( + TextNode textNode, + Paint originalPaint, + AffineMatrix data, + TextConfig textConfig, + SvgFilterLayer layer, + ) { + final AffineMatrix layerData = data.translated(layer.dx, layer.dy); + final TextConfig layerTextConfig = (layer.dx == 0 && layer.dy == 0) + ? textConfig + : textNode.computeTextConfig(_bounds, layerData); + + final Paint paint = _createLayerPaint(originalPaint, layer, data); + + return ResolvedTextNode(textConfig: layerTextConfig, paint: paint); + } + Gradient _blackenGradient(Gradient gradient) { + if (gradient.colors == null) { + return gradient; + } + if (_blackenedGradients.containsKey(gradient)) { + return _blackenedGradients[gradient]!; + } + final List colors = + gradient.colors!.map((Color c) => Color.fromARGB(c.a, 0, 0, 0)).toList(); + final Gradient newGradient; + if (gradient is LinearGradient) { + newGradient = LinearGradient( + id: gradient.id, + from: gradient.from, + to: gradient.to, + colors: colors, + offsets: gradient.offsets, + tileMode: gradient.tileMode, + unitMode: gradient.unitMode, + transform: gradient.transform, + ); + } else if (gradient is RadialGradient) { + newGradient = RadialGradient( + id: gradient.id, + center: gradient.center, + radius: gradient.radius, + colors: colors, + offsets: gradient.offsets, + tileMode: gradient.tileMode, + transform: gradient.transform, + focalPoint: gradient.focalPoint, + unitMode: gradient.unitMode, + ); + } else { + newGradient = gradient; + } + _blackenedGradients[gradient] = newGradient; + return newGradient; + } + + Paint _createLayerPaint(Paint originalPaint, SvgFilterLayer layer, [AffineMatrix transform = AffineMatrix.identity]) { + final bool hasBlur = layer.sigmaX > 0 || layer.sigmaY > 0; + final bool isSourceAlpha = layer.isSourceAlpha || (_currentLayer?.isSourceAlpha ?? false); + return Paint( + blendMode: originalPaint.blendMode, + fill: isSourceAlpha + ? (originalPaint.fill != null + ? Fill( + color: Color.fromARGB(originalPaint.fill!.color.a, 0, 0, 0), + shader: originalPaint.fill!.shader != null + ? _blackenGradient(originalPaint.fill!.shader!) + : null, + ) + : null) + : originalPaint.fill, + stroke: isSourceAlpha + ? (originalPaint.stroke != null + ? Stroke( + color: Color.fromARGB(originalPaint.stroke!.color.a, 0, 0, 0), + shader: originalPaint.stroke!.shader != null + ? _blackenGradient(originalPaint.stroke!.shader!) + : null, + width: originalPaint.stroke!.width, + cap: originalPaint.stroke!.cap, + join: originalPaint.stroke!.join, + miterLimit: originalPaint.stroke!.miterLimit, + ) + : null) + : originalPaint.stroke, + filterBlurX: hasBlur ? layer.sigmaX * transform.xScale : null, + filterBlurY: hasBlur ? layer.sigmaY * transform.yScale : null, + ); + } + @override Node visitViewportNode(ViewportNode viewportNode, AffineMatrix data) { _bounds = Rect.fromLTWH(0, 0, viewportNode.width, viewportNode.height); @@ -443,7 +821,12 @@ class ResolvedClipNode extends Node { /// This should only be constructed from a [MaskNode] in a [ResolvingVisitor]. class ResolvedMaskNode extends Node { /// Create a new [ResolvedMaskNode]. - ResolvedMaskNode({required this.child, required this.mask, required this.blendMode}); + ResolvedMaskNode({ + required this.child, + required this.mask, + required this.blendMode, + this.maskType, + }); /// The child to apply as a mask. final Node mask; @@ -454,6 +837,9 @@ class ResolvedMaskNode extends Node { /// The blend mode to apply when saving a layer for the mask, if any. final BlendMode? blendMode; + /// The `mask-type` attribute of the mask, if any. + final String? maskType; + @override S accept(Visitor visitor, V data) { return visitor.visitResolvedMaskNode(this, data); @@ -550,3 +936,66 @@ class ResolvedPatternNode extends Node { return visitor.visitResolvedPatternNode(this, data); } } + +/// Represents a layer in an SVG filter. +@immutable +class SvgFilterLayer { + /// Creates a new [SvgFilterLayer]. + const SvgFilterLayer({ + required this.isSourceAlpha, + required this.sigmaX, + required this.sigmaY, + required this.dx, + required this.dy, + }); + + /// Whether this layer is derived from the alpha channel of the source. + final bool isSourceAlpha; + + /// The standard deviation along the X axis for the blur. + final double sigmaX; + + /// The standard deviation along the Y axis for the blur. + final double sigmaY; + + /// The horizontal offset. + final double dx; + + /// The vertical offset. + final double dy; + + /// Whether this layer has any visual effect (blur or offset). + bool get hasEffect => sigmaX > 0 || sigmaY > 0 || dx != 0 || dy != 0; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SvgFilterLayer && + runtimeType == other.runtimeType && + isSourceAlpha == other.isSourceAlpha && + sigmaX == other.sigmaX && + sigmaY == other.sigmaY && + dx == other.dx && + dy == other.dy; + + @override + int get hashCode => Object.hash(isSourceAlpha, sigmaX, sigmaY, dx, dy); +} + +/// Represents an SVG filter containing one or more layers. +@immutable +class SvgFilter { + /// Creates a new [SvgFilter] with the given [layers]. + const SvgFilter(this.layers); + + /// The layers of this filter, to be drawn sequentially. + final List layers; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SvgFilter && runtimeType == other.runtimeType && listEquals(layers, other.layers); + + @override + int get hashCode => Object.hashAll(layers); +} diff --git a/packages/vector_graphics_compiler/lib/src/svg/visitor.dart b/packages/vector_graphics_compiler/lib/src/svg/visitor.dart index dd42b8e6318c..51ebbe02dbdf 100644 --- a/packages/vector_graphics_compiler/lib/src/svg/visitor.dart +++ b/packages/vector_graphics_compiler/lib/src/svg/visitor.dart @@ -164,7 +164,11 @@ class CommandBuilderVisitor extends Visitor with ErrorOnUnResolvedNo void visitResolvedMaskNode(ResolvedMaskNode maskNode, void data) { _builder.addSaveLayer(Paint(blendMode: maskNode.blendMode, fill: const Fill())); maskNode.child.accept(this, data); - _builder.addMask(); + if (maskNode.maskType == 'alpha') { + _builder.addSaveLayer(const Paint(blendMode: BlendMode.dstIn, fill: Fill())); + } else { + _builder.addMask(); + } maskNode.mask.accept(this, data); _builder.restore(); _builder.restore(); diff --git a/packages/vector_graphics_compiler/lib/vector_graphics_compiler.dart b/packages/vector_graphics_compiler/lib/vector_graphics_compiler.dart index 931b216dca9e..dd05cc64e5f6 100644 --- a/packages/vector_graphics_compiler/lib/vector_graphics_compiler.dart +++ b/packages/vector_graphics_compiler/lib/vector_graphics_compiler.dart @@ -176,6 +176,9 @@ Uint8List _encodeInstructions(VectorInstructions instructions, bool useHalfPreci final int? shaderId = shaderIds[fill.shader]; final int fillId = codec.writeFill(buffer, fill.color.value, paint.blendMode.index, shaderId); fillIds[nextPaintId] = fillId; + if (paint.filterBlurX != null || paint.filterBlurY != null) { + codec.writePaintBlur(buffer, fillId, paint.filterBlurX ?? 0.0, paint.filterBlurY ?? 0.0); + } } if (stroke != null) { final int? shaderId = shaderIds[stroke.shader]; @@ -190,6 +193,9 @@ Uint8List _encodeInstructions(VectorInstructions instructions, bool useHalfPreci shaderId, ); strokeIds[nextPaintId] = strokeId; + if (paint.filterBlurX != null || paint.filterBlurY != null) { + codec.writePaintBlur(buffer, strokeId, paint.filterBlurX ?? 0.0, paint.filterBlurY ?? 0.0); + } } nextPaintId += 1; } diff --git a/packages/vector_graphics_compiler/pubspec.yaml b/packages/vector_graphics_compiler/pubspec.yaml index 31dbd1909577..b48235f9dde1 100644 --- a/packages/vector_graphics_compiler/pubspec.yaml +++ b/packages/vector_graphics_compiler/pubspec.yaml @@ -2,7 +2,7 @@ name: vector_graphics_compiler description: A compiler to convert SVGs to the binary format used by `package:vector_graphics`. repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics_compiler issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22 -version: 1.2.5 +version: 1.2.6 executables: vector_graphics_compiler: @@ -15,7 +15,7 @@ dependencies: meta: ^1.7.0 path: ^1.8.0 path_parsing: ^1.0.1 - vector_graphics_codec: ^1.1.11+1 + vector_graphics_codec: ^1.1.14 # This uses an exact upper range because it is an external dependency (see # https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#dependencies # for more information), and this mitigates transitive risk exposure to @@ -45,3 +45,4 @@ platforms: topics: - svg - vector-graphics + diff --git a/packages/vector_graphics_compiler/test/end_to_end_test.dart b/packages/vector_graphics_compiler/test/end_to_end_test.dart index 4f7786201103..4f0a6e8d56b4 100644 --- a/packages/vector_graphics_compiler/test/end_to_end_test.dart +++ b/packages/vector_graphics_compiler/test/end_to_end_test.dart @@ -327,6 +327,218 @@ void main() { ]), ]); }); + + group('SVG Filter and Mask Gallery', () { + test('Classic Drop Shadow', () { + const svg = ''' + + + + + + + + + + + + + + + + + + +'''; + final Uint8List bytes = encodeSvg( + xml: svg, + debugName: 'drop-shadow', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ); + final listener = TestListener(); + const codec = VectorGraphicsCodec(); + codec.decode(bytes.buffer.asByteData(), listener); + + final Iterable blurs = listener.commands.whereType().where( + (b) => b.sigmaX == 6.0 && b.sigmaY == 6.0, + ); + expect(blurs.length, 1); + }); + + test('Glowing Neon Text Effect', () { + const svg = ''' + + + + + + + + + + + + + + + + + + + + + OPEN 24/7 + + +'''; + final Uint8List bytes = encodeSvg( + xml: svg, + debugName: 'neon-glow', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ); + final listener = TestListener(); + const codec = VectorGraphicsCodec(); + codec.decode(bytes.buffer.asByteData(), listener); + + final Iterable blurs = listener.commands.whereType().where( + (b) => b.sigmaX == 4.0 && b.sigmaY == 4.0, + ); + expect(blurs.length, 2); // fill and stroke of text + }); + + test('Smooth UI Background Gradient (Mesh Style)', () { + const svg = ''' + + + + + + + + + + + + + + + + + + + +'''; + final Uint8List bytes = encodeSvg( + xml: svg, + debugName: 'heavy-blur', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ); + final listener = TestListener(); + const codec = VectorGraphicsCodec(); + codec.decode(bytes.buffer.asByteData(), listener); + + final Iterable blurs = listener.commands.whereType().where( + (b) => b.sigmaX == 80.0 && b.sigmaY == 80.0, + ); + expect(blurs.length, 1); // applied to the save layer of the group + }); + + test('Glassmorphism / Frosted Glass Panel', () { + const svg = ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +'''; + final Uint8List bytes = encodeSvg( + xml: svg, + debugName: 'glassmorphism', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ); + final listener = TestListener(); + const codec = VectorGraphicsCodec(); + codec.decode(bytes.buffer.asByteData(), listener); + + final Iterable blurs = listener.commands.whereType().where( + (b) => b.sigmaX == 20.0 && b.sigmaY == 20.0, + ); + expect(blurs.length, 2); // two blurred circles + }); + + test('Directional Motion Blur', () { + const svg = ''' + + + + + + + + + + + + + + + + + +'''; + final Uint8List bytes = encodeSvg( + xml: svg, + debugName: 'motion-blur', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ); + final listener = TestListener(); + const codec = VectorGraphicsCodec(); + codec.decode(bytes.buffer.asByteData(), listener); + + final Iterable blurs = listener.commands.whereType().where( + (b) => b.sigmaX == 25.0 && b.sigmaY == 0.0, + ); + expect(blurs.length, 1); // directional blur (25, 0) + }); + }); } class TestListener extends VectorGraphicsCodecListener { @@ -383,6 +595,11 @@ class TestListener extends VectorGraphicsCodecListener { ); } + @override + void onPaintBlur(int paintId, double sigmaX, double sigmaY) { + commands.add(OnPaintBlur(paintId, sigmaX, sigmaY)); + } + @override void onPathClose() { commands.add(const OnPathClose()); @@ -1112,3 +1329,24 @@ bool _listEquals(List? left, List? right) { } return true; } + +@immutable +class OnPaintBlur { + const OnPaintBlur(this.paintId, this.sigmaX, this.sigmaY); + final int paintId; + final double sigmaX; + final double sigmaY; + + @override + int get hashCode => Object.hash(paintId, sigmaX, sigmaY); + + @override + bool operator ==(Object other) => + other is OnPaintBlur && + other.paintId == paintId && + other.sigmaX == sigmaX && + other.sigmaY == sigmaY; + + @override + String toString() => 'OnPaintBlur(paintId: $paintId, sigmaX: $sigmaX, sigmaY: $sigmaY)'; +} diff --git a/packages/vector_graphics_compiler/test/parser_test.dart b/packages/vector_graphics_compiler/test/parser_test.dart index c0b04d07f9a1..bcaf9809300f 100644 --- a/packages/vector_graphics_compiler/test/parser_test.dart +++ b/packages/vector_graphics_compiler/test/parser_test.dart @@ -1603,6 +1603,500 @@ ${[for (var i = 2; i <= 30; i++) ' + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paths, [ + PathBuilder().addRect(const Rect.fromLTWH(0, 0, 100, 100)).toPath(), + PathBuilder().addOval(const Rect.fromCircle(50, 50, 50)).toPath(), + ]); + + expect(instructions.paints, const [ + Paint(fill: Fill()), + Paint(fill: Fill(color: Color(0xff0000ff))), + Paint(blendMode: BlendMode.dstIn, fill: Fill()), + Paint(fill: Fill(color: Color(0xffffffff))), + ]); + + expect(instructions.commands, const [ + DrawCommand(DrawCommandType.saveLayer, paintId: 0), + DrawCommand(DrawCommandType.path, objectId: 0, paintId: 1), + DrawCommand(DrawCommandType.saveLayer, paintId: 2), + DrawCommand(DrawCommandType.path, objectId: 1, paintId: 3), + DrawCommand(DrawCommandType.restore), + DrawCommand(DrawCommandType.restore), + ]); + }); + + test('Handles filters (blur) correctly', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paths, [ + PathBuilder().addRect(const Rect.fromLTWH(0, 0, 100, 100)).toPath(), + ]); + + expect(instructions.paints, const [ + Paint(fill: Fill(color: Color(0xff0000ff)), filterBlurX: 5.0, filterBlurY: 5.0), + ]); + + expect(instructions.commands, const [ + DrawCommand(DrawCommandType.path, objectId: 0, paintId: 0), + ]); + }); + + test('Handles directional filters (blur) correctly', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [ + Paint(fill: Fill(color: Color(0xff0000ff)), filterBlurX: 5.0, filterBlurY: 10.0), + ]); + }); + + test('Ignores directional filters if one value is negative', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Ignores negative stdDeviation in filters', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Ignores invalid stdDeviation in filters', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Handles elements referencing non-existent filters', () { + const svg = ''' + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Ignores empty filters', () { + const svg = ''' + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Handles nested filters correctly', () { + const svg = ''' + + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paths, [ + PathBuilder().addRect(const Rect.fromLTWH(0, 0, 100, 100)).toPath(), + ]); + + expect(instructions.paints, const [ + Paint(filterBlurX: 5.0, filterBlurY: 5.0, fill: Fill(color: Color.opaqueBlack)), + Paint(fill: Fill(color: Color(0xff0000ff)), filterBlurX: 10.0, filterBlurY: 10.0), + ]); + + expect(instructions.commands, const [ + DrawCommand(DrawCommandType.saveLayer, paintId: 0), + DrawCommand(DrawCommandType.path, objectId: 0, paintId: 1), + DrawCommand(DrawCommandType.restore), + ]); + }); + + test('Handles zero stdDeviation (disables blur)', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Handles omitted stdDeviation (defaults to zero)', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [Paint(fill: Fill(color: Color(0xff0000ff)))]); + }); + + test('Handles directional zero stdDeviation (blurs one axis)', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [ + Paint(fill: Fill(color: Color(0xff0000ff)), filterBlurX: 0.0, filterBlurY: 5.0), + ]); + }); + + test('Handles feMerge and multiple feGaussianBlur correctly (Neon Glow)', () { + const svg = ''' + + + + + + + + + + + OPEN + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 3); + expect(instructions.paints[0].filterBlurX, 12.0); + expect(instructions.paints[0].filterBlurY, 12.0); + expect(instructions.paints[1].filterBlurX, 4.0); + expect(instructions.paints[1].filterBlurY, 4.0); + expect(instructions.paints[2].filterBlurX, null); + expect(instructions.paints[2].filterBlurY, null); + }); + + test('Handles comma-separated stdDeviation', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints, const [ + Paint(fill: Fill(color: Color(0xff0000ff)), filterBlurX: 5.0, filterBlurY: 10.0), + ]); + }); + + test('Handles feMergeNode with omitted "in" attribute', () { + const svg = ''' + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 2); + expect(instructions.paints[0].filterBlurX, 5.0); + expect(instructions.paints[1].filterBlurX, null); + }); + + test('Preserves original opacity when applying SourceAlpha filter', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 1); + expect(instructions.paints[0].fill?.color, const Color(0x80000000)); + }); + + test('Preserves gradient stop opacity when applying SourceAlpha filter', () { + const svg = ''' + + + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 1); + final Fill? fill = instructions.paints[0].fill; + expect(fill, isNotNull); + expect(fill!.shader, isNotNull); + expect(fill.shader!.colors, [ + Color.opaqueBlack, // Black (opaque) + const Color(0x7F000000), // Black (0.5 opacity) + ]); + }); + + test('Nested groups inherit SourceAlpha state', () { + const svg = ''' + + + + + + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + final List fillPaints = instructions.paints.where((p) => p.fill != null).toList(); + expect(fillPaints.length, 3); + for (final Paint paint in fillPaints) { + expect(paint.fill!.color, Color.opaqueBlack); + } + }); + + test('Inner path filter combines with outer SourceAlpha state', () { + const svg = ''' + + + + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + final List fillPaints = instructions.paints.where((p) => p.fill != null).toList(); + expect(fillPaints.length, 2); + for (final Paint paint in fillPaints) { + expect(paint.fill!.color, Color.opaqueBlack); + } + }); + + test('Text position node inherits SourceAlpha state and restores it', () { + const svg = ''' + + + + + + + + + + + Hello + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + final List fillPaints = instructions.paints.where((p) => p.fill != null).toList(); + expect(fillPaints.length, 2); + for (final Paint paint in fillPaints) { + expect(paint.fill!.color, Color.opaqueBlack); + } + }); + + test('Scales blur standard deviation by transform scale factors', () { + const svg = ''' + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 1); + final Paint paint = instructions.paints.first; + expect(paint.filterBlurX, 10.0); + expect(paint.filterBlurY, 15.0); + }); + + test('Handles cyclic filter definitions gracefully without stack overflow', () { + const svg = ''' + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 1); + expect(instructions.paints[0].filterBlurX, null); + }); + + test('Handles feMerge followed by other primitives', () { + const svg = ''' + + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 2); + expect(instructions.paints[0].filterBlurX, closeTo(11.180339887498949, 1e-9)); + expect(instructions.paints[0].filterBlurY, closeTo(11.180339887498949, 1e-9)); + expect(instructions.paints[1].filterBlurX, 10.0); + expect(instructions.paints[1].filterBlurY, 10.0); + }); + + test('Handles self-closing feMerge element', () { + const svg = ''' + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + expect(instructions.paints.length, 1); + expect(instructions.paints[0].filterBlurX, null); + expect(instructions.paints[0].filterBlurY, null); + }); + + test('Handles multi-layer filters on ParentNode', () { + const svg = ''' + + + + + + + + + + + + + + + + +'''; + final VectorInstructions instructions = parseWithoutOptimizers(svg); + // 4 paths: 2 original rects, 2 offset rects (for shadow) + expect(instructions.paths.length, 4); + + // We expect SaveLayer for shadow (blur 5), SaveLayer for glow (blur 10), and then original drawing. + // Paints should include: + // - SaveLayer 1 paint (blur 5) + // - SaveLayer 2 paint (blur 10) + // - Red fill paint + // - Blue fill paint + // - Black fill paint (for shadow) + // Let's verify we have these. + final List blurs = instructions.paints.map((p) => p.filterBlurX).toList(); + expect(blurs, contains(5.0)); + expect(blurs, contains(10.0)); + + final List colors = instructions.paints.map((p) => p.fill?.color).toList(); + expect(colors, contains(const Color(0xffff0000))); // Red + expect(colors, contains(const Color(0xff0000ff))); // Blue + expect(colors, contains(Color.opaqueBlack)); // Black (shadow) + + final List bounds = instructions.paths.map((p) => p.bounds()).toList(); + expect(bounds, contains(const Rect.fromLTWH(5, 5, 50, 50))); + expect(bounds, contains(const Rect.fromLTWH(55, 55, 50, 50))); + expect(bounds, contains(const Rect.fromLTWH(0, 0, 50, 50))); + expect(bounds, contains(const Rect.fromLTWH(50, 50, 50, 50))); + }); + test('Handles viewBox transformations correctly', () { const svg = '''