From f760ca0959efffa098b966a57d33d605fd5aebfe Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 30 Aug 2026 11:27:10 +0200 Subject: [PATCH] feat(svm): fill with the gradients, hatches and transparency a metafile asks for `GRADIENT`, `GRADIENTEX`, `HATCH` and `TRANSPARENT` were skipped. All four map onto svg declaratively - a `` or ``, a `` of lines, an `opacity` - so nothing is rasterised. None of them occurs in the 1125 metafiles harvested from the fixtures, so LibreOffice was the oracle: a metafile written by hand for the purpose, converted with `--convert-to svg`, and the two exports compared. That found three things a reading of the format alone did not: **A colour inside an object is not a colour.** An action's own colour is a plain `uint32` that `SvmReader::ReadColor` reads; a gradient's two ends and a hatch's lines are not. `GenericTypeSerializer::readColor` reads a `uint16` name id first, and only the user id (`0x8000`) carries three 16-bit channels behind it - anything else indexes a palette of 31. Reading four bytes there desynchronises the stream, and the action-length check then throws the whole image away. LibreOffice's export of the hand-written file said the gradient was black, which is what a wrongly written colour looks like from the other side. **An axial ramp has its colours the other way round.** `DrawLinearGradient` swaps them: the end colour is at both ends of the axis, the start colour in the middle. **A radial ramp ends in the middle.** `DrawComplexGradient` fills with the start colour and shrinks rings inwards towards the end colour. The linear ramp's vector is `Gradient::GetBoundRect`'s: the bounds grown so that turning still covers them, from the top of that to the bottom, turned about the centre. For the same action LibreOffice writes (450,100) to (750,400), and so do we. `SQUARE` and `RECT` shrink a rectangle rather than an ellipse, which svg has no gradient for; they come out as the ellipse they are closest to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CmCr22NW6wPQKiQidk96bq --- CHANGELOG.md | 3 + src/odr/internal/svm/PLAN.md | 5 +- src/odr/internal/svm/svm_format.cpp | 60 +++++++ src/odr/internal/svm/svm_format.hpp | 50 ++++++ src/odr/internal/svm/svm_to_svg.cpp | 267 ++++++++++++++++++++++++++++ test/src/internal/svm/svm_test.cpp | 156 ++++++++++++++++ 6 files changed, 539 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 988c57be..9c909d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A StarView metafile draws its gradients, hatches and transparent shapes: + `GRADIENT`, `GRADIENTEX`, `HATCH` and `TRANSPARENT`. + - A spreadsheet decodes in less memory: 626 MB peak instead of 914 MB on a 297 MB `content.xml`. Rendered output is unchanged. diff --git a/src/odr/internal/svm/PLAN.md b/src/odr/internal/svm/PLAN.md index 534dcff8..193a50e6 100644 --- a/src/odr/internal/svm/PLAN.md +++ b/src/odr/internal/svm/PLAN.md @@ -33,8 +33,9 @@ Each stage is one pull request, stacked on the one before it. which stencils one colour through a bitmap, and `ZCOMPRESS`, which needs inflating first, are still open. See the shortcut below. 6. **Primitives** - done. -7. **Fills and transparency.** `GRADIENT`, `GRADIENTEX`, `HATCH`, - `WALLPAPER`, `TRANSPARENT`, `FLOATTRANSPARENT`. +7. **Fills and transparency** - `GRADIENT`, `GRADIENTEX`, `HATCH` and + `TRANSPARENT` are done. `WALLPAPER` and `FLOATTRANSPARENT` are not: the + first has a format of its own, the second nests a whole metafile. 8. **The map mode's unit** (#772 defect 6), see below. 9. **Stretch.** Bézier flags (#772 defect 4), the `EPS` substitute metafile, and version-1 (pre-`VCLMTF`) files via `SvmConverter.cxx`. diff --git a/src/odr/internal/svm/svm_format.cpp b/src/odr/internal/svm/svm_format.cpp index a0bd70c1..26a97322 100644 --- a/src/odr/internal/svm/svm_format.cpp +++ b/src/odr/internal/svm/svm_format.cpp @@ -830,6 +830,66 @@ svm::BitmapAction svm::read_bitmap_action(std::istream &in, return result; } +std::uint32_t svm::read_object_color(std::istream &in) { + // `COL_NAME_USER`: the id that means three channels follow + constexpr std::uint16_t color_name_user = 0x8000; + // the palette `GenericTypeSerializer::readColor` indexes, its system colours + // resolved as it resolves them + static constexpr std::array palette = { + 0x000000, 0x000080, 0x008000, 0x008080, 0x800000, 0x800080, 0x808000, + 0x808080, 0xc0c0c0, 0x0000ff, 0x00ff00, 0x00ffff, 0xff0000, 0xff00ff, + 0xffff00, 0xffffff, 0xffffff, 0x000000, 0xffffff, 0x000000, 0x000000, + 0xffffff, 0x000000, 0xffffff, 0x000000, 0xc0c0c0, 0xffffff, 0x808080, + 0xc0c0c0, 0xffffff, 0x000000}; + + std::uint16_t name{}; + read_primitive(in, name); + + if ((name & color_name_user) == 0) { + return name < palette.size() ? palette[name] : 0; + } + + std::uint16_t red{}; + std::uint16_t green{}; + std::uint16_t blue{}; + read_primitive(in, red); + read_primitive(in, green); + read_primitive(in, blue); + return static_cast(red >> 8) << 16 | + static_cast(green >> 8) << 8 | + static_cast(blue >> 8); +} + +svm::Gradient svm::read_gradient(std::istream &in) { + Gradient result; + + read_version_length(in); + read_primitive(in, result.style); + result.start_color = read_object_color(in); + result.end_color = read_object_color(in); + read_primitive(in, result.angle); + read_primitive(in, result.border); + read_primitive(in, result.offset_x); + read_primitive(in, result.offset_y); + read_primitive(in, result.start_intensity); + read_primitive(in, result.end_intensity); + read_primitive(in, result.step_count); + + return result; +} + +svm::Hatch svm::read_hatch(std::istream &in) { + Hatch result; + + read_version_length(in); + read_primitive(in, result.style); + result.color = read_object_color(in); + read_primitive(in, result.distance); + read_primitive(in, result.angle); + + return result; +} + std::optional svm::read_region(std::istream &in) { const VersionLength vl = read_version_length(in); std::uint16_t content_version{}; diff --git a/src/odr/internal/svm/svm_format.hpp b/src/odr/internal/svm/svm_format.hpp index 9096c789..48cb576a 100644 --- a/src/odr/internal/svm/svm_format.hpp +++ b/src/odr/internal/svm/svm_format.hpp @@ -61,6 +61,23 @@ enum MetaFontStrikeout { STRIKEOUT_NONE = 0, }; +/// `awt::GradientStyle`. +enum MetaGradientStyle { + GRADIENT_LINEAR = 0, + GRADIENT_AXIAL = 1, + GRADIENT_RADIAL = 2, + GRADIENT_ELLIPTICAL = 3, + GRADIENT_SQUARE = 4, + GRADIENT_RECT = 5, +}; + +/// `HatchStyle`: one set of lines, two crossing, or three. +enum MetaHatchStyle { + HATCH_SINGLE = 0, + HATCH_DOUBLE = 1, + HATCH_TRIPLE = 2, +}; + /// `LineStyle`, what a `LineInfo` draws with. enum MetaLineStyle { LINE_NONE = 0, @@ -294,6 +311,33 @@ struct TextRectangleAction final { std::uint16_t style{}; }; +/// `Gradient`: the two colours, which way the ramp runs, and where it sits. +struct Gradient final { + std::uint16_t style{}; + std::uint32_t start_color{}; + std::uint32_t end_color{}; + /// Tenths of a degree, counter-clockwise. + std::uint16_t angle{}; + /// Percent of the ramp the start colour holds before it ramps. + std::uint16_t border{}; + /// Percent of the bounds, where a complex gradient's centre sits. + std::uint16_t offset_x{}; + std::uint16_t offset_y{}; + /// Percent, what the colours are scaled by. + std::uint16_t start_intensity{100}; + std::uint16_t end_intensity{100}; + std::uint16_t step_count{}; +}; + +/// `Hatch`: the lines drawn across a shape. +struct Hatch final { + std::uint16_t style{}; + std::uint32_t color{}; + std::int32_t distance{}; + /// Tenths of a degree, counter-clockwise. + std::uint16_t angle{}; +}; + /// A clip region: bands covering it as a union of rectangles, and from /// version 2 the poly-polygon those were rasterised from. An *empty* region /// covers nothing and so clips everything away; `REGION_NULL`, which does not @@ -394,6 +438,12 @@ 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 colour *inside* an object, which is not the plain `uint32` an action's +/// own colour is: `GenericTypeSerializer::readColor` reads a name id, and only +/// the user one carries three 16-bit channels behind it. +std::uint32_t read_object_color(std::istream &in); +Gradient read_gradient(std::istream &in); +Hatch read_hatch(std::istream &in); /// A region, as `ReadRegion` reads one: a band list, and from version 2 the /// poly-polygon it came from. `std::nullopt` where it does not clip at all. std::optional read_region(std::istream &in); diff --git a/src/odr/internal/svm/svm_to_svg.cpp b/src/odr/internal/svm/svm_to_svg.cpp index e94c3f5e..892d2d6c 100644 --- a/src/odr/internal/svm/svm_to_svg.cpp +++ b/src/odr/internal/svm/svm_to_svg.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -391,6 +392,246 @@ void ensure_clip(Context &context) { } } +/// A percent out of the file, clamped: it is drawn with, not validated. +double percent(const std::uint16_t value) { + return std::min(value, 100) / 100.0; +} + +/// The bounds of what is about to be filled, in the drawing's own coordinates. +struct Bounds final { + double left{}; + double top{}; + double right{}; + double bottom{}; + + [[nodiscard]] double width() const { return right - left; } + [[nodiscard]] double height() const { return bottom - top; } + [[nodiscard]] double center_x() const { return (left + right) / 2; } + [[nodiscard]] double center_y() const { return (top + bottom) / 2; } +}; + +Bounds get_bounds(const std::span> polygons, + const Context &context) { + Bounds result{std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::lowest(), + std::numeric_limits::lowest()}; + + for (const auto &polygon : polygons) { + for (const IntPair point : polygon) { + const double x = transform_x(point.x, context); + const double y = transform_y(point.y, context); + result.left = std::min(result.left, x); + result.top = std::min(result.top, y); + result.right = std::max(result.right, x); + result.bottom = std::max(result.bottom, y); + } + } + + return result; +} + +/// A gradient end's colour, scaled by the intensity the file gives it. +std::string get_gradient_color_string(const std::uint32_t color, + const std::uint16_t intensity) { + const auto scale = [&](const std::uint32_t shift) { + return static_cast((color >> shift & 0xff) * + percent(intensity)); + }; + return get_svg_color_string(scale(16) << 16 | scale(8) << 8 | scale(0)); +} + +void write_gradient_stop(svg::SvgWriter &out, const double offset, + const std::string &color) { + out.write_element_begin("stop"); + out.write_attribute("offset", offset); + out.write_attribute("stop-color", color); + out.write_element_end(); +} + +/// `Gradient::GetBoundRect`: a linear ramp runs across the bounds grown so +/// that turning it still covers them, from the top of that to the bottom, +/// turned about the centre - which is `(sin, cos)` of the angle. +void write_linear_gradient(const std::string &id, const Gradient &gradient, + const Bounds &bounds, const Context &context) { + svg::SvgWriter &out = *context.out; + + const double angle = gradient.angle % 3600 * std::numbers::pi / 1800; + const double grown = bounds.height() * std::abs(std::cos(angle)) + + bounds.width() * std::abs(std::sin(angle)); + const double x = std::sin(angle) * grown / 2; + const double y = std::cos(angle) * grown / 2; + + out.write_element_begin("linearGradient"); + out.write_attribute("id", id); + out.write_attribute("gradientUnits", "userSpaceOnUse"); + out.write_attribute("x1", bounds.center_x() - x); + out.write_attribute("y1", bounds.center_y() - y); + out.write_attribute("x2", bounds.center_x() + x); + out.write_attribute("y2", bounds.center_y() + y); + + const std::string start = + get_gradient_color_string(gradient.start_color, gradient.start_intensity); + const std::string end = + get_gradient_color_string(gradient.end_color, gradient.end_intensity); + const double border = percent(gradient.border); + + if (gradient.style == GRADIENT_AXIAL) { + // `DrawLinearGradient` swaps the two for an axial ramp: the end colour is + // what both ends of the axis get, and the start colour the middle + write_gradient_stop(out, 0, end); + if (border > 0) { + write_gradient_stop(out, border / 2, end); + } + write_gradient_stop(out, 0.5, start); + if (border > 0) { + write_gradient_stop(out, 1 - border / 2, end); + } + write_gradient_stop(out, 1, end); + } else { + write_gradient_stop(out, 0, start); + if (border > 0) { + write_gradient_stop(out, border, start); + } + write_gradient_stop(out, 1, end); + } + + out.write_element_end(); +} + +/// The complex styles, which vcl draws as rings shrinking towards the centre: +/// the start colour is the outside and the end colour the middle. `SQUARE` +/// and `RECT` shrink a rectangle rather than an ellipse, which svg has no +/// gradient for - they come out as the ellipse they are closest to. +void write_radial_gradient(const std::string &id, const Gradient &gradient, + const Bounds &bounds, const Context &context) { + svg::SvgWriter &out = *context.out; + + const bool round = gradient.style == GRADIENT_RADIAL; + const double radius_x = round + ? std::hypot(bounds.width(), bounds.height()) / 2 + : bounds.width() * std::numbers::sqrt2 / 2; + const double radius_y = + round ? radius_x : bounds.height() * std::numbers::sqrt2 / 2; + + out.write_element_begin("radialGradient"); + out.write_attribute("id", id); + out.write_attribute("gradientUnits", "userSpaceOnUse"); + out.write_attribute("cx", bounds.left + + bounds.width() * percent(gradient.offset_x)); + out.write_attribute("cy", bounds.top + + bounds.height() * percent(gradient.offset_y)); + // the circle the transform below stretches to @ref radius_x + out.write_attribute("r", radius_y * (1 - percent(gradient.border))); + if (radius_x != radius_y) { + out.write_attribute( + "gradientTransform", + "matrix(" + svg::format_number(radius_x / radius_y) + " 0 0 1 " + + svg::format_number(bounds.center_x() * (1 - radius_x / radius_y)) + + " 0)"); + } + + write_gradient_stop( + out, 0, + get_gradient_color_string(gradient.end_color, gradient.end_intensity)); + write_gradient_stop(out, 1, + get_gradient_color_string(gradient.start_color, + gradient.start_intensity)); + out.write_element_end(); +} + +/// The hatch lines, as a tile the fill repeats. vcl turns them +/// counter-clockwise, svg clockwise. +void write_hatch_pattern(const std::string &id, const Hatch &hatch, + const Context &context) { + svg::SvgWriter &out = *context.out; + + const double distance = + std::max(transform_width(hatch.distance, context), 1.0); + + out.write_element_begin("pattern"); + out.write_attribute("id", id); + out.write_attribute("patternUnits", "userSpaceOnUse"); + out.write_attribute("width", distance); + out.write_attribute("height", distance); + out.write_attribute("patternTransform", + "rotate(" + svg::format_number(hatch.angle / -10.0) + + ")"); + + const auto line = [&](const std::string &path_data) { + out.write_element_begin("path"); + out.write_attribute("d", path_data); + out.write_style("stroke", get_svg_color_string(hatch.color)); + out.write_style("vector-effect", "non-scaling-stroke"); + out.write_style("fill", "none"); + out.write_element_end(); + }; + + const std::string size = svg::format_number(distance); + line("M 0,0 L " + size + ",0"); + if (hatch.style == HATCH_DOUBLE || hatch.style == HATCH_TRIPLE) { + line("M 0,0 L 0," + size); + } + if (hatch.style == HATCH_TRIPLE) { + line("M 0,0 L " + size + "," + size); + } + + out.write_element_end(); +} + +/// Fills the shape with the paint @p fill names, which is written above it. +void write_filled_path(const std::span> polygons, + const std::string &fill, const Context &context) { + svg::SvgWriter &out = *context.out; + + out.write_element_begin("path"); + out.write_attribute("d", get_path_data_string(polygons, true, context)); + out.write_style("fill", fill); + out.write_style("fill-rule", "evenodd"); + out.write_style("stroke", "none"); + out.write_element_end(); +} + +void write_gradient(const std::span> polygons, + const Gradient &gradient, Context &context) { + const Bounds bounds = get_bounds(polygons, context); + if (bounds.left > bounds.right) { + return; + } + + const std::string id = + "odr-gradient-" + std::to_string(++context.element_count); + if (gradient.style == GRADIENT_LINEAR || gradient.style == GRADIENT_AXIAL) { + write_linear_gradient(id, gradient, bounds, context); + } else { + write_radial_gradient(id, gradient, bounds, context); + } + write_filled_path(polygons, "url(#" + id + ")", context); +} + +void write_hatch(const std::span> polygons, + const Hatch &hatch, Context &context) { + const std::string id = "odr-hatch-" + std::to_string(++context.element_count); + + write_hatch_pattern(id, hatch, context); + write_filled_path(polygons, "url(#" + id + ")", context); +} + +/// The state's own colours, drawn through: `DrawTransparent` is the fill and +/// the pen at a transparency, not a colour of its own. +void write_transparent(const std::span> polygons, + const std::uint16_t transparence, + const Context &context) { + svg::SvgWriter &out = *context.out; + + out.write_element_begin("path"); + out.write_attribute("d", get_path_data_string(polygons, true, context)); + write_shape_style(out, context, true); + // the shape as a whole, not its fill and its stroke separately + out.write_style("opacity", 1 - percent(transparence)); + out.write_element_end(); +} + /// One path for all of them: the fill rule only cuts holes within a path. void write_path(const std::span> polygons, const bool fill, const LineInfo *line_info, @@ -854,6 +1095,32 @@ void translate_action(const ActionHeader &action_header, std::istream &in, ensure_clip(context); write_arc(action, get_arc_kind(action_header.type), context); } break; + case META_GRADIENT_ACTION: { + const Rectangle rect = read_rectangle(in); + const Gradient gradient = read_gradient(in); + const std::vector polygon = get_rectangle_polygon(rect); + ensure_clip(context); + write_gradient({&polygon, 1}, gradient, context); + } break; + case META_GRADIENTEX_ACTION: { + const std::vector> polygons = read_poly_polygon(in); + const Gradient gradient = read_gradient(in); + ensure_clip(context); + write_gradient(polygons, gradient, context); + } break; + case META_HATCH_ACTION: { + const std::vector> polygons = read_poly_polygon(in); + const Hatch hatch = read_hatch(in); + ensure_clip(context); + write_hatch(polygons, hatch, context); + } break; + case META_TRANSPARENT_ACTION: { + const std::vector> polygons = read_poly_polygon(in); + std::uint16_t transparence{}; + read_primitive(in, transparence); + ensure_clip(context); + write_transparent(polygons, transparence, context); + } break; case META_RECT_ACTION: { const Rectangle action = read_rectangle(in); ensure_clip(context); diff --git a/test/src/internal/svm/svm_test.cpp b/test/src/internal/svm/svm_test.cpp index 9d5108d2..6c51aaaa 100644 --- a/test/src/internal/svm/svm_test.cpp +++ b/test/src/internal/svm/svm_test.cpp @@ -110,6 +110,36 @@ class SvmBuilder final { .end(); } + /// A colour *inside* an object, which is not the plain `uint32` an action's + /// own colour is: a name id, and three 16-bit channels behind the user one. + SvmBuilder &object_color(const std::uint32_t rgb) { + return u16(0x8000) + .u16(static_cast((rgb >> 16 & 0xff) << 8)) + .u16(static_cast((rgb >> 8 & 0xff) << 8)) + .u16(static_cast((rgb & 0xff) << 8)); + } + + SvmBuilder &gradient(const std::uint16_t style, const std::uint32_t start, + const std::uint32_t end, const std::uint16_t angle = 0) { + begin().u16(style).object_color(start).object_color(end); + return u16(angle) + .u16(0) // border + .u16(50) // offset x + .u16(50) // offset y + .u16(100) // start intensity + .u16(100) // end intensity + .u16(0) // step count + .end(); + } + + /// A poly-polygon of one rectangle. + SvmBuilder &poly_rectangle(const std::int32_t left, const std::int32_t top, + const std::int32_t right, + const std::int32_t bottom) { + return u16(1).polygon( + {{left, top}, {right, top}, {right, bottom}, {left, bottom}}); + } + /// A region of one band, the shape `ReadRegion` reads a rectangle as. SvmBuilder ®ion(const std::int32_t left, const std::int32_t top, const std::int32_t right, const std::int32_t bottom) { @@ -829,3 +859,129 @@ TEST(SvmToSvg, a_point_and_a_pixel_are_dots) { svg.find("d=\"M 5,6 Z\" style=\"stroke-linecap:round;" "stroke:rgb(0,0,255)")); } + +/// The ramp runs from the top of the bounds to the bottom, turned about the +/// centre - the same vector LibreOffice's own export writes. +TEST(SvmToSvg, linear_gradient) { + const std::string svg = + translate(SvmBuilder() + .action(svm::META_GRADIENT_ACTION) + .rectangle(100, 100, 400, 400) + .gradient(svm::GRADIENT_LINEAR, 0xff0000, 0x0000ff) + .end() + .file()); + + EXPECT_NE(std::string::npos, + svg.find("