Skip to content

Commit 7987980

Browse files
authored
[web] Fix image gap due to svg element without position attribute (flutter#21939)
1 parent 149ef70 commit 7987980

File tree

3 files changed

+146
-62
lines changed

3 files changed

+146
-62
lines changed

lib/web_ui/lib/src/engine/html/color_filter.dart

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,9 @@ String? svgFilterFromBlendMode(
208208
case ui.BlendMode.dstOver:
209209
case ui.BlendMode.clear:
210210
case ui.BlendMode.srcOver:
211-
assert(false, 'Invalid svg filter request for blend-mode '
211+
assert(
212+
false,
213+
'Invalid svg filter request for blend-mode '
212214
'$colorFilterBlendMode');
213215
break;
214216
}
@@ -232,7 +234,7 @@ int _filterIdCounter = 0;
232234
// A' = a1*R + a2*G + a3*B + a4*A + a5
233235
String _srcInColorFilterToSvg(ui.Color? color) {
234236
_filterIdCounter += 1;
235-
return '<svg width="0" height="0">'
237+
return '$kSvgResourceHeader'
236238
'<filter id="_fcf$_filterIdCounter" '
237239
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
238240
'<feColorMatrix values="0 0 0 0 1 ' // Ignore input, set it to absolute.
@@ -249,7 +251,7 @@ String _srcInColorFilterToSvg(ui.Color? color) {
249251

250252
String _srcOutColorFilterToSvg(ui.Color? color) {
251253
_filterIdCounter += 1;
252-
return '<svg width="0" height="0">'
254+
return '$kSvgResourceHeader'
253255
'<filter id="_fcf$_filterIdCounter" '
254256
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
255257
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
@@ -261,7 +263,7 @@ String _srcOutColorFilterToSvg(ui.Color? color) {
261263

262264
String _xorColorFilterToSvg(ui.Color? color) {
263265
_filterIdCounter += 1;
264-
return '<svg width="0" height="0">'
266+
return '$kSvgResourceHeader'
265267
'<filter id="_fcf$_filterIdCounter" '
266268
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
267269
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
@@ -276,7 +278,7 @@ String _xorColorFilterToSvg(ui.Color? color) {
276278
String _compositeColorFilterToSvg(
277279
ui.Color? color, double k1, double k2, double k3, double k4) {
278280
_filterIdCounter += 1;
279-
return '<svg width="0" height="0">'
281+
return '$kSvgResourceHeader'
280282
'<filter id="_fcf$_filterIdCounter" '
281283
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
282284
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
@@ -295,7 +297,7 @@ String _modulateColorFilterToSvg(ui.Color color) {
295297
final double r = color.red / 255.0;
296298
final double b = color.blue / 255.0;
297299
final double g = color.green / 255.0;
298-
return '<svg width="0" height="0">'
300+
return '$kSvgResourceHeader'
299301
'<filter id="_fcf$_filterIdCounter" '
300302
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
301303
'<feColorMatrix values="0 0 0 0 $r ' // Ignore input, set it to absolute.
@@ -312,7 +314,7 @@ String _modulateColorFilterToSvg(ui.Color color) {
312314
String _blendColorFilterToSvg(ui.Color? color, String? feBlend,
313315
{bool swapLayers = false}) {
314316
_filterIdCounter += 1;
315-
return '<svg width="0" height="0">'
317+
return '$kSvgResourceHeader'
316318
'<filter id="_fcf$_filterIdCounter" filterUnits="objectBoundingBox" '
317319
'x="0%" y="0%" width="100%" height="100%">'
318320
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'

lib/web_ui/lib/src/engine/util.dart

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ TransformKind transformKindOf(Float32List matrix) {
111111

112112
// If matrix contains scaling, rotation, z translation or
113113
// perspective transform, it is not considered simple.
114-
final bool isSimple2dTransform =
115-
m[15] == 1.0 && // start reading from the last element to eliminate range checks in subsequent reads.
114+
final bool isSimple2dTransform = m[15] ==
115+
1.0 && // start reading from the last element to eliminate range checks in subsequent reads.
116116
m[14] == 0.0 && // z translation is NOT simple
117117
// m[13] - y translation is simple
118118
// m[12] - x translation is simple
@@ -126,8 +126,8 @@ TransformKind transformKindOf(Float32List matrix) {
126126
// m[4] - 2D rotation is simple
127127
m[3] == 0.0 &&
128128
m[2] == 0.0;
129-
// m[1] - 2D rotation is simple
130-
// m[0] - scale x is simple
129+
// m[1] - 2D rotation is simple
130+
// m[0] - scale x is simple
131131

132132
if (!isSimple2dTransform) {
133133
return TransformKind.complex;
@@ -136,8 +136,7 @@ TransformKind transformKindOf(Float32List matrix) {
136136
// From this point on we're sure the transform is 2D, but we don't know if
137137
// it's identity or not. To check, we need to look at the remaining elements
138138
// that were not checked above.
139-
final bool isIdentityTransform =
140-
m[0] == 1.0 &&
139+
final bool isIdentityTransform = m[0] == 1.0 &&
141140
m[1] == 0.0 &&
142141
m[4] == 0.0 &&
143142
m[5] == 1.0 &&
@@ -165,7 +164,7 @@ bool isIdentityFloat32ListTransform(Float32List matrix) {
165164
/// transform. Consider removing the CSS `transform` property from elements
166165
/// that apply identity transform.
167166
String float64ListToCssTransform2d(Float32List matrix) {
168-
assert (transformKindOf(matrix) != TransformKind.complex);
167+
assert(transformKindOf(matrix) != TransformKind.complex);
169168
return 'matrix(${matrix[0]},${matrix[1]},${matrix[4]},${matrix[5]},${matrix[12]},${matrix[13]})';
170169
}
171170

@@ -279,10 +278,22 @@ void transformLTRB(Matrix4 transform, Float32List ltrb) {
279278

280279
_tempPointMatrix.multiplyTranspose(transform);
281280

282-
ltrb[0] = math.min(math.min(math.min(_tempPointData[0], _tempPointData[1]), _tempPointData[2]), _tempPointData[3]);
283-
ltrb[1] = math.min(math.min(math.min(_tempPointData[4], _tempPointData[5]), _tempPointData[6]), _tempPointData[7]);
284-
ltrb[2] = math.max(math.max(math.max(_tempPointData[0], _tempPointData[1]), _tempPointData[2]), _tempPointData[3]);
285-
ltrb[3] = math.max(math.max(math.max(_tempPointData[4], _tempPointData[5]), _tempPointData[6]), _tempPointData[7]);
281+
ltrb[0] = math.min(
282+
math.min(
283+
math.min(_tempPointData[0], _tempPointData[1]), _tempPointData[2]),
284+
_tempPointData[3]);
285+
ltrb[1] = math.min(
286+
math.min(
287+
math.min(_tempPointData[4], _tempPointData[5]), _tempPointData[6]),
288+
_tempPointData[7]);
289+
ltrb[2] = math.max(
290+
math.max(
291+
math.max(_tempPointData[0], _tempPointData[1]), _tempPointData[2]),
292+
_tempPointData[3]);
293+
ltrb[3] = math.max(
294+
math.max(
295+
math.max(_tempPointData[4], _tempPointData[5]), _tempPointData[6]),
296+
_tempPointData[7]);
286297
}
287298

288299
/// Returns true if [rect] contains every point that is also contained by the
@@ -300,6 +311,13 @@ bool rectContainsOther(ui.Rect rect, ui.Rect other) {
300311
/// Counter used for generating clip path id inside an svg <defs> tag.
301312
int _clipIdCounter = 0;
302313

314+
/// Used for clipping and filter svg resources.
315+
///
316+
/// Position needs to be absolute since these svgs are sandwiched between
317+
/// canvas elements and can cause layout shifts otherwise.
318+
const String kSvgResourceHeader = '<svg width="0" height="0" '
319+
'style="position:absolute">';
320+
303321
/// Converts Path to svg element that contains a clip-path definition.
304322
///
305323
/// Calling this method updates [_clipIdCounter]. The HTML id of the generated
@@ -311,8 +329,7 @@ String _pathToSvgClipPath(ui.Path path,
311329
double scaleY = 1.0}) {
312330
_clipIdCounter += 1;
313331
final StringBuffer sb = StringBuffer();
314-
sb.write('<svg width="0" height="0" '
315-
'style="position:absolute">');
332+
sb.write(kSvgResourceHeader);
316333
sb.write('<defs>');
317334

318335
final String clipId = 'svgClip$_clipIdCounter';
@@ -420,10 +437,11 @@ const Set<String> _genericFontFamilies = <String>{
420437
///
421438
/// For iOS, default to -apple-system, where it should be available, otherwise
422439
/// default to Arial. BlinkMacSystemFont is used for Chrome on iOS.
423-
final String _fallbackFontFamily = _isMacOrIOS ?
424-
'-apple-system, BlinkMacSystemFont' : 'Arial';
440+
final String _fallbackFontFamily =
441+
_isMacOrIOS ? '-apple-system, BlinkMacSystemFont' : 'Arial';
425442

426-
bool get _isMacOrIOS => operatingSystem == OperatingSystem.iOs ||
443+
bool get _isMacOrIOS =>
444+
operatingSystem == OperatingSystem.iOs ||
427445
operatingSystem == OperatingSystem.macOs;
428446

429447
/// Create a font-family string appropriate for CSS.
@@ -439,8 +457,10 @@ String? canonicalizeFontFamily(String? fontFamily) {
439457
// on sans-serif.
440458
// Map to San Francisco Text/Display fonts, use -apple-system,
441459
// BlinkMacSystemFont.
442-
if (fontFamily == '.SF Pro Text' || fontFamily == '.SF Pro Display' ||
443-
fontFamily == '.SF UI Text' || fontFamily == '.SF UI Display') {
460+
if (fontFamily == '.SF Pro Text' ||
461+
fontFamily == '.SF Pro Display' ||
462+
fontFamily == '.SF UI Text' ||
463+
fontFamily == '.SF UI Display') {
444464
return _fallbackFontFamily;
445465
}
446466
}
@@ -474,27 +494,27 @@ void applyWebkitClipFix(html.Element? containerElement) {
474494
}
475495
}
476496

477-
final ByteData? _fontChangeMessage = JSONMessageCodec().encodeMessage(<String, dynamic>{'type': 'fontsChange'});
497+
final ByteData? _fontChangeMessage =
498+
JSONMessageCodec().encodeMessage(<String, dynamic>{'type': 'fontsChange'});
478499

479500
// Font load callbacks will typically arrive in sequence, we want to prevent
480501
// sendFontChangeMessage of causing multiple synchronous rebuilds.
481502
// This flag ensures we properly schedule a single call to framework.
482503
bool _fontChangeScheduled = false;
483504

484505
FutureOr<void> sendFontChangeMessage() async {
485-
if (window._onPlatformMessage != null)
486-
if (!_fontChangeScheduled) {
487-
_fontChangeScheduled = true;
488-
// Batch updates into next animationframe.
489-
html.window.requestAnimationFrame((num _) {
490-
_fontChangeScheduled = false;
491-
window.invokeOnPlatformMessage(
492-
'flutter/system',
493-
_fontChangeMessage,
494-
(_) {},
495-
);
496-
});
497-
}
506+
if (window._onPlatformMessage != null && !_fontChangeScheduled) {
507+
_fontChangeScheduled = true;
508+
// Batch updates into next animationframe.
509+
html.window.requestAnimationFrame((num _) {
510+
_fontChangeScheduled = false;
511+
window.invokeOnPlatformMessage(
512+
'flutter/system',
513+
_fontChangeMessage,
514+
(_) {},
515+
);
516+
});
517+
}
498518
}
499519

500520
// Stores matrix in a form that allows zero allocation transforms.
@@ -535,14 +555,12 @@ bool isUnsoundNull(dynamic object) {
535555
}
536556

537557
bool _offsetIsValid(ui.Offset offset) {
538-
assert(offset != null, 'Offset argument was null.'); // ignore: unnecessary_null_comparison
539558
assert(!offset.dx.isNaN && !offset.dy.isNaN,
540559
'Offset argument contained a NaN value.');
541560
return true;
542561
}
543562

544563
bool _matrix4IsValid(Float32List matrix4) {
545-
assert(matrix4 != null, 'Matrix4 argument was null.'); // ignore: unnecessary_null_comparison
546564
assert(matrix4.length == 16, 'Matrix4 must have 16 entries.');
547565
return true;
548566
}

lib/web_ui/test/golden_tests/engine/color_filter_golden_test.dart

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
// @dart = 2.6
5+
// @dart = 2.10
66
import 'dart:html' as html;
7+
import 'dart:js_util' as js_util;
78

89
import 'package:test/bootstrap/browser.dart';
910
import 'package:test/test.dart';
@@ -35,55 +36,118 @@ void testMain() async {
3536
final SurfaceSceneBuilder builder = SurfaceSceneBuilder();
3637
final Picture backgroundPicture = _drawBackground();
3738
builder.addPicture(Offset.zero, backgroundPicture);
38-
builder.pushColorFilter(EngineColorFilter.mode(Color(0xF0000080),
39-
BlendMode.color));
39+
builder.pushColorFilter(
40+
EngineColorFilter.mode(Color(0xF0000080), BlendMode.color));
4041
final Picture circles1 = _drawTestPictureWithCircles(30, 30);
4142
builder.addPicture(Offset.zero, circles1);
4243
builder.pop();
43-
html.document.body.append(builder
44-
.build()
45-
.webOnlyRootElement);
44+
html.document.body!.append(builder.build().webOnlyRootElement!);
4645

4746
await matchGoldenFile('color_filter_blendMode_color.png', region: region);
4847
});
48+
49+
/// Regression test for https://github.com/flutter/flutter/issues/59451.
50+
///
51+
/// Picture with overlay blend inside a physical shape. Should show image
52+
/// at 0,0. In the filed issue it was leaving a gap on top.
53+
test('Should render image with color filter without gap', () async {
54+
final SurfaceSceneBuilder builder = SurfaceSceneBuilder();
55+
final Path path = Path();
56+
path.addRRect(RRect.fromRectAndRadius(
57+
Rect.fromLTRB(0, 0, 400, 400), Radius.circular(2)));
58+
PhysicalShapeEngineLayer oldLayer = builder.pushPhysicalShape(
59+
path: path, color: Color(0xFFFFFFFF), elevation: 0);
60+
final Picture circles1 = _drawTestPictureWithImage(
61+
ColorFilter.mode(Color(0x3C4043), BlendMode.overlay));
62+
builder.addPicture(Offset(10, 0), circles1);
63+
builder.pop();
64+
builder.build();
65+
66+
final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder();
67+
builder2.pushPhysicalShape(
68+
path: path, color: Color(0xFFFFFFFF), elevation: 0, oldLayer: oldLayer);
69+
builder2.addPicture(Offset(10, 0), circles1);
70+
builder2.pop();
71+
72+
html.document.body!.append(builder2.build().webOnlyRootElement!);
73+
74+
await matchGoldenFile('color_filter_blendMode_overlay.png',
75+
region: region);
76+
});
4977
}
5078

5179
Picture _drawTestPictureWithCircles(double offsetX, double offsetY) {
52-
final EnginePictureRecorder recorder = PictureRecorder();
80+
final EnginePictureRecorder recorder =
81+
PictureRecorder() as EnginePictureRecorder;
5382
final RecordingCanvas canvas =
54-
recorder.beginRecording(const Rect.fromLTRB(0, 0, 400, 400));
55-
canvas.drawCircle(
56-
Offset(offsetX + 10, offsetY + 10), 10, Paint()..style = PaintingStyle.fill);
83+
recorder.beginRecording(const Rect.fromLTRB(0, 0, 400, 400));
84+
canvas.drawCircle(Offset(offsetX + 10, offsetY + 10), 10,
85+
(Paint()..style = PaintingStyle.fill) as SurfacePaint);
5786
canvas.drawCircle(
5887
Offset(offsetX + 60, offsetY + 10),
5988
10,
60-
Paint()
89+
(Paint()
6190
..style = PaintingStyle.fill
62-
..color = const Color.fromRGBO(255, 0, 0, 1));
91+
..color = const Color.fromRGBO(255, 0, 0, 1)) as SurfacePaint);
6392
canvas.drawCircle(
6493
Offset(offsetX + 10, offsetY + 60),
6594
10,
66-
Paint()
95+
(Paint()
6796
..style = PaintingStyle.fill
68-
..color = const Color.fromRGBO(0, 255, 0, 1));
97+
..color = const Color.fromRGBO(0, 255, 0, 1)) as SurfacePaint);
6998
canvas.drawCircle(
7099
Offset(offsetX + 60, offsetY + 60),
71100
10,
72-
Paint()
101+
(Paint()
73102
..style = PaintingStyle.fill
74-
..color = const Color.fromRGBO(0, 0, 255, 1));
103+
..color = const Color.fromRGBO(0, 0, 255, 1)) as SurfacePaint);
104+
return recorder.endRecording();
105+
}
106+
107+
Picture _drawTestPictureWithImage(ColorFilter filter) {
108+
final EnginePictureRecorder recorder =
109+
PictureRecorder() as EnginePictureRecorder;
110+
final RecordingCanvas canvas =
111+
recorder.beginRecording(const Rect.fromLTRB(0, 0, 400, 400));
112+
final Image testImage = createTestImage();
113+
canvas.drawImageRect(
114+
testImage,
115+
Rect.fromLTWH(0, 0, 200, 150),
116+
Rect.fromLTWH(0, 0, 300, 300),
117+
(Paint()
118+
..style = PaintingStyle.fill
119+
..colorFilter = filter
120+
..color = const Color.fromRGBO(0, 0, 255, 1)) as SurfacePaint);
75121
return recorder.endRecording();
76122
}
77123

78124
Picture _drawBackground() {
79-
final EnginePictureRecorder recorder = PictureRecorder();
125+
final EnginePictureRecorder recorder =
126+
PictureRecorder() as EnginePictureRecorder;
80127
final RecordingCanvas canvas =
81-
recorder.beginRecording(const Rect.fromLTRB(0, 0, 400, 400));
128+
recorder.beginRecording(const Rect.fromLTRB(0, 0, 400, 400));
82129
canvas.drawRect(
83130
Rect.fromLTWH(8, 8, 400.0 - 16, 400.0 - 16),
84-
Paint()
131+
(Paint()
85132
..style = PaintingStyle.fill
86-
..color = Color(0xFFE0FFE0)
87-
);
133+
..color = Color(0xFFE0FFE0)) as SurfacePaint);
88134
return recorder.endRecording();
89135
}
136+
137+
HtmlImage createTestImage({int width = 200, int height = 150}) {
138+
html.CanvasElement canvas =
139+
new html.CanvasElement(width: width, height: height);
140+
html.CanvasRenderingContext2D ctx = canvas.context2D;
141+
ctx.fillStyle = '#E04040';
142+
ctx.fillRect(0, 0, width / 3, height);
143+
ctx.fill();
144+
ctx.fillStyle = '#40E080';
145+
ctx.fillRect(width / 3, 0, width / 3, height);
146+
ctx.fill();
147+
ctx.fillStyle = '#2040E0';
148+
ctx.fillRect(2 * width / 3, 0, width / 3, height);
149+
ctx.fill();
150+
html.ImageElement imageElement = html.ImageElement();
151+
imageElement.src = js_util.callMethod(canvas, 'toDataURL', <dynamic>[]);
152+
return HtmlImage(imageElement, width, height);
153+
}

0 commit comments

Comments
 (0)