diff --git a/src/odr/internal/oldms/text/AGENTS.md b/src/odr/internal/oldms/text/AGENTS.md index af7956655..56898e258 100644 --- a/src/odr/internal/oldms/text/AGENTS.md +++ b/src/odr/internal/oldms/text/AGENTS.md @@ -193,6 +193,11 @@ WordDocument stream (`PlcBtePapx` → `PapxFkp`) belongs here too. - **Fields show only the cached result** (§2.8.25) — never evaluated. Acceptable for "visible text". +- **No page layout** — `text_root_page_layout` returns `{}`, so the paginated + HTML renders a page box that hugs the text instead of a sheet of paper with + margins. Section properties (`PlcfSed` → `Sepx`, `sprmSXaPage`/`sprmSYaPage` + for the size, `sprmSDxaLeft`/`Right`, `sprmSDyaTop`/`sprmSDyaBottom` for the + margins, §2.6.4) are unparsed. - **Images / OLE / drawn objects** — anchor chars dropped; would need `PlcfSpa` / Office Art (`dggInfo`). - **Encrypted / obfuscated** — `fEncrypted`/`fObfuscated` parsed but not acted on; diff --git a/src/odr/internal/ooxml/text/AGENTS.md b/src/odr/internal/ooxml/text/AGENTS.md index 64ceea203..c087fdf86 100644 --- a/src/odr/internal/ooxml/text/AGENTS.md +++ b/src/odr/internal/ooxml/text/AGENTS.md @@ -38,6 +38,13 @@ interleaved list. `w:lvlText` is the template, `%N` naming a level's counter; the shared expansion and the number formats are in `common/list_numbering.*`, where ODF lowers to the same shape. +**Page layout comes from the first `w:sectPr` in document order.** The model +carries one `PageLayout` per text root, while Word carries one per section — +and `w:body/w:sectPr` describes the *last* section, a section being closed by +the `w:pPr/w:sectPr` of its final paragraph. Taking the first one in document +order therefore gives the layout the document opens with, and collapses to the +body's own for the single-section documents that are the norm. + **Style resolution mixes a static hierarchy with a runtime cascade.** `StyleRegistry` indexes `w:style` by `w:styleId` and pre-flattens the `w:basedOn` chain: each `Style` recursively resolves its parent, copies the diff --git a/src/odr/internal/ooxml/text/README.md b/src/odr/internal/ooxml/text/README.md index f4fb4cb8b..ec649652c 100644 --- a/src/odr/internal/ooxml/text/README.md +++ b/src/odr/internal/ooxml/text/README.md @@ -60,7 +60,8 @@ Roughly ordered by importance. - [x] cell vertical alignment, borders - [ ] cell width (parsed but not applied) - [ ] table row styles -- [x] page layout (via master page) +- [x] page layout (`w:sectPr`: size, orientation, margins) + - [ ] one layout per section; the first section's applies to the document - [ ] graphic / drawing styles ## References diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 5e55601c4..1c5b2d47a 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -23,8 +23,45 @@ namespace odr::internal::ooxml::text { namespace { std::unique_ptr create_element_adapter(const Document &document, ElementRegistry ®istry); + +/// `w:top` and `w:bottom` are signed: a negative margin lets the header flow +/// into the body, which as a CSS margin would push the text off the page. +std::optional read_page_margin(const pugi::xml_attribute attribute) { + const std::optional margin = read_twips_attribute(attribute); + if (margin.has_value() && margin->magnitude() < 0) { + return Measure(0, margin->unit()); + } + return margin; } +/// [ECMA-376] 17.6.17 `w:sectPr`. The body's own are the *last* section's, so +/// the first in document order is the one the document opens with. +PageLayout read_page_layout(const pugi::xml_node body) { + const pugi::xml_node section_properties = + body.select_node(".//w:sectPr").node(); + + PageLayout result; + + const pugi::xml_node page_size = section_properties.child("w:pgSz"); + result.width = read_twips_attribute(page_size.attribute("w:w")); + result.height = read_twips_attribute(page_size.attribute("w:h")); + if (const pugi::xml_attribute orientation = page_size.attribute("w:orient")) { + result.print_orientation = + std::strcmp("landscape", orientation.value()) == 0 + ? PrintOrientation::landscape + : PrintOrientation::portrait; + } + + const pugi::xml_node page_margin = section_properties.child("w:pgMar"); + result.margin.right = read_page_margin(page_margin.attribute("w:right")); + result.margin.top = read_page_margin(page_margin.attribute("w:top")); + result.margin.left = read_page_margin(page_margin.attribute("w:left")); + result.margin.bottom = read_page_margin(page_margin.attribute("w:bottom")); + + return result; +} +} // namespace + Document::Document(std::shared_ptr files) : internal::Document(FileType::office_open_xml_document, DocumentType::text, std::move(files)) { @@ -40,6 +77,9 @@ Document::Document(std::shared_ptr files) m_document_relations = parse_relationships(*m_files, AbsPath("/word/document.xml")); + m_page_layout = + read_page_layout(m_document_xml.document_element().child("w:body")); + m_root_element = parse_tree( m_element_registry, m_document_xml.document_element().child("w:body")); @@ -69,6 +109,8 @@ const Relations &Document::document_relations() const { return m_document_relations; } +const PageLayout &Document::page_layout() const { return m_page_layout; } + bool Document::is_editable() const noexcept { return true; } bool Document::is_savable(const bool encrypted) const noexcept { @@ -242,7 +284,7 @@ class ElementAdapter final : public abstract::ElementAdapter, [[nodiscard]] PageLayout text_root_page_layout( [[maybe_unused]] const ElementIdentifier element_id) const override { - return {}; + return m_document->page_layout(); } [[nodiscard]] ElementIdentifier text_root_first_master_page( [[maybe_unused]] const ElementIdentifier element_id) const override { diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.hpp b/src/odr/internal/ooxml/text/ooxml_text_document.hpp index e002ffd33..8b3ff0d68 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.hpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -23,6 +25,7 @@ class Document final : public internal::Document { [[nodiscard]] const ElementRegistry &element_registry() const; [[nodiscard]] const StyleRegistry &style_registry() const; [[nodiscard]] const Relations &document_relations() const; + [[nodiscard]] const PageLayout &page_layout() const; [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; @@ -37,6 +40,8 @@ class Document final : public internal::Document { Relations m_document_relations; + PageLayout m_page_layout; + ElementRegistry m_element_registry; StyleRegistry m_style_registry; NumberingRegistry m_numbering_registry; diff --git a/test/data.cmake b/test/data.cmake index 104b3fd8d..3784108e7 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 "0a1114a9a50417db31cb91193b356cf56b745b8e") + REVISION "40c457831835a8e9437bfe6494cb96b4c6159fab") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "20a86c3be30a879ff6c4be21a246c4a17f95ff2e") + REVISION "8f7383f64c795602793a8c20a9c76bea197ad9c1") diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index fba93cd82..50825825e 100644 --- a/test/src/document_test.cpp +++ b/test/src/document_test.cpp @@ -108,6 +108,26 @@ TEST(Document, odt) { EXPECT_EQ(Measure("0.7874in"), page_layout.margin.top); } +TEST(Document, docx_page_layout) { + const Logger logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const DocumentFile document_file( + TestData::test_file_path("odr-public/docx/sample3.docx"), logger); + + const Document document = document_file.document(); + + const PageLayout page_layout = + document.root_element().as_text_root().page_layout(); + // Two sections; the 0.5in bottom margin is the first one's, the body's own + // `w:sectPr` says 1in. + EXPECT_EQ(Measure("8.5in"), page_layout.width); + EXPECT_EQ(Measure("11in"), page_layout.height); + EXPECT_EQ(Measure("1in"), page_layout.margin.top); + EXPECT_EQ(Measure("1in"), page_layout.margin.right); + EXPECT_EQ(Measure("0.5in"), page_layout.margin.bottom); + EXPECT_EQ(Measure("1in"), page_layout.margin.left); +} + TEST(Document, xlsx_sheet_names) { const Logger logger = Logger::create_stdio("odr-test", LogLevel::verbose);