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