diff --git a/CHANGELOG.md b/CHANGELOG.md index a1463e270..f48187aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A `.docx` `wp:anchor` drawing floats at its offset with its wrap and side, + instead of sitting in the line. Closes #803. + +- A `.docx` table's own borders (`w:tblPr/w:tblBorders`) are drawn, and no + longer four times too thick. New `TableStyle::border`, + `::border_inside_horizontal` and `::border_inside_vertical`, mirrored in the + JNI, Apple and Python bindings. + +- A table cell in a text document starts its content at the top, as word and + odf do. + - A fitted or zoomed view scales its text with the page in WebKit, where the type used to stay at its unscaled size. `HtmlViewportMode::fit_width_by_view` is usable on iOS. Closes #761. diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index 1dcd7ad07..e1320289a 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -177,6 +177,9 @@ NS_SWIFT_NAME(ParagraphStyle) NS_SWIFT_NAME(TableStyle) @interface ODRTableStyle : NSObject @property(nonatomic, readonly, nullable) ODRMeasure *width; +@property(nonatomic, readonly) ODRDirectionalString *border; +@property(nonatomic, readonly, nullable, copy) NSString *borderInsideHorizontal; +@property(nonatomic, readonly, nullable, copy) NSString *borderInsideVertical; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index 5b18eda30..13a509935 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -231,6 +231,9 @@ @implementation ODRTableStyle + (instancetype)styleWithHandle:(const odr::TableStyle &)handle { ODRTableStyle *const result = [[ODRTableStyle alloc] init]; result->_width = box(handle.width); + result->_border = [ODRDirectionalString directionalWithHandle:handle.border]; + result->_borderInsideHorizontal = box_string(handle.border_inside_horizontal); + result->_borderInsideVertical = box_string(handle.border_inside_vertical); return result; } diff --git a/jni/java/app/opendocument/core/TableStyle.java b/jni/java/app/opendocument/core/TableStyle.java index 9fe6efc56..a8d0b7397 100644 --- a/jni/java/app/opendocument/core/TableStyle.java +++ b/jni/java/app/opendocument/core/TableStyle.java @@ -3,8 +3,18 @@ /** Style of a table. Mirrors {@code odr::TableStyle}; fields may be {@code null}. */ public final class TableStyle { public final Measure width; + public final DirectionalString border; + public final String borderInsideHorizontal; + public final String borderInsideVertical; - TableStyle(Measure width) { + TableStyle( + Measure width, + DirectionalString border, + String borderInsideHorizontal, + String borderInsideVertical) { this.width = width; + this.border = border; + this.borderInsideHorizontal = borderInsideHorizontal; + this.borderInsideVertical = borderInsideVertical; } } diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index f15831e85..913f67c30 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -282,8 +282,13 @@ jobject make_paragraph_style(JNIEnv *env, const odr::ParagraphStyle &style) { jobject make_table_style(JNIEnv *env, const odr::TableStyle &style) { return new_object(env, "app/opendocument/core/TableStyle", - "(Lapp/opendocument/core/Measure;)V", - make_measure(env, style.width)); + "(Lapp/opendocument/core/Measure;" + "Lapp/opendocument/core/DirectionalString;" + "Ljava/lang/String;Ljava/lang/String;)V", + make_measure(env, style.width), + make_directional_string(env, style.border), + make_string_opt(env, style.border_inside_horizontal), + make_string_opt(env, style.border_inside_vertical)); } jobject make_table_column_style(JNIEnv *env, diff --git a/python/src/bind_style.cpp b/python/src/bind_style.cpp index 86f58a322..caf5d366f 100644 --- a/python/src/bind_style.cpp +++ b/python/src/bind_style.cpp @@ -157,7 +157,12 @@ void odr_python::bind_style(py::module_ &m) { py::class_(m, "TableStyle") .def(py::init<>()) - .def_readwrite("width", &odr::TableStyle::width); + .def_readwrite("width", &odr::TableStyle::width) + .def_readwrite("border", &odr::TableStyle::border) + .def_readwrite("border_inside_horizontal", + &odr::TableStyle::border_inside_horizontal) + .def_readwrite("border_inside_vertical", + &odr::TableStyle::border_inside_vertical); py::class_(m, "TableColumnStyle") .def(py::init<>()) diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 1c1635ff1..3ecd6c96a 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -513,15 +513,18 @@ std::string html::translate_frame_properties(const Frame &frame) { result += "display:block;"; result += "float:right;clear:both;"; result += "shape-outside:content-box;"; - if (const std::optional x = frame.x(); x.has_value()) { + const std::optional x = frame.x(); + if (x.has_value()) { result += "margin-left:" + x->to_string() + ";"; } if (const std::optional y = frame.y(); y.has_value()) { result += "margin-top:" + y->to_string() + ";"; } - if (const std::optional width = frame.width(); width.has_value()) { + // holds the frame at its offset; with no offset it would pin it left + if (const std::optional width = frame.width(); + width.has_value() && x.has_value()) { result += "margin-right:calc(100% - "; - result += frame.x().value_or(Measure(0, DynamicUnit("in"))).to_string(); + result += x->to_string(); result += " - "; result += width->to_string(); result += ");"; diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 0fee0a501..246b67dc9 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -24,6 +24,9 @@ body{margin:0;background:#fff} /* What the formats anchor against: a page for shapes, a paragraph or a cell for frames. */ x-p,td,.odr-page-outer{position:relative} +/* Word and odf start a cell at its top where the browser default centres it; a + sheet says its own, more specifically. */ +td{vertical-align:top} x-p{display:block} x-s{display:inline} .odr-background{padding:0;background:#525659} diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 649b27787..905a6ae51 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -85,6 +85,14 @@ ooxml::read_half_point_attribute(const pugi::xml_attribute attribute) { return Measure(attribute.as_double() * 0.5, DynamicUnit("pt")); } +std::optional +ooxml::read_eighth_point_attribute(const pugi::xml_attribute attribute) { + if (!attribute) { + return {}; + } + return Measure(attribute.as_double() * 0.125, DynamicUnit("pt")); +} + std::optional ooxml::read_hundredth_point_attribute(const pugi::xml_attribute attribute) { if (!attribute) { @@ -101,6 +109,13 @@ ooxml::read_emus_attribute(const pugi::xml_attribute attribute) { return Measure(attribute.as_double() / 914400.0, DynamicUnit("in")); } +std::optional ooxml::read_emus_text(const pugi::xml_node node) { + if (!node) { + return {}; + } + return Measure(node.text().as_double() / 914400.0, DynamicUnit("in")); +} + std::optional ooxml::read_twips_attribute(const pugi::xml_attribute attribute) { if (!attribute) { @@ -308,24 +323,33 @@ std::optional ooxml::read_border_node(const pugi::xml_node node) { return {}; } const char *val = node.attribute("w:val").value(); - if (std::strcmp("nil", val) == 0) { - return {}; + if (std::strcmp("nil", val) == 0 || std::strcmp("none", val) == 0) { + return "0 none"; } const std::optional size = - read_half_point_attribute(node.attribute("w:sz")); + read_eighth_point_attribute(node.attribute("w:sz")); if (!size.has_value()) { return {}; } - std::string result; - result.append(size->to_string()).append(" "); - result.append(std::strcmp("none", val) == 0 ? "none " : "solid "); + // `auto` reads as no color, which css then takes from the text + std::string result = size->to_string() + " solid"; if (const std::optional color = read_color_attribute(node.attribute("w:color"))) { - result.append(html::color(*color)); + result.append(" ").append(html::color(*color)); } return result; } +DirectionalStyle +ooxml::read_borders_node(const pugi::xml_node node) { + DirectionalStyle result; + result.right = read_border_node(node.child("w:right")); + result.top = read_border_node(node.child("w:top")); + result.left = read_border_node(node.child("w:left")); + result.bottom = read_border_node(node.child("w:bottom")); + return result; +} + std::unordered_map ooxml::parse_relationships(const pugi::xml_document &relations) { std::unordered_map result; diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index a029b04d7..fa1cc8557 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -29,8 +29,11 @@ namespace odr::internal::ooxml { std::optional read_string_attribute(pugi::xml_attribute); std::optional read_color_attribute(pugi::xml_attribute); std::optional read_half_point_attribute(pugi::xml_attribute); +std::optional read_eighth_point_attribute(pugi::xml_attribute); std::optional read_hundredth_point_attribute(pugi::xml_attribute); std::optional read_emus_attribute(pugi::xml_attribute); +/// EMUs written as a node's text, the way `wp:posOffset` states an offset. +std::optional read_emus_text(pugi::xml_node); std::optional read_twips_attribute(pugi::xml_attribute); std::optional read_pct_attribute(pugi::xml_attribute); std::optional read_width_attribute(pugi::xml_node); @@ -49,7 +52,11 @@ std::optional read_drawing_text_align_attribute(pugi::xml_attribute); std::optional read_vertical_align_attribute(pugi::xml_attribute); std::optional read_drawing_vertical_align_attribute(pugi::xml_attribute); +/// [ECMA-376] 17.3.4 `CT_Border`; `w:sz` is in eighths of a point. `nil` and +/// `none` draw nothing, which is not the same as saying nothing. std::optional read_border_node(pugi::xml_node); +/// The four sides of a `w:tblBorders`/`w:tcBorders`. +DirectionalStyle read_borders_node(pugi::xml_node); using Relations = std::unordered_map; using XmlDocumentsAndRelations = diff --git a/src/odr/internal/ooxml/text/AGENTS.md b/src/odr/internal/ooxml/text/AGENTS.md index 44b904ef5..f0e4ef763 100644 --- a/src/odr/internal/ooxml/text/AGENTS.md +++ b/src/odr/internal/ooxml/text/AGENTS.md @@ -59,6 +59,20 @@ A table resolves its `w:tblStyle` the same way a paragraph resolves its paragraph and text properties of everything inside the table, and the cascade is what carries them down. Its conditional formats (`w:tblStylePr`) are ignored. +**A table's borders are lowered onto its cells**, because css cannot draw an +inside rule from the ``. `table_cell_border` resolves each edge as the +cell's own `w:tcBorders`, then the neighbour's opposite edge, then the table's +`w:tblPr/w:tblBorders` — and returns only the edges the cell *leads*: its top +and left, plus the frame it closes on the last row and column. So a rule between +two cells is one line, whichever of them states it, and `nil` has to read as "no +border" rather than as silence. Word picks the heavier of two competing borders; +here the leading cell's own wins. + +**A drawing anchored to the text stays in the text.** `wp:anchor` reports +`AnchorType::at_paragraph` whatever `relativeFrom` says, because css only makes +text flow around a box that is in the flow — hence also a dropped page-relative +offset, and `wrapText="bothSides"`/`"largest"` taking its side from `wp:align`. + **Contextual spacing is decided per paragraph, not per style.** `w:contextualSpacing` drops the spacing towards a neighbouring paragraph of the same style, which is what keeps a list tight, so it cannot live in the resolved @@ -97,8 +111,8 @@ Style/element coverage is in [`README.md`](README.md). Foundational gaps: 3. **Theme fonts unhandled.** `w:rFonts w:asciiTheme="minorHAnsi"` (etc.) is ignored — only literal `w:ascii` names are read (README example `Sample large docx.docx`). -4. **Style stubs**: `resolve_graphic_style_` is empty; table cell width is - parsed but not applied; the `w:default="1"` style flag is ignored. Paragraph +4. **Style stubs**: table cell width is parsed but not applied; the + `w:default="1"` style flag is ignored. Paragraph spacing reads `w:before`/`w:after`/`w:line` but not `w:beforeLines`/ `w:afterLines`, and drops the value an autospacing flag shadows rather than computing what word would. `w:lineRule="atLeast"` lowers to the same fixed diff --git a/src/odr/internal/ooxml/text/README.md b/src/odr/internal/ooxml/text/README.md index e1cf48fe4..607eb7c1e 100644 --- a/src/odr/internal/ooxml/text/README.md +++ b/src/odr/internal/ooxml/text/README.md @@ -63,15 +63,27 @@ Roughly ordered by importance. is taller than it does not grow the way word grows it - [x] tables - [x] table width - - [x] cell vertical alignment, borders + - [x] cell vertical alignment + - [x] borders, on the cell (`w:tcBorders`) and on the table + (`w:tblPr/w:tblBorders`, frame + `w:insideH`/`w:insideV`, lowered onto the + cells) + - [ ] word's conflict resolution where two cells meet at a rule — the leading + cell's own border wins rather than the heavier of the two - [x] row height (`w:trHeight`, as a minimum — `w:hRule="exact"` is not) - - [x] table style reference (`w:tblStyle`, cascading its paragraph and text - properties into the table) + - [x] table style reference (`w:tblStyle`, cascading its paragraph, text and + border properties into the table) - [ ] cell width (parsed but not applied) - [ ] conditional table formatting (`w:tblStylePr`: banding, first row, …) - [x] page layout (`w:sectPr`: size, orientation, margins) - [ ] one layout per section; the first section's applies to the document -- [ ] graphic / drawing styles +- [x] graphic / drawing styles + - [x] floating drawings (`wp:anchor`): `wp:positionH`/`wp:positionV` offsets, + `wp:align` side, `wp:wrapSquare`/`Tight`/`Through`/`TopAndBottom`/`None` + - [x] `behindDoc`, as a negative z-index + - [ ] a page-relative origin, and `wp:positionV`'s `wp:align` — a frame is + kept in the text flow, so neither has an origin to measure against + - [ ] `relativeHeight` as the stacking order between two drawings + - [ ] fill and stroke ## References diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index fc3e6f5df..7e04345ea 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -515,23 +515,33 @@ class ElementAdapter final : public abstract::ElementAdapter, [[nodiscard]] TableCellStyle table_cell_style(const ElementIdentifier element_id) const override { const pugi::xml_node node = get_node(element_id); - return m_document->style_registry() - .partial_table_cell_style(node) - .table_cell_style; + const StyleRegistry &styles = m_document->style_registry(); + + TableCellStyle result = + styles.partial_table_cell_style(node).table_cell_style; + result.border = table_cell_border( + node, get_cell_above(node), + styles.partial_table_style(node.parent().parent()).table_style, + table_cell_span(element_id).rows); + return result; } - [[nodiscard]] AnchorType frame_anchor_type( - [[maybe_unused]] const ElementIdentifier element_id) const override { - // TODO `wp:anchor` is floating, not as_char - return AnchorType::as_char; + [[nodiscard]] AnchorType + frame_anchor_type(const ElementIdentifier element_id) const override { + // [ECMA-376] 20.4.2.8. Whatever a `wp:anchor` says it is relative to, css + // flows text around a box only while that box is in the flow. + return get_node(element_id).child("wp:anchor") ? AnchorType::at_paragraph + : AnchorType::as_char; } [[nodiscard]] std::optional - frame_x([[maybe_unused]] const ElementIdentifier element_id) const override { - return std::nullopt; + frame_x(const ElementIdentifier element_id) const override { + return read_frame_offset( + get_frame_inner_node(element_id).child("wp:positionH")); } [[nodiscard]] std::optional - frame_y([[maybe_unused]] const ElementIdentifier element_id) const override { - return std::nullopt; + frame_y(const ElementIdentifier element_id) const override { + return read_frame_offset( + get_frame_inner_node(element_id).child("wp:positionV")); } [[nodiscard]] std::optional frame_width(const ElementIdentifier element_id) const override { @@ -543,17 +553,22 @@ class ElementAdapter final : public abstract::ElementAdapter, const pugi::xml_node inner_node = get_frame_inner_node(element_id); return read_emus_attribute(inner_node.child("wp:extent").attribute("cy")); } - [[nodiscard]] std::optional frame_z_index( - [[maybe_unused]] const ElementIdentifier element_id) const override { + [[nodiscard]] std::optional + frame_z_index(const ElementIdentifier element_id) const override { + // [ECMA-376] 20.4.2.3 `behindDoc`, the drawing word paints under the text. + if (read_on_off_attribute( + get_frame_inner_node(element_id).attribute("behindDoc"))) { + return -1; + } 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 {}; + [[nodiscard]] GraphicStyle + frame_style(const ElementIdentifier element_id) const override { + return read_frame_style(get_frame_inner_node(element_id)); } [[nodiscard]] bool @@ -616,6 +631,13 @@ class ElementAdapter final : public abstract::ElementAdapter, return !val || std::strcmp(val.value(), "continue") == 0; } + /// The cell the previous row puts at this one's grid column, if any. + [[nodiscard]] static pugi::xml_node + get_cell_above(const pugi::xml_node cell_node) { + return get_cell_at_grid_column(cell_node.parent().previous_sibling("w:tr"), + get_grid_column(cell_node)); + } + /// Grid column a `w:tc` starts at, i.e. the preceding cells' `w:gridSpan`s. [[nodiscard]] static std::uint32_t get_grid_column(const pugi::xml_node cell_node) { diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.cpp b/src/odr/internal/ooxml/text/ooxml_text_style.cpp index 442cb36d5..8b436d99d 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.cpp @@ -122,6 +122,18 @@ void resolve_table_style_(const pugi::xml_node node, TableStyle &result) { read_width_attribute(table_properties.child("w:tblW"))) { result.width = width; } + + // [ECMA-376] 17.4.39 + const pugi::xml_node borders = table_properties.child("w:tblBorders"); + result.border.override(read_borders_node(borders)); + if (const std::optional inside_horizontal = + read_border_node(borders.child("w:insideH"))) { + result.border_inside_horizontal = inside_horizontal; + } + if (const std::optional inside_vertical = + read_border_node(borders.child("w:insideV"))) { + result.border_inside_vertical = inside_vertical; + } } void resolve_table_row_style_(const pugi::xml_node node, @@ -152,26 +164,77 @@ void resolve_table_cell_style_(const pugi::xml_node node, table_cell_properties.child("w:vAlign").attribute("w:val"))) { result.vertical_align = vertical_align; } - if (const std::optional border_right = read_border_node( - table_cell_properties.child("w:tcBorders").child("w:right"))) { - result.border.right = border_right; - } - if (const std::optional border_top = read_border_node( - table_cell_properties.child("w:tcBorders").child("w:top"))) { - result.border.top = border_top; - } - if (const std::optional border_left = read_border_node( - table_cell_properties.child("w:tcBorders").child("w:left"))) { - result.border.left = border_left; + result.border.override( + read_borders_node(table_cell_properties.child("w:tcBorders"))); +} + +std::optional read_cell_border(const pugi::xml_node cell, + const char *side) { + return read_border_node( + cell.child("w:tcPr").child("w:tcBorders").child(side)); +} + +/// A cell's edge: its own, then the neighbour's opposite edge, then the +/// table's. +std::optional +resolve_cell_border(std::optional own, + std::optional neighbour, + std::optional table) { + if (own.has_value()) { + return own; } - if (const std::optional border_bottom = read_border_node( - table_cell_properties.child("w:tcBorders").child("w:bottom"))) { - result.border.bottom = border_bottom; + return neighbour.has_value() ? std::move(neighbour) : std::move(table); +} + +/// [ECMA-376] 20.4.2.*. `wrapText` names the side the text keeps; where it +/// names both, css has to pick one, and no float holds a centred frame. +std::optional +read_frame_text_wrap(const pugi::xml_node anchor, + const std::optional position) { + if (anchor.child("wp:wrapNone")) { + return TextWrap::run_through; + } + if (anchor.child("wp:wrapTopAndBottom")) { + return TextWrap::none; + } + for (const char *name : {"wp:wrapSquare", "wp:wrapTight", "wp:wrapThrough"}) { + const pugi::xml_node wrap = anchor.child(name); + if (!wrap) { + continue; + } + const char *wrap_text = wrap.attribute("wrapText").value(); + if (std::strcmp("left", wrap_text) == 0) { + return TextWrap::before; + } + if (std::strcmp("right", wrap_text) == 0) { + return TextWrap::after; + } + if (position == HorizontalAlign::center) { + return TextWrap::none; + } + return position == HorizontalAlign::left ? TextWrap::after + : TextWrap::before; } + return {}; } -void resolve_graphic_style_(pugi::xml_node, GraphicStyle &) { - // TODO +/// [ECMA-376] 20.4.3.1 `wp:align`, the side a frame takes instead of an offset. +std::optional +read_frame_horizontal_position(const pugi::xml_node align) { + if (!align) { + return {}; + } + const char *value = align.text().get(); + if (std::strcmp("left", value) == 0 || std::strcmp("inside", value) == 0) { + return HorizontalAlign::left; + } + if (std::strcmp("center", value) == 0) { + return HorizontalAlign::center; + } + if (std::strcmp("right", value) == 0 || std::strcmp("outside", value) == 0) { + return HorizontalAlign::right; + } + return {}; } /// A `w:sdt` and its `w:sdtContent` become a group, which the html renderer @@ -274,7 +337,6 @@ void Style::resolve_style_() { resolve_table_style_(m_node, m_resolved.table_style); resolve_table_row_style_(m_node, m_resolved.table_row_style); resolve_table_cell_style_(m_node, m_resolved.table_cell_style); - resolve_graphic_style_(m_node, m_resolved.graphic_style); } void Style::resolve_default_style_() { @@ -445,3 +507,59 @@ Style *StyleRegistry::generate_style_(const std::string &name, } } // namespace odr::internal::ooxml::text + +namespace odr::internal::ooxml { + +DirectionalStyle text::table_cell_border( + const pugi::xml_node node, const pugi::xml_node cell_above, + const TableStyle &table_style, const std::uint32_t rows) { + const pugi::xml_node row_node = node.parent(); + const pugi::xml_node cell_before = node.previous_sibling("w:tc"); + + DirectionalStyle result; + result.top = resolve_cell_border( + read_cell_border(node, "w:top"), read_cell_border(cell_above, "w:bottom"), + row_node.previous_sibling("w:tr") ? table_style.border_inside_horizontal + : table_style.border.top); + result.left = + resolve_cell_border(read_cell_border(node, "w:left"), + read_cell_border(cell_before, "w:right"), + cell_before ? table_style.border_inside_vertical + : table_style.border.left); + if (!node.next_sibling("w:tc")) { + result.right = resolve_cell_border(read_cell_border(node, "w:right"), {}, + table_style.border.right); + } + // a merged cell reaches down to where its continuations end + pugi::xml_node last_row_node = row_node; + for (std::uint32_t row = 1; row < rows; ++row) { + const pugi::xml_node next_row_node = last_row_node.next_sibling("w:tr"); + if (!next_row_node) { + break; + } + last_row_node = next_row_node; + } + if (!last_row_node.next_sibling("w:tr")) { + result.bottom = resolve_cell_border(read_cell_border(node, "w:bottom"), {}, + table_style.border.bottom); + } + return result; +} + +GraphicStyle text::read_frame_style(const pugi::xml_node inner_node) { + GraphicStyle result; + result.horizontal_position = read_frame_horizontal_position( + inner_node.child("wp:positionH").child("wp:align")); + result.text_wrap = + read_frame_text_wrap(inner_node, result.horizontal_position); + return result; +} + +std::optional text::read_frame_offset(const pugi::xml_node position) { + if (std::strcmp("page", position.attribute("relativeFrom").value()) == 0) { + return {}; + } + return read_emus_text(position.child("wp:posOffset")); +} + +} // namespace odr::internal::ooxml diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.hpp b/src/odr/internal/ooxml/text/ooxml_text_style.hpp index 9658a5a17..9b99aad65 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.hpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.hpp @@ -1,8 +1,13 @@ #pragma once +#include + #include +#include #include +#include +#include #include #include @@ -62,4 +67,20 @@ class StyleRegistry final { Style *generate_style_(const std::string &name, pugi::xml_node node); }; +/// Borders of a `w:tc` spanning `rows` rows under `cell_above`: its own +/// `w:tcBorders` over the neighbour's over the table's ([ECMA-376] 17.4.39). +/// Only the edges the cell leads, so a rule between two cells is one line. +DirectionalStyle table_cell_border(pugi::xml_node node, + pugi::xml_node cell_above, + const TableStyle &table_style, + std::uint32_t rows); + +/// The graphic style a drawing's `wp:anchor`/`wp:inline` states itself; +/// `styles.xml` carries none. +GraphicStyle read_frame_style(pugi::xml_node inner_node); + +/// [ECMA-376] 20.4.2.10/11. A page-relative offset is dropped: the frame stays +/// in the text flow, which is not what it would measure against. +std::optional read_frame_offset(pugi::xml_node position); + } // namespace odr::internal::ooxml::text diff --git a/src/odr/style.cpp b/src/odr/style.cpp index c4b5d6ec9..f1989501c 100644 --- a/src/odr/style.cpp +++ b/src/odr/style.cpp @@ -72,6 +72,9 @@ void ParagraphStyle::override(const ParagraphStyle &other) { void TableStyle::override(const TableStyle &other) { override_if_set(width, other.width); + border.override(other.border); + override_if_set(border_inside_horizontal, other.border_inside_horizontal); + override_if_set(border_inside_vertical, other.border_inside_vertical); } void TableColumnStyle::override(const TableColumnStyle &other) { diff --git a/src/odr/style.hpp b/src/odr/style.hpp index a0c494506..3ee31476b 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -178,6 +178,12 @@ struct ParagraphStyle final { /// @brief Represents a style for tables. struct TableStyle final { std::optional width; + /// The frame around the table, and the rules between its rows and columns. + /// @note What the table states; the cells are what draws it, through + /// @ref TableCellStyle::border. + DirectionalStyle border; + std::optional border_inside_horizontal; + std::optional border_inside_vertical; void override(const TableStyle &other); }; diff --git a/test/data.cmake b/test/data.cmake index f92d93c69..6f22a9ade 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -7,7 +7,7 @@ odr_test_data( PATH "input/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.git" - REVISION "c70c296ebe08ba4e109312dcbf55227206b44eaa") + REVISION "a64cddf5413dc0a1140d9ecd560e9eec4f7a8d03") odr_test_data( PATH "input/odr-private" @@ -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 "deb25f76e313bd3c5242b57d1aca7de31f1f46ef") + REVISION "1f930506b317de555b769e49536640fd89051dc0") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "bcea37b997a045387b7069412ba668fa44a7e26b") + REVISION "fe59450aaf323c4964e704a4c399100877bf2520") diff --git a/test/src/internal/ooxml/ooxml_text_style_test.cpp b/test/src/internal/ooxml/ooxml_text_style_test.cpp index b6c7bdb4a..eec6ffef1 100644 --- a/test/src/internal/ooxml/ooxml_text_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_text_style_test.cpp @@ -302,3 +302,275 @@ TEST(ooxml_text_style, paragraph_contextual_spacing_through_wrappers) { EXPECT_EQ(Measure(240 / 1440.0, DynamicUnit("in")), *after_wrapper.margin.bottom); } + +/// [ECMA-376] 17.4.39; `w:sz` is in eighths of a point. +TEST(ooxml_text_style, table_borders) { + pugi::xml_document document; + const pugi::xml_node table = + node_of(R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const TableStyle style = + StyleRegistry().partial_table_style(table).table_style; + + EXPECT_EQ("1pt solid #000000", style.border.top); + EXPECT_EQ("1pt solid #000000", style.border.left); + EXPECT_EQ("1pt solid #000000", style.border.bottom); + EXPECT_EQ("1pt solid #000000", style.border.right); + EXPECT_EQ("0.5pt solid #808080", style.border_inside_horizontal); + EXPECT_EQ("0.5pt solid #808080", style.border_inside_vertical); +} + +/// A `w:tblStyle` carries the borders down as it carries the rest. +TEST(ooxml_text_style, table_borders_through_the_style_reference) { + pugi::xml_document document; + const StyleRegistry registry = registry_of( + R"()" + R"()" + R"()" + R"()", + document); + + pugi::xml_document table_document; + const pugi::xml_node table = + node_of(R"()" + R"()" + R"()", + table_document); + + const TableStyle style = registry.partial_table_style(table).table_style; + + // the table's own beats the style's, side by side + EXPECT_EQ("3pt solid #ff0000", style.border.top); + EXPECT_EQ("1pt solid #000000", style.border_inside_horizontal); +} + +/// `nil` and `none` draw nothing, `auto` leaves the colour to the text. +TEST(ooxml_text_style, a_cell_border_of_nil_is_not_silence) { + pugi::xml_document document; + const pugi::xml_node cell = + node_of(R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const TableCellStyle style = + StyleRegistry().partial_table_cell_style(cell).table_cell_style; + + EXPECT_EQ("0 none", style.border.top); + EXPECT_EQ("0 none", style.border.bottom); + EXPECT_EQ("1pt solid", style.border.right); + EXPECT_FALSE(style.border.left.has_value()); +} + +namespace { + +TableStyle grid_table_style() { + TableStyle result; + result.border = DirectionalStyle(std::string("frame")); + result.border_inside_horizontal = "rule-h"; + result.border_inside_vertical = "rule-v"; + return result; +} + +pugi::xml_node cell_at(const pugi::xml_node table, const std::size_t row, + const std::size_t column) { + pugi::xml_node row_node = table.child("w:tr"); + for (std::size_t i = 0; i < row; ++i) { + row_node = row_node.next_sibling("w:tr"); + } + pugi::xml_node cell_node = row_node.child("w:tc"); + for (std::size_t i = 0; i < column; ++i) { + cell_node = cell_node.next_sibling("w:tc"); + } + return cell_node; +} + +/// `table_cell_border` for a plain grid, where the cell above is one row up. +DirectionalStyle border_at(const pugi::xml_node table, + const std::size_t row, + const std::size_t column, + const TableStyle &table_style, + const std::uint32_t rows = 1) { + const pugi::xml_node above = + row == 0 ? pugi::xml_node() : cell_at(table, row - 1, column); + return table_cell_border(cell_at(table, row, column), above, table_style, + rows); +} + +} // namespace + +/// A grid line is drawn once, by the cell that leads it. +TEST(ooxml_text_style, a_cell_draws_only_the_table_borders_it_leads) { + pugi::xml_document document; + const pugi::xml_node table = node_of(R"()" + R"()" + R"()" + R"()", + document); + const TableStyle table_style = grid_table_style(); + + const DirectionalStyle top_left = + border_at(table, 0, 0, table_style); + EXPECT_EQ("frame", top_left.top); + EXPECT_EQ("frame", top_left.left); + EXPECT_FALSE(top_left.bottom.has_value()); + EXPECT_FALSE(top_left.right.has_value()); + + const DirectionalStyle bottom_right = + border_at(table, 1, 1, table_style); + EXPECT_EQ("rule-h", bottom_right.top); + EXPECT_EQ("rule-v", bottom_right.left); + EXPECT_EQ("frame", bottom_right.bottom); + EXPECT_EQ("frame", bottom_right.right); +} + +/// A rule the trailing cell states is drawn by the leading one, once. +TEST(ooxml_text_style, a_rule_the_trailing_cell_states_is_drawn_once) { + pugi::xml_document document; + const pugi::xml_node table = node_of( + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()", + document); + const TableStyle table_style = grid_table_style(); + + const DirectionalStyle stating = + border_at(table, 0, 0, table_style); + EXPECT_FALSE(stating.right.has_value()); + EXPECT_FALSE(stating.bottom.has_value()); + EXPECT_EQ("3pt solid #ff0000", border_at(table, 0, 1, table_style).left); + EXPECT_EQ("3pt solid #00ff00", border_at(table, 1, 0, table_style).top); +} + +/// A cell's own border beats what the neighbour and the table say. +TEST(ooxml_text_style, a_cell_border_beats_the_neighbours_and_the_tables) { + pugi::xml_document document; + const pugi::xml_node table = node_of( + R"()" + R"()" + R"()" + R"()", + document); + const TableStyle table_style = grid_table_style(); + + EXPECT_EQ("3pt solid #ff0000", border_at(table, 0, 1, table_style).left); + EXPECT_EQ("0 none", border_at(table, 1, 0, table_style).top); +} + +/// A covered cell never renders, so the cell merged over it closes the frame. +TEST(ooxml_text_style, a_merged_cell_closes_the_frame_it_reaches) { + pugi::xml_document document; + const pugi::xml_node table = node_of(R"()" + R"()" + R"()" + R"()" + R"()", + document); + const TableStyle table_style = grid_table_style(); + + EXPECT_EQ("frame", border_at(table, 1, 0, table_style, 2).bottom); + EXPECT_FALSE(border_at(table, 1, 0, table_style, 1).bottom.has_value()); + // a merge running past the last row still closes it + EXPECT_EQ("frame", border_at(table, 1, 0, table_style, 9).bottom); +} + +/// The cell above sits at this one's grid column, which `w:gridSpan` moves. +TEST(ooxml_text_style, a_spanned_cell_is_led_by_the_column_it_starts_at) { + pugi::xml_document document; + const pugi::xml_node table = node_of( + R"()" + R"()" + R"()" + R"()", + document); + const TableStyle table_style = grid_table_style(); + + // the spanning cell covers grid columns 0 and 1 + EXPECT_EQ("3pt solid #ff0000", + table_cell_border(cell_at(table, 1, 0), cell_at(table, 0, 0), + table_style, 1) + .top); + EXPECT_EQ("rule-h", table_cell_border(cell_at(table, 1, 2), + cell_at(table, 0, 1), table_style, 1) + .top); + EXPECT_EQ("frame", border_at(table, 0, 1, table_style).right); +} + +/// `TextWrap::before` leaves the text on the frame's left. +TEST(ooxml_text_style, frame_wrap_takes_the_side_from_wrap_text) { + const auto wrap_of = [](const char *xml) { + pugi::xml_document document; + return read_frame_style(node_of(xml, document)).text_wrap; + }; + + EXPECT_EQ( + TextWrap::before, + wrap_of(R"()")); + EXPECT_EQ( + TextWrap::after, + wrap_of(R"()")); + EXPECT_EQ( + TextWrap::before, + wrap_of(R"()")); + EXPECT_EQ(TextWrap::none, + wrap_of(R"()")); + EXPECT_EQ(TextWrap::run_through, + wrap_of(R"()")); + // a `wp:inline` states no wrap at all + EXPECT_FALSE(wrap_of(R"()").has_value()); +} + +/// Where word wraps both sides, the frame's own side picks the float's. +TEST(ooxml_text_style, frame_wrap_on_both_sides_follows_the_frame) { + const auto style_of = [](const char *align, const char *wrap_text) { + pugi::xml_document document; + const std::string xml = + R"()" + + std::string(align) + R"()" + + R"()"; + return read_frame_style(node_of(xml.c_str(), document)); + }; + + EXPECT_EQ(HorizontalAlign::left, + style_of("left", "bothSides").horizontal_position); + EXPECT_EQ(TextWrap::after, style_of("left", "bothSides").text_wrap); + EXPECT_EQ(TextWrap::before, style_of("right", "bothSides").text_wrap); + EXPECT_EQ(TextWrap::none, style_of("center", "largest").text_wrap); + EXPECT_EQ(HorizontalAlign::center, + style_of("center", "largest").horizontal_position); +} + +/// A page-relative offset has no meaning for a frame that stays in the flow. +TEST(ooxml_text_style, frame_offset_is_read_where_it_flows_with_the_text) { + pugi::xml_document document; + const pugi::xml_node anchor = node_of( + R"()" + R"(2286000)" + R"(457200)" + R"(-457200)" + R"()", + document); + + EXPECT_EQ(Measure(2.5, DynamicUnit("in")), + read_frame_offset(anchor.child("wp:positionH"))); + EXPECT_FALSE(read_frame_offset(anchor.child("wp:positionV")).has_value()); + // no `relativeFrom` is not the page + EXPECT_EQ(Measure(-0.5, DynamicUnit("in")), + read_frame_offset(anchor.child("wp:simplePos"))); + EXPECT_FALSE(read_frame_offset(anchor.child("wp:noSuchChild")).has_value()); +}