Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit a0983d3

Browse files
ferhatbHarry Terkelsen
andauthored
[web] Add support for ColorFilter on images (#18111)
* Implement Color filter for images * Add blend modes * complete set of blend modes * Add golden test for blend modes, fix size setting when using drawImage instead of drawImageRect * Add comments * Update golden locks * Fix analyzer errors Co-authored-by: Harry Terkelsen <[email protected]> * fix blend group count in test
1 parent c2c6ad9 commit a0983d3

File tree

5 files changed

+521
-14
lines changed

5 files changed

+521
-14
lines changed

lib/web_ui/dev/goldens_lock.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
repository: https://github.com/flutter/goldens.git
2-
revision: f64d8957ae281d1558647f0591ff9742e6135385
2+
revision: 790616cbfb269fe17d44840ce52ec187fff5f9a7

lib/web_ui/lib/src/engine/bitmap_canvas.dart

Lines changed: 292 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -353,21 +353,56 @@ class BitmapCanvas extends EngineCanvas {
353353

354354
@override
355355
void drawImage(ui.Image image, ui.Offset p, SurfacePaintData paint) {
356-
_drawImage(image, p, paint);
356+
final html.HtmlElement imageElement = _drawImage(image, p, paint);
357+
if (paint.colorFilter != null) {
358+
_applyTargetSize(imageElement, image.width.toDouble(),
359+
image.height.toDouble());
360+
}
357361
_childOverdraw = true;
358362
_canvasPool.closeCurrentCanvas();
359363
_cachedLastStyle = null;
360364
}
361365

362-
html.ImageElement _drawImage(
366+
html.HtmlElement _drawImage(
363367
ui.Image image, ui.Offset p, SurfacePaintData paint) {
364368
final HtmlImage htmlImage = image;
365-
final html.Element imgElement = htmlImage.cloneImageElement();
366369
final ui.BlendMode blendMode = paint.blendMode;
370+
final EngineColorFilter colorFilter = paint.colorFilter as EngineColorFilter;
371+
final ui.BlendMode colorFilterBlendMode = colorFilter?._blendMode;
372+
html.HtmlElement imgElement;
373+
if (colorFilterBlendMode == null) {
374+
// No Blending, create an image by cloning original loaded image.
375+
imgElement = htmlImage.cloneImageElement();
376+
} else {
377+
switch (colorFilterBlendMode) {
378+
case ui.BlendMode.colorBurn:
379+
case ui.BlendMode.colorDodge:
380+
case ui.BlendMode.hue:
381+
case ui.BlendMode.modulate:
382+
case ui.BlendMode.overlay:
383+
case ui.BlendMode.plus:
384+
case ui.BlendMode.srcIn:
385+
case ui.BlendMode.srcATop:
386+
case ui.BlendMode.srcOut:
387+
case ui.BlendMode.saturation:
388+
case ui.BlendMode.color:
389+
case ui.BlendMode.luminosity:
390+
case ui.BlendMode.xor:
391+
imgElement = _createImageElementWithSvgFilter(image,
392+
colorFilter._color, colorFilterBlendMode, paint);
393+
break;
394+
default:
395+
imgElement = _createBackgroundImageWithBlend(image,
396+
colorFilter._color, colorFilterBlendMode, paint);
397+
break;
398+
}
399+
}
367400
imgElement.style.mixBlendMode = _stringForBlendMode(blendMode);
368401
if (_canvasPool.isClipped) {
369402
// Reset width/height since they may have been previously set.
370-
imgElement.style..removeProperty('width')..removeProperty('height');
403+
imgElement.style
404+
..removeProperty('width')
405+
..removeProperty('height');
371406
final List<html.Element> clipElements = _clipContent(
372407
_canvasPool._clipStack, imgElement, p, _canvasPool.currentTransform);
373408
for (html.Element clipElement in clipElements) {
@@ -396,10 +431,19 @@ class BitmapCanvas extends EngineCanvas {
396431
src.top != 0 ||
397432
src.width != image.width ||
398433
src.height != image.height;
434+
// If source and destination sizes are identical, we can skip the longer
435+
// code path that sets the size of the element and clips.
436+
//
437+
// If there is a color filter set however, we maybe using background-image
438+
// to render therefore we have to explicitely set width/height of the
439+
// element for blending to work with background-color.
399440
if (dst.width == image.width &&
400441
dst.height == image.height &&
401-
!requiresClipping) {
402-
drawImage(image, dst.topLeft, paint);
442+
!requiresClipping &&
443+
paint.colorFilter == null) {
444+
_drawImage(image, dst.topLeft, paint);
445+
_childOverdraw = true;
446+
_canvasPool.closeCurrentCanvas();
403447
} else {
404448
if (requiresClipping) {
405449
save();
@@ -418,7 +462,7 @@ class BitmapCanvas extends EngineCanvas {
418462
}
419463
}
420464

421-
final html.ImageElement imgElement =
465+
final html.Element imgElement =
422466
_drawImage(image, ui.Offset(targetLeft, targetTop), paint);
423467
// To scale set width / height on destination image.
424468
// For clipping we need to scale according to
@@ -430,17 +474,147 @@ class BitmapCanvas extends EngineCanvas {
430474
targetWidth *= image.width / src.width;
431475
targetHeight *= image.height / src.height;
432476
}
433-
final html.CssStyleDeclaration imageStyle = imgElement.style;
434-
imageStyle
435-
..width = '${targetWidth.toStringAsFixed(2)}px'
436-
..height = '${targetHeight.toStringAsFixed(2)}px';
477+
_applyTargetSize(imgElement, targetWidth, targetHeight);
437478
if (requiresClipping) {
438479
restore();
439480
}
440481
}
441482
_closeCurrentCanvas();
442483
}
443484

485+
void _applyTargetSize(html.HtmlElement imageElement, double targetWidth,
486+
double targetHeight) {
487+
final html.CssStyleDeclaration imageStyle = imageElement.style;
488+
final String widthPx = '${targetWidth.toStringAsFixed(2)}px';
489+
final String heightPx = '${targetHeight.toStringAsFixed(2)}px';
490+
imageStyle
491+
// left,top are set to 0 (although position is absolute) because
492+
// Chrome will glitch if you leave them out, reproducable with
493+
// canvas_image_blend_test on row 6, MacOS / Chrome 81.04.
494+
..left = "0px"
495+
..top = "0px"
496+
..width = widthPx
497+
..height = heightPx;
498+
if (imageElement is! html.ImageElement) {
499+
imageElement.style.backgroundSize = '$widthPx $heightPx';
500+
}
501+
}
502+
503+
// Creates a Div element to render an image using background-image css
504+
// attribute to be able to use background blend mode(s) when possible.
505+
//
506+
// Example: <div style="
507+
// position:absolute;
508+
// background-image:url(....);
509+
// background-blend-mode:"darken"
510+
// background-color: #RRGGBB">
511+
//
512+
// Special cases:
513+
// For clear,dstOut it generates a blank element.
514+
// For src,srcOver it only sets background-color attribute.
515+
// For dst,dstIn , it only sets source not background color.
516+
html.HtmlElement _createBackgroundImageWithBlend(HtmlImage image,
517+
ui.Color filterColor, ui.BlendMode colorFilterBlendMode,
518+
SurfacePaintData paint) {
519+
// When blending with color we can't use an image element.
520+
// Instead use a div element with background image, color and
521+
// background blend mode.
522+
final html.HtmlElement imgElement = html.DivElement();
523+
final html.CssStyleDeclaration style = imgElement.style;
524+
switch (colorFilterBlendMode) {
525+
case ui.BlendMode.clear:
526+
case ui.BlendMode.dstOut:
527+
style.position = 'absolute';
528+
break;
529+
case ui.BlendMode.src:
530+
case ui.BlendMode.srcOver:
531+
style
532+
..position = 'absolute'
533+
..backgroundColor = colorToCssString(filterColor);
534+
break;
535+
case ui.BlendMode.dst:
536+
case ui.BlendMode.dstIn:
537+
style
538+
..position = 'absolute'
539+
..backgroundImage = "url('${image.imgElement.src}')";
540+
break;
541+
default:
542+
style
543+
..position = 'absolute'
544+
..backgroundImage = "url('${image.imgElement.src}')"
545+
..backgroundBlendMode = _stringForBlendMode(colorFilterBlendMode)
546+
..backgroundColor = colorToCssString(filterColor);
547+
break;
548+
}
549+
return imgElement;
550+
}
551+
552+
// Creates an image element and an svg filter to apply on the element.
553+
html.HtmlElement _createImageElementWithSvgFilter(HtmlImage image,
554+
ui.Color filterColor, ui.BlendMode colorFilterBlendMode,
555+
SurfacePaintData paint) {
556+
// For srcIn blendMode, we use an svg filter to apply to image element.
557+
String svgFilter;
558+
switch (colorFilterBlendMode) {
559+
case ui.BlendMode.srcIn:
560+
case ui.BlendMode.srcATop:
561+
svgFilter = _srcInColorFilterToSvg(filterColor);
562+
break;
563+
case ui.BlendMode.srcOut:
564+
svgFilter = _srcOutColorFilterToSvg(filterColor);
565+
break;
566+
case ui.BlendMode.xor:
567+
svgFilter = _xorColorFilterToSvg(filterColor);
568+
break;
569+
case ui.BlendMode.plus:
570+
// Porter duff source + destination.
571+
svgFilter = _compositeColorFilterToSvg(filterColor, 0, 1, 1, 0);
572+
break;
573+
case ui.BlendMode.modulate:
574+
// Porter duff source * destination but preserves alpha.
575+
svgFilter = _modulateColorFilterToSvg(filterColor);
576+
break;
577+
case ui.BlendMode.overlay:
578+
// Since overlay is the same as hard-light by swapping layers,
579+
// pass hard-light blend function.
580+
svgFilter = _blendColorFilterToSvg(filterColor, 'hard-light',
581+
swapLayers: true);
582+
break;
583+
// Several of the filters below (although supported) do not render the
584+
// same (close but not exact) as native flutter when used as blend mode
585+
// for a background-image with a background color. They only look
586+
// identical when feBlend is used within an svg filter definition.
587+
//
588+
// Saturation filter uses destination when source is transparent.
589+
// cMax = math.max(r, math.max(b, g));
590+
// cMin = math.min(r, math.min(b, g));
591+
// delta = cMax - cMin;
592+
// lightness = (cMax + cMin) / 2.0;
593+
// saturation = delta / (1.0 - (2 * lightness - 1.0).abs());
594+
case ui.BlendMode.saturation:
595+
case ui.BlendMode.colorDodge:
596+
case ui.BlendMode.colorBurn:
597+
case ui.BlendMode.hue:
598+
case ui.BlendMode.color:
599+
case ui.BlendMode.luminosity:
600+
svgFilter = _blendColorFilterToSvg(filterColor,
601+
_stringForBlendMode(colorFilterBlendMode));
602+
break;
603+
default:
604+
break;
605+
}
606+
final html.Element filterElement =
607+
html.Element.html(svgFilter, treeSanitizer: _NullTreeSanitizer());
608+
rootElement.append(filterElement);
609+
_children.add(filterElement);
610+
final html.HtmlElement imgElement = image.cloneImageElement();
611+
imgElement.style.filter = 'url(#_fcf${_filterIdCounter})';
612+
if (colorFilterBlendMode == ui.BlendMode.saturation) {
613+
imgElement.style.backgroundColor = colorToCssString(filterColor);
614+
}
615+
return imgElement;
616+
}
617+
444618
// Should be called when we add new html elements into rootElement so that
445619
// paint order is preserved.
446620
//
@@ -797,3 +971,110 @@ String _maskFilterToCss(ui.MaskFilter maskFilter) {
797971
}
798972
return 'blur(${maskFilter.webOnlySigma}px)';
799973
}
974+
975+
int _filterIdCounter = 0;
976+
977+
// The color matrix for feColorMatrix element changes colors based on
978+
// the following:
979+
//
980+
// | R' | | r1 r2 r3 r4 r5 | | R |
981+
// | G' | | g1 g2 g3 g4 g5 | | G |
982+
// | B' | = | b1 b2 b3 b4 b5 | * | B |
983+
// | A' | | a1 a2 a3 a4 a5 | | A |
984+
// | 1 | | 0 0 0 0 1 | | 1 |
985+
//
986+
// R' = r1*R + r2*G + r3*B + r4*A + r5
987+
// G' = g1*R + g2*G + g3*B + g4*A + g5
988+
// B' = b1*R + b2*G + b3*B + b4*A + b5
989+
// A' = a1*R + a2*G + a3*B + a4*A + a5
990+
String _srcInColorFilterToSvg(ui.Color color) {
991+
_filterIdCounter += 1;
992+
return '<svg width="0" height="0">'
993+
'<filter id="_fcf$_filterIdCounter" '
994+
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
995+
'<feColorMatrix values="0 0 0 0 1 ' // Ignore input, set it to absolute.
996+
'0 0 0 0 1 '
997+
'0 0 0 0 1 '
998+
'0 0 0 1 0" result="destalpha"/>' // Just take alpha channel of destination
999+
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
1000+
'</feFlood>'
1001+
'<feComposite in="flood" in2="destalpha" '
1002+
'operator="arithmetic" k1="1" k2="0" k3="0" k4="0" result="comp">'
1003+
'</feComposite>'
1004+
'</filter></svg>';
1005+
}
1006+
1007+
String _srcOutColorFilterToSvg(ui.Color color) {
1008+
_filterIdCounter += 1;
1009+
return '<svg width="0" height="0">'
1010+
'<filter id="_fcf$_filterIdCounter" '
1011+
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
1012+
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
1013+
'</feFlood>'
1014+
'<feComposite in="flood" in2="SourceGraphic" operator="out" result="comp">'
1015+
'</feComposite>'
1016+
'</filter></svg>';
1017+
}
1018+
1019+
String _xorColorFilterToSvg(ui.Color color) {
1020+
_filterIdCounter += 1;
1021+
return '<svg width="0" height="0">'
1022+
'<filter id="_fcf$_filterIdCounter" '
1023+
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
1024+
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
1025+
'</feFlood>'
1026+
'<feComposite in="flood" in2="SourceGraphic" operator="xor" result="comp">'
1027+
'</feComposite>'
1028+
'</filter></svg>';
1029+
}
1030+
1031+
// The source image and color are composited using :
1032+
// result = k1 *in*in2 + k2*in + k3*in2 + k4.
1033+
String _compositeColorFilterToSvg(ui.Color color, double k1, double k2, double k3 , double k4) {
1034+
_filterIdCounter += 1;
1035+
return '<svg width="0" height="0">'
1036+
'<filter id="_fcf$_filterIdCounter" '
1037+
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
1038+
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
1039+
'</feFlood>'
1040+
'<feComposite in="flood" in2="SourceGraphic" '
1041+
'operator="arithmetic" k1="$k1" k2="$k2" k3="$k3" k4="$k4" result="comp">'
1042+
'</feComposite>'
1043+
'</filter></svg>';
1044+
}
1045+
1046+
// Porter duff source * destination , keep source alpha.
1047+
// First apply color filter to source to change it to [color], then
1048+
// composite using multiplication.
1049+
String _modulateColorFilterToSvg(ui.Color color) {
1050+
_filterIdCounter += 1;
1051+
final double r = color.red / 255.0;
1052+
final double b = color.blue / 255.0;
1053+
final double g = color.green / 255.0;
1054+
return '<svg width="0" height="0">'
1055+
'<filter id="_fcf$_filterIdCounter" '
1056+
'filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">'
1057+
'<feColorMatrix values="0 0 0 0 $r ' // Ignore input, set it to absolute.
1058+
'0 0 0 0 $g '
1059+
'0 0 0 0 $b '
1060+
'0 0 0 1 0" result="recolor"/>'
1061+
'<feComposite in="recolor" in2="SourceGraphic" '
1062+
'operator="arithmetic" k1="1" k2="0" k3="0" k4="0" result="comp">'
1063+
'</feComposite>'
1064+
'</filter></svg>';
1065+
}
1066+
1067+
// Uses feBlend element to blend source image with a color.
1068+
String _blendColorFilterToSvg(ui.Color color, String feBlend,
1069+
{bool swapLayers = false}) {
1070+
_filterIdCounter += 1;
1071+
return '<svg width="0" height="0">'
1072+
'<filter id="_fcf$_filterIdCounter" filterUnits="objectBoundingBox" '
1073+
'x="0%" y="0%" width="100%" height="100%">'
1074+
'<feFlood flood-color="${colorToCssString(color)}" flood-opacity="1" result="flood">'
1075+
'</feFlood>' +
1076+
(swapLayers
1077+
? '<feBlend in="SourceGraphic" in2="flood" mode="$feBlend"/>'
1078+
: '<feBlend in="flood" in2="SourceGraphic" mode="$feBlend"/>') +
1079+
'</filter></svg>';
1080+
}

lib/web_ui/lib/src/engine/html_image_codec.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ class HtmlImage implements ui.Image {
149149
return imgElement.clone(true);
150150
} else {
151151
_requiresClone = true;
152-
imgElement.style..position = 'absolute';
152+
imgElement.style.position = 'absolute';
153153
return imgElement;
154154
}
155155
}

0 commit comments

Comments
 (0)