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 = '''