From 13bf98d57c2fea1b619ea26b761b84e555d642ff Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 30 Aug 2026 22:20:40 +0200 Subject: [PATCH 1/2] feat(odf): draw a shape where its `draw:transform` puts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `draw:transform` (19.228) was not read at all, so a rotated, skewed or matrix-placed shape was drawn unrotated — and a shape placed by a transform rather than by `svg:x`/`svg:y`, which is what libreoffice writes for a rotated one, was drawn at the origin. 25 occurrences over 4 files in the test corpus, on `draw:custom-shape`, `draw:frame` and `draw:path`. `odf_geometry.cpp` composes the operation list into one `DrawingTransform` — four unitless numbers and a translation carrying a length, which is what `matrix(a b c d e f)` means — and `Frame`, `Rect`, `Line`, `Circle` and `CustomShape` hand it out. The renderer writes it as css, with `transform-origin:0 0`, because css otherwise turns a box about its centre. Composing rather than passing the string through is not optional: ODF angles are radians where css wants degrees, and no input markup authors our output. Two things the spec text does not settle, both decided against libreoffice's own svg export of `style-drawing-1.odp`: - the list applies to the shape left to right, so the `translate` in `rotate (a) translate (x y)` is *not* itself rotated. The other reading puts the shape's left edge at 22779; libreoffice reports 23084, which is what this one gives, to the unit. - `rotate` is counter-clockwise for a positive angle, the opposite of svg's handedness in the same y-down space. Libreoffice writes `rotate(-19.4)` in degrees for the file's `rotate (0.340164671213695)`. A transform is an element accessor rather than a `GraphicStyle` field because `get_intermediate_style` overrides down the element parent chain, which would leak a group's transform onto every child that carries none of its own. The four engines that implement `FrameAdapter` without a transform to give return nothing; ooxml's presentation adapter gets the `TODO` for `a:xfrm`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017XABfEapaADjFQCt1vjmDF --- CHANGELOG.md | 4 + CMakeLists.txt | 1 + .../include/OdrCoreObjC/ODRDocumentElement.h | 19 ++ apple/src/ODRDocumentElement.mm | 64 +++++ apple/src/ODRPrivate.h | 5 + jni/CMakeLists.txt | 1 + jni/java/app/opendocument/core/Circle.java | 6 + .../app/opendocument/core/CustomShape.java | 6 + .../opendocument/core/DrawingTransform.java | 43 +++ jni/java/app/opendocument/core/Frame.java | 6 + jni/java/app/opendocument/core/Line.java | 6 + jni/java/app/opendocument/core/Rect.java | 6 + jni/src/jni_convert.hpp | 4 + jni/src/jni_document.cpp | 45 +++ jni/src/jni_style.cpp | 14 + python/src/bind_document.cpp | 14 + src/odr/document_element.cpp | 25 ++ src/odr/document_element.hpp | 19 ++ src/odr/internal/abstract/document.hpp | 11 + src/odr/internal/html/document_element.cpp | 3 +- src/odr/internal/html/document_style.cpp | 44 +++ src/odr/internal/html/document_style.hpp | 5 + src/odr/internal/iwork/iwork_document.cpp | 4 + src/odr/internal/odf/PLAN.md | 13 +- src/odr/internal/odf/README.md | 3 +- src/odr/internal/odf/odf_document.cpp | 21 ++ src/odr/internal/odf/odf_geometry.cpp | 262 ++++++++++++++++++ src/odr/internal/odf/odf_geometry.hpp | 23 ++ .../oldms/presentation/ppt_document.cpp | 4 + .../ooxml_presentation_document.cpp | 5 + .../ooxml_spreadsheet_document.cpp | 4 + .../ooxml/text/ooxml_text_document.cpp | 4 + test/CMakeLists.txt | 1 + .../src/internal/html/document_style_test.cpp | 55 ++++ test/src/internal/odf/odf_geometry_test.cpp | 162 +++++++++++ 35 files changed, 907 insertions(+), 5 deletions(-) create mode 100644 jni/java/app/opendocument/core/DrawingTransform.java create mode 100644 src/odr/internal/odf/odf_geometry.cpp create mode 100644 src/odr/internal/odf/odf_geometry.hpp create mode 100644 test/src/internal/odf/odf_geometry_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e33cf93c..b8a212077 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 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. + - A StarView metafile's text is decoded by the charset it names. Every encoding but `UCS2` used to emit the file's own bytes, and the invalid utf-8 a non-ascii label made of that cost the whole image. diff --git a/CMakeLists.txt b/CMakeLists.txt index d635ded0b..0d8e0b7b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -182,6 +182,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/odf/odf_element_registry.cpp" "src/odr/internal/odf/odf_file.cpp" "src/odr/internal/odf/odf_flat_file.cpp" + "src/odr/internal/odf/odf_geometry.cpp" "src/odr/internal/odf/odf_list.cpp" "src/odr/internal/odf/odf_manifest.cpp" "src/odr/internal/odf/odf_meta.cpp" diff --git a/apple/include/OdrCoreObjC/ODRDocumentElement.h b/apple/include/OdrCoreObjC/ODRDocumentElement.h index 97ce71903..280a77de6 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::DrawingTransform`. +NS_SWIFT_NAME(DrawingTransform) +@interface ODRDrawingTransform : NSObject +@property(nonatomic, readonly) double a; +@property(nonatomic, readonly) double b; +@property(nonatomic, readonly) double c; +@property(nonatomic, readonly) double d; +@property(nonatomic, readonly) ODRMeasure *e; +@property(nonatomic, readonly) ODRMeasure *f; + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +@end + /// `odr::Frame`. NS_SWIFT_NAME(Frame) @interface ODRFrame : ODRElement @@ -259,6 +273,7 @@ NS_SWIFT_NAME(Frame) @property(nonatomic, readonly, nullable) ODRMeasure *height; /// `int32_t`, boxed; `nil` when the document did not set one. @property(nonatomic, readonly, nullable) NSNumber *zIndex; +@property(nonatomic, readonly, nullable) ODRDrawingTransform *transform; @property(nonatomic, readonly) ODRGraphicStyle *style; @end @@ -269,6 +284,7 @@ NS_SWIFT_NAME(Rect) @property(nonatomic, readonly) ODRMeasure *y; @property(nonatomic, readonly) ODRMeasure *width; @property(nonatomic, readonly) ODRMeasure *height; +@property(nonatomic, readonly, nullable) ODRDrawingTransform *transform; @property(nonatomic, readonly) ODRGraphicStyle *style; @end @@ -279,6 +295,7 @@ NS_SWIFT_NAME(Line) @property(nonatomic, readonly) ODRMeasure *y1; @property(nonatomic, readonly) ODRMeasure *x2; @property(nonatomic, readonly) ODRMeasure *y2; +@property(nonatomic, readonly, nullable) ODRDrawingTransform *transform; @property(nonatomic, readonly) ODRGraphicStyle *style; @end @@ -289,6 +306,7 @@ NS_SWIFT_NAME(Circle) @property(nonatomic, readonly) ODRMeasure *y; @property(nonatomic, readonly) ODRMeasure *width; @property(nonatomic, readonly) ODRMeasure *height; +@property(nonatomic, readonly, nullable) ODRDrawingTransform *transform; @property(nonatomic, readonly) ODRGraphicStyle *style; @end @@ -299,6 +317,7 @@ NS_SWIFT_NAME(CustomShape) @property(nonatomic, readonly, nullable) ODRMeasure *y; @property(nonatomic, readonly) ODRMeasure *width; @property(nonatomic, readonly) ODRMeasure *height; +@property(nonatomic, readonly, nullable) ODRDrawingTransform *transform; @property(nonatomic, readonly) ODRGraphicStyle *style; @end diff --git a/apple/src/ODRDocumentElement.mm b/apple/src/ODRDocumentElement.mm index fe0921f0f..22297465c 100644 --- a/apple/src/ODRDocumentElement.mm +++ b/apple/src/ODRDocumentElement.mm @@ -733,6 +733,15 @@ - (nullable NSNumber *)zIndex { nil); } +- (nullable ODRDrawingTransform *)transform { + return guarded_value( + [&]() -> ODRDrawingTransform * { + return [ODRDrawingTransform + transformWithHandle:self.handle.as_frame().transform()]; + }, + nil); +} + - (ODRGraphicStyle *)style { return guarded_value( [&]() -> ODRGraphicStyle * { @@ -743,6 +752,25 @@ - (ODRGraphicStyle *)style { @end +@implementation ODRDrawingTransform + ++ (nullable instancetype)transformWithHandle: + (const std::optional &)handle { + if (!handle.has_value()) { + return nil; + } + ODRDrawingTransform *const result = [[ODRDrawingTransform alloc] init]; + result->_a = handle->a; + result->_b = handle->b; + result->_c = handle->c; + result->_d = handle->d; + result->_e = [ODRMeasure measureWithHandle:handle->e]; + result->_f = [ODRMeasure measureWithHandle:handle->f]; + return result; +} + +@end + @implementation ODRRect - (ODRMeasure *)x { @@ -773,6 +801,15 @@ - (ODRMeasure *)height { nil); } +- (nullable ODRDrawingTransform *)transform { + return guarded_value( + [&]() -> ODRDrawingTransform * { + return [ODRDrawingTransform + transformWithHandle:self.handle.as_rect().transform()]; + }, + nil); +} + - (ODRGraphicStyle *)style { return guarded_value( [&]() -> ODRGraphicStyle * { @@ -809,6 +846,15 @@ - (ODRMeasure *)y2 { nil); } +- (nullable ODRDrawingTransform *)transform { + return guarded_value( + [&]() -> ODRDrawingTransform * { + return [ODRDrawingTransform + transformWithHandle:self.handle.as_line().transform()]; + }, + nil); +} + - (ODRGraphicStyle *)style { return guarded_value( [&]() -> ODRGraphicStyle * { @@ -853,6 +899,15 @@ - (ODRMeasure *)height { nil); } +- (nullable ODRDrawingTransform *)transform { + return guarded_value( + [&]() -> ODRDrawingTransform * { + return [ODRDrawingTransform + transformWithHandle:self.handle.as_circle().transform()]; + }, + nil); +} + - (ODRGraphicStyle *)style { return guarded_value( [&]() -> ODRGraphicStyle * { @@ -894,6 +949,15 @@ - (ODRMeasure *)height { nil); } +- (nullable ODRDrawingTransform *)transform { + return guarded_value( + [&]() -> ODRDrawingTransform * { + return [ODRDrawingTransform + transformWithHandle:self.handle.as_custom_shape().transform()]; + }, + nil); +} + - (ODRGraphicStyle *)style { return guarded_value( [&]() -> ODRGraphicStyle * { diff --git a/apple/src/ODRPrivate.h b/apple/src/ODRPrivate.h index 279d73fd7..4c0944f12 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 ODRDrawingTransform (Private) ++ (nullable instancetype)transformWithHandle: + (const std::optional &)handle; +@end + @interface ODRGraphicStyle (Private) + (instancetype)styleWithHandle:(const odr::GraphicStyle &)handle; @end diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 32230c008..4e4b37e5b 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/DrawingTransform.java" "java/app/opendocument/core/Element.java" "java/app/opendocument/core/ElementType.java" "java/app/opendocument/core/EncryptionState.java" diff --git a/jni/java/app/opendocument/core/Circle.java b/jni/java/app/opendocument/core/Circle.java index 222073d71..037d451ed 100644 --- a/jni/java/app/opendocument/core/Circle.java +++ b/jni/java/app/opendocument/core/Circle.java @@ -22,6 +22,10 @@ public Measure height() { return heightNative(handle()); } + public DrawingTransform transform() { + return transformNative(handle()); + } + public GraphicStyle style() { return styleNative(handle()); } @@ -34,5 +38,7 @@ public GraphicStyle style() { private native Measure heightNative(long handle); + private native DrawingTransform transformNative(long handle); + private native GraphicStyle styleNative(long handle); } diff --git a/jni/java/app/opendocument/core/CustomShape.java b/jni/java/app/opendocument/core/CustomShape.java index de48d666e..babac4713 100644 --- a/jni/java/app/opendocument/core/CustomShape.java +++ b/jni/java/app/opendocument/core/CustomShape.java @@ -22,6 +22,10 @@ public Measure height() { return heightNative(handle()); } + public DrawingTransform transform() { + return transformNative(handle()); + } + public GraphicStyle style() { return styleNative(handle()); } @@ -34,5 +38,7 @@ public GraphicStyle style() { private native Measure heightNative(long handle); + private native DrawingTransform transformNative(long handle); + private native GraphicStyle styleNative(long handle); } diff --git a/jni/java/app/opendocument/core/DrawingTransform.java b/jni/java/app/opendocument/core/DrawingTransform.java new file mode 100644 index 000000000..4ea09d7f0 --- /dev/null +++ b/jni/java/app/opendocument/core/DrawingTransform.java @@ -0,0 +1,43 @@ +package app.opendocument.core; + +import java.util.Objects; + +/** The affine transform a drawing shape carries. Mirrors {@code odr::DrawingTransform}. */ +public final class DrawingTransform { + public final double a; + public final double b; + public final double c; + public final double d; + public final Measure e; + public final Measure f; + + public DrawingTransform(double a, double b, double c, double d, Measure e, Measure f) { + this.a = a; + this.b = b; + this.c = c; + this.d = d; + this.e = Objects.requireNonNull(e); + this.f = Objects.requireNonNull(f); + } + + @Override + public boolean equals(Object other) { + return other instanceof DrawingTransform transform + && a == transform.a + && b == transform.b + && c == transform.c + && d == transform.d + && e.equals(transform.e) + && f.equals(transform.f); + } + + @Override + public int hashCode() { + return Objects.hash(a, b, c, d, e, f); + } + + @Override + public String toString() { + return "matrix(" + a + " " + b + " " + c + " " + d + " " + e + " " + f + ")"; + } +} diff --git a/jni/java/app/opendocument/core/Frame.java b/jni/java/app/opendocument/core/Frame.java index d7153f1fb..dabcaefac 100644 --- a/jni/java/app/opendocument/core/Frame.java +++ b/jni/java/app/opendocument/core/Frame.java @@ -30,6 +30,10 @@ public Integer zIndex() { return zIndexNative(handle()); } + public DrawingTransform transform() { + return transformNative(handle()); + } + public GraphicStyle style() { return styleNative(handle()); } @@ -46,5 +50,7 @@ public GraphicStyle style() { private native Integer zIndexNative(long handle); + private native DrawingTransform transformNative(long handle); + private native GraphicStyle styleNative(long handle); } diff --git a/jni/java/app/opendocument/core/Line.java b/jni/java/app/opendocument/core/Line.java index 113653563..ff20ab3c8 100644 --- a/jni/java/app/opendocument/core/Line.java +++ b/jni/java/app/opendocument/core/Line.java @@ -22,6 +22,10 @@ public Measure y2() { return y2Native(handle()); } + public DrawingTransform transform() { + return transformNative(handle()); + } + public GraphicStyle style() { return styleNative(handle()); } @@ -34,5 +38,7 @@ public GraphicStyle style() { private native Measure y2Native(long handle); + private native DrawingTransform transformNative(long handle); + private native GraphicStyle styleNative(long handle); } diff --git a/jni/java/app/opendocument/core/Rect.java b/jni/java/app/opendocument/core/Rect.java index ae3b41238..71dd4aa11 100644 --- a/jni/java/app/opendocument/core/Rect.java +++ b/jni/java/app/opendocument/core/Rect.java @@ -22,6 +22,10 @@ public Measure height() { return heightNative(handle()); } + public DrawingTransform transform() { + return transformNative(handle()); + } + public GraphicStyle style() { return styleNative(handle()); } @@ -34,5 +38,7 @@ public GraphicStyle style() { private native Measure heightNative(long handle); + private native DrawingTransform transformNative(long handle); + private native GraphicStyle styleNative(long handle); } diff --git a/jni/src/jni_convert.hpp b/jni/src/jni_convert.hpp index 91af3f0f0..7c4435b44 100644 --- a/jni/src/jni_convert.hpp +++ b/jni/src/jni_convert.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -34,6 +35,9 @@ jobject make_table_column_style(JNIEnv *env, jobject make_table_row_style(JNIEnv *env, const odr::TableRowStyle &style); jobject make_table_cell_style(JNIEnv *env, const odr::TableCellStyle &style); jobject make_graphic_style(JNIEnv *env, const odr::GraphicStyle &style); +jobject +make_drawing_transform(JNIEnv *env, + const std::optional &transform); 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 47533f536..ae2e5f975 100644 --- a/jni/src/jni_document.cpp +++ b/jni/src/jni_document.cpp @@ -830,6 +830,15 @@ Java_app_opendocument_core_Frame_zIndexNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_Frame_transformNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_drawing_transform( + env, element(handle).as_frame().transform()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_Frame_styleNative(JNIEnv *env, jobject, jlong handle) { @@ -870,6 +879,15 @@ Java_app_opendocument_core_Rect_heightNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_Rect_transformNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_drawing_transform( + env, element(handle).as_rect().transform()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_Rect_styleNative(JNIEnv *env, jobject, jlong handle) { @@ -908,6 +926,15 @@ Java_app_opendocument_core_Line_y2Native(JNIEnv *env, jobject, jlong handle) { }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_Line_transformNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_drawing_transform( + env, element(handle).as_line().transform()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_Line_styleNative(JNIEnv *env, jobject, jlong handle) { @@ -948,6 +975,15 @@ Java_app_opendocument_core_Circle_heightNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_Circle_transformNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_drawing_transform( + env, element(handle).as_circle().transform()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_Circle_styleNative(JNIEnv *env, jobject, jlong handle) { @@ -993,6 +1029,15 @@ Java_app_opendocument_core_CustomShape_heightNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_CustomShape_transformNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_drawing_transform( + env, element(handle).as_custom_shape().transform()); + }); +} + 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 2bc76103b..1d3d1c10a 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -160,6 +160,20 @@ jobject make_measure(JNIEnv *env, const std::optional &value) { return value.has_value() ? make_measure(env, *value) : nullptr; } +jobject +make_drawing_transform(JNIEnv *env, + const std::optional &transform) { + if (!transform.has_value()) { + return nullptr; + } + return new_object(env, "app/opendocument/core/DrawingTransform", + "(DDDDLapp/opendocument/core/Measure;" + "Lapp/opendocument/core/Measure;)V", + transform->a, transform->b, transform->c, transform->d, + make_measure(env, transform->e), + make_measure(env, transform->f)); +} + 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 456c19f60..aad0a3072 100644 --- a/python/src/bind_document.cpp +++ b/python/src/bind_document.cpp @@ -277,6 +277,15 @@ void odr_python::bind_document(py::module_ &m) { .def("value_type", &odr::TableCell::value_type) .def("style", &odr::TableCell::style); + py::class_(m, "DrawingTransform") + .def(py::init<>()) + .def_readwrite("a", &odr::DrawingTransform::a) + .def_readwrite("b", &odr::DrawingTransform::b) + .def_readwrite("c", &odr::DrawingTransform::c) + .def_readwrite("d", &odr::DrawingTransform::d) + .def_readwrite("e", &odr::DrawingTransform::e) + .def_readwrite("f", &odr::DrawingTransform::f); + bind_element(m, "Frame") .def("anchor_type", &odr::Frame::anchor_type) .def("x", &odr::Frame::x) @@ -284,6 +293,7 @@ void odr_python::bind_document(py::module_ &m) { .def("width", &odr::Frame::width) .def("height", &odr::Frame::height) .def("z_index", &odr::Frame::z_index) + .def("transform", &odr::Frame::transform) .def("style", &odr::Frame::style); bind_element(m, "Rect") @@ -291,6 +301,7 @@ void odr_python::bind_document(py::module_ &m) { .def("y", &odr::Rect::y) .def("width", &odr::Rect::width) .def("height", &odr::Rect::height) + .def("transform", &odr::Rect::transform) .def("style", &odr::Rect::style); bind_element(m, "Line") @@ -298,6 +309,7 @@ void odr_python::bind_document(py::module_ &m) { .def("y1", &odr::Line::y1) .def("x2", &odr::Line::x2) .def("y2", &odr::Line::y2) + .def("transform", &odr::Line::transform) .def("style", &odr::Line::style); bind_element(m, "Circle") @@ -305,6 +317,7 @@ void odr_python::bind_document(py::module_ &m) { .def("y", &odr::Circle::y) .def("width", &odr::Circle::width) .def("height", &odr::Circle::height) + .def("transform", &odr::Circle::transform) .def("style", &odr::Circle::style); bind_element(m, "CustomShape") @@ -312,6 +325,7 @@ void odr_python::bind_document(py::module_ &m) { .def("y", &odr::CustomShape::y) .def("width", &odr::CustomShape::width) .def("height", &odr::CustomShape::height) + .def("transform", &odr::CustomShape::transform) .def("style", &odr::CustomShape::style); bind_element(m, "Image") diff --git a/src/odr/document_element.cpp b/src/odr/document_element.cpp index d9c03e9eb..853c84da2 100644 --- a/src/odr/document_element.cpp +++ b/src/odr/document_element.cpp @@ -577,6 +577,11 @@ std::optional Frame::z_index() const { : std::optional(); } +std::optional Frame::transform() const { + return exists_() ? m_adapter2->frame_transform(m_identifier) + : std::optional(); +} + GraphicStyle Frame::style() const { return exists_() ? m_adapter2->frame_style(m_identifier) : GraphicStyle(); } @@ -597,6 +602,11 @@ Measure Rect::height() const { return exists_() ? m_adapter2->rect_height(m_identifier) : Measure(0, {}); } +std::optional Rect::transform() const { + return exists_() ? m_adapter2->rect_transform(m_identifier) + : std::optional(); +} + GraphicStyle Rect::style() const { return exists_() ? m_adapter2->rect_style(m_identifier) : GraphicStyle(); } @@ -617,6 +627,11 @@ Measure Line::y2() const { return exists_() ? m_adapter2->line_y2(m_identifier) : Measure(0, {}); } +std::optional Line::transform() const { + return exists_() ? m_adapter2->line_transform(m_identifier) + : std::optional(); +} + GraphicStyle Line::style() const { return exists_() ? m_adapter2->line_style(m_identifier) : GraphicStyle(); } @@ -637,6 +652,11 @@ Measure Circle::height() const { return exists_() ? m_adapter2->circle_height(m_identifier) : Measure(0, {}); } +std::optional Circle::transform() const { + return exists_() ? m_adapter2->circle_transform(m_identifier) + : std::optional(); +} + GraphicStyle Circle::style() const { return exists_() ? m_adapter2->circle_style(m_identifier) : GraphicStyle(); } @@ -661,6 +681,11 @@ Measure CustomShape::height() const { : Measure(0, {}); } +std::optional CustomShape::transform() const { + return exists_() ? m_adapter2->custom_shape_transform(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 5a38d684b..334e5964f 100644 --- a/src/odr/document_element.hpp +++ b/src/odr/document_element.hpp @@ -482,6 +482,20 @@ class TableCell final [[nodiscard]] TableCellStyle style() const; }; +/// @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 +/// `matrix(a b c d e f)`. `e` and `f` are lengths in the unit the document +/// wrote, and unitless only where they are zero. +struct DrawingTransform final { + double a{1}; + double b{0}; + double c{0}; + double d{1}; + Measure e{0, DynamicUnit()}; + Measure f{0, DynamicUnit()}; +}; + /// @brief Represents a frame element in a document. class Frame final : public ElementBase { public: @@ -493,6 +507,7 @@ class Frame final : public ElementBase { [[nodiscard]] std::optional width() const; [[nodiscard]] std::optional height() const; [[nodiscard]] std::optional z_index() const; + [[nodiscard]] std::optional transform() const; [[nodiscard]] GraphicStyle style() const; }; @@ -506,6 +521,7 @@ class Rect final : public ElementBase { [[nodiscard]] Measure y() const; [[nodiscard]] Measure width() const; [[nodiscard]] Measure height() const; + [[nodiscard]] std::optional transform() const; [[nodiscard]] GraphicStyle style() const; }; @@ -519,6 +535,7 @@ class Line final : public ElementBase { [[nodiscard]] Measure y1() const; [[nodiscard]] Measure x2() const; [[nodiscard]] Measure y2() const; + [[nodiscard]] std::optional transform() const; [[nodiscard]] GraphicStyle style() const; }; @@ -532,6 +549,7 @@ class Circle final : public ElementBase { [[nodiscard]] Measure y() const; [[nodiscard]] Measure width() const; [[nodiscard]] Measure height() const; + [[nodiscard]] std::optional transform() const; [[nodiscard]] GraphicStyle style() const; }; @@ -546,6 +564,7 @@ class CustomShape final [[nodiscard]] std::optional y() const; [[nodiscard]] Measure width() const; [[nodiscard]] Measure height() const; + [[nodiscard]] std::optional transform() const; [[nodiscard]] GraphicStyle style() const; }; diff --git a/src/odr/internal/abstract/document.hpp b/src/odr/internal/abstract/document.hpp index a24139762..130f3a13f 100644 --- a/src/odr/internal/abstract/document.hpp +++ b/src/odr/internal/abstract/document.hpp @@ -28,6 +28,7 @@ struct TableCellStyle; struct TextStyle; struct ParagraphStyle; struct GraphicStyle; +struct DrawingTransform; } // namespace odr namespace odr::internal::abstract { @@ -442,6 +443,8 @@ class FrameAdapter { frame_height(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual std::optional frame_z_index(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual std::optional + frame_transform(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual GraphicStyle frame_style(ElementIdentifier element_id) const = 0; @@ -457,6 +460,8 @@ class RectAdapter { rect_width(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual Measure rect_height(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual std::optional + rect_transform(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual GraphicStyle rect_style(ElementIdentifier element_id) const = 0; @@ -470,6 +475,8 @@ class LineAdapter { [[nodiscard]] virtual Measure line_y1(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual Measure line_x2(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual Measure line_y2(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual std::optional + line_transform(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual GraphicStyle line_style(ElementIdentifier element_id) const = 0; @@ -487,6 +494,8 @@ class CircleAdapter { circle_width(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual Measure circle_height(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual std::optional + circle_transform(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual GraphicStyle circle_style(ElementIdentifier element_id) const = 0; @@ -504,6 +513,8 @@ class CustomShapeAdapter { custom_shape_width(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual Measure custom_shape_height(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual std::optional + custom_shape_transform(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 bb2ece79f..e5e8b2b5e 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -622,7 +622,8 @@ void html::translate_line(const Element &element, const WritingState &state) { {"version", "1.1"}, {"overflow", "visible"}}) .set_style("z-index:-1;position:absolute;top:0;left:0;" + - translate_drawing_style(style))); + translate_drawing_style(style) + + translate_drawing_transform(line.transform()))); state.out().write_element_begin( "line", diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 3da69f987..1eb761477 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace odr::internal { @@ -416,6 +417,45 @@ std::string html::translate_drawing_style(const GraphicStyle &graphic_style) { return result; } +std::string html::translate_drawing_transform( + const std::optional &transform) { + if (!transform.has_value()) { + return ""; + } + + const auto number = [](const double value) { + return util::number::to_string_significant(value, 7); + }; + + // Css applies the list right to left, so the linear part goes last. + std::string result; + if (transform->e.magnitude() != 0 || transform->f.magnitude() != 0) { + result.append("translate(") + .append(transform->e.to_string()) + .append(",") + .append(transform->f.to_string()) + .append(")"); + } + if (transform->a != 1 || transform->b != 0 || transform->c != 0 || + transform->d != 1) { + result.append(result.empty() ? "" : " ") + .append("matrix(") + .append(number(transform->a)) + .append(",") + .append(number(transform->b)) + .append(",") + .append(number(transform->c)) + .append(",") + .append(number(transform->d)) + .append(",0,0)"); + } + if (result.empty()) { + return ""; + } + // Css would otherwise turn the box about its centre. + return "transform:" + result + ";transform-origin:0 0;"; +} + std::string html::translate_frame_properties(const Frame &frame) { const GraphicStyle style = frame.style(); @@ -510,6 +550,7 @@ std::string html::translate_frame_properties(const Frame &frame) { z_index.has_value()) { result += "z-index:" + std::to_string(*z_index) + ";"; } + result += translate_drawing_transform(frame.transform()); return result; } @@ -520,6 +561,7 @@ std::string html::translate_rect_properties(const Rect &rect) { result += "top:" + rect.y().to_string() + ";"; result += "width:" + rect.width().to_string() + ";"; result += "height:" + rect.height().to_string() + ";"; + result += translate_drawing_transform(rect.transform()); return result; } @@ -530,6 +572,7 @@ std::string html::translate_circle_properties(const Circle &circle) { result += "top:" + circle.y().to_string() + ";"; result += "width:" + circle.width().to_string() + ";"; result += "height:" + circle.height().to_string() + ";"; + result += translate_drawing_transform(circle.transform()); return result; } @@ -549,6 +592,7 @@ html::translate_custom_shape_properties(const CustomShape &custom_shape) { } result += "width:" + custom_shape.width().to_string() + ";"; result += "height:" + custom_shape.height().to_string() + ";"; + result += translate_drawing_transform(custom_shape.transform()); return result; } diff --git a/src/odr/internal/html/document_style.hpp b/src/odr/internal/html/document_style.hpp index e2a0245bd..433dd9a1b 100644 --- a/src/odr/internal/html/document_style.hpp +++ b/src/odr/internal/html/document_style.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include namespace odr { @@ -22,6 +23,7 @@ struct TableColumnStyle; struct TableRowStyle; struct TableCellStyle; struct GraphicStyle; +struct DrawingTransform; struct PageLayout; } // namespace odr @@ -47,6 +49,9 @@ translate_table_column_style(const TableColumnStyle &table_column_style); std::string translate_table_row_style(const TableRowStyle &table_row_style); std::string translate_table_cell_style(const TableCellStyle &table_cell_style); std::string translate_drawing_style(const GraphicStyle &graphic_style); +/// Empty for a transform that moves nothing. +std::string +translate_drawing_transform(const std::optional &transform); std::string translate_frame_properties(const Frame &frame); std::string translate_rect_properties(const Rect &rect); diff --git a/src/odr/internal/iwork/iwork_document.cpp b/src/odr/internal/iwork/iwork_document.cpp index 404c3a885..245ab3055 100644 --- a/src/odr/internal/iwork/iwork_document.cpp +++ b/src/odr/internal/iwork/iwork_document.cpp @@ -365,6 +365,10 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return std::nullopt; } + [[nodiscard]] std::optional frame_transform( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return std::nullopt; + } [[nodiscard]] GraphicStyle frame_style( [[maybe_unused]] const ElementIdentifier element_id) const override { return {}; diff --git a/src/odr/internal/odf/PLAN.md b/src/odr/internal/odf/PLAN.md index d5948cc18..553669ddc 100644 --- a/src/odr/internal/odf/PLAN.md +++ b/src/odr/internal/odf/PLAN.md @@ -94,12 +94,19 @@ leaves behind. Each stage is one pull request, stacked on the one before. -### 1 — `draw:transform` +### 1 — `draw:transform` — landed `DrawingTransform` in the public header; `transform()` on `Frame`, `Rect`, -`Line`, `Circle` and `CustomShape`; the odf adapter reads and composes the +`Line`, `Circle` and `CustomShape`; `odf_geometry.cpp` reads and composes the attribute; the renderer writes `transform` + `transform-origin:0 0`. -Visible on 4 files. Closes the `transform` box in `README.md`. + +Two things the spec text does not settle, both fixed against libreoffice's own +svg export of `style-drawing-1.odp`: the list applies to the shape **left to +right**, so a `translate` after a `rotate` is not itself rotated, and `rotate` +is **counter-clockwise** for a positive angle — libreoffice writes svg's +`rotate(-19.4°)` for the file's `rotate (0.34 rad)`. Composing both readings +and comparing against the bounding box libreoffice reports (`x=23084`, exact) +is what decided it. ### 2 — the missing shape elements diff --git a/src/odr/internal/odf/README.md b/src/odr/internal/odf/README.md index b46f54fed..c3fadd692 100644 --- a/src/odr/internal/odf/README.md +++ b/src/odr/internal/odf/README.md @@ -66,7 +66,8 @@ Roughly ordered by importance. - [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 - - [ ] transform (e.g. flip, rotate) + - [x] transform (`draw:transform`, its operation list composed to one matrix) + - [ ] mirror (`style:mirror`, `draw:mirror-horizontal` / `-vertical`) - [x] page layout (size, orientation, margins) - [ ] annotations (`office:annotation`) diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index 3f5d32814..2c987fa44 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -838,6 +839,10 @@ class ElementAdapter final : public abstract::ElementAdapter, } return static_cast(attribute.as_int()); } + [[nodiscard]] std::optional + frame_transform(const ElementIdentifier element_id) const override { + return read_transform(get_node(element_id)); + } [[nodiscard]] GraphicStyle frame_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).graphic_style; @@ -859,6 +864,10 @@ class ElementAdapter final : public abstract::ElementAdapter, rect_height(const ElementIdentifier element_id) const override { return read_measure_or_zero(get_node(element_id).attribute("svg:height")); } + [[nodiscard]] std::optional + rect_transform(const ElementIdentifier element_id) const override { + return read_transform(get_node(element_id)); + } [[nodiscard]] GraphicStyle rect_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).graphic_style; @@ -880,6 +889,10 @@ class ElementAdapter final : public abstract::ElementAdapter, line_y2(const ElementIdentifier element_id) const override { return read_measure_or_zero(get_node(element_id).attribute("svg:y2")); } + [[nodiscard]] std::optional + line_transform(const ElementIdentifier element_id) const override { + return read_transform(get_node(element_id)); + } [[nodiscard]] GraphicStyle line_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).graphic_style; @@ -901,6 +914,10 @@ class ElementAdapter final : public abstract::ElementAdapter, circle_height(const ElementIdentifier element_id) const override { return read_measure_or_zero(get_node(element_id).attribute("svg:height")); } + [[nodiscard]] std::optional + circle_transform(const ElementIdentifier element_id) const override { + return read_transform(get_node(element_id)); + } [[nodiscard]] GraphicStyle circle_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).graphic_style; @@ -922,6 +939,10 @@ class ElementAdapter final : public abstract::ElementAdapter, custom_shape_height(const ElementIdentifier element_id) const override { return read_measure_or_zero(get_node(element_id).attribute("svg:height")); } + [[nodiscard]] std::optional + custom_shape_transform(const ElementIdentifier element_id) const override { + return read_transform(get_node(element_id)); + } [[nodiscard]] GraphicStyle custom_shape_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).graphic_style; diff --git a/src/odr/internal/odf/odf_geometry.cpp b/src/odr/internal/odf/odf_geometry.cpp new file mode 100644 index 000000000..f961da6a6 --- /dev/null +++ b/src/odr/internal/odf/odf_geometry.cpp @@ -0,0 +1,262 @@ +#include + +#include + +#include + +#include +#include +#include +#include + +namespace odr::internal::odf { + +namespace { + +/// Zero for a unit that is not an absolute length. +double centimetres_per(const std::string_view unit) { + if (unit == "cm") { + return 1.0; + } + if (unit == "mm") { + return 0.1; + } + if (unit == "in") { + return 2.54; + } + if (unit == "pt") { + return 2.54 / 72.0; + } + if (unit == "pc") { + return 2.54 / 6.0; + } + if (unit == "px") { + return 2.54 / 96.0; + } + 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 { +public: + explicit TransformParser(const std::string_view value) : m_rest{value} {} + + [[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; + + /// 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; + } + 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; + } + + void skip_separators() { + while (is_separator(peek())) { + m_rest.remove_prefix(1); + } + } + + [[nodiscard]] std::string_view read_name() { return take_while(is_letter); } + + [[nodiscard]] bool consume(const char c) { + skip_separators(); + if (peek() != c) { + return false; + } + m_rest.remove_prefix(1); + 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. + [[nodiscard]] std::optional read_number() { + skip_separators(); + const std::string number(peek_while(is_number_char)); + char *end = nullptr; + const double value = std::strtod(number.c_str(), &end); + if (end == number.c_str()) { + return {}; + } + // `strtod` may stop short of the run, on a trailing `e` say + m_rest.remove_prefix(static_cast(end - number.c_str())); + return value; + } + + /// Reduced to centimetres. + [[nodiscard]] std::optional read_length() { + const std::optional value = read_number(); + if (!value.has_value()) { + return {}; + } + const std::string_view unit = + take_while([](const char c) { return is_letter(c) || c == '%'; }); + + // A zero needs no unit. + if (unit.empty()) { + return *value == 0.0 ? std::optional(0.0) : std::nullopt; + } + const double scale = centimetres_per(unit); + if (scale == 0.0) { + return {}; + } + if (m_unit.empty()) { + m_unit = unit; + } else if (m_unit != unit) { + m_unit = "cm"; + } + return *value * scale; + } + + /// The list applies left to right, so a `translate` after a `rotate` is not + /// itself rotated. + void compose(const util::math::Transform2D &operation) { + m_transform = m_transform * operation; + } + + [[nodiscard]] bool parse_operation() { + const std::string_view name = read_name(); + if (name.empty() || !consume('(')) { + return false; + } + + if (name == "matrix") { + const std::optional a = read_number(); + const std::optional b = read_number(); + const std::optional c = read_number(); + const std::optional d = read_number(); + const std::optional e = read_length(); + const std::optional f = read_length(); + if (!a || !b || !c || !d || !e || !f) { + return false; + } + compose({*a, *b, *c, *d, *e, *f}); + } else if (name == "translate") { + const std::optional x = read_length(); + if (!x) { + return false; + } + const std::optional y = + peek_argument() ? read_length() : std::optional(0); + if (!y) { + return false; + } + compose(util::math::Transform2D::translation(*x, *y)); + } else if (name == "scale") { + const std::optional x = read_number(); + if (!x) { + return false; + } + const std::optional y = peek_argument() ? read_number() : x; + if (!y) { + return false; + } + compose(util::math::Transform2D::scaling(*x, *y)); + } else if (name == "rotate" || name == "skewX" || name == "skewY") { + const std::optional angle = read_number(); + if (!angle) { + return false; + } + // Radians, counter-clockwise, so the sine changes sign against svg's + // `rotate` in the same y-down space; the skews take that handedness. No + // corpus file skews measurably. + if (name == "rotate") { + const double sin = std::sin(*angle); + const double cos = std::cos(*angle); + compose({cos, -sin, sin, cos, 0, 0}); + } else if (name == "skewX") { + compose({1, 0, -std::tan(*angle), 1, 0, 0}); + } else { + compose({1, -std::tan(*angle), 0, 1, 0, 0}); + } + } else { + return false; + } + + return consume(')'); + } + + [[nodiscard]] bool peek_argument() { + skip_separators(); + return peek() != ')' && peek() != '\0'; + } +}; + +} // namespace + +} // namespace odr::internal::odf + +namespace odr::internal { + +std::optional odf::read_transform(const pugi::xml_node node) { + const pugi::xml_attribute attribute = node.attribute("draw:transform"); + if (!attribute) { + return {}; + } + return parse_transform(attribute.value()); +} + +std::optional +odf::parse_transform(const std::string_view value) { + return odf::TransformParser(value).parse(); +} + +} // namespace odr::internal diff --git a/src/odr/internal/odf/odf_geometry.hpp b/src/odr/internal/odf/odf_geometry.hpp new file mode 100644 index 000000000..9e98f4d50 --- /dev/null +++ b/src/odr/internal/odf/odf_geometry.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include + +namespace odr { +struct DrawingTransform; +} + +namespace odr::internal::odf { + +/// `draw:transform` (19.228) off @p node, its operation list composed into one +/// transform. Nothing where the attribute is absent or unreadable. +[[nodiscard]] std::optional +read_transform(pugi::xml_node node); + +/// Angles are radians; the list applies left to right. +[[nodiscard]] std::optional +parse_transform(std::string_view value); + +} // namespace odr::internal::odf diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index d6a1a1154..475079959 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -215,6 +215,10 @@ class ElementAdapter final : public abstract::ElementAdapter, frame_z_index(const ElementIdentifier /*element_id*/) const override { return std::nullopt; } + [[nodiscard]] std::optional + frame_transform(const ElementIdentifier /*element_id*/) const override { + return std::nullopt; + } [[nodiscard]] GraphicStyle frame_style(const ElementIdentifier /*element_id*/) const override { return {}; diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp index 8d28da329..078a21903 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -496,6 +496,11 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return std::nullopt; } + [[nodiscard]] std::optional frame_transform( + [[maybe_unused]] const ElementIdentifier element_id) const override { + // TODO the rotation `a:xfrm` carries + return std::nullopt; + } [[nodiscard]] GraphicStyle frame_style(const ElementIdentifier element_id) const override { const pugi::xml_node node = get_node(element_id); diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp index 58e3e412a..7cc761986 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp @@ -393,6 +393,10 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return std::nullopt; } + [[nodiscard]] std::optional frame_transform( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return std::nullopt; + } [[nodiscard]] GraphicStyle frame_style( [[maybe_unused]] const ElementIdentifier element_id) const override { return {}; diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 9095afea9..fc3e6f5df 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -547,6 +547,10 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return std::nullopt; } + [[nodiscard]] std::optional frame_transform( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return std::nullopt; + } [[nodiscard]] GraphicStyle frame_style( [[maybe_unused]] const ElementIdentifier element_id) const override { return {}; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 19f618cc8..a451a1b6b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -74,6 +74,7 @@ add_executable(odr_test "src/internal/rtf/rtf_tokenizer_test.cpp" "src/internal/odf/odf_flat_file_test.cpp" + "src/internal/odf/odf_geometry_test.cpp" "src/internal/odf/odf_sheet_repeat_test.cpp" "src/internal/odf/odf_table_test.cpp" diff --git a/test/src/internal/html/document_style_test.cpp b/test/src/internal/html/document_style_test.cpp index eaf1aedb7..85349e261 100644 --- a/test/src/internal/html/document_style_test.cpp +++ b/test/src/internal/html/document_style_test.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -5,6 +6,8 @@ #include +#include + using namespace odr; namespace ihtml = odr::internal::html; @@ -17,6 +20,26 @@ PageLayout a4_page_layout() { return page_layout; } +DrawingTransform identity() { + return DrawingTransform{.a = 1, + .b = 0, + .c = 0, + .d = 1, + .e = Measure(0, DynamicUnit()), + .f = Measure(0, DynamicUnit())}; +} + +/// A quarter turn counter-clockwise, the shape of every rotation the corpus +/// carries. +DrawingTransform quarter_turn() { + DrawingTransform transform = identity(); + transform.a = 0; + transform.b = 1; + transform.c = -1; + transform.d = 0; + return transform; +} + } // namespace TEST(html_document_style, outer_page_style_fixes_both_dimensions) { @@ -70,3 +93,35 @@ TEST(html_document_style, block_font_style_leaves_what_paints_to_the_run) { TEST(html_document_style, block_font_style_of_a_style_naming_no_font) { EXPECT_EQ(ihtml::translate_block_font_style(TextStyle()), ""); } + +TEST(html_document_style, drawing_transform_of_nothing_is_nothing) { + EXPECT_EQ(ihtml::translate_drawing_transform(std::nullopt), ""); +} + +TEST(html_document_style, drawing_transform_of_the_identity_is_nothing) { + EXPECT_EQ(ihtml::translate_drawing_transform(identity()), ""); +} + +TEST(html_document_style, drawing_transform_of_a_translation_writes_no_matrix) { + DrawingTransform transform = identity(); + transform.e = Measure(1, DynamicUnit("cm")); + transform.f = Measure(2, DynamicUnit("cm")); + EXPECT_EQ(ihtml::translate_drawing_transform(transform), + "transform:translate(1cm,2cm);transform-origin:0 0;"); +} + +TEST(html_document_style, drawing_transform_of_a_rotation_writes_no_translate) { + EXPECT_EQ(ihtml::translate_drawing_transform(quarter_turn()), + "transform:matrix(0,1,-1,0,0,0);transform-origin:0 0;"); +} + +TEST(html_document_style, drawing_transform_writes_the_linear_part_last) { + // Css applies the list right to left, so the shape turns about its own + // origin and is moved after, which is what the composed matrix means. + DrawingTransform transform = quarter_turn(); + transform.e = Measure(1, DynamicUnit("cm")); + transform.f = Measure(2, DynamicUnit("cm")); + EXPECT_EQ(ihtml::translate_drawing_transform(transform), + "transform:translate(1cm,2cm) matrix(0,1,-1,0,0,0);" + "transform-origin:0 0;"); +} diff --git a/test/src/internal/odf/odf_geometry_test.cpp b/test/src/internal/odf/odf_geometry_test.cpp new file mode 100644 index 000000000..6f54675b0 --- /dev/null +++ b/test/src/internal/odf/odf_geometry_test.cpp @@ -0,0 +1,162 @@ +#include + +#include + +#include + +#include +#include +#include + +using namespace odr; +using namespace odr::internal::odf; + +namespace { + +void expect_linear(const DrawingTransform &transform, const double a, + const double b, const double c, const double d) { + EXPECT_NEAR(a, transform.a, 1e-9); + EXPECT_NEAR(b, transform.b, 1e-9); + EXPECT_NEAR(c, transform.c, 1e-9); + EXPECT_NEAR(d, transform.d, 1e-9); +} + +} // namespace + +TEST(OdfTransform, empty_list_is_the_identity) { + const std::optional transform = parse_transform(""); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 1, 0, 0, 1); + EXPECT_EQ(Measure(0, DynamicUnit()), transform->e); + EXPECT_EQ(Measure(0, DynamicUnit()), transform->f); +} + +TEST(OdfTransform, translate_keeps_the_unit_it_was_written_in) { + const std::optional transform = + parse_transform("translate (23.084cm 2.415cm)"); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 1, 0, 0, 1); + EXPECT_EQ(Measure(23.084, DynamicUnit("cm")), transform->e); + EXPECT_EQ(Measure(2.415, DynamicUnit("cm")), transform->f); +} + +TEST(OdfTransform, translate_defaults_its_second_coordinate_to_zero) { + const std::optional transform = + parse_transform("translate (4in)"); + ASSERT_TRUE(transform.has_value()); + EXPECT_EQ(Measure(4, DynamicUnit("in")), transform->e); + EXPECT_EQ(Measure(0, DynamicUnit("in")), transform->f); +} + +TEST(OdfTransform, rotate_is_counter_clockwise_and_in_radians) { + const std::optional transform = + parse_transform("rotate (0.340164671213695)"); + ASSERT_TRUE(transform.has_value()); + const double angle = 0.340164671213695; + expect_linear(*transform, std::cos(angle), -std::sin(angle), std::sin(angle), + std::cos(angle)); +} + +TEST(OdfTransform, a_translate_after_a_rotate_is_not_itself_rotated) { + const std::optional transform = parse_transform( + "rotate (0.340164671213695) translate (23.084cm 2.415cm)"); + ASSERT_TRUE(transform.has_value()); + EXPECT_EQ(Measure(23.084, DynamicUnit("cm")), transform->e); + EXPECT_EQ(Measure(2.415, DynamicUnit("cm")), transform->f); +} + +TEST(OdfTransform, a_rotate_after_a_translate_turns_the_translation_too) { + const std::optional transform = + parse_transform("translate (1cm 0cm) rotate (1.5707963267948966)"); + ASSERT_TRUE(transform.has_value()); + // A quarter turn counter-clockwise sends (1cm, 0) to (0, -1cm). + EXPECT_NEAR(0, transform->e.magnitude(), 1e-9); + EXPECT_NEAR(-1, transform->f.magnitude(), 1e-9); +} + +TEST(OdfTransform, matrix_is_taken_as_written) { + const std::optional transform = + parse_transform("matrix (2 0 0 3 1cm 2cm)"); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 2, 0, 0, 3); + EXPECT_EQ(Measure(1, DynamicUnit("cm")), transform->e); + EXPECT_EQ(Measure(2, DynamicUnit("cm")), transform->f); +} + +TEST(OdfTransform, scale_takes_one_factor_for_both_axes) { + const std::optional transform = + parse_transform("scale (2)"); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 2, 0, 0, 2); +} + +TEST(OdfTransform, lengths_that_disagree_on_a_unit_reduce_to_centimetres) { + const std::optional transform = + parse_transform("translate (1in 0cm) translate (1cm 0cm)"); + ASSERT_TRUE(transform.has_value()); + EXPECT_EQ(DynamicUnit("cm"), transform->e.unit()); + EXPECT_NEAR(3.54, transform->e.magnitude(), 1e-9); +} + +TEST(OdfTransform, a_skew_composes_with_the_rest) { + const std::optional transform = + parse_transform("skewX (0.5) translate (1cm 0cm)"); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 1, 0, -std::tan(0.5), 1); + EXPECT_EQ(Measure(1, DynamicUnit("cm")), transform->e); +} + +TEST(OdfTransform, separators_may_be_commas) { + const std::optional transform = + parse_transform("translate(1cm,2cm),scale(2,3)"); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 2, 0, 0, 3); + EXPECT_EQ(Measure(2, DynamicUnit("cm")), transform->e); + EXPECT_EQ(Measure(6, DynamicUnit("cm")), transform->f); +} + +TEST(OdfTransform, an_unreadable_list_is_dropped_whole) { + EXPECT_FALSE(parse_transform("rotate").has_value()); + EXPECT_FALSE(parse_transform("rotate (").has_value()); + EXPECT_FALSE(parse_transform("wobble (1)").has_value()); + EXPECT_FALSE(parse_transform("translate (1cm 2cm").has_value()); +} + +TEST(OdfTransform, a_length_in_a_relative_unit_is_not_a_length) { + EXPECT_FALSE(parse_transform("translate (1em 2em)").has_value()); + EXPECT_FALSE(parse_transform("translate (50% 0%)").has_value()); + EXPECT_FALSE(parse_transform("translate (1 2)").has_value()); +} + +TEST(OdfTransform, a_leading_plus_is_a_sign) { + const std::optional transform = + parse_transform("translate (+1cm +2cm)"); + ASSERT_TRUE(transform.has_value()); + EXPECT_EQ(Measure(1, DynamicUnit("cm")), transform->e); + EXPECT_EQ(Measure(2, DynamicUnit("cm")), transform->f); +} + +TEST(OdfTransform, an_exponent_is_a_number) { + const std::optional transform = + parse_transform("skewX (-5.59465989067396E-017)"); + ASSERT_TRUE(transform.has_value()); + expect_linear(*transform, 1, 0, 5.59465989067396E-017, 1); +} + +TEST(OdfTransform, the_view_ends_the_list) { + // Nothing here reads for a terminator, so the operation the view cuts off is + // not seen. + const std::string_view value = "translate (1cm 2cm) translate (4cm 8cm)"; + const std::optional transform = + parse_transform(value.substr(0, 19)); + ASSERT_TRUE(transform.has_value()); + EXPECT_EQ(Measure(1, DynamicUnit("cm")), transform->e); + EXPECT_EQ(Measure(2, DynamicUnit("cm")), transform->f); +} + +TEST(OdfTransform, a_zero_needs_no_unit) { + const std::optional transform = + parse_transform("translate (0 0)"); + ASSERT_TRUE(transform.has_value()); + EXPECT_EQ(Measure(0, DynamicUnit()), transform->e); +} From eb177f2fd2b2166edaa330e2d5bc14d1da776330 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 31 Aug 2026 19:59:34 +0200 Subject: [PATCH 2/2] test: advance the reference output to the composed `draw:transform` The four drawing files the transform moves: two `draw:frame` on a slide of `style-drawing-1.odp`, one on `presentation-yunation.odp`, and the shapes of `Sealver Measurements.odg`. Every changed line differs by the inserted `transform` / `transform-origin` declaration and nothing else. 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 fda352b4e..a437e3f16 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 "05441f24b4fcc9398b5115718a7d812ab4d63cea") + REVISION "b8560b554a01d66f6b41bcd70569578da5ae408b") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "931589fe01472f0f02eae8423c713afa4db90bd8") + REVISION "dfb67a4683d60b519602760d3702f3df210ddbb3")