diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a212077..96250ecb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- 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 + `CustomShape`, mirrored in the JNI, Apple and Python bindings. + +- An ODF `draw:circle` is drawn as an ellipse rather than a circle inscribed in + its box. + - An ODF drawing shape is drawn where its `draw:transform` puts it. New `transform()` on `Frame`, `Rect`, `Line`, `Circle` and `CustomShape`, mirrored in the JNI, Apple and Python bindings. diff --git a/apple/include/OdrCoreObjC/ODRDocumentElement.h b/apple/include/OdrCoreObjC/ODRDocumentElement.h index 280a77de6..8a763d683 100644 --- a/apple/include/OdrCoreObjC/ODRDocumentElement.h +++ b/apple/include/OdrCoreObjC/ODRDocumentElement.h @@ -249,6 +249,20 @@ NS_SWIFT_NAME(TableCell) @property(nonatomic, readonly) ODRTableCellStyle *style; @end +/// `odr::DrawingPath`: an outline, in the user-space box `x`/`y`/`width`/ +/// `height` that the shape's own box stretches to. +NS_SWIFT_NAME(DrawingPath) +@interface ODRDrawingPath : NSObject +@property(nonatomic, readonly, copy) NSString *data; +@property(nonatomic, readonly) double x; +@property(nonatomic, readonly) double y; +@property(nonatomic, readonly) double width; +@property(nonatomic, readonly) double height; + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +@end + /// `odr::DrawingTransform`. NS_SWIFT_NAME(DrawingTransform) @interface ODRDrawingTransform : NSObject @@ -318,6 +332,8 @@ NS_SWIFT_NAME(CustomShape) @property(nonatomic, readonly) ODRMeasure *width; @property(nonatomic, readonly) ODRMeasure *height; @property(nonatomic, readonly, nullable) ODRDrawingTransform *transform; +/// `nil` for a shape whose geometry we cannot read, leaving its box. +@property(nonatomic, readonly, nullable) ODRDrawingPath *path; @property(nonatomic, readonly) ODRGraphicStyle *style; @end diff --git a/apple/src/ODRDocumentElement.mm b/apple/src/ODRDocumentElement.mm index 22297465c..b69080f38 100644 --- a/apple/src/ODRDocumentElement.mm +++ b/apple/src/ODRDocumentElement.mm @@ -752,6 +752,24 @@ - (ODRGraphicStyle *)style { @end +@implementation ODRDrawingPath + ++ (nullable instancetype)pathWithHandle: + (const std::optional &)handle { + if (!handle.has_value()) { + return nil; + } + ODRDrawingPath *const result = [[ODRDrawingPath alloc] init]; + result->_data = to_nsstring(handle->data); + result->_x = handle->x; + result->_y = handle->y; + result->_width = handle->width; + result->_height = handle->height; + return result; +} + +@end + @implementation ODRDrawingTransform + (nullable instancetype)transformWithHandle: @@ -958,6 +976,15 @@ - (nullable ODRDrawingTransform *)transform { nil); } +- (nullable ODRDrawingPath *)path { + return guarded_value( + [&]() -> ODRDrawingPath * { + return [ODRDrawingPath + pathWithHandle:self.handle.as_custom_shape().path()]; + }, + nil); +} + - (ODRGraphicStyle *)style { return guarded_value( [&]() -> ODRGraphicStyle * { diff --git a/apple/src/ODRPrivate.h b/apple/src/ODRPrivate.h index 4c0944f12..6013e113a 100644 --- a/apple/src/ODRPrivate.h +++ b/apple/src/ODRPrivate.h @@ -125,6 +125,11 @@ NS_ASSUME_NONNULL_BEGIN + (instancetype)styleWithHandle:(const odr::TableCellStyle &)handle; @end +@interface ODRDrawingPath (Private) ++ (nullable instancetype)pathWithHandle: + (const std::optional &)handle; +@end + @interface ODRDrawingTransform (Private) + (nullable instancetype)transformWithHandle: (const std::optional &)handle; diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 4e4b37e5b..809a319cb 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -79,6 +79,7 @@ add_jar(odr_java "java/app/opendocument/core/DocumentFile.java" "java/app/opendocument/core/DocumentPath.java" "java/app/opendocument/core/DocumentType.java" + "java/app/opendocument/core/DrawingPath.java" "java/app/opendocument/core/DrawingTransform.java" "java/app/opendocument/core/Element.java" "java/app/opendocument/core/ElementType.java" diff --git a/jni/java/app/opendocument/core/CustomShape.java b/jni/java/app/opendocument/core/CustomShape.java index babac4713..2c33e944c 100644 --- a/jni/java/app/opendocument/core/CustomShape.java +++ b/jni/java/app/opendocument/core/CustomShape.java @@ -26,6 +26,10 @@ public DrawingTransform transform() { return transformNative(handle()); } + public DrawingPath path() { + return pathNative(handle()); + } + public GraphicStyle style() { return styleNative(handle()); } @@ -40,5 +44,7 @@ public GraphicStyle style() { private native DrawingTransform transformNative(long handle); + private native DrawingPath pathNative(long handle); + private native GraphicStyle styleNative(long handle); } diff --git a/jni/java/app/opendocument/core/DrawingPath.java b/jni/java/app/opendocument/core/DrawingPath.java new file mode 100644 index 000000000..572d052ca --- /dev/null +++ b/jni/java/app/opendocument/core/DrawingPath.java @@ -0,0 +1,40 @@ +package app.opendocument.core; + +import java.util.Objects; + +/** A drawing shape's outline, as an svg path. Mirrors {@code odr::DrawingPath}. */ +public final class DrawingPath { + public final String data; + public final double x; + public final double y; + public final double width; + public final double height; + + public DrawingPath(String data, double x, double y, double width, double height) { + this.data = Objects.requireNonNull(data); + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public boolean equals(Object other) { + return other instanceof DrawingPath path + && data.equals(path.data) + && x == path.x + && y == path.y + && width == path.width + && height == path.height; + } + + @Override + public int hashCode() { + return Objects.hash(data, x, y, width, height); + } + + @Override + public String toString() { + return data; + } +} diff --git a/jni/src/jni_convert.hpp b/jni/src/jni_convert.hpp index 7c4435b44..bcbd783db 100644 --- a/jni/src/jni_convert.hpp +++ b/jni/src/jni_convert.hpp @@ -38,6 +38,8 @@ jobject make_graphic_style(JNIEnv *env, const odr::GraphicStyle &style); jobject make_drawing_transform(JNIEnv *env, const std::optional &transform); +jobject make_drawing_path(JNIEnv *env, + const std::optional &path); jobject make_page_layout(JNIEnv *env, const odr::PageLayout &layout); jobject make_table_dimensions(JNIEnv *env, const odr::TableDimensions &dimensions); diff --git a/jni/src/jni_document.cpp b/jni/src/jni_document.cpp index ae2e5f975..cf3409a99 100644 --- a/jni/src/jni_document.cpp +++ b/jni/src/jni_document.cpp @@ -1038,6 +1038,15 @@ Java_app_opendocument_core_CustomShape_transformNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_CustomShape_pathNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_drawing_path(env, + element(handle).as_custom_shape().path()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_CustomShape_styleNative(JNIEnv *env, jobject, jlong handle) { diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 1d3d1c10a..8afa6cb7f 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -174,6 +174,16 @@ make_drawing_transform(JNIEnv *env, make_measure(env, transform->f)); } +jobject make_drawing_path(JNIEnv *env, + const std::optional &path) { + if (!path.has_value()) { + return nullptr; + } + return new_object(env, "app/opendocument/core/DrawingPath", + "(Ljava/lang/String;DDDD)V", to_jstring(env, path->data), + path->x, path->y, path->width, path->height); +} + jobject make_color(JNIEnv *env, const std::optional &value) { if (!value.has_value()) { return nullptr; diff --git a/python/src/bind_document.cpp b/python/src/bind_document.cpp index aad0a3072..f22af4278 100644 --- a/python/src/bind_document.cpp +++ b/python/src/bind_document.cpp @@ -277,6 +277,14 @@ void odr_python::bind_document(py::module_ &m) { .def("value_type", &odr::TableCell::value_type) .def("style", &odr::TableCell::style); + py::class_(m, "DrawingPath") + .def(py::init<>()) + .def_readwrite("data", &odr::DrawingPath::data) + .def_readwrite("x", &odr::DrawingPath::x) + .def_readwrite("y", &odr::DrawingPath::y) + .def_readwrite("width", &odr::DrawingPath::width) + .def_readwrite("height", &odr::DrawingPath::height); + py::class_(m, "DrawingTransform") .def(py::init<>()) .def_readwrite("a", &odr::DrawingTransform::a) @@ -326,6 +334,7 @@ void odr_python::bind_document(py::module_ &m) { .def("width", &odr::CustomShape::width) .def("height", &odr::CustomShape::height) .def("transform", &odr::CustomShape::transform) + .def("path", &odr::CustomShape::path) .def("style", &odr::CustomShape::style); bind_element(m, "Image") diff --git a/src/odr/document_element.cpp b/src/odr/document_element.cpp index 853c84da2..d7dc24aca 100644 --- a/src/odr/document_element.cpp +++ b/src/odr/document_element.cpp @@ -686,6 +686,11 @@ std::optional CustomShape::transform() const { : std::optional(); } +std::optional CustomShape::path() const { + return exists_() ? m_adapter2->custom_shape_path(m_identifier) + : std::optional(); +} + GraphicStyle CustomShape::style() const { return exists_() ? m_adapter2->custom_shape_style(m_identifier) : GraphicStyle(); diff --git a/src/odr/document_element.hpp b/src/odr/document_element.hpp index 334e5964f..706b15c29 100644 --- a/src/odr/document_element.hpp +++ b/src/odr/document_element.hpp @@ -482,6 +482,18 @@ class TableCell final [[nodiscard]] TableCellStyle style() const; }; +/// @brief Represents a drawing shape's outline, as an svg path. +/// +/// `data` is written in the user-space box `x`, `y`, `width`, `height`, which +/// the shape's own box stretches to, aspect ratio not preserved. +struct DrawingPath final { + std::string data; + double x{0}; + double y{0}; + double width{0}; + double height{0}; +}; + /// @brief Represents the affine transform a drawing shape carries. /// /// `(x, y)` maps to `(a*x + c*y + e, b*x + d*y + f)`, the lettering of @@ -565,6 +577,8 @@ class CustomShape final [[nodiscard]] Measure width() const; [[nodiscard]] Measure height() const; [[nodiscard]] std::optional transform() const; + /// Nothing for a shape whose geometry we cannot read, leaving its box. + [[nodiscard]] std::optional path() const; [[nodiscard]] GraphicStyle style() const; }; diff --git a/src/odr/internal/abstract/document.hpp b/src/odr/internal/abstract/document.hpp index 130f3a13f..4d406676c 100644 --- a/src/odr/internal/abstract/document.hpp +++ b/src/odr/internal/abstract/document.hpp @@ -29,6 +29,7 @@ struct TextStyle; struct ParagraphStyle; struct GraphicStyle; struct DrawingTransform; +struct DrawingPath; } // namespace odr namespace odr::internal::abstract { @@ -515,6 +516,8 @@ class CustomShapeAdapter { custom_shape_height(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual std::optional custom_shape_transform(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual std::optional + custom_shape_path(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual GraphicStyle custom_shape_style(ElementIdentifier element_id) const = 0; diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index e5e8b2b5e..5f2f0ca05 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -12,8 +12,11 @@ #include #include #include +#include #include +#include + namespace odr::internal { void html::translate_children(const ElementRange &range, @@ -635,6 +638,21 @@ void html::translate_line(const Element &element, const WritingState &state) { {"y2", line.y2().to_string()}})); state.out().write_element_end("svg"); + + // A line's own text sits at its middle; most carry an empty paragraph and + // want no box at all. + if (std::ranges::any_of(line.children(), [](const Element &child) { + return has_content(child.children()); + })) { + const std::string middle = + "position:absolute;left:calc((" + line.x1().to_string() + " + " + + line.x2().to_string() + ")/2);top:calc((" + line.y1().to_string() + + " + " + line.y2().to_string() + ")/2);transform:translate(-50%,-100%);"; + state.out().write_element_begin("div", + HtmlElementOptions().set_style(middle)); + translate_children(line.children(), state); + state.out().write_element_end("div"); + } } void html::translate_circle(const Element &element, const WritingState &state) { @@ -648,7 +666,7 @@ void html::translate_circle(const Element &element, const WritingState &state) { state.out().write_new_line(); translate_children(circle.children(), state); state.out().write_raw( - R"()"); + R"()"); state.out().write_element_end("div"); } @@ -662,7 +680,42 @@ void html::translate_custom_shape(const Element &element, translate_custom_shape_properties(custom_shape) + translate_drawing_style(style))); translate_children(custom_shape.children(), state); - // TODO draw shape in svg + + if (const std::optional path = custom_shape.path(); + path.has_value()) { + const auto number = [](const double value) { + return util::number::to_string_significant(value, 7); + }; + state.out().write_new_line(); + state.out().write_element_begin( + "svg", + HtmlElementOptions() + .set_attributes(HtmlAttributesVector{ + {"xmlns", "http://www.w3.org/2000/svg"}, + {"version", "1.1"}, + {"overflow", "visible"}, + {"preserveAspectRatio", "none"}, + {"viewBox", number(path->x) + " " + number(path->y) + " " + + number(path->width) + " " + + number(path->height)}}) + .set_style("z-index:-1;width:inherit;height:inherit;position:" + "absolute;top:0;left:0;padding:inherit;")); + HtmlAttributesVector attributes{ + {"d", path->data}, + // 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 + // it did. + if (path->data.find_first_of("Zz") == std::string::npos) { + attributes.emplace_back("fill", "none"); + } + state.out().write_element_begin("path", + HtmlElementOptions() + .set_close_type(HtmlCloseType::trailing) + .set_attributes(std::move(attributes))); + state.out().write_element_end("svg"); + } + state.out().write_element_end("div"); } diff --git a/src/odr/internal/odf/PLAN.md b/src/odr/internal/odf/PLAN.md index 553669ddc..0e72cb426 100644 --- a/src/odr/internal/odf/PLAN.md +++ b/src/odr/internal/odf/PLAN.md @@ -58,9 +58,10 @@ onto the existing types: | tag | type | why | |---|---|---| -| `draw:path`, `draw:polygon`, `draw:polyline`, `draw:regular-polygon`, `draw:connector`, `draw:caption` | `custom_shape` | geometry given as a path | -| `draw:ellipse` | `circle` | `svg:x/y/width/height`, same as `draw:circle` | +| `draw:path`, `draw:polygon`, `draw:polyline`, `draw:regular-polygon`, `draw:connector` | `custom_shape` | geometry given as a path | +| `draw:ellipse`, and `draw:circle` `draw:kind` cuts | `circle` / `custom_shape` | box, or the arc it traces | | `draw:measure` | `line` | `svg:x1/y1/x2/y2`, same as `draw:line` | +| `draw:caption` | `rect` | the box; the callout tail is dropped | | `dr3d:scene` | — | not modelled; a 3-D scene is not a 2-D path | **Geometry reaches the renderer as an SVG path.** `CustomShape` grows @@ -108,12 +109,16 @@ is **counter-clockwise** for a positive angle — libreoffice writes svg's and comparing against the bounding box libreoffice reports (`x=23084`, exact) is what decided it. -### 2 — the missing shape elements +### 2 — the missing shape elements — landed -The tag → type table above, the geometry conversions, `path()`/`view_box()` on -`CustomShape`, and the renderer branch that draws a path. `translate_circle` -becomes an `` while it is being touched — it writes `r="50%"` today, -which is wrong for every non-square box. +The tag → type table above, the geometry conversions, `DrawingPath` and +`CustomShape::path()`, and the renderer branch that draws it into an +``. `translate_circle` became an ``; it wrote `r="50%"`, +wrong for every non-square box. `text:measure` is parsed too, or a measure's +label comes out empty. + +`vector-effect="non-scaling-stroke"` on the path: the view box scales, and with +`preserveAspectRatio="none"` unevenly, which the stroke must not follow. ### 3 — `draw:enhanced-path` and `draw:equation`, parser only diff --git a/src/odr/internal/odf/README.md b/src/odr/internal/odf/README.md index c3fadd692..099ae04c4 100644 --- a/src/odr/internal/odf/README.md +++ b/src/odr/internal/odf/README.md @@ -62,10 +62,17 @@ Roughly ordered by importance. - [x] column and row spans, covered cells - [x] drawings / shapes - [x] frame, group (`draw:g`), text box - - [x] line, rect, circle + - [x] line, rect, circle, ellipse + - [x] path, polygon, polyline, regular polygon, connector (drawn as svg) + - [x] measure (`draw:measure`, with its `text:measure` label) + - [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] 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`) - [x] page layout (size, orientation, margins) diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index 2c987fa44..82a440d2c 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -165,6 +165,19 @@ Measure read_measure_or_zero(const pugi::xml_attribute attribute) { return read_measure(attribute).value_or(Measure(0, DynamicUnit())); } +/// The unit an `svg:d` with no view box is written in (19.180). +Measure hundredth_millimetres(const double value) { + return Measure(value / 100.0, DynamicUnit("mm")); +} + +/// A `draw:connector` states no box of its own, so its path is what places it. +std::optional connector_box(const pugi::xml_node node) { + if (std::strcmp(node.name(), "draw:connector") != 0) { + return {}; + } + return read_path(node); +} + class ElementAdapter final : public abstract::ElementAdapter, public abstract::TextRootAdapter, public abstract::SlideAdapter, @@ -925,19 +938,53 @@ class ElementAdapter final : public abstract::ElementAdapter, [[nodiscard]] std::optional custom_shape_x(const ElementIdentifier element_id) const override { - return read_measure(get_node(element_id).attribute("svg:x")); + const pugi::xml_node node = get_node(element_id); + if (const std::optional measure = + read_measure(node.attribute("svg:x"))) { + return measure; + } + if (const std::optional box = connector_box(node)) { + return hundredth_millimetres(box->x); + } + return {}; } [[nodiscard]] std::optional custom_shape_y(const ElementIdentifier element_id) const override { - return read_measure(get_node(element_id).attribute("svg:y")); + const pugi::xml_node node = get_node(element_id); + if (const std::optional measure = + read_measure(node.attribute("svg:y"))) { + return measure; + } + if (const std::optional box = connector_box(node)) { + return hundredth_millimetres(box->y); + } + return {}; } [[nodiscard]] Measure custom_shape_width(const ElementIdentifier element_id) const override { - return read_measure_or_zero(get_node(element_id).attribute("svg:width")); + const pugi::xml_node node = get_node(element_id); + if (const pugi::xml_attribute attribute = node.attribute("svg:width")) { + return read_measure_or_zero(attribute); + } + if (const std::optional box = connector_box(node)) { + return hundredth_millimetres(box->width); + } + return Measure(0, DynamicUnit()); } [[nodiscard]] Measure custom_shape_height(const ElementIdentifier element_id) const override { - return read_measure_or_zero(get_node(element_id).attribute("svg:height")); + const pugi::xml_node node = get_node(element_id); + if (const pugi::xml_attribute attribute = node.attribute("svg:height")) { + return read_measure_or_zero(attribute); + } + if (const std::optional box = connector_box(node)) { + return hundredth_millimetres(box->height); + } + return Measure(0, DynamicUnit()); + } + [[nodiscard]] std::optional + custom_shape_path(const ElementIdentifier element_id) const override { + return read_path(get_node(element_id)); } [[nodiscard]] std::optional custom_shape_transform(const ElementIdentifier element_id) const override { diff --git a/src/odr/internal/odf/odf_geometry.cpp b/src/odr/internal/odf/odf_geometry.cpp index f961da6a6..24d3678d0 100644 --- a/src/odr/internal/odf/odf_geometry.cpp +++ b/src/odr/internal/odf/odf_geometry.cpp @@ -3,16 +3,27 @@ #include #include +#include +#include +#include +#include #include #include #include +#include #include +#include namespace odr::internal::odf { namespace { +/// The square a shape with no view box of its own is drawn into; the size is +/// arbitrary, and this is the one `draw:enhanced-geometry` uses. +constexpr double view_box_size = 21600; +constexpr double view_box_centre = view_box_size / 2; + /// Zero for a unit that is not an absolute length. double centimetres_per(const std::string_view unit) { if (unit == "cm") { @@ -36,75 +47,26 @@ double centimetres_per(const std::string_view unit) { return 0.0; } -/// Composes the operation list, holding the translation in centimetres. The -/// remaining input bounds every read, so nothing here depends on a terminator. -class TransformParser { +/// A cursor over the input every reader here shares. Reads are bounded by what +/// remains, which carries no terminator. +class Scanner { public: - explicit TransformParser(const std::string_view value) : m_rest{value} {} + explicit Scanner(const std::string_view input) : m_rest{input} {} - [[nodiscard]] std::optional parse() { - while (true) { - skip_separators(); - if (m_rest.empty()) { - break; - } - if (!parse_operation()) { - return {}; - } - } - - const double scale = m_unit.empty() ? 1.0 : centimetres_per(m_unit); - const DynamicUnit unit{m_unit}; - return DrawingTransform{ - .a = m_transform.a, - .b = m_transform.b, - .c = m_transform.c, - .d = m_transform.d, - .e = Measure(m_transform.e / scale, unit), - .f = Measure(m_transform.f / scale, unit), - }; - } - -private: - static bool is_separator(const char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ','; - } - static bool is_letter(const char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); - } - /// A superset of what a number is made of, to bound the run `std::strtod` - /// then reads properly. - static bool is_number_char(const char c) { - return (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || - c == 'e' || c == 'E'; - } - - std::string_view m_rest; - - util::math::Transform2D m_transform; - /// What every length agreed on, or `cm` where they did not; empty until one - /// is seen, which keeps a list of pure rotations unitless. - std::string m_unit; + [[nodiscard]] bool empty() const { return m_rest.empty(); } /// The next character, or `\0` where the input ended. [[nodiscard]] char peek() const { return m_rest.empty() ? '\0' : m_rest.front(); } - /// The leading run of characters @p accept admits, left in place. - [[nodiscard]] std::string_view peek_while(bool (*accept)(char)) const { - std::size_t length = 0; - while (length < m_rest.size() && accept(m_rest[length])) { - ++length; + /// The next character, consumed. + char take() { + const char c = peek(); + if (!m_rest.empty()) { + m_rest.remove_prefix(1); } - return m_rest.substr(0, length); - } - - /// The same run, consumed. - [[nodiscard]] std::string_view take_while(bool (*accept)(char)) { - const std::string_view taken = peek_while(accept); - m_rest.remove_prefix(taken.size()); - return taken; + return c; } void skip_separators() { @@ -113,8 +75,6 @@ class TransformParser { } } - [[nodiscard]] std::string_view read_name() { return take_while(is_letter); } - [[nodiscard]] bool consume(const char c) { skip_separators(); if (peek() != c) { @@ -124,10 +84,24 @@ class TransformParser { return true; } - /// `std::strtod` wants a terminator, so the run the view bounds is copied - /// out rather than read in place: libc++ has no floating-point - /// `std::from_chars` until llvm 20, which the ndk, emscripten and xcode 16 - /// are all short of. + /// The leading run of characters @p accept admits, left in place. + [[nodiscard]] std::string_view peek_while(bool (*accept)(char)) const { + std::size_t length = 0; + while (length < m_rest.size() && accept(m_rest[length])) { + ++length; + } + return m_rest.substr(0, length); + } + + /// The same run, consumed. + [[nodiscard]] std::string_view take_while(bool (*accept)(char)) { + const std::string_view taken = peek_while(accept); + m_rest.remove_prefix(taken.size()); + return taken; + } + + /// `std::strtod` wants a terminator, which the view does not promise, so the + /// run it bounds is copied out. [[nodiscard]] std::optional read_number() { skip_separators(); const std::string number(peek_while(is_number_char)); @@ -141,6 +115,64 @@ class TransformParser { return value; } + [[nodiscard]] bool starts_number() const { + const char c = peek(); + return c == '-' || c == '+' || c == '.' || (c >= '0' && c <= '9'); + } + + static bool is_letter(const char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + +private: + static bool is_separator(const char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ','; + } + /// A superset of a number's characters, to bound the run `std::strtod` reads. + static bool is_number_char(const char c) { + return (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || + c == 'e' || c == 'E'; + } + + std::string_view m_rest; +}; + +/// Composes the operation list, holding the translation in centimetres. +class TransformParser : private Scanner { +public: + using Scanner::Scanner; + + [[nodiscard]] std::optional parse() { + while (true) { + skip_separators(); + if (empty()) { + break; + } + if (!parse_operation()) { + return {}; + } + } + + const double scale = m_unit.empty() ? 1.0 : centimetres_per(m_unit); + const DynamicUnit unit{m_unit}; + return DrawingTransform{ + .a = m_transform.a, + .b = m_transform.b, + .c = m_transform.c, + .d = m_transform.d, + .e = Measure(m_transform.e / scale, unit), + .f = Measure(m_transform.f / scale, unit), + }; + } + +private: + util::math::Transform2D m_transform; + /// What every length agreed on, or `cm` where they did not; empty until one + /// is seen, which keeps a list of pure rotations unitless. + std::string m_unit; + + [[nodiscard]] std::string_view read_name() { return take_while(is_letter); } + /// Reduced to centimetres. [[nodiscard]] std::optional read_length() { const std::optional value = read_number(); @@ -240,6 +272,334 @@ class TransformParser { } }; +/// Reads an svg `d` (19.180) and writes it back out, boxed by every point and +/// control point it names. Only the numbers are re-rendered. +class PathParser : private Scanner { +public: + using Scanner::Scanner; + + [[nodiscard]] std::optional parse() { + while (true) { + skip_separators(); + if (empty()) { + break; + } + if (!parse_command()) { + return {}; + } + } + if (m_empty) { + return {}; + } + return DrawingPath{ + .data = m_out, + .x = m_min_x, + .y = m_min_y, + .width = m_max_x - m_min_x, + .height = m_max_y - m_min_y, + }; + } + +private: + std::string m_out; + char m_command{'\0'}; + + /// The pen, and the current subpath's start, both absolute. + double m_x{0}; + double m_y{0}; + double m_start_x{0}; + double m_start_y{0}; + + bool m_empty{true}; + double m_min_x{0}; + double m_min_y{0}; + double m_max_x{0}; + double m_max_y{0}; + + void write_command(const char command) { + if (!m_out.empty()) { + m_out += ' '; + } + m_out += command; + } + + void write_number(const double value) { + m_out += ' '; + m_out += util::number::to_string_significant(value, 7); + } + + void cover(const double x, const double y) { + if (m_empty) { + m_min_x = m_max_x = x; + m_min_y = m_max_y = y; + m_empty = false; + return; + } + m_min_x = std::min(m_min_x, x); + m_min_y = std::min(m_min_y, y); + m_max_x = std::max(m_max_x, x); + m_max_y = std::max(m_max_y, y); + } + + /// Numbers per repetition; `-1` for a letter that is not a command. + [[nodiscard]] static int arity(const char command) { + switch (std::tolower(static_cast(command))) { + case 'z': + return 0; + case 'h': + case 'v': + return 1; + case 'm': + case 'l': + case 't': + return 2; + case 's': + case 'q': + return 4; + case 'c': + return 6; + case 'a': + return 7; + default: + return -1; + } + } + + /// A command letter, then as many argument sets as follow it (19.180). + [[nodiscard]] bool parse_command() { + if (arity(peek()) >= 0) { + m_command = take(); + } else if (m_command == '\0') { + return false; + } + do { + if (!write_arguments()) { + return false; + } + skip_separators(); + } while (arity(m_command) > 0 && starts_number()); + return true; + } + + [[nodiscard]] bool write_arguments() { + const char command = m_command; + const int count = arity(command); + const bool relative = command >= 'a' && command <= 'z'; + + if (count == 0) { + write_command(command); + m_x = m_start_x; + m_y = m_start_y; + return true; + } + + std::vector arguments(static_cast(count)); + for (double &argument : arguments) { + const std::optional value = read_number(); + if (!value.has_value()) { + return false; + } + argument = *value; + } + + write_command(command); + for (const double argument : arguments) { + write_number(argument); + } + + const double origin_x = relative ? m_x : 0; + const double origin_y = relative ? m_y : 0; + + switch (std::tolower(static_cast(command))) { + case 'h': + m_x = origin_x + arguments[0]; + break; + case 'v': + m_y = origin_y + arguments[0]; + break; + case 'a': + // The radii, rotation and flags ahead of the endpoint are not points. + m_x = origin_x + arguments[5]; + m_y = origin_y + arguments[6]; + break; + default: + // Coordinate pairs, the last of which is where the pen lands. + for (std::size_t i = 0; i + 1 < arguments.size(); i += 2) { + cover(origin_x + arguments[i], origin_y + arguments[i + 1]); + } + m_x = origin_x + arguments[arguments.size() - 2]; + m_y = origin_y + arguments[arguments.size() - 1]; + break; + } + cover(m_x, m_y); + + if (command == 'M' || command == 'm') { + m_start_x = m_x; + m_start_y = m_y; + // A repeated `moveto` pair is a `lineto` (19.180). + m_command = command == 'M' ? 'L' : 'l'; + } + + return true; + } +}; + +/// The user-space box a shape's geometry is written in. +struct ViewBox { + double x{0}; + double y{0}; + double width{0}; + double height{0}; +}; + +/// `svg:viewBox` (19.508). +std::optional read_view_box(const pugi::xml_node node) { + const pugi::xml_attribute attribute = node.attribute("svg:viewBox"); + if (!attribute) { + return {}; + } + Scanner in(attribute.value()); + std::array values{}; + for (double &value : values) { + const std::optional number = in.read_number(); + if (!number.has_value()) { + return {}; + } + value = *number; + } + if (values[2] <= 0 || values[3] <= 0) { + return {}; + } + return ViewBox{ + .x = values[0], .y = values[1], .width = values[2], .height = values[3]}; +} + +/// `draw:points` (19.187): `x,y` pairs in the view box's coordinates. +std::optional read_points(const pugi::xml_node node, + const bool close) { + const pugi::xml_attribute attribute = node.attribute("draw:points"); + if (!attribute) { + return {}; + } + Scanner in(attribute.value()); + + std::string result; + while (true) { + in.skip_separators(); + if (in.empty()) { + break; + } + const std::optional x = in.read_number(); + const std::optional y = in.read_number(); + if (!x.has_value() || !y.has_value()) { + return {}; + } + result += result.empty() ? "M " : " L "; + result += util::number::to_string_significant(*x, 7); + result += ' '; + result += util::number::to_string_significant(*y, 7); + } + if (result.empty()) { + return {}; + } + if (close) { + result += " Z"; + } + return result; +} + +/// `draw:regular-polygon` (10.3.9): `draw:corners` vertices from the top, a +/// concave one alternating with a vertex `draw:sharpness` of the way in. +std::optional read_regular_polygon(const pugi::xml_node node) { + const int corners = node.attribute("draw:corners").as_int(0); + if (corners < 3) { + return {}; + } + const bool concave = node.attribute("draw:concave").as_bool(false); + const double sharpness = + node.attribute("draw:sharpness").as_double(50.0) / 100.0; + + const int vertices = concave ? corners * 2 : corners; + + std::string result; + for (int i = 0; i < vertices; ++i) { + const double radius = (concave && i % 2 == 1) + ? view_box_centre * (1 - sharpness) + : view_box_centre; + const double angle = + -std::numbers::pi / 2 + 2 * std::numbers::pi * i / vertices; + result += result.empty() ? "M " : " L "; + result += util::number::to_string_significant( + view_box_centre + radius * std::cos(angle), 7); + result += ' '; + result += util::number::to_string_significant( + view_box_centre + radius * std::sin(angle), 7); + } + result += " Z"; + + return DrawingPath{.data = result, + .x = 0, + .y = 0, + .width = view_box_size, + .height = view_box_size}; +} + +/// 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(); + if (kind != "arc" && kind != "cut" && kind != "section") { + return {}; + } + + const double start = node.attribute("draw:start-angle").as_double(0); + const double end = node.attribute("draw:end-angle").as_double(360); + + const auto point = [](const double degrees) { + const double radians = degrees * std::numbers::pi / 180; + // Counter-clockwise from the positive x axis (19.203), y down. + return std::array{ + view_box_centre + view_box_centre * std::cos(radians), + view_box_centre - view_box_centre * std::sin(radians)}; + }; + const auto write = [](const double value) { + return util::number::to_string_significant(value, 7); + }; + + double swept = std::fmod(end - start, 360.0); + if (swept <= 0) { + swept += 360.0; + } + const std::array from = point(start); + // Counter-clockwise in y-down is svg's sweep flag clear. + const auto arc_to = [&](const std::array &target, + const bool large) { + return " A " + write(view_box_centre) + " " + write(view_box_centre) + + " 0 " + (large ? "1" : "0") + " 0 " + write(target[0]) + " " + + write(target[1]); + }; + + std::string result = "M " + write(from[0]) + " " + write(from[1]); + if (swept >= 360) { + // Svg draws nothing for an arc ending where it starts, so a full sweep + // goes round through the opposite point. + result += arc_to(point(start + 180), false) + arc_to(from, false); + } else { + result += arc_to(point(end), swept > 180); + } + if (kind == "section") { + result += + " L " + write(view_box_centre) + " " + write(view_box_centre) + " Z"; + } else if (kind == "cut") { + result += " Z"; + } + + return DrawingPath{.data = result, + .x = 0, + .y = 0, + .width = view_box_size, + .height = view_box_size}; +} + } // namespace } // namespace odr::internal::odf @@ -259,4 +619,64 @@ odf::parse_transform(const std::string_view value) { return odf::TransformParser(value).parse(); } +std::optional odf::parse_path_data(const std::string_view data) { + return odf::PathParser(data).parse(); +} + +std::optional odf::read_path(const pugi::xml_node node) { + const std::string_view name = node.name(); + + if (name == "draw:path") { + std::optional data = + parse_path_data(node.attribute("svg:d").value()); + if (!data.has_value()) { + return {}; + } + // The path's own extent is only the fallback for a shape stating none. + if (const std::optional view_box = odf::read_view_box(node)) { + data->x = view_box->x; + data->y = view_box->y; + data->width = view_box->width; + data->height = view_box->height; + } + return data; + } + + if (name == "draw:polygon" || name == "draw:polyline") { + const std::optional data = + odf::read_points(node, name == "draw:polygon"); + const std::optional view_box = odf::read_view_box(node); + if (!data.has_value() || !view_box.has_value()) { + return {}; + } + return DrawingPath{.data = *data, + .x = view_box->x, + .y = view_box->y, + .width = view_box->width, + .height = view_box->height}; + } + + if (name == "draw:regular-polygon") { + return odf::read_regular_polygon(node); + } + + if (name == "draw:connector") { + // A connector's `svg:d` is in the page's own coordinates, so the path is + // its own box; one unit keeps a straight one's box legal in svg. + std::optional path = + parse_path_data(node.attribute("svg:d").value()); + if (path.has_value()) { + path->width = std::max(path->width, 1.0); + path->height = std::max(path->height, 1.0); + } + return path; + } + + if (name == "draw:circle" || name == "draw:ellipse") { + return odf::read_elliptical_kind(node); + } + + return {}; +} + } // namespace odr::internal diff --git a/src/odr/internal/odf/odf_geometry.hpp b/src/odr/internal/odf/odf_geometry.hpp index 9e98f4d50..a312e27eb 100644 --- a/src/odr/internal/odf/odf_geometry.hpp +++ b/src/odr/internal/odf/odf_geometry.hpp @@ -1,13 +1,15 @@ #pragma once #include +#include #include #include namespace odr { struct DrawingTransform; -} +struct DrawingPath; +} // namespace odr namespace odr::internal::odf { @@ -20,4 +22,13 @@ read_transform(pugi::xml_node node); [[nodiscard]] std::optional parse_transform(std::string_view value); +/// The outline @p node draws: `draw:path`, `draw:polygon`, `draw:polyline`, +/// `draw:regular-polygon`, `draw:connector`, and a `draw:circle`/`draw:ellipse` +/// that `draw:kind` cuts. Nothing for a shape with no geometry we can read. +[[nodiscard]] std::optional read_path(pugi::xml_node node); + +/// An svg `d` (19.180), read and written back out, boxed by every point and +/// control point in it. Nothing where it does not parse. +[[nodiscard]] std::optional parse_path_data(std::string_view data); + } // namespace odr::internal::odf diff --git a/src/odr/internal/odf/odf_parser.cpp b/src/odr/internal/odf/odf_parser.cpp index 67ff1a2e8..20c907182 100644 --- a/src/odr/internal/odf/odf_parser.cpp +++ b/src/odr/internal/odf/odf_parser.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -236,6 +237,17 @@ parse_sheet(ElementRegistry ®istry, const pugi::xml_node node) { return {element_id, node.next_sibling()}; } +/// `draw:circle` / `draw:ellipse`: a full one is the box, one that +/// `draw:kind` (19.212) cuts needs the path its arc traces. +std::tuple +parse_elliptical_element(ElementRegistry ®istry, const pugi::xml_node node) { + const std::string_view kind = node.attribute("draw:kind").value(); + const ElementType type = (kind == "arc" || kind == "cut" || kind == "section") + ? ElementType::custom_shape + : ElementType::circle; + return parse_element_tree(registry, type, node, parse_any_element_children); +} + void parse_presentation_children(ElementRegistry ®istry, const ElementIdentifier root_id, const pugi::xml_node node) { @@ -304,6 +316,8 @@ parse_any_element_tree(ElementRegistry ®istry, const pugi::xml_node node) { {"text:illustration-index", create_default_tree_parser(ElementType::group)}, {"text:index-body", create_default_tree_parser(ElementType::group)}, + // A `draw:measure` writes its label as `text:measure` runs. + {"text:measure", create_default_tree_parser(ElementType::group)}, {"text:soft-page-break", create_default_tree_parser(ElementType::page_break)}, {"text:date", create_default_tree_parser(ElementType::group)}, @@ -322,9 +336,19 @@ parse_any_element_tree(ElementRegistry ®istry, const pugi::xml_node node) { {"draw:image", create_default_tree_parser(ElementType::image)}, {"draw:rect", create_default_tree_parser(ElementType::rect)}, {"draw:line", create_default_tree_parser(ElementType::line)}, - {"draw:circle", create_default_tree_parser(ElementType::circle)}, + {"draw:circle", parse_elliptical_element}, {"draw:custom-shape", create_default_tree_parser(ElementType::custom_shape)}, + // A shape whose geometry is given rather than named is a custom shape. + {"draw:path", create_default_tree_parser(ElementType::custom_shape)}, + {"draw:polygon", create_default_tree_parser(ElementType::custom_shape)}, + {"draw:polyline", create_default_tree_parser(ElementType::custom_shape)}, + {"draw:regular-polygon", + create_default_tree_parser(ElementType::custom_shape)}, + {"draw:connector", create_default_tree_parser(ElementType::custom_shape)}, + {"draw:caption", create_default_tree_parser(ElementType::rect)}, + {"draw:measure", create_default_tree_parser(ElementType::line)}, + {"draw:ellipse", parse_elliptical_element}, {"draw:text-box", create_default_tree_parser(ElementType::group)}, {"draw:g", create_default_tree_parser(ElementType::frame)}, {"draw:a", create_default_tree_parser(ElementType::link)}, diff --git a/test/data.cmake b/test/data.cmake index a437e3f16..4ff8bc244 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 "b8560b554a01d66f6b41bcd70569578da5ae408b") + REVISION "7ed0b50514bbd3a17e35254139d1ba7bce2f32cc") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "dfb67a4683d60b519602760d3702f3df210ddbb3") + REVISION "400a73efcd9ed311a3118de84687b7922af91fb3") diff --git a/test/src/internal/odf/odf_geometry_test.cpp b/test/src/internal/odf/odf_geometry_test.cpp index 6f54675b0..2606cb433 100644 --- a/test/src/internal/odf/odf_geometry_test.cpp +++ b/test/src/internal/odf/odf_geometry_test.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include @@ -160,3 +162,156 @@ TEST(OdfTransform, a_zero_needs_no_unit) { ASSERT_TRUE(transform.has_value()); EXPECT_EQ(Measure(0, DynamicUnit()), transform->e); } + +namespace { + +pugi::xml_node parse_shape(pugi::xml_document &document, + const std::string &xml) { + EXPECT_TRUE(document.load_string(xml.c_str())); + return document.first_child(); +} + +} // namespace + +TEST(OdfPath, commands_are_kept_and_numbers_re_rendered) { + const std::optional path = + parse_path_data("M10,20L30,40 50,60Z"); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 10 20 L 30 40 L 50 60 Z", path->data); +} + +TEST(OdfPath, a_repeated_moveto_pair_is_a_lineto) { + const std::optional path = parse_path_data("m0 0 10 10 20 0"); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("m 0 0 l 10 10 l 20 0", path->data); +} + +TEST(OdfPath, the_box_covers_every_point_and_control_point) { + const std::optional path = + parse_path_data("M 0 0 C 10 -20 30 40 20 0"); + ASSERT_TRUE(path.has_value()); + EXPECT_DOUBLE_EQ(0, path->x); + EXPECT_DOUBLE_EQ(-20, path->y); + EXPECT_DOUBLE_EQ(30, path->width); + EXPECT_DOUBLE_EQ(60, path->height); +} + +TEST(OdfPath, a_relative_command_is_boxed_where_it_lands) { + const std::optional path = + parse_path_data("m 100 100 h 50 v 25"); + ASSERT_TRUE(path.has_value()); + EXPECT_DOUBLE_EQ(100, path->x); + EXPECT_DOUBLE_EQ(100, path->y); + EXPECT_DOUBLE_EQ(50, path->width); + EXPECT_DOUBLE_EQ(25, path->height); +} + +TEST(OdfPath, an_unreadable_path_is_dropped_whole) { + EXPECT_FALSE(parse_path_data("").has_value()); + EXPECT_FALSE(parse_path_data("10 20").has_value()); + EXPECT_FALSE(parse_path_data("M 10").has_value()); + EXPECT_FALSE(parse_path_data("M 0 0 W 1 2").has_value()); +} + +TEST(OdfShape, draw_path_is_written_in_its_view_box) { + pugi::xml_document document; + const std::optional path = read_path(parse_shape( + document, + R"()")); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 0 0 L 100 200 Z", path->data); + EXPECT_DOUBLE_EQ(0, path->x); + EXPECT_DOUBLE_EQ(100, path->width); + EXPECT_DOUBLE_EQ(200, path->height); +} + +TEST(OdfShape, a_polygon_closes_and_a_polyline_does_not) { + pugi::xml_document polygon; + const std::optional closed = + read_path(parse_shape(polygon, R"()")); + ASSERT_TRUE(closed.has_value()); + EXPECT_EQ("M 0 0 L 10 0 L 10 10 Z", closed->data); + + pugi::xml_document polyline; + const std::optional open = read_path( + parse_shape(polyline, R"()")); + ASSERT_TRUE(open.has_value()); + EXPECT_EQ("M 0 0 L 10 0 L 10 10", open->data); +} + +TEST(OdfShape, a_regular_polygon_starts_at_the_top) { + pugi::xml_document document; + const std::optional path = read_path( + parse_shape(document, R"()")); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 10800 0 L 21600 10800 L 10800 21600 L 0 10800 Z", path->data); + EXPECT_DOUBLE_EQ(21600, path->width); +} + +TEST(OdfShape, a_connector_is_boxed_by_its_own_path) { + pugi::xml_document document; + const std::optional path = read_path(parse_shape( + document, R"()")); + ASSERT_TRUE(path.has_value()); + EXPECT_DOUBLE_EQ(4400, path->x); + EXPECT_DOUBLE_EQ(6400, path->y); + EXPECT_DOUBLE_EQ(1575, path->width); + EXPECT_DOUBLE_EQ(5000, path->height); +} + +TEST(OdfShape, a_straight_connector_keeps_a_box_svg_accepts) { + pugi::xml_document document; + const std::optional path = read_path(parse_shape( + document, R"()")); + ASSERT_TRUE(path.has_value()); + EXPECT_DOUBLE_EQ(400, path->width); + EXPECT_DOUBLE_EQ(1, path->height); +} + +TEST(OdfShape, a_full_ellipse_has_no_path_of_its_own) { + pugi::xml_document document; + EXPECT_FALSE( + read_path(parse_shape(document, R"()")) + .has_value()); + pugi::xml_document full; + EXPECT_FALSE( + read_path(parse_shape(full, R"()")) + .has_value()); +} + +TEST(OdfShape, an_elliptical_arc_traces_its_angles_counter_clockwise) { + pugi::xml_document document; + const std::optional path = read_path( + parse_shape(document, R"()")); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 21600 10800 A 10800 10800 0 0 0 10800 0", path->data); +} + +TEST(OdfShape, a_section_closes_through_the_centre_and_a_cut_across_it) { + pugi::xml_document section_document; + const std::optional section = read_path(parse_shape( + section_document, R"()")); + ASSERT_TRUE(section.has_value()); + EXPECT_TRUE(section->data.ends_with("L 10800 10800 Z")); + + pugi::xml_document cut_document; + const std::optional cut = read_path(parse_shape( + cut_document, R"()")); + ASSERT_TRUE(cut.has_value()); + EXPECT_TRUE(cut->data.ends_with("10800 0 Z")); +} + +TEST(OdfShape, an_arc_over_half_the_ellipse_sets_the_large_arc_flag) { + pugi::xml_document document; + const std::optional path = read_path( + parse_shape(document, R"()")); + ASSERT_TRUE(path.has_value()); + EXPECT_NE(std::string::npos, path->data.find(" 0 1 0 ")); +}