From 512d0dd3495ee649bc90cf7bacaa4d265643c01e Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Mon, 8 Nov 2021 10:23:18 -0800 Subject: [PATCH] Revert "Revert "[Web] Fix BMP encoder (#29448)" (#29580)" This reverts commit 469d6f1a09f46eb77098b75b8ec435479bcf03b4. --- lib/web_ui/lib/src/ui/painting.dart | 99 ++++++++++-------- lib/web_ui/test/html/image_test.dart | 148 +++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 44 deletions(-) create mode 100644 lib/web_ui/test/html/image_test.dart diff --git a/lib/web_ui/lib/src/ui/painting.dart b/lib/web_ui/lib/src/ui/painting.dart index 910f092a2b50b..8082670085a95 100644 --- a/lib/web_ui/lib/src/ui/painting.dart +++ b/lib/web_ui/lib/src/ui/painting.dart @@ -500,6 +500,12 @@ Future _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 _createBmp( Uint8List pixels, int width, @@ -507,64 +513,70 @@ Future _createBmp( 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; } } @@ -803,4 +815,3 @@ class FragmentProgram { required Float32List floatUniforms, }) => throw UnsupportedError('FragmentProgram is not supported for the CanvasKit or HTML renderers.'); } - diff --git a/lib/web_ui/test/html/image_test.dart b/lib/web_ui/test/html/image_test.dart new file mode 100644 index 0000000000000..6b6ec48bd312c --- /dev/null +++ b/lib/web_ui/test/html/image_test.dart @@ -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 = bool Function(List); +_ListPredicate deepEqualList(List a) { + return (List 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 source, {int tolerance = 0}) { + return predicate( + (List 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 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 _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 testMain() async { + test('Correctly encodes an opaque image', () async { + // A 2x2 testing image without transparency. + final Image sourceImage = await _encodeToHtmlThenDecode( + _pixelsToBytes( + [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( + [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( + [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( + [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( + [0xFF800006, 0xFF800080, 0xFF8000C0, 0xFF8000FF], + ), 2, 2, + ); + final Image blueBackground = await _encodeToHtmlThenDecode( + _pixelsToBytes( + [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 actualPixels = imageData.data; + + final Uint8List benchmarkPixels = _pixelsToBytes( + [0x0603F9FF, 0x80407FFF, 0xC0603FFF, 0xFF8000FF], + ); + expect(actualPixels, listEqual(benchmarkPixels, tolerance: 1)); + }); +}