diff --git a/CHANGELOG.md b/CHANGELOG.md index fee4e65ca..12a5cf3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ The release run heads these entries with the version and opens a fresh - A spreadsheet decodes in less memory: 626 MB peak instead of 914 MB on a 297 MB `content.xml`. Rendered output is unchanged. +- A StarView metafile draws its bitmaps (#194): `BMP`, `BMPEX` and their + scaling and part variants, transparency mask included. Such a chart used to + render as an empty frame. + - A text action in a StarView metafile draws the run it names, not the whole string. Text drawn in runs used to overprint itself into a smear. diff --git a/CMakeLists.txt b/CMakeLists.txt index 81d879491..906e17f59 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -289,6 +289,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/util/number_util.cpp" "src/odr/internal/util/odr_meta_util.cpp" "src/odr/internal/util/stream_util.cpp" + "src/odr/internal/util/png_util.cpp" "src/odr/internal/util/string_util.cpp" "src/odr/internal/util/xml_util.cpp" diff --git a/src/odr/internal/pdf/pdf_image.cpp b/src/odr/internal/pdf/pdf_image.cpp index 2cc123dac..c0eb4e0f0 100644 --- a/src/odr/internal/pdf/pdf_image.cpp +++ b/src/odr/internal/pdf/pdf_image.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -18,17 +19,6 @@ namespace odr::internal::pdf { namespace { -/// Append a PNG chunk: length, four-byte type, data, CRC over type+data. -void write_chunk(std::string &out, const std::string_view type, - const std::string &data) { - util::byte_string::put_u32_be(out, static_cast(data.size())); - const std::size_t crc_start = out.size(); - out.append(type); - out.append(data); - util::byte_string::put_u32_be( - out, crypto::util::crc32(std::string_view(out).substr(crc_start))); -} - /// Reads fixed-width big-endian sample values out of a byte buffer, MSB first. /// Constructed at a row offset; rows are byte-aligned (8.9.5.2). Reads past the /// end yield zero (lenient for a truncated stream). @@ -144,48 +134,6 @@ encode_jpx(const std::string &data, const ColorSpaceDef *color_space, namespace odr::internal { -std::string pdf::write_png(const std::string &pixels, const std::int32_t width, - const std::int32_t height, - const std::int32_t channels) { - if (width <= 0 || height <= 0 || (channels != 3 && channels != 4)) { - return {}; - } - const auto stride = - static_cast(width) * static_cast(channels); - if (pixels.size() < stride * static_cast(height)) { - return {}; - } - - // Filter type 0 (None) prefixes each scanline (PNG 9.2); the rows are then - // deflated as one zlib stream into the single IDAT. - std::string raw; - raw.reserve((stride + 1) * static_cast(height)); - for (std::int32_t y = 0; y < height; ++y) { - raw.push_back(0); - raw.append(pixels, static_cast(y) * stride, stride); - } - - static constexpr std::array signature = { - static_cast(0x89), 'P', 'N', 'G', '\r', '\n', - static_cast(0x1A), '\n'}; - std::string out; - out.append(signature.data(), signature.size()); - - std::string ihdr; - util::byte_string::put_u32_be(ihdr, static_cast(width)); - util::byte_string::put_u32_be(ihdr, static_cast(height)); - ihdr.push_back(8); // bit depth - // Colour type: 2 = truecolour (RGB), 6 = truecolour with alpha (RGBA). - ihdr.push_back(channels == 4 ? 6 : 2); - ihdr.push_back(0); // compression: deflate - ihdr.push_back(0); // filter method: adaptive - ihdr.push_back(0); // interlace: none - write_chunk(out, "IHDR", ihdr); - write_chunk(out, "IDAT", crypto::util::zlib_deflate(raw)); - write_chunk(out, "IEND", ""); - return out; -} - std::string pdf::encode_image_png(const std::string &samples, const std::int32_t width, const std::int32_t height, @@ -270,7 +218,7 @@ std::string pdf::encode_image_png(const std::string &samples, } } - return write_png(out, width, height, has_alpha ? 4 : 3); + return util::png::write(out, width, height, has_alpha ? 4 : 3); } std::vector pdf::decode_mask_alpha( @@ -363,7 +311,7 @@ std::string pdf::encode_stencil_png(const std::string &samples, rgba[out_index++] = static_cast(paint ? 0xFF : 0x00); } } - return write_png(rgba, width, height, 4); + return util::png::write(rgba, width, height, 4); } std::optional pdf::encode_image( diff --git a/src/odr/internal/pdf/pdf_image.hpp b/src/odr/internal/pdf/pdf_image.hpp index 6f007b416..6b0a6704d 100644 --- a/src/odr/internal/pdf/pdf_image.hpp +++ b/src/odr/internal/pdf/pdf_image.hpp @@ -66,11 +66,6 @@ decode_mask_alpha(const std::string &samples, std::int32_t width, std::span decode, bool stencil, std::int32_t base_width, std::int32_t base_height); -/// Wrap 8-bit pixels (row-major, unpadded) into a PNG: single `IDAT`, no -/// interlacing. `channels` is 3 (RGB) or 4 (RGBA); anything else yields "". -std::string write_png(const std::string &pixels, std::int32_t width, - std::int32_t height, std::int32_t channels); - /// Paint a 1-bpc stencil mask (ISO 32000-1 8.9.6.2) into an RGBA PNG: a sample /// decoding to 0 paints `color` (sRGB in [0, 1]) opaquely, a 1 is transparent, /// and a `/Decode` of `[1 0]` swaps that. `color` is the fill colour at draw diff --git a/src/odr/internal/svm/AGENTS.md b/src/odr/internal/svm/AGENTS.md index d676252a5..e9cef0e02 100644 --- a/src/odr/internal/svm/AGENTS.md +++ b/src/odr/internal/svm/AGENTS.md @@ -66,6 +66,20 @@ The format is not specified anywhere. The references, best first: file's own bytes for every encoding but `UCS2`, so a latin-1 label emits invalid utf-8 and the parser refuses the document all the same. +## Bitmaps do not go through a decoder + +A dib in a metafile carries its own `BITMAPFILEHEADER`, so it *is* a `.bmp` +file — no pixel decoding is needed to show one, only its length, which the +header's `biSizeImage` gives and whose `bfSize` does not (that one is written +from the uncompressed size). Where the pixels are plain enough to copy out row +by row they are re-packed as a png, which for a chart is fifty times smaller; +anything compressed goes out as the bmp it is. + +A `BMPEX` may carry a transparency mask: a second dib, white where the bitmap +does *not* show. An svg `` keeps what is white, so the mask image goes +through an inverting `feColorMatrix` — `filter="url(#odr-invert)"`, written +once per document. + ## Testing `svm_test.cpp` builds its input as bytes inline through `SvmBuilder`, so an diff --git a/src/odr/internal/svm/PLAN.md b/src/odr/internal/svm/PLAN.md index 07b9c5f02..a93372ec5 100644 --- a/src/odr/internal/svm/PLAN.md +++ b/src/odr/internal/svm/PLAN.md @@ -29,7 +29,9 @@ Each stage is one pull request, stacked on the one before it. which costs the image exactly as an unescaped `&` did. 4. **Clipping.** `CLIPREGION`, `ISECTRECTCLIPREGION`, `ISECTREGIONCLIPREGION`, `MOVECLIPREGION`. -5. **Bitmaps** (#194). See the shortcut below. +5. **Bitmaps** (#194) - done for the `BMP` and `BMPEX` families; `MASK`, + which stencils one colour through a bitmap, and `ZCOMPRESS`, which needs + inflating first, are still open. See the shortcut below. 6. **Primitives.** `PIXEL`, `POINT`, `LINE`, `ROUNDRECT`, `ELLIPSE`, `ARC`, `PIE`, `CHORD` — one `svgwriter.cxx` case each. 7. **Fills and transparency.** `GRADIENT`, `GRADIENTEX`, `HATCH`, @@ -97,16 +99,24 @@ are worth writing down: ## Shortcuts worth taking -- **Bitmaps are `.bmp` files already.** `SvmReader` reads them with - `ReadDIB(…, bFileHeader=true)`, i.e. the action body holds a DIB *with* its - `BITMAPFILEHEADER` — `"BM"`, `bfSize`, `bfOffBits`. So a `BMP` action needs - no pixel decoding at all: read the header far enough to know the byte length, - hand the bytes to the browser as `data:image/bmp;base64,…` inside an - ``. Palettes, RLE4/RLE8 and bit fields are then the browser's problem, - not ours. Two cases still need work: `ZCOMPRESS` (a LibreOffice-only - compression — inflate with miniz, then rewrite the header), and the alpha - mask of `BMPEX` (a second DIB, `1` = transparent) which becomes an SVG - `` over an inverting `feColorMatrix`. +- **Bitmaps are `.bmp` files already** — this one is taken. `SvmReader` reads + them with `ReadDIB(…, bFileHeader=true)`, i.e. the action body holds a dib + *with* its `BITMAPFILEHEADER`, so the bytes are a `.bmp` file and the browser + can read them as one. Only the length has to be worked out, and `bfSize` is + no help: it is written from the *uncompressed* size, so for a compressed dib + it lies. The header's own `biSizeImage` (or width, height and bit count) is + the answer. + + The bytes then go out as a png rather than a bmp wherever the pixels can be + copied row by row — uncompressed, 1/4/8/24/32 bits, palette expanded. That is + not decoding so much as re-packing, and it is worth it: the one real bitmap + in the corpus is 1.59 MB as a bmp and 33 KB as a png, so the page it sits on + goes from 2.9 MB to 158 KB. A compressed dib (RLE, bit fields) still goes out + as the bmp it is; browsers read those. + + What is left: `ZCOMPRESS`, a LibreOffice-only compression whose zlib stream + would have to be inflated (miniz is already a dependency) before any of the + above, and the `MASK` family, which stencils one colour through a bitmap. - **Béziers are cheap once the flags are read.** A polygon flag of `PolyFlags::Control` marks a control point, so a flagged polygon maps onto an SVG path's `C` segments directly. The reader is the part that is missing. diff --git a/src/odr/internal/svm/svm_format.cpp b/src/odr/internal/svm/svm_format.cpp index b0d9390d1..f0e2fe907 100644 --- a/src/odr/internal/svm/svm_format.cpp +++ b/src/odr/internal/svm/svm_format.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -22,6 +23,117 @@ std::string read_bytes(std::istream &in, const std::uint64_t size) { } } +/// A `.bmp` starts with `"BM"`; `"BA"`, an os/2 bitmap array, does not. +constexpr std::uint16_t bmp_magic = 0x4d42; +constexpr std::uint32_t bmp_file_header_size = 14; +constexpr std::uint32_t dib_core_header_size = 12; +/// `ZCOMPRESS` - LibreOffice's own, a zlib stream where the pixels go. +constexpr std::uint32_t dib_zcompress = ('S' | ('D' << 8)) | 0x01000000; +/// `ReadDIBBitmapEx`: what marks the transparency data behind a dib. +constexpr std::uint32_t bitmap_ex_magic_1 = 0x25091962; +constexpr std::uint32_t bitmap_ex_magic_2 = 0xacb20201; +/// `TransparentType::Bitmap`, the only one that carries a second dib. +constexpr std::uint8_t bitmap_ex_mask = 2; + +std::uint16_t read_u16(const std::string &bytes, const std::size_t offset) { + std::uint16_t result{}; + std::memcpy(&result, bytes.data() + offset, sizeof(result)); + return result; +} + +std::uint32_t read_u32(const std::string &bytes, const std::size_t offset) { + std::uint32_t result{}; + std::memcpy(&result, bytes.data() + offset, sizeof(result)); + return result; +} + +/// As much of a dib's header as unpacking its pixels needs. +struct DibLayout final { + std::uint32_t off_bits{}; + std::uint32_t header_size{}; + std::int32_t width{}; + /// As the header states it: negative for a top-down dib. + std::int32_t height{}; + std::uint32_t bit_count{}; + std::uint32_t compression{}; +}; + +/// The dib's pixels as 8-bit rgb rows, top to bottom - a png's layout. Empty +/// where the dib is compressed, bit-fielded, or 16 bits a pixel. +std::string get_rgb_rows(const std::string &bmp, const DibLayout &layout) { + if (layout.compression != 0 || layout.width <= 0 || layout.height == 0) { + return {}; + } + if (layout.bit_count != 32 && layout.bit_count != 24 && + layout.bit_count != 8 && layout.bit_count != 4 && layout.bit_count != 1) { + return {}; + } + + const auto width = static_cast(layout.width); + const auto height = static_cast(std::abs(layout.height)); + const std::size_t stride = ((width * layout.bit_count + 31) / 32) * 4; + if (bmp.size() < layout.off_bits + stride * height) { + return {}; + } + + // a palette sits between the header and the pixels, three bytes an entry in + // the core header's dib and four in every later one + const std::size_t entry_size = + layout.header_size == dib_core_header_size ? 3 : sizeof(std::uint32_t); + const std::size_t palette_offset = bmp_file_header_size + layout.header_size; + const std::size_t palette_entries = + layout.off_bits > palette_offset + ? (layout.off_bits - palette_offset) / entry_size + : 0; + if (layout.bit_count <= 8 && palette_entries == 0) { + return {}; + } + + std::string rows(width * height * 3, '\0'); + for (std::size_t y = 0; y < height; ++y) { + // rows run bottom-up unless the height says otherwise + const std::size_t source = layout.height < 0 ? y : height - 1 - y; + const char *pixels = bmp.data() + layout.off_bits + source * stride; + char *target = rows.data() + y * width * 3; + + for (std::size_t x = 0; x < width; ++x) { + const auto *bgr = reinterpret_cast(pixels); + std::size_t index = 0; + + switch (layout.bit_count) { + case 32: + case 24: { + const std::size_t at = x * (layout.bit_count / 8); + target[x * 3 + 0] = static_cast(bgr[at + 2]); + target[x * 3 + 1] = static_cast(bgr[at + 1]); + target[x * 3 + 2] = static_cast(bgr[at + 0]); + continue; + } + case 8: + index = bgr[x]; + break; + case 4: + index = (bgr[x / 2] >> (x % 2 == 0 ? 4 : 0)) & 0x0f; + break; + default: + index = (bgr[x / 8] >> (7 - x % 8)) & 0x01; + break; + } + + if (index >= palette_entries) { + return {}; + } + const auto *entry = reinterpret_cast( + bmp.data() + palette_offset + index * entry_size); + target[x * 3 + 0] = static_cast(entry[2]); + target[x * 3 + 1] = static_cast(entry[1]); + target[x * 3 + 2] = static_cast(entry[0]); + } + } + + return rows; +} + /// `DrawText(…, index, len)`: a text action names the run of its string that /// it draws, and a length past the end means the rest of it. template @@ -539,6 +651,132 @@ svm::read_text_rectangle_action(std::istream &in, const VersionLength &vl, return result; } +svm::Image svm::read_dib(std::istream &in, const std::uint32_t limit) { + Image result; + DibLayout layout; + + std::string bytes = read_bytes(in, bmp_file_header_size); + if (read_u16(bytes, 0) != bmp_magic) { + throw MalformedSvmFile(); + } + layout.off_bits = read_u32(bytes, 10); + + bytes += read_bytes(in, sizeof(std::uint32_t)); + layout.header_size = read_u32(bytes, bmp_file_header_size); + if (layout.header_size < dib_core_header_size || layout.header_size > limit) { + throw MalformedSvmFile(); + } + bytes += read_bytes(in, layout.header_size - sizeof(std::uint32_t)); + + std::uint32_t size_image{}; + if (layout.header_size == dib_core_header_size) { + layout.width = read_u16(bytes, 18); + layout.height = read_u16(bytes, 20); + layout.bit_count = read_u16(bytes, 24); + } else { + layout.width = static_cast(read_u32(bytes, 18)); + layout.height = static_cast(read_u32(bytes, 22)); + layout.bit_count = read_u16(bytes, 28); + layout.compression = read_u32(bytes, 30); + size_image = read_u32(bytes, 34); + } + // a negative height is a top-down dib; it is still that many rows + result.size_pixel = {layout.width, std::abs(layout.height)}; + + if (layout.compression == dib_zcompress) { + // the palette and the pixels are inside the stream, so nothing but its + // length can be read without inflating it + const std::string prefix = read_bytes(in, 3 * sizeof(std::uint32_t)); + read_bytes(in, read_u32(prefix, 0)); + return result; + } + + if (layout.off_bits < bytes.size() || layout.off_bits > limit) { + throw MalformedSvmFile(); + } + // the palette, and any gap the writer left before the pixels + bytes += read_bytes(in, layout.off_bits - bytes.size()); + + // `bfSize` is written from the uncompressed size, so it says nothing about a + // compressed dib; the header's own numbers do + const auto stride = + ((static_cast(result.size_pixel.x) * layout.bit_count + + 31) / + 32) * + 4; + const std::uint64_t pixels = layout.compression == 0 + ? stride * result.size_pixel.y + : static_cast(size_image); + if (pixels == 0 || pixels > limit) { + throw MalformedSvmFile(); + } + bytes += read_bytes(in, pixels); + + if (const std::string rows = get_rgb_rows(bytes, layout); !rows.empty()) { + result.data = + util::png::write(rows, result.size_pixel.x, result.size_pixel.y, 3); + result.mime_type = "image/png"; + } + if (result.data.empty()) { + result.data = std::move(bytes); + result.mime_type = "image/bmp"; + } + return result; +} + +svm::Bitmap svm::read_dib_bitmap_ex(std::istream &in, + const std::uint32_t limit) { + Bitmap result; + result.image = read_dib(in, limit); + + // the transparency data is optional, so what is not it is put back + const std::istream::pos_type position = in.tellg(); + std::uint32_t magic_1{}; + std::uint32_t magic_2{}; + read_primitive(in, magic_1); + read_primitive(in, magic_2); + if (magic_1 != bitmap_ex_magic_1 || magic_2 != bitmap_ex_magic_2) { + in.seekg(position); + return result; + } + + std::uint8_t type{}; + read_primitive(in, type); + if (type == bitmap_ex_mask) { + result.mask = read_dib(in, limit); + } + + return result; +} + +svm::BitmapAction svm::read_bitmap_action(std::istream &in, + const std::uint16_t type, + const VersionLength &vl) { + BitmapAction result; + + const bool with_mask = type == META_BMPEX_ACTION || + type == META_BMPEXSCALE_ACTION || + type == META_BMPEXSCALEPART_ACTION; + if (with_mask) { + result.bitmap = read_dib_bitmap_ex(in, vl.length); + } else { + result.bitmap.image = read_dib(in, vl.length); + } + + result.point = read_int_pair(in); + + if (type == META_BMPSCALE_ACTION || type == META_BMPEXSCALE_ACTION) { + result.size = read_int_pair(in); + } else if (type == META_BMPSCALEPART_ACTION || + type == META_BMPEXSCALEPART_ACTION) { + result.size = read_int_pair(in); + result.source_point = read_int_pair(in); + result.source_size = read_int_pair(in); + } + + return result; +} + std::uint16_t svm::read_text_align_action(std::istream &in) { std::uint16_t result; read_primitive(in, result); diff --git a/src/odr/internal/svm/svm_format.hpp b/src/odr/internal/svm/svm_format.hpp index 6676dd2c3..f11f5cbbb 100644 --- a/src/odr/internal/svm/svm_format.hpp +++ b/src/odr/internal/svm/svm_format.hpp @@ -267,6 +267,35 @@ struct TextRectangleAction final { std::uint16_t style{}; }; +/// A dib as something a browser reads. A metafile stores a dib *with* its +/// `BITMAPFILEHEADER`, so its bytes already are a `.bmp` file; they are only +/// unpacked where a png would be smaller, which for a chart is by fifty. +struct Image final { + /// Empty where the dib is one we could not hand on, its bytes read anyway. + std::string data; + std::string mime_type; + IntPair size_pixel; +}; + +struct Bitmap final { + Image image; + /// The transparency mask, whose *white* is where @ref image does not show. + /// Empty where the action carried none. + Image mask; +}; + +/// The `BMP` family. @ref size is the logical size to draw at, empty for the +/// actions that draw at the bitmap's own; @ref source_point and +/// @ref source_size name the part to draw, in pixels, and are empty for the +/// actions that draw all of it. +struct BitmapAction final { + Bitmap bitmap; + IntPair point; + IntPair size; + IntPair source_point; + IntPair source_size; +}; + struct TextLineAction final { IntPair position; std::int32_t width{}; @@ -325,5 +354,15 @@ TextLineAction read_text_line_action(std::istream &in, const VersionLength &vl); std::uint16_t read_push_action(std::istream &in, const VersionLength &vl); /// The `TextAlign` of a `TEXTALIGN`. std::uint16_t read_text_align_action(std::istream &in); +/// A dib with its file header, as `ReadDIB(…, bFileHeader=true)` reads one. +/// @p limit is what the enclosing action declared, so a length field cannot +/// ask for more than the file holds. +Image read_dib(std::istream &in, std::uint32_t limit); +/// @ref read_dib plus the optional transparency mask behind it, as +/// `ReadDIBBitmapEx` reads one. +Bitmap read_dib_bitmap_ex(std::istream &in, std::uint32_t limit); +/// One of the `BMP` family, @p type saying which. +BitmapAction read_bitmap_action(std::istream &in, std::uint16_t type, + const VersionLength &vl); } // namespace odr::internal::svm diff --git a/src/odr/internal/svm/svm_to_svg.cpp b/src/odr/internal/svm/svm_to_svg.cpp index 4d82eb217..47a788fe3 100644 --- a/src/odr/internal/svm/svm_to_svg.cpp +++ b/src/odr/internal/svm/svm_to_svg.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -56,6 +57,10 @@ struct Context final { GraphicsState state; std::vector stack; + + /// Names the masks and clip paths apart. + std::uint32_t element_count{}; + bool inverter_written{}; }; double scale(const IntPair fraction) { @@ -337,6 +342,140 @@ std::string get_x_list_string(const IntPair &point, /// @p dx_array places the characters one by one where the file measured them; /// @p width, from a stretch text, is the advance the whole run has to fill. +/// Nothing in the file says how big a pixel is; this is what +/// `MapUnit::MapPixel` would resolve against a device. +constexpr double assumed_dpi = 96; +/// The unit the header of every metafile in the wild uses. +constexpr double hundredth_mm_per_inch = 2540; + +/// A metafile's mask is white where the bitmap does not show; an svg mask +/// keeps what is white. +void write_inverter(const Context &context) { + svg::SvgWriter &out = *context.out; + + out.write_element_begin("filter"); + out.write_attribute("id", "odr-invert"); + out.write_attribute("color-interpolation-filters", "sRGB"); + out.write_element_begin("feColorMatrix"); + out.write_attribute("type", "matrix"); + out.write_attribute("values", "-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0"); + out.write_element_end(); + out.write_element_end(); +} + +/// In the drawing's own coordinates. +struct BitmapBox final { + double x{}; + double y{}; + double width{}; + double height{}; +}; + +/// The box the whole bitmap covers - for a part action, bigger than what is +/// drawn: the source rectangle scales onto the destination and the rest is +/// clipped. +BitmapBox get_bitmap_box(const BitmapAction &action, const Context &context) { + const IntPair &size_pixel = action.bitmap.image.size_pixel; + + IntPair size = action.size; + if (size.x == 0 || size.y == 0) { + // the pixel count multiplies before the division truncates: `2540 / 96` + // is 26.458, and rounding that off per pixel loses 1.7% of the size + const auto scale_pixel = [](const std::int32_t pixels) { + return static_cast(pixels * hundredth_mm_per_inch / + assumed_dpi); + }; + size = {scale_pixel(size_pixel.x), scale_pixel(size_pixel.y)}; + } + + BitmapBox box{transform_x(action.point.x, context), + transform_y(action.point.y, context), + transform_width(size.x, context), + transform_height(size.y, context)}; + + if (action.source_size.x == 0 || action.source_size.y == 0) { + return box; + } + + // the source rectangle, in pixels, is what fills the box, so the bitmap + // around it is drawn at the same scale and clipped off + const double scale_x = box.width / action.source_size.x; + const double scale_y = box.height / action.source_size.y; + return {box.x - action.source_point.x * scale_x, + box.y - action.source_point.y * scale_y, size_pixel.x * scale_x, + size_pixel.y * scale_y}; +} + +/// Opens the `` and leaves it open: the caller adds the attributes +/// that are its own and ends it. +void write_bitmap_image(const Image &image, const BitmapBox &box, + const Context &context) { + svg::SvgWriter &out = *context.out; + + out.write_element_begin("image"); + out.write_attribute("x", box.x); + out.write_attribute("y", box.y); + out.write_attribute("width", box.width); + out.write_attribute("height", box.height); + // the box is where the file puts it, aspect ratio included + out.write_attribute("preserveAspectRatio", "none"); + out.write_attribute("href", "data:" + image.mime_type + ";base64," + + crypto::util::base64_encode(image.data)); +} + +void write_bitmap(const BitmapAction &action, Context &context) { + svg::SvgWriter &out = *context.out; + + if (action.bitmap.image.data.empty()) { + ODR_WARNING(*context.logger, "a bitmap we cannot read, drawing nothing"); + return; + } + + const BitmapBox box = get_bitmap_box(action, context); + std::string mask_id; + + if (!action.bitmap.mask.data.empty()) { + if (!context.inverter_written) { + write_inverter(context); + context.inverter_written = true; + } + mask_id = "odr-mask-" + std::to_string(++context.element_count); + + out.write_element_begin("mask"); + out.write_attribute("id", mask_id); + out.write_attribute("maskUnits", "userSpaceOnUse"); + write_bitmap_image(action.bitmap.mask, box, context); + out.write_attribute("filter", "url(#odr-invert)"); + out.write_element_end(); + out.write_element_end(); + } + + const bool clipped = action.source_size.x != 0 && action.source_size.y != 0; + if (clipped) { + const std::string clip_id = + "odr-clip-" + std::to_string(++context.element_count); + out.write_element_begin("clipPath"); + out.write_attribute("id", clip_id); + out.write_element_begin("rect"); + out.write_attribute("x", transform_x(action.point.x, context)); + out.write_attribute("y", transform_y(action.point.y, context)); + out.write_attribute("width", transform_width(action.size.x, context)); + out.write_attribute("height", transform_height(action.size.y, context)); + out.write_element_end(); + out.write_element_end(); + + write_bitmap_image(action.bitmap.image, box, context); + out.write_attribute("clip-path", "url(#" + clip_id + ")"); + } else { + write_bitmap_image(action.bitmap.image, box, context); + } + + if (!mask_id.empty()) { + out.write_attribute("mask", "url(#" + mask_id + ")"); + } + out.write_element_end(); +} + void write_text(const IntPair &point, const std::string &text, const std::vector &dx_array, const std::uint32_t width, const Context &context) { @@ -482,6 +621,16 @@ void translate_action(const ActionHeader &action_header, std::istream &in, const auto [polygons] = read_poly_polygon_action(in, action_header.vl); write_path(polygons, true, nullptr, context); } break; + case META_BMP_ACTION: + case META_BMPSCALE_ACTION: + case META_BMPSCALEPART_ACTION: + case META_BMPEX_ACTION: + case META_BMPEXSCALE_ACTION: + case META_BMPEXSCALEPART_ACTION: { + const BitmapAction action = + read_bitmap_action(in, action_header.type, action_header.vl); + write_bitmap(action, context); + } break; case META_TEXTALIGN_ACTION: state.text_align = read_text_align_action(in); break; diff --git a/src/odr/internal/util/png_util.cpp b/src/odr/internal/util/png_util.cpp new file mode 100644 index 000000000..39dfd44fb --- /dev/null +++ b/src/odr/internal/util/png_util.cpp @@ -0,0 +1,69 @@ +#include + +#include +#include + +#include +#include + +namespace odr::internal { + +namespace { + +/// Appends a png chunk: length, four-byte type, data, crc over type+data. +void write_chunk(std::string &out, const std::string_view type, + const std::string &data) { + util::byte_string::put_u32_be(out, static_cast(data.size())); + const std::size_t crc_start = out.size(); + out.append(type); + out.append(data); + util::byte_string::put_u32_be( + out, crypto::util::crc32(std::string_view(out).substr(crc_start))); +} + +} // namespace + +std::string util::png::write(const std::string &pixels, + const std::int32_t width, + const std::int32_t height, + const std::int32_t channels) { + if (width <= 0 || height <= 0 || (channels != 3 && channels != 4)) { + return {}; + } + const auto stride = + static_cast(width) * static_cast(channels); + if (pixels.size() < stride * static_cast(height)) { + return {}; + } + + // Filter type 0 (None) prefixes each scanline (PNG 9.2); the rows are then + // deflated as one zlib stream into the single IDAT. + std::string raw; + raw.reserve((stride + 1) * static_cast(height)); + for (std::int32_t y = 0; y < height; ++y) { + raw.push_back(0); + raw.append(pixels, static_cast(y) * stride, stride); + } + + static constexpr std::array signature = { + static_cast(0x89), 'P', 'N', 'G', '\r', '\n', + static_cast(0x1A), '\n'}; + std::string out; + out.append(signature.data(), signature.size()); + + std::string ihdr; + util::byte_string::put_u32_be(ihdr, static_cast(width)); + util::byte_string::put_u32_be(ihdr, static_cast(height)); + ihdr.push_back(8); // bit depth + // colour type: 2 = truecolour (rgb), 6 = truecolour with alpha (rgba) + ihdr.push_back(channels == 4 ? 6 : 2); + ihdr.push_back(0); // compression: deflate + ihdr.push_back(0); // filter method: adaptive + ihdr.push_back(0); // interlace: none + write_chunk(out, "IHDR", ihdr); + write_chunk(out, "IDAT", crypto::util::zlib_deflate(raw)); + write_chunk(out, "IEND", ""); + return out; +} + +} // namespace odr::internal diff --git a/src/odr/internal/util/png_util.hpp b/src/odr/internal/util/png_util.hpp new file mode 100644 index 000000000..9d1735de8 --- /dev/null +++ b/src/odr/internal/util/png_util.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace odr::internal::util::png { + +/// Wraps 8-bit pixels (row-major, unpadded, top to bottom) into a png: one +/// `IDAT`, no interlacing, every scanline filtered as `None` (PNG 9.2). +/// @p channels is 3 (rgb) or 4 (rgba); anything else yields `""`. +std::string write(const std::string &pixels, std::int32_t width, + std::int32_t height, std::int32_t channels); + +} // namespace odr::internal::util::png diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9fac57a22..a2ab2e65c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -124,6 +124,7 @@ add_executable(odr_test "src/internal/util/map_util_test.cpp" "src/internal/util/number_util_test.cpp" "src/internal/util/stream_util_test.cpp" + "src/internal/util/png_util_test.cpp" "src/internal/util/string_util_test.cpp" "src/internal/util/xml_util_test.cpp" diff --git a/test/data.cmake b/test/data.cmake index d332ccfab..4bc978b8e 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -22,4 +22,4 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "8acbfdb5abb092e97bfe492281831c96f7dcc303") + REVISION "898f1d823afdca5008ebb4a6579e0d4d9e412554") diff --git a/test/src/internal/pdf/pdf_image.cpp b/test/src/internal/pdf/pdf_image.cpp index 26b42379d..7b82e7122 100644 --- a/test/src/internal/pdf/pdf_image.cpp +++ b/test/src/internal/pdf/pdf_image.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -146,21 +147,6 @@ std::string bytes(std::initializer_list values) { } // namespace -TEST(PdfImage, write_png_rgb_round_trip) { - // 2x2: red, green / blue, white. - const std::string rgb = - bytes({255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255}); - const DecodedPng png = decode_png(write_png(rgb, 2, 2, 3)); - EXPECT_EQ(png.width, 2); - EXPECT_EQ(png.height, 2); - EXPECT_EQ(png.rgb, rgb); -} - -TEST(PdfImage, write_png_rgb_rejects_short_buffer) { - EXPECT_TRUE(write_png(bytes({255, 0, 0}), 2, 2, 3).empty()); - EXPECT_TRUE(write_png("", 0, 0, 3).empty()); -} - TEST(PdfImage, encode_rgb_8bpc) { const std::string samples = bytes({10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120}); @@ -243,10 +229,11 @@ TEST(PdfImage, encode_rejects_bad_parameters) { EXPECT_TRUE(encode_image_png("", 1, 1, 8, zero, {}).empty()); } -TEST(PdfImage, write_png_rgba_round_trip) { +TEST(PdfImage, png_rgba_round_trip) { // 2x1: opaque red, half-transparent green. const std::string rgba = bytes({255, 0, 0, 255, 0, 255, 0, 128}); - const DecodedPngRgba png = decode_png_rgba(write_png(rgba, 2, 1, 4)); + const DecodedPngRgba png = + decode_png_rgba(odr::internal::util::png::write(rgba, 2, 1, 4)); EXPECT_EQ(png.width, 2); EXPECT_EQ(png.height, 1); EXPECT_EQ(png.rgba, rgba); diff --git a/test/src/internal/svm/svm_test.cpp b/test/src/internal/svm/svm_test.cpp index 46a6e1666..da0bd208f 100644 --- a/test/src/internal/svm/svm_test.cpp +++ b/test/src/internal/svm/svm_test.cpp @@ -110,6 +110,39 @@ class SvmBuilder final { .end(); } + /// A 24-bit uncompressed dib with the `BITMAPFILEHEADER` a metafile stores + /// it behind. @p pixels is @p width * @p height bgr triples, top row first; + /// the rows go out bottom-up and padded, as a dib holds them. + SvmBuilder &dib(const std::int32_t width, const std::int32_t height, + const std::string &pixels) { + const std::size_t stride = ((width * 24 + 31) / 32) * 4; + const auto image_size = static_cast(stride * height); + + u16(0x4d42); // "BM" + u32(54 + image_size) // bfSize + .u16(0) // reserved + .u16(0) // reserved + .u32(54); // bfOffBits + u32(40) // header size + .i32(width) + .i32(height) + .u16(1) // planes + .u16(24) // bit count + .u32(0) // compression + .u32(image_size) + .i32(0) // pixels per metre x + .i32(0) // pixels per metre y + .u32(0) // colours used + .u32(0); + for (std::int32_t y = height - 1; y >= 0; --y) { + const std::string row = + pixels.substr(static_cast(y) * width * 3, width * 3); + m_data += row; + m_data.append(stride - row.size(), '\0'); + } + return *this; + } + /// A pascal string, as `read_uint16_prefixed_ascii_string` reads it. SvmBuilder &ascii_string(const std::string &value) { u16(static_cast(value.size())); @@ -529,3 +562,60 @@ TEST(SvmToSvg, text_draws_the_run_it_names) { EXPECT_NE(std::string::npos, svg.find(">cde")); } + +/// A metafile stores a dib behind its file header, so the bytes already are a +/// bmp - and where the pixels can simply be copied out, a png instead, which +/// is what keeps a chart from costing megabytes. +TEST(SvmToSvg, a_bitmap_is_drawn_where_the_action_puts_it) { + const std::string white_black_red_blue("\xff\xff\xff" + "\x00\x00\x00" + "\x00\x00\xff" + "\xff\x00\x00", + 12); + const std::string svg = translate(SvmBuilder() + .action(svm::META_BMPSCALE_ACTION) + .dib(2, 2, white_black_red_blue) + .point(10, 20) + .point(30, 40) + .end() + .file()); + + EXPECT_NE(std::string::npos, + svg.find(" + +#include + +#include +#include + +#include + +using namespace odr::internal; + +namespace { + +std::string bytes(const std::initializer_list values) { + std::string result; + for (const int value : values) { + result.push_back(static_cast(value)); + } + return result; +} + +std::uint32_t be32(const std::string &data, const std::size_t at) { + return static_cast(data[at]) << 24 | + static_cast(data[at + 1]) << 16 | + static_cast(data[at + 2]) << 8 | + static_cast(data[at + 3]); +} + +struct DecodedPng final { + std::int32_t width{}; + std::int32_t height{}; + std::string rgb; +}; + +/// Reads back what @ref util::png::write wrote: the chunks, then the one +/// zlib stream their `IDAT` holds, minus the filter byte per row. +DecodedPng decode_png(const std::string &png) { + DecodedPng result; + EXPECT_EQ(png.substr(1, 3), "PNG"); + + std::string idat; + std::size_t at = 8; + while (at + 12 <= png.size()) { + const auto length = static_cast(be32(png, at)); + const std::string type = png.substr(at + 4, 4); + const std::string data = png.substr(at + 8, length); + if (type == "IHDR") { + result.width = static_cast(be32(data, 0)); + result.height = static_cast(be32(data, 4)); + EXPECT_EQ(static_cast(data[8]), 8); // bit depth + EXPECT_EQ(static_cast(data[9]), 2); // colour type rgb + } else if (type == "IDAT") { + idat += data; + } else if (type == "IEND") { + break; + } + at += 12 + length; + } + + const std::string raw = crypto::util::zlib_inflate(idat); + const auto stride = static_cast(result.width) * 3; + for (std::int32_t y = 0; y < result.height; ++y) { + const std::size_t row = static_cast(y) * (stride + 1); + EXPECT_EQ(static_cast(raw[row]), 0); // filter type none + result.rgb.append(raw, row + 1, stride); + } + return result; +} + +} // namespace + +TEST(PngUtil, rgb_round_trip) { + // 2x2: red, green / blue, white + const std::string rgb = + bytes({255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255}); + + const DecodedPng png = decode_png(util::png::write(rgb, 2, 2, 3)); + + EXPECT_EQ(2, png.width); + EXPECT_EQ(2, png.height); + EXPECT_EQ(rgb, png.rgb); +} + +TEST(PngUtil, a_buffer_too_short_for_the_size_is_refused) { + EXPECT_TRUE(util::png::write(bytes({255, 0, 0}), 2, 2, 3).empty()); + EXPECT_TRUE(util::png::write("", 0, 0, 3).empty()); +} + +TEST(PngUtil, only_three_or_four_channels) { + const std::string pixels(2 * 2 * 4, '\0'); + EXPECT_TRUE(util::png::write(pixels, 2, 2, 1).empty()); + EXPECT_TRUE(util::png::write(pixels, 2, 2, 2).empty()); + EXPECT_FALSE(util::png::write(pixels, 2, 2, 4).empty()); +}