From 018080bb5db3eccc7309b0b16056df71573c82cf Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 30 Aug 2026 22:51:05 +0200 Subject: [PATCH 1/2] feat(odf): draw a custom shape as the shape, not as its box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `html::translate_custom_shape` carried a `// TODO draw shape in svg` and emitted a positioned `
` with fill and stroke — the bounding box, never the shape. Every one of the corpus's 350 custom shapes came out a rectangle, arrows, callouts, rings and stars alike. Closes #159. `read_path` now resolves a `draw:custom-shape`'s `draw:enhanced-geometry` through the languages the previous change added: `svg:viewBox` gives the box the formulas measure against, `draw:modifiers` fills `$0`, `$1`, …, the `draw:equation` children resolve on demand and are memoised, with a reference that cycles resolving to nothing rather than recursing, and `draw:enhanced-path` becomes the svg `d`. `draw:mirror-horizontal` and `draw:mirror-vertical` are folded into the coordinates as they are written, rather than into a transform: the pen keeps tracking the unmirrored geometry, so the angles and radii an arc needs are computed once, and only the emitted numbers and the sweep flags are reflected. The path is drawn with `fill-rule="evenodd"`, which is what leaves a ring its hole; the corpus has one. Not done, and recorded in `PLAN.md`: `draw:text-areas`, so a shape's text is still laid out in the whole box; `draw:handle`, which only an editor needs; and `F`/`S`, which want one subpath painted differently from the rest. `hasstroke` and `hasfill` evaluate as true because the geometry reader has no style in hand — no corpus formula reads them. Checked against libreoffice's own png export of `sample.odg` and `SpotfaceCoord40270-L.odg`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017XABfEapaADjFQCt1vjmDF --- CHANGELOG.md | 4 + src/odr/internal/html/document_element.cpp | 2 + src/odr/internal/odf/PLAN.md | 21 +++- src/odr/internal/odf/README.md | 9 +- .../internal/odf/odf_enhanced_geometry.cpp | 24 +++-- .../internal/odf/odf_enhanced_geometry.hpp | 3 + src/odr/internal/odf/odf_geometry.cpp | 98 +++++++++++++++++++ 7 files changed, 149 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96250ecb6..4afccd454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- An ODF custom shape is drawn as the shape its `draw:enhanced-geometry` + describes rather than as its bounding box: `draw:enhanced-path`, + `draw:equation`, `draw:modifiers` and the two mirror attributes. Closes #159. + - ODF draws the shape elements it used to drop whole: `draw:path`, `draw:polygon`, `draw:polyline`, `draw:regular-polygon`, `draw:connector`, `draw:ellipse`, `draw:measure` and `draw:caption`. New `path()` on diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index 5f2f0ca05..7c58f0241 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -702,6 +702,8 @@ void html::translate_custom_shape(const Element &element, "absolute;top:0;left:0;padding:inherit;")); HtmlAttributesVector attributes{ {"d", path->data}, + // A ring is two subpaths, and only even-odd leaves its hole. + {"fill-rule", "evenodd"}, // The view box scales, and not evenly; the stroke must not. {"vector-effect", "non-scaling-stroke"}}; // An outline that never closes is a line, which svg would else fill as if diff --git a/src/odr/internal/odf/PLAN.md b/src/odr/internal/odf/PLAN.md index 529510e39..56655c5d8 100644 --- a/src/odr/internal/odf/PLAN.md +++ b/src/odr/internal/odf/PLAN.md @@ -133,11 +133,20 @@ turn, so the large-arc flag is never needed and a full `U … 0 360` — which o svg `A` cannot express — still draws; `F` and `S` are read and dropped, since painting one subpath differently is more than one `d` can say. -### 4 — enhanced geometry, rendered +### 4 — enhanced geometry, rendered — landed -Stage 3 wired into `CustomShape::path()`: `svg:viewBox`, `draw:modifiers`, -`draw:mirror-vertical` / `-horizontal`, `draw:text-areas` for where the shape's -text goes. Closes #159. This is the stage that regenerates reference output. +Stage 3 wired into `CustomShape::path()`: `svg:viewBox`, `draw:modifiers`, the +`draw:equation` children resolved on demand and memoised, and +`draw:mirror-horizontal` / `-vertical` folded into the coordinates as they are +written. Closes #159. + +Still open, and deliberately: `draw:text-areas`, so a shape's text is laid out +in the whole box rather than the region the geometry reserves for it; +`draw:handle`, which only matters to an editor; and `F`/`S`, which want one +subpath painted differently from the rest. + +`hasstroke` and `hasfill` are always true — the geometry reader has no style +in hand — and no corpus formula reads them. ### 5 — `draw:object` charts @@ -155,3 +164,7 @@ nothing with stages 1–4 but the `draw:frame` it hangs off. written by the producer, and drawing that is the whole win. Re-routing a connector between the shapes it names is layout, not decoding. - **Editing any of this.** The editor is text-content only (`AGENTS.md`). +- **`draw:type`'s preset shapes.** A named type with no `draw:enhanced-path` + would need libreoffice's preset table; all 350 in the corpus write the path. +- **`draw:marker`** (28 in the corpus): the arrowheads a stroke ends with, an + svg `marker` and a separate piece of style work. diff --git a/src/odr/internal/odf/README.md b/src/odr/internal/odf/README.md index 099ae04c4..0cc7428d1 100644 --- a/src/odr/internal/odf/README.md +++ b/src/odr/internal/odf/README.md @@ -68,13 +68,18 @@ Roughly ordered by importance. - [x] caption (`draw:caption`; the box, not the tail) - [ ] 3-D scene (`dr3d:scene`) - [x] custom shapes (bounding box, fill/stroke) #159 - - [ ] enhanced geometry / shape path rendering + - [x] enhanced geometry (`draw:enhanced-path`, `draw:equation`, + `draw:modifiers`, `draw:mirror-horizontal` / `-vertical`) + - [ ] `draw:text-areas` (text is laid out in the whole box) + - [ ] `draw:handle` (the interactive control points) + - [ ] `F` / `S`: one subpath painted differently from the rest - [x] graphic style: stroke width/color, fill color, vertical align, text wrap - [ ] gradient and hatch fills, `draw:opacity` / `draw:opacity-name`, and the dash a `draw:stroke` names (a solid `draw:fill-color` and a solid line) - [ ] arrowheads (`draw:marker`, `draw:marker-start` / `-end`) - [x] transform (`draw:transform`, its operation list composed to one matrix) - - [ ] mirror (`style:mirror`, `draw:mirror-horizontal` / `-vertical`) + - [ ] mirror (`style:mirror`, and `draw:mirror-*` on a shape with no + enhanced geometry) - [x] page layout (size, orientation, margins) - [ ] annotations (`office:annotation`) diff --git a/src/odr/internal/odf/odf_enhanced_geometry.cpp b/src/odr/internal/odf/odf_enhanced_geometry.cpp index 5a61ad34a..ea290787f 100644 --- a/src/odr/internal/odf/odf_enhanced_geometry.cpp +++ b/src/odr/internal/odf/odf_enhanced_geometry.cpp @@ -301,13 +301,27 @@ class EnhancedPathParser : private Scanner { m_out += util::number::to_string_significant(value == 0 ? 0 : value, 7); } + /// The pen tracks the unmirrored geometry; only what is written is + /// reflected, so the angles and radii above are computed once. void write_point(const double x, const double y) { - write(x); - write(y); + write(m_context->mirror_horizontal ? m_context->left + m_context->right - x + : x); + write(m_context->mirror_vertical ? m_context->top + m_context->bottom - y + : y); m_x = x; m_y = y; } + /// A reflection in one axis reverses the way an arc turns; in both, it does + /// not. + void write_sweep(const bool clockwise) { + m_out += " 0 0 "; + m_out += (clockwise != + (m_context->mirror_horizontal != m_context->mirror_vertical)) + ? '1' + : '0'; + } + /// An elliptical arc from @p from to @p to degrees, in segments of at most /// a half turn, so a full turn — which one `A` cannot express — still draws. void write_arc(const double cx, const double cy, const double rx, @@ -326,8 +340,7 @@ class EnhancedPathParser : private Scanner { write('A'); write(rx); write(ry); - m_out += " 0 0 "; - m_out += swept < 0 ? '0' : '1'; + write_sweep(swept >= 0); write_point(end[0], end[1]); } } @@ -413,8 +426,7 @@ class EnhancedPathParser : private Scanner { write('A'); write(rx); write(ry); - m_out += " 0 0 "; - m_out += (descending == x_first) ? '1' : '0'; + write_sweep(descending == x_first); write_point(*x, *y); return true; } diff --git a/src/odr/internal/odf/odf_enhanced_geometry.hpp b/src/odr/internal/odf/odf_enhanced_geometry.hpp index cd9e1c38a..c57dc9963 100644 --- a/src/odr/internal/odf/odf_enhanced_geometry.hpp +++ b/src/odr/internal/odf/odf_enhanced_geometry.hpp @@ -24,6 +24,9 @@ struct EnhancedGeometryContext final { bool has_fill{true}; /// `draw:modifiers`, which `$0`, `$1`, … index. std::vector modifiers; + /// `draw:mirror-horizontal` / `-vertical`, reflected about the view box. + bool mirror_horizontal{false}; + bool mirror_vertical{false}; }; /// Resolves a `?name` reference to the `draw:equation` it names. diff --git a/src/odr/internal/odf/odf_geometry.cpp b/src/odr/internal/odf/odf_geometry.cpp index 1b9477541..4ab214036 100644 --- a/src/odr/internal/odf/odf_geometry.cpp +++ b/src/odr/internal/odf/odf_geometry.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -12,7 +13,10 @@ #include #include #include +#include +#include #include +#include #include #include @@ -455,6 +459,96 @@ std::optional read_regular_polygon(const pugi::xml_node node) { .height = view_box_size}; } +/// Space-separated numbers, as `draw:modifiers` (19.214) writes them. +std::vector read_numbers(const pugi::xml_attribute attribute) { + Scanner in(attribute.value()); + std::vector result; + while (true) { + const std::optional value = in.read_number(); + if (!value.has_value()) { + break; + } + result.push_back(*value); + } + return result; +} + +/// The `draw:equation` children by name, resolved on demand and memoised. A +/// reference that cycles resolves to nothing rather than recursing. +class Equations { +public: + Equations(const pugi::xml_node geometry, + const EnhancedGeometryContext &context) + : m_geometry{geometry}, m_context{&context} {} + + std::optional operator()(const std::string_view name) { + const std::string key(name); + if (const auto it = m_resolved.find(key); it != m_resolved.end()) { + return it->second; + } + if (!m_resolving.insert(key).second) { + return {}; + } + std::optional value; + for (const pugi::xml_node equation : m_geometry.children("draw:equation")) { + if (key == equation.attribute("draw:name").value()) { + value = evaluate_formula(equation.attribute("draw:formula").value(), + *m_context, std::ref(*this)); + break; + } + } + m_resolving.erase(key); + m_resolved.emplace(key, value); + return value; + } + +private: + pugi::xml_node m_geometry; + const EnhancedGeometryContext *m_context{nullptr}; + std::map> m_resolved; + std::set m_resolving; +}; + +/// `draw:enhanced-geometry` (10.6.1) on a `draw:custom-shape`, resolved to the +/// outline its `draw:enhanced-path` traces. +std::optional read_enhanced_geometry(const pugi::xml_node node) { + const pugi::xml_node geometry = node.child("draw:enhanced-geometry"); + if (!geometry) { + return {}; + } + const pugi::xml_attribute path = geometry.attribute("draw:enhanced-path"); + if (!path) { + return {}; + } + + EnhancedGeometryContext context; + if (const std::optional view_box = read_view_box(geometry)) { + context.left = view_box->x; + context.top = view_box->y; + context.right = view_box->x + view_box->width; + context.bottom = view_box->y + view_box->height; + } + context.logical_width = context.right - context.left; + context.logical_height = context.bottom - context.top; + context.modifiers = read_numbers(geometry.attribute("draw:modifiers")); + context.mirror_horizontal = + geometry.attribute("draw:mirror-horizontal").as_bool(false); + context.mirror_vertical = + geometry.attribute("draw:mirror-vertical").as_bool(false); + + Equations equations(geometry, context); + const std::optional data = + convert_enhanced_path(path.value(), context, std::ref(equations)); + if (!data.has_value()) { + return {}; + } + return DrawingPath{.data = *data, + .x = context.left, + .y = context.top, + .width = context.right - context.left, + .height = context.bottom - context.top}; +} + /// A `draw:circle` / `draw:ellipse` that `draw:kind` (19.212) cuts. std::optional read_elliptical_kind(const pugi::xml_node node) { const std::string_view kind = node.attribute("draw:kind").value(); @@ -587,6 +681,10 @@ std::optional odf::read_path(const pugi::xml_node node) { return odf::read_elliptical_kind(node); } + if (name == "draw:custom-shape") { + return odf::read_enhanced_geometry(node); + } + return {}; } From 3b8c0a07f9019abe1574df3be3a62186ed2f5601 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 31 Aug 2026 21:49:37 +0200 Subject: [PATCH 2/2] test: advance the reference output to the drawn custom shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 36 files. Every changed line is an insertion — 740 `` pairs where a custom shape used to be a plain box; nothing is removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UJ6NPShQBp1nX7WuMvty8V --- test/data.cmake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data.cmake b/test/data.cmake index 4ff8bc244..6bfd2f1f2 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "7ed0b50514bbd3a17e35254139d1ba7bce2f32cc") + REVISION "cb5b29143d4cf88340ccbca106bd8d3ab39429bf") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "400a73efcd9ed311a3118de84687b7922af91fb3") + REVISION "c3ec7fd812443b8d0520259cb0e3b10299a6a8da")