diff --git a/lib/src/decoders/cur_decoder.dart b/lib/src/decoders/cur_decoder.dart index 747bd09..bfc89c3 100644 --- a/lib/src/decoders/cur_decoder.dart +++ b/lib/src/decoders/cur_decoder.dart @@ -133,6 +133,26 @@ class CurDecoder { throw const FormatException("cursor pixel data bounds truncated"); } + // Classic "invert video" cursors encode their whole shape in the XOR image + // while the AND mask stays solid: Windows paints each non-empty XOR pixel + // by inverting the screen underneath, which keeps the cursor visible on + // any backdrop. Static raster cursors can't invert the desktop, so those + // pixels are approximated with opaque black. + var isInvertVideo = bpp <= 8; + if (isInvertVideo) { + outer: + for (var row = 0; row < height; row++) { + final srcRow = height - 1 - row; + final base = offset + andOffset + srcRow * andStride; + for (var x = 0; x < width; x++) { + if (((bytes[base + (x >> 3)] >> (7 - (x & 7))) & 1) == 0) { + isInvertVideo = false; + break outer; + } + } + } + } + final rgba = Uint8List(width * height * 4); for (var row = 0; row < height; row++) { final srcRow = height - 1 - row; // DIB rows are bottom-up @@ -142,15 +162,48 @@ class CurDecoder { for (var x = 0; x < width; x++) { final dest = (row * width + x) * 4; - final andByte = bytes[andBase + (x >> 3)]; - final isTransparent = ((andByte >> (7 - (x & 7))) & 1) != 0; + int? index; + if (bpp == 1) { + final byte = bytes[xorBase + (x >> 3)]; + index = (byte >> (7 - (x & 7))) & 1; + } else if (bpp == 4) { + final byte = bytes[xorBase + (x >> 1)]; + index = x.isEven ? (byte >> 4) & 0xF : byte & 0xF; + } else if (bpp == 8) { + index = bytes[xorBase + x]; + } - if (isTransparent) { - // Masked out: transparent. (RGBA array is zero-initialized by - // default.) + if (index != null) { + if (isInvertVideo) { + // The glyph is wherever the XOR image is non-empty; its pixels can + // only be approximated, so they are drawn as opaque black. + if (index != 0) rgba[dest + 3] = 255; + continue; + } + + final andByte = bytes[andBase + (x >> 3)]; + final andBit = ((andByte >> (7 - (x & 7))) & 1) != 0; + if (andBit) continue; // Masked out: transparent. + + final paletteIndex = index * 4; + // Gracefully handle malformed indices that overrun the provided + // palette. + if (paletteIndex + 3 < paletteCount * 4) { + final p = offset + paletteOffset + paletteIndex; + rgba[dest] = bytes[p + 2]; // R + rgba[dest + 1] = bytes[p + 1]; // G + rgba[dest + 2] = bytes[p]; // B + rgba[dest + 3] = 255; // A + } else { + rgba[dest + 3] = 255; // Draw fallback visible pixel (Black). + } continue; } + final andByte = bytes[andBase + (x >> 3)]; + final andBit = ((andByte >> (7 - (x & 7))) & 1) != 0; + if (andBit) continue; // Masked out: transparent. + if (bpp == 32) { final p = xorBase + x * 4; rgba[dest] = bytes[p + 2]; // R @@ -173,32 +226,7 @@ class CurDecoder { continue; } - int index; - if (bpp == 1) { - final byte = bytes[xorBase + (x >> 3)]; - index = (byte >> (7 - (x & 7))) & 1; - } else if (bpp == 4) { - final byte = bytes[xorBase + (x >> 1)]; - index = x.isEven ? (byte >> 4) & 0xF : byte & 0xF; - } else if (bpp == 8) { - index = bytes[xorBase + x]; - } else { - throw FormatException("unsupported bpp: $bpp"); - } - - final paletteIndex = index * 4; - - // Gracefully handle malformed indices that overrun the provided - // palette. - if (paletteIndex + 3 < paletteCount * 4) { - final p = offset + paletteOffset + paletteIndex; - rgba[dest] = bytes[p + 2]; // R - rgba[dest + 1] = bytes[p + 1]; // G - rgba[dest + 2] = bytes[p]; // B - rgba[dest + 3] = 255; // A - } else { - rgba[dest + 3] = 255; // Draw fallback visible pixel (Black). - } + throw FormatException("unsupported bpp: $bpp"); } } diff --git a/test/decoder_regression_test.dart b/test/decoder_regression_test.dart new file mode 100644 index 0000000..3c4b4f0 --- /dev/null +++ b/test/decoder_regression_test.dart @@ -0,0 +1,114 @@ +import "dart:io"; +import "dart:typed_data"; +import "dart:ui" as ui; + +import "package:custom_cursor/custom_cursor.dart"; +import "package:flutter_test/flutter_test.dart"; + +/// Regression coverage for the curated `broken-cursors` corpus. +/// +/// The remaining fixtures are classic monochrome Windows cursors whose shapes +/// were encoded as "screen invert" pixels (an all-set AND mask with the XOR +/// mask carving out the glyph). The decoder used to drop those pixels, +/// producing fully transparent (invisible) cursors; they become visible once +/// the invert pixels are rendered opaque. +void main() { + Uint8List bytesOf(String path) { + final file = File(path); + return file.existsSync() + ? file.readAsBytesSync() + : fail("missing fixture $path"); + } + + Future opaqueCount(ui.Image image) async { + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes = data!.buffer.asUint8List(); + var opaque = 0; + for (var i = 0; i < bytes.length; i += 4) { + if (bytes[i + 3] != 0) opaque++; + } + return opaque; + } + + Cursor firstFrame(CursorAsset asset) => switch (asset) { + StaticCursor(:final cursor) => cursor, + AnimatedCursorAsset(:final cursor) => cursor.frames.first, + }; + + group("monochrome screen-invert cursors decode visibly", () { + const fixtures = [ + "beam_l.cur", + "beam_m.cur", + "Beam.cur", + "cross_l.cur", + "cross_m.cur", + "cross-xp.cur", + "lcross.cur", + "libeam.cur", + "Cursor_4.cur", + "default_ibeam.cur", + ]; + for (final name in fixtures) { + test(name, () async { + final asset = await decodeCursorAsset( + bytesOf("broken-cursors/$name"), + options: const DecodeOptions(targetSize: 32), + ); + final cursor = firstFrame(asset); + expect(cursor.image.width, 32); + expect(cursor.image.height, 32); + expect(await opaqueCount(cursor.image), greaterThan(0)); + }); + } + }); + + test("Cursor_4 keeps its hotspot and renders a black beam", () async { + final asset = await decodeCursorAsset( + bytesOf("broken-cursors/Cursor_4.cur"), + ); + final cursor = firstFrame(asset); + expect(cursor.hotspotX, 10); + expect(cursor.hotspotY, 10); + + final data = await cursor.image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + final bytes = data!.buffer.asUint8List(); + final width = cursor.image.width; + expect(width, 32); + final height = cursor.image.height; + final opaquePixels = []; + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final p = (y * width + x) * 4; + if (bytes[p + 3] != 0) opaquePixels.add(p); + } + } + expect(opaquePixels, isNotEmpty); + expect( + opaquePixels.every( + (p) => bytes[p] == 0 && bytes[p + 1] == 0 && bytes[p + 2] == 0, + ), + isTrue, + ); + }); + + test("reference cursors still decode", () async { + for (final name in ["normal", "move", "resize"]) { + final asset = await decodeCursorAsset( + bytesOf("example/assets/cursors/$name.cur"), + ); + expect(await opaqueCount(firstFrame(asset).image), greaterThan(0)); + } + }); + + test("every remaining binary fixture decodes without errors", () async { + for (final file in Directory( + "broken-cursors", + ).listSync().whereType()) { + if (!file.path.endsWith(".cur")) continue; + final asset = await decodeCursorAsset(file.readAsBytesSync()); + expect(asset, isNotNull); + } + }); +}