Skip to content

Commit

Permalink
[Web] Fix BMP encoder (flutter#29448)
Browse files Browse the repository at this point in the history
This PR fixes 2 bugs of how an image is encoded into a BMP.
  • Loading branch information
dkwingsmt committed Nov 4, 2021
1 parent b7f4651 commit 15d5a23
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 44 deletions.
99 changes: 55 additions & 44 deletions lib/web_ui/lib/src/ui/painting.dart
Original file line number Diff line number Diff line change
Expand Up @@ -500,71 +500,83 @@ Future<void> _decodeImageFromListAsync(Uint8List list, ImageDecoderCallback call
final FrameInfo frameInfo = await codec.getNextFrame();
callback(frameInfo.image);
}

// Encodes the input pixels into a BMP file that supports transparency.
//
// The `pixels` should be the scanlined raw pixels, 4 bytes per pixel, from left
// to right, then from top to down. The order of the 4 bytes of pixels is
// decided by `format`.
Future<Codec> _createBmp(
Uint8List pixels,
int width,
int height,
int rowBytes,
PixelFormat format,
) {
late bool swapRedBlue;
switch (format) {
case PixelFormat.bgra8888:
swapRedBlue = true;
break;
case PixelFormat.rgba8888:
swapRedBlue = false;
break;
}

// See https://en.wikipedia.org/wiki/BMP_file_format for format examples.
final int bufferSize = 0x36 + (width * height * 4);
// The header is in the 108-byte BITMAPV4HEADER format, or as called by
// Chromium, WindowsV4. Do not use the 56-byte or 52-byte Adobe formats, since
// they're not supported.
const int dibSize = 0x6C /* 108: BITMAPV4HEADER */;
const int headerSize = dibSize + 0x0E;
final int bufferSize = headerSize + (width * height * 4);
final ByteData bmpData = ByteData(bufferSize);
// 'BM' header
bmpData.setUint8(0x00, 0x42);
bmpData.setUint8(0x01, 0x4D);
bmpData.setUint16(0x00, 0x424D, Endian.big);
// Size of data
bmpData.setUint32(0x02, bufferSize, Endian.little);
// Offset where pixel array begins
bmpData.setUint32(0x0A, 0x36, Endian.little);
bmpData.setUint32(0x0A, headerSize, Endian.little);
// Bytes in DIB header
bmpData.setUint32(0x0E, 0x28, Endian.little);
// width
bmpData.setUint32(0x0E, dibSize, Endian.little);
// Width
bmpData.setUint32(0x12, width, Endian.little);
// height
// Height
bmpData.setUint32(0x16, height, Endian.little);
// Color panes
// Color panes (always 1)
bmpData.setUint16(0x1A, 0x01, Endian.little);
// 32 bpp
bmpData.setUint16(0x1C, 0x20, Endian.little);
// no compression
bmpData.setUint32(0x1E, 0x00, Endian.little);
// raw bitmap data size
// bpp: 32
bmpData.setUint16(0x1C, 32, Endian.little);
// Compression method is BITFIELDS to enable bit fields
bmpData.setUint32(0x1E, 3, Endian.little);
// Raw bitmap data size
bmpData.setUint32(0x22, width * height, Endian.little);
// print DPI width
// Print DPI width
bmpData.setUint32(0x26, width, Endian.little);
// print DPI height
// Print DPI height
bmpData.setUint32(0x2A, height, Endian.little);
// colors in the palette
// Colors in the palette
bmpData.setUint32(0x2E, 0x00, Endian.little);
// important colors
// Important colors
bmpData.setUint32(0x32, 0x00, Endian.little);


int pixelDestinationIndex = 0;
late bool swapRedBlue;
switch (format) {
case PixelFormat.bgra8888:
swapRedBlue = true;
break;
case PixelFormat.rgba8888:
swapRedBlue = false;
break;
}
for (int pixelSourceIndex = 0; pixelSourceIndex < pixels.length; pixelSourceIndex += 4) {
final int r = swapRedBlue ? pixels[pixelSourceIndex + 2] : pixels[pixelSourceIndex];
final int b = swapRedBlue ? pixels[pixelSourceIndex] : pixels[pixelSourceIndex + 2];
final int g = pixels[pixelSourceIndex + 1];
final int a = pixels[pixelSourceIndex + 3];

// Set the pixel past the header data.
bmpData.setUint8(pixelDestinationIndex + 0x36, r);
bmpData.setUint8(pixelDestinationIndex + 0x37, g);
bmpData.setUint8(pixelDestinationIndex + 0x38, b);
bmpData.setUint8(pixelDestinationIndex + 0x39, a);
pixelDestinationIndex += 4;
if (rowBytes != width && pixelSourceIndex % width == 0) {
pixelSourceIndex += rowBytes - width;
// Bitmask R
bmpData.setUint32(0x36, swapRedBlue ? 0x00FF0000 : 0x000000FF, Endian.little);
// Bitmask G
bmpData.setUint32(0x3A, 0x0000FF00, Endian.little);
// Bitmask B
bmpData.setUint32(0x3E, swapRedBlue ? 0x000000FF : 0x00FF0000, Endian.little);
// Bitmask A
bmpData.setUint32(0x42, 0xFF000000, Endian.little);

int destinationByte = headerSize;
final Uint32List combinedPixels = Uint32List.sublistView(pixels);
// BMP is scanlined from bottom to top. Rearrange here.
for (int rowCount = height - 1; rowCount >= 0; rowCount -= 1) {
int sourcePixel = rowCount * rowBytes;
for (int colCount = 0; colCount < width; colCount += 1) {
bmpData.setUint32(destinationByte, combinedPixels[sourcePixel], Endian.little);
destinationByte += 4;
sourcePixel += 1;
}
}

Expand Down Expand Up @@ -801,4 +813,3 @@ class FragmentProgram {
required Float32List floatUniforms,
}) => throw UnsupportedError('FragmentProgram is not supported for the CanvasKit or HTML renderers.');
}

148 changes: 148 additions & 0 deletions lib/web_ui/test/html/image_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:html';
import 'dart:typed_data';

import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' hide TextStyle;

void main() {
internalBootstrapBrowserTest(() => testMain);
}

typedef _ListPredicate<T> = bool Function(List<T>);
_ListPredicate<T> deepEqualList<T>(List<T> a) {
return (List<T> b) {
if (a.length != b.length)
return false;
for (int i = 0; i < a.length; i += 1) {
if (a[i] != b[i])
return false;
}
return true;
};
}

Matcher listEqual(List<int> source, {int tolerance = 0}) {
return predicate(
(List<int> target) {
if (source.length != target.length)
return false;
for (int i = 0; i < source.length; i += 1) {
if ((source[i] - target[i]).abs() > tolerance)
return false;
}
return true;
},
source.toString(),
);
}

// Converts `rawPixels` into a list of bytes that represent raw pixels in rgba8888.
//
// Each element of `rawPixels` represents a bytes in order 0xRRGGBBAA, with
// pixel order Left to right, then top to bottom.
Uint8List _pixelsToBytes(List<int> rawPixels) {
return Uint8List.fromList((() sync* {
for (final int pixel in rawPixels) {
yield (pixel >> 24) & 0xff; // r
yield (pixel >> 16) & 0xff; // g
yield (pixel >> 8) & 0xff; // b
yield (pixel >> 0) & 0xff; // a
}
})().toList());
}

Future<Image> _encodeToHtmlThenDecode(
Uint8List rawBytes,
int width,
int height, {
PixelFormat pixelFormat = PixelFormat.rgba8888,
}) async {
final ImageDescriptor descriptor = ImageDescriptor.raw(
await ImmutableBuffer.fromUint8List(rawBytes),
width: width,
height: height,
pixelFormat: pixelFormat,
);
return (await (await descriptor.instantiateCodec()).getNextFrame()).image;
}

Future<void> testMain() async {
test('Correctly encodes an opaque image', () async {
// A 2x2 testing image without transparency.
final Image sourceImage = await _encodeToHtmlThenDecode(
_pixelsToBytes(
<int>[0xFF0102FF, 0x04FE05FF, 0x0708FDFF, 0x0A0B0C00],
), 2, 2,
);
final Uint8List actualPixels = Uint8List.sublistView(
(await sourceImage.toByteData(format: ImageByteFormat.rawStraightRgba))!);
// The `benchmarkPixels` is identical to `sourceImage` except for the fully
// transparent last pixel, whose channels are turned 0.
final Uint8List benchmarkPixels = _pixelsToBytes(
<int>[0xFF0102FF, 0x04FE05FF, 0x0708FDFF, 0x00000000],
);
expect(actualPixels, listEqual(benchmarkPixels));
});

test('Correctly encodes an opaque image in bgra8888', () async {
// A 2x2 testing image without transparency.
final Image sourceImage = await _encodeToHtmlThenDecode(
_pixelsToBytes(
<int>[0xFF0102FF, 0x04FE05FF, 0x0708FDFF, 0x0A0B0C00],
), 2, 2, pixelFormat: PixelFormat.bgra8888,
);
final Uint8List actualPixels = Uint8List.sublistView(
(await sourceImage.toByteData(format: ImageByteFormat.rawStraightRgba))!);
// The `benchmarkPixels` is the same as `sourceImage` except that the R and
// G channels are swapped and the fully transparent last pixel is turned 0.
final Uint8List benchmarkPixels = _pixelsToBytes(
<int>[0x0201FFFF, 0x05FE04FF, 0xFD0807FF, 0x00000000],
);
expect(actualPixels, listEqual(benchmarkPixels));
});

test('Correctly encodes a transparent image', () async {
// A 2x2 testing image with transparency.
final Image sourceImage = await _encodeToHtmlThenDecode(
_pixelsToBytes(
<int>[0xFF800006, 0xFF800080, 0xFF8000C0, 0xFF8000FF],
), 2, 2,
);
final Image blueBackground = await _encodeToHtmlThenDecode(
_pixelsToBytes(
<int>[0x0000FFFF, 0x0000FFFF, 0x0000FFFF, 0x0000FFFF],
), 2, 2,
);
// The standard way of testing the raw bytes of `sourceImage` is to draw
// the image onto a canvas and fetch its data (see HtmlImage.toByteData).
// But here, we draw an opaque background first before drawing the image,
// and test if the blended result is expected.
//
// This is because, if we only draw the `sourceImage`, the resulting pixels
// will be slightly off from the raw pixels. The reason is unknown, but
// very likely because the canvas.getImageData introduces rounding errors
// if any pixels are left semi-transparent, which might be caused by
// converting to and from pre-multiplied values. See
// https://github.com/flutter/flutter/issues/92958 .
final CanvasElement canvas = CanvasElement()
..width = 2
..height = 2;
final CanvasRenderingContext2D ctx = canvas.context2D;
ctx.drawImage((blueBackground as HtmlImage).imgElement, 0, 0);
ctx.drawImage((sourceImage as HtmlImage).imgElement, 0, 0);

final ImageData imageData = ctx.getImageData(0, 0, 2, 2);
final List<int> actualPixels = imageData.data;

final Uint8List benchmarkPixels = _pixelsToBytes(
<int>[0x0603F9FF, 0x80407FFF, 0xC0603FFF, 0xFF8000FF],
);
expect(actualPixels, listEqual(benchmarkPixels, tolerance: 1));
});
}

0 comments on commit 15d5a23

Please sign in to comment.