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 {}; } 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")