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

Commit 64a9a1a

Browse files
authored
Revert "Revert "[Web] Fix BMP encoder (#29448)" (#29580)" (#29593)
This PR fixes 2 bugs of how an image is encoded into a BMP.
1 parent fffaf70 commit 64a9a1a

File tree

2 files changed

+203
-44
lines changed

2 files changed

+203
-44
lines changed

lib/web_ui/lib/src/ui/painting.dart

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -500,71 +500,83 @@ Future<void> _decodeImageFromListAsync(Uint8List list, ImageDecoderCallback call
500500
final FrameInfo frameInfo = await codec.getNextFrame();
501501
callback(frameInfo.image);
502502
}
503+
504+
// Encodes the input pixels into a BMP file that supports transparency.
505+
//
506+
// The `pixels` should be the scanlined raw pixels, 4 bytes per pixel, from left
507+
// to right, then from top to down. The order of the 4 bytes of pixels is
508+
// decided by `format`.
503509
Future<Codec> _createBmp(
504510
Uint8List pixels,
505511
int width,
506512
int height,
507513
int rowBytes,
508514
PixelFormat format,
509515
) {
516+
late bool swapRedBlue;
517+
switch (format) {
518+
case PixelFormat.bgra8888:
519+
swapRedBlue = true;
520+
break;
521+
case PixelFormat.rgba8888:
522+
swapRedBlue = false;
523+
break;
524+
}
525+
510526
// See https://en.wikipedia.org/wiki/BMP_file_format for format examples.
511-
final int bufferSize = 0x36 + (width * height * 4);
527+
// The header is in the 108-byte BITMAPV4HEADER format, or as called by
528+
// Chromium, WindowsV4. Do not use the 56-byte or 52-byte Adobe formats, since
529+
// they're not supported.
530+
const int dibSize = 0x6C /* 108: BITMAPV4HEADER */;
531+
const int headerSize = dibSize + 0x0E;
532+
final int bufferSize = headerSize + (width * height * 4);
512533
final ByteData bmpData = ByteData(bufferSize);
513534
// 'BM' header
514-
bmpData.setUint8(0x00, 0x42);
515-
bmpData.setUint8(0x01, 0x4D);
535+
bmpData.setUint16(0x00, 0x424D, Endian.big);
516536
// Size of data
517537
bmpData.setUint32(0x02, bufferSize, Endian.little);
518538
// Offset where pixel array begins
519-
bmpData.setUint32(0x0A, 0x36, Endian.little);
539+
bmpData.setUint32(0x0A, headerSize, Endian.little);
520540
// Bytes in DIB header
521-
bmpData.setUint32(0x0E, 0x28, Endian.little);
522-
// width
541+
bmpData.setUint32(0x0E, dibSize, Endian.little);
542+
// Width
523543
bmpData.setUint32(0x12, width, Endian.little);
524-
// height
544+
// Height
525545
bmpData.setUint32(0x16, height, Endian.little);
526-
// Color panes
546+
// Color panes (always 1)
527547
bmpData.setUint16(0x1A, 0x01, Endian.little);
528-
// 32 bpp
529-
bmpData.setUint16(0x1C, 0x20, Endian.little);
530-
// no compression
531-
bmpData.setUint32(0x1E, 0x00, Endian.little);
532-
// raw bitmap data size
548+
// bpp: 32
549+
bmpData.setUint16(0x1C, 32, Endian.little);
550+
// Compression method is BITFIELDS to enable bit fields
551+
bmpData.setUint32(0x1E, 3, Endian.little);
552+
// Raw bitmap data size
533553
bmpData.setUint32(0x22, width * height, Endian.little);
534-
// print DPI width
554+
// Print DPI width
535555
bmpData.setUint32(0x26, width, Endian.little);
536-
// print DPI height
556+
// Print DPI height
537557
bmpData.setUint32(0x2A, height, Endian.little);
538-
// colors in the palette
558+
// Colors in the palette
539559
bmpData.setUint32(0x2E, 0x00, Endian.little);
540-
// important colors
560+
// Important colors
541561
bmpData.setUint32(0x32, 0x00, Endian.little);
542-
543-
544-
int pixelDestinationIndex = 0;
545-
late bool swapRedBlue;
546-
switch (format) {
547-
case PixelFormat.bgra8888:
548-
swapRedBlue = true;
549-
break;
550-
case PixelFormat.rgba8888:
551-
swapRedBlue = false;
552-
break;
553-
}
554-
for (int pixelSourceIndex = 0; pixelSourceIndex < pixels.length; pixelSourceIndex += 4) {
555-
final int r = swapRedBlue ? pixels[pixelSourceIndex + 2] : pixels[pixelSourceIndex];
556-
final int b = swapRedBlue ? pixels[pixelSourceIndex] : pixels[pixelSourceIndex + 2];
557-
final int g = pixels[pixelSourceIndex + 1];
558-
final int a = pixels[pixelSourceIndex + 3];
559-
560-
// Set the pixel past the header data.
561-
bmpData.setUint8(pixelDestinationIndex + 0x36, r);
562-
bmpData.setUint8(pixelDestinationIndex + 0x37, g);
563-
bmpData.setUint8(pixelDestinationIndex + 0x38, b);
564-
bmpData.setUint8(pixelDestinationIndex + 0x39, a);
565-
pixelDestinationIndex += 4;
566-
if (rowBytes != width && pixelSourceIndex % width == 0) {
567-
pixelSourceIndex += rowBytes - width;
562+
// Bitmask R
563+
bmpData.setUint32(0x36, swapRedBlue ? 0x00FF0000 : 0x000000FF, Endian.little);
564+
// Bitmask G
565+
bmpData.setUint32(0x3A, 0x0000FF00, Endian.little);
566+
// Bitmask B
567+
bmpData.setUint32(0x3E, swapRedBlue ? 0x000000FF : 0x00FF0000, Endian.little);
568+
// Bitmask A
569+
bmpData.setUint32(0x42, 0xFF000000, Endian.little);
570+
571+
int destinationByte = headerSize;
572+
final Uint32List combinedPixels = Uint32List.sublistView(pixels);
573+
// BMP is scanlined from bottom to top. Rearrange here.
574+
for (int rowCount = height - 1; rowCount >= 0; rowCount -= 1) {
575+
int sourcePixel = rowCount * rowBytes;
576+
for (int colCount = 0; colCount < width; colCount += 1) {
577+
bmpData.setUint32(destinationByte, combinedPixels[sourcePixel], Endian.little);
578+
destinationByte += 4;
579+
sourcePixel += 1;
568580
}
569581
}
570582

@@ -803,4 +815,3 @@ class FragmentProgram {
803815
required Float32List floatUniforms,
804816
}) => throw UnsupportedError('FragmentProgram is not supported for the CanvasKit or HTML renderers.');
805817
}
806-

lib/web_ui/test/html/image_test.dart

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:html';
6+
import 'dart:typed_data';
7+
8+
import 'package:test/bootstrap/browser.dart';
9+
import 'package:test/test.dart';
10+
import 'package:ui/src/engine.dart';
11+
import 'package:ui/ui.dart' hide TextStyle;
12+
13+
void main() {
14+
internalBootstrapBrowserTest(() => testMain);
15+
}
16+
17+
typedef _ListPredicate<T> = bool Function(List<T>);
18+
_ListPredicate<T> deepEqualList<T>(List<T> a) {
19+
return (List<T> b) {
20+
if (a.length != b.length)
21+
return false;
22+
for (int i = 0; i < a.length; i += 1) {
23+
if (a[i] != b[i])
24+
return false;
25+
}
26+
return true;
27+
};
28+
}
29+
30+
Matcher listEqual(List<int> source, {int tolerance = 0}) {
31+
return predicate(
32+
(List<int> target) {
33+
if (source.length != target.length)
34+
return false;
35+
for (int i = 0; i < source.length; i += 1) {
36+
if ((source[i] - target[i]).abs() > tolerance)
37+
return false;
38+
}
39+
return true;
40+
},
41+
source.toString(),
42+
);
43+
}
44+
45+
// Converts `rawPixels` into a list of bytes that represent raw pixels in rgba8888.
46+
//
47+
// Each element of `rawPixels` represents a bytes in order 0xRRGGBBAA, with
48+
// pixel order Left to right, then top to bottom.
49+
Uint8List _pixelsToBytes(List<int> rawPixels) {
50+
return Uint8List.fromList((() sync* {
51+
for (final int pixel in rawPixels) {
52+
yield (pixel >> 24) & 0xff; // r
53+
yield (pixel >> 16) & 0xff; // g
54+
yield (pixel >> 8) & 0xff; // b
55+
yield (pixel >> 0) & 0xff; // a
56+
}
57+
})().toList());
58+
}
59+
60+
Future<Image> _encodeToHtmlThenDecode(
61+
Uint8List rawBytes,
62+
int width,
63+
int height, {
64+
PixelFormat pixelFormat = PixelFormat.rgba8888,
65+
}) async {
66+
final ImageDescriptor descriptor = ImageDescriptor.raw(
67+
await ImmutableBuffer.fromUint8List(rawBytes),
68+
width: width,
69+
height: height,
70+
pixelFormat: pixelFormat,
71+
);
72+
return (await (await descriptor.instantiateCodec()).getNextFrame()).image;
73+
}
74+
75+
Future<void> testMain() async {
76+
test('Correctly encodes an opaque image', () async {
77+
// A 2x2 testing image without transparency.
78+
final Image sourceImage = await _encodeToHtmlThenDecode(
79+
_pixelsToBytes(
80+
<int>[0xFF0102FF, 0x04FE05FF, 0x0708FDFF, 0x0A0B0C00],
81+
), 2, 2,
82+
);
83+
final Uint8List actualPixels = Uint8List.sublistView(
84+
(await sourceImage.toByteData(format: ImageByteFormat.rawStraightRgba))!);
85+
// The `benchmarkPixels` is identical to `sourceImage` except for the fully
86+
// transparent last pixel, whose channels are turned 0.
87+
final Uint8List benchmarkPixels = _pixelsToBytes(
88+
<int>[0xFF0102FF, 0x04FE05FF, 0x0708FDFF, 0x00000000],
89+
);
90+
expect(actualPixels, listEqual(benchmarkPixels));
91+
});
92+
93+
test('Correctly encodes an opaque image in bgra8888', () async {
94+
// A 2x2 testing image without transparency.
95+
final Image sourceImage = await _encodeToHtmlThenDecode(
96+
_pixelsToBytes(
97+
<int>[0xFF0102FF, 0x04FE05FF, 0x0708FDFF, 0x0A0B0C00],
98+
), 2, 2, pixelFormat: PixelFormat.bgra8888,
99+
);
100+
final Uint8List actualPixels = Uint8List.sublistView(
101+
(await sourceImage.toByteData(format: ImageByteFormat.rawStraightRgba))!);
102+
// The `benchmarkPixels` is the same as `sourceImage` except that the R and
103+
// G channels are swapped and the fully transparent last pixel is turned 0.
104+
final Uint8List benchmarkPixels = _pixelsToBytes(
105+
<int>[0x0201FFFF, 0x05FE04FF, 0xFD0807FF, 0x00000000],
106+
);
107+
expect(actualPixels, listEqual(benchmarkPixels));
108+
});
109+
110+
test('Correctly encodes a transparent image', () async {
111+
// A 2x2 testing image with transparency.
112+
final Image sourceImage = await _encodeToHtmlThenDecode(
113+
_pixelsToBytes(
114+
<int>[0xFF800006, 0xFF800080, 0xFF8000C0, 0xFF8000FF],
115+
), 2, 2,
116+
);
117+
final Image blueBackground = await _encodeToHtmlThenDecode(
118+
_pixelsToBytes(
119+
<int>[0x0000FFFF, 0x0000FFFF, 0x0000FFFF, 0x0000FFFF],
120+
), 2, 2,
121+
);
122+
// The standard way of testing the raw bytes of `sourceImage` is to draw
123+
// the image onto a canvas and fetch its data (see HtmlImage.toByteData).
124+
// But here, we draw an opaque background first before drawing the image,
125+
// and test if the blended result is expected.
126+
//
127+
// This is because, if we only draw the `sourceImage`, the resulting pixels
128+
// will be slightly off from the raw pixels. The reason is unknown, but
129+
// very likely because the canvas.getImageData introduces rounding errors
130+
// if any pixels are left semi-transparent, which might be caused by
131+
// converting to and from pre-multiplied values. See
132+
// https://github.com/flutter/flutter/issues/92958 .
133+
final CanvasElement canvas = CanvasElement()
134+
..width = 2
135+
..height = 2;
136+
final CanvasRenderingContext2D ctx = canvas.context2D;
137+
ctx.drawImage((blueBackground as HtmlImage).imgElement, 0, 0);
138+
ctx.drawImage((sourceImage as HtmlImage).imgElement, 0, 0);
139+
140+
final ImageData imageData = ctx.getImageData(0, 0, 2, 2);
141+
final List<int> actualPixels = imageData.data;
142+
143+
final Uint8List benchmarkPixels = _pixelsToBytes(
144+
<int>[0x0603F9FF, 0x80407FFF, 0xC0603FFF, 0xFF8000FF],
145+
);
146+
expect(actualPixels, listEqual(benchmarkPixels, tolerance: 1));
147+
});
148+
}

0 commit comments

Comments
 (0)