diff --git a/CHANGELOG.md b/CHANGELOG.md index 62182ab9d..ebac4cfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,13 @@ The release run heads these entries with the version and opens a fresh - New `HtmlViewportMode::fit_width_by_view`: the view measures the fit and keeps it current, so a rotation refits instead of holding what it opened at. For a host whose web view does not fit a top-level document itself. +- A `.pptx` whose package relates a part that is not xml — a Google Slides + export relates a protobuf — opens instead of throwing `NoXmlFile`. Only the + slides `p:sldIdLst` names are read. +- A `.pptx` line break (`a:br`) renders as one instead of joining the runs + around it, and a run's font, a paragraph's alignment and its left and right + margins arrive: they were read from the wordprocessingml attributes, which a + pptx never carries. ## v6.10.1 - 2026-08-21 diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 4aea768d2..ae6389b71 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -250,6 +250,26 @@ ooxml::read_text_align_attribute(const pugi::xml_attribute attribute) { return {}; } +/// [ECMA-376] 20.1.10.59 ST_TextAlignType — drawingml spells the same values +/// differently than wordprocessingml does. +std::optional +ooxml::read_drawing_text_align_attribute(const pugi::xml_attribute attribute) { + const char *val = attribute.value(); + if (std::strcmp("l", val) == 0) { + return TextAlign::left; + } + if (std::strcmp("r", val) == 0) { + return TextAlign::right; + } + if (std::strcmp("ctr", val) == 0) { + return TextAlign::center; + } + if (std::strcmp("just", val) == 0) { + return TextAlign::justify; + } + return {}; +} + std::optional ooxml::read_vertical_align_attribute(const pugi::xml_attribute attribute) { const char *val = attribute.value(); diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index 6f4ce0d5e..e4204fe57 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -44,6 +44,7 @@ std::optional read_font_weight_attribute(pugi::xml_node); std::optional read_font_style_attribute(pugi::xml_attribute); std::optional read_font_style_attribute(pugi::xml_node); std::optional read_text_align_attribute(pugi::xml_attribute); +std::optional read_drawing_text_align_attribute(pugi::xml_attribute); std::optional read_vertical_align_attribute(pugi::xml_attribute); std::optional read_border_node(pugi::xml_node); diff --git a/src/odr/internal/ooxml/presentation/AGENTS.md b/src/odr/internal/ooxml/presentation/AGENTS.md index 40428b2f7..f8310055b 100644 --- a/src/odr/internal/ooxml/presentation/AGENTS.md +++ b/src/odr/internal/ooxml/presentation/AGENTS.md @@ -10,21 +10,32 @@ runs, tables, inline text styling. ## Design decisions -**Parsing follows the slide-id list.** The `Document` ctor loads every -relationship target of `presentation.xml` into an `rId → xml` map (masters, -layouts, theme included — only slides are actually walked). Slide **order = the -document order of `p:sldId` in `p:sldIdLst`** (not filename/rId order); each -slide's `r:id` looks up its part, and parsing descends `p:cSld/p:spTree`. -Dispatch table: `p:sp`→**frame** (shapes are frames), `p:graphicFrame`→frame -(descends `a:graphic/a:graphicData`), `p:txBody`→group, `a:p`→paragraph, -`a:r`→span, `a:t`→text, `a:tbl`→table (columns from `a:tblGrid/a:gridCol` via -`append_column`, rows/cells from `a:tr`/`a:tc`; spans from -`gridSpan`/`rowSpan`, covered cells from `hMerge`/`vMerge`). +**Parsing follows the slide-id list.** Slide **order = the document order of +`p:sldId` in `p:sldIdLst`** (not filename/rId order), and that list is also what +the `Document` ctor loads: each `p:sldId`'s `r:id` resolves through +`presentation.xml`'s relationships into the `rId → xml` map, and nothing else +does. Loading *every* relationship target instead is what broke a Google Slides +export — it relates a protobuf blob (`ppt/metadata`) to the presentation, and +parsing that as xml threw `NoXmlFile` before a single slide was read. A package +may relate anything at all; only slides are xml we can use. Parsing then +descends `p:cSld/p:spTree`. Dispatch table: `p:sp`→**frame** (shapes are +frames), `p:graphicFrame`→frame (descends `a:graphic/a:graphicData`), +`p:txBody`→group, `a:p`→paragraph, `a:r`→span, `a:t`→text, `a:br`→line break, +`a:tbl`→table (columns from `a:tblGrid/a:gridCol` via `append_column`, +rows/cells from `a:tr`/`a:tc`; spans from `gridSpan`/`rowSpan`, covered cells +from `hMerge`/`vMerge`). **Styles are resolved inline — there is no `StyleRegistry`.** Free functions in -the document read `a:rPr` / `a:pPr` attributes directly (font, size in -hundredth-points, bold/italic/underline/strike/shadow/colour/highlight; align, -`a:ind` margins in twips). The element-parent cascade +the document read `a:rPr` / `a:pPr` directly: font from `a:latin/@typeface`, +size in hundredth-points, bold/italic/underline/strike/shadow; align from +`@algn` ([ECMA-376] 20.1.10.59 `ST_TextAlignType`, which spells the values +differently than wordprocessingml does), `@marL`/`@marR` margins in EMUs. **Read +them where drawingml puts them, not where wordprocessingml does** — these were +`rFonts@ascii`, `@jc` and `a:ind` for a while, none of which a pptx ever +carries, so the properties simply never arrived. Run **colour is still unread** +on purpose: it lives in `a:solidFill`, but nothing paints a shape or slide +background yet, so honouring a white run would put white text on white — it +lands with background fill, not before. The element-parent cascade (`get_intermediate_style` → `.override()`) is the same shape as ODF/docx but computed on-demand from the XML with no cached or master/default-style contribution. diff --git a/src/odr/internal/ooxml/presentation/README.md b/src/odr/internal/ooxml/presentation/README.md index 5b07c1949..5a8c2dfd8 100644 --- a/src/odr/internal/ooxml/presentation/README.md +++ b/src/odr/internal/ooxml/presentation/README.md @@ -4,10 +4,10 @@ Reader for presentation documents (`.pptx`). This implementation relies on [OOXML](../README.md). -The presentation is parsed from `ppt/presentation.xml`, with each slide pulled in -via relationships and its shape tree (`p:cSld` / `p:spTree`) walked for content -(see `ooxml_presentation_parser.cpp`). Text and paragraph styles are resolved -inline from the run / paragraph properties (see +The presentation is parsed from `ppt/presentation.xml`, with each slide named by +`p:sldIdLst` pulled in via relationships and its shape tree (`p:cSld` / +`p:spTree`) walked for content (see `ooxml_presentation_parser.cpp`). Text and +paragraph styles are resolved inline from the run / paragraph properties (see `ooxml_presentation_document.cpp`). ## Features @@ -38,16 +38,17 @@ Roughly ordered by importance. ### Styles - [x] font - - [x] family (`rFonts`) + - [x] family (`a:latin`) - [x] size - [x] italic, bold - [x] underline, strike through - - [x] color, background (highlight) + - [ ] color, background (highlight) — waits on background fill, see + [`AGENTS.md`](AGENTS.md) - [x] shadow - [ ] superscript, subscript - [x] paragraph - - [x] alignment - - [x] indentation / left & right margins + - [x] alignment (`a:pPr/@algn`) + - [x] indentation / left & right margins (`@marL` / `@marR`) - [x] tables (column widths, row heights; no `a:tcPr` cell styles) - [x] page layout (slide size) - [ ] graphic / drawing styles diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp index 9b6c7d26e..fd5db12e6 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -27,10 +27,16 @@ Document::Document(std::shared_ptr files) DocumentType::presentation, std::move(files)) { m_document_xml = util::xml::parse(*m_files, AbsPath("/ppt/presentation.xml")); - for (const auto &[id, target] : - parse_relationships(*m_files, AbsPath("/ppt/presentation.xml"))) { - m_slides_xml[id] = - util::xml::parse(*m_files, AbsPath("/ppt").join(RelPath(target))); + // Only the parts the slide-id list names: a package may relate anything at + // all to the presentation, and a Google Slides export relates a protobuf. + const Relations relations = + parse_relationships(*m_files, AbsPath("/ppt/presentation.xml")); + for (const pugi::xml_node slide_id : m_document_xml.document_element() + .child("p:sldIdLst") + .children("p:sldId")) { + const std::string id = slide_id.attribute("r:id").value(); + m_slides_xml[id] = util::xml::parse( + *m_files, AbsPath("/ppt").join(RelPath(relations.at(id)))); } // ECMA-376 default slide size when p:sldSz is absent. @@ -81,7 +87,7 @@ void resolve_text_style_(const pugi::xml_node node, TextStyle &result) { const pugi::xml_node run_properties = node.child("a:rPr"); if (const pugi::xml_attribute font_name = - run_properties.child("rFonts").attribute("ascii")) { + run_properties.child("a:latin").attribute("typeface")) { result.font_name = font_name.value(); } if (const std::optional font_size = @@ -108,14 +114,8 @@ void resolve_text_style_(const pugi::xml_node node, TextStyle &result) { read_shadow_attribute(run_properties.attribute("shadow"))) { result.font_shadow = font_shadow; } - if (const std::optional font_color = - read_color_attribute(run_properties.attribute("color"))) { - result.font_color = font_color; - } - if (const std::optional background_color = - read_color_attribute(run_properties.attribute("highlight"))) { - result.background_color = background_color; - } + // `a:solidFill` colour is left unread on purpose until backgrounds are + // painted — see AGENTS.md. } void resolve_paragraph_style_(const pugi::xml_node node, @@ -123,23 +123,16 @@ void resolve_paragraph_style_(const pugi::xml_node node, const pugi::xml_node paragraph_properties = node.child("a:pPr"); if (const std::optional text_align = - read_text_align_attribute(paragraph_properties.attribute("jc"))) { + read_drawing_text_align_attribute( + paragraph_properties.attribute("algn"))) { result.text_align = text_align; } - if (const std::optional margin_left = read_twips_attribute( - paragraph_properties.child("ind").attribute("left"))) { - result.margin.left = margin_left; - } - if (const std::optional margin_left = read_twips_attribute( - paragraph_properties.child("ind").attribute("start"))) { + if (const std::optional margin_left = + read_emus_attribute(paragraph_properties.attribute("marL"))) { result.margin.left = margin_left; } - if (const std::optional margin_right = read_twips_attribute( - paragraph_properties.child("ind").attribute("right"))) { - result.margin.right = margin_right; - } - if (const std::optional margin_right = read_twips_attribute( - paragraph_properties.child("ind").attribute("end"))) { + if (const std::optional margin_right = + read_emus_attribute(paragraph_properties.attribute("marR"))) { result.margin.right = margin_right; } } diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_parser.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_parser.cpp index a233d5719..d7d3b7398 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_parser.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_parser.cpp @@ -171,6 +171,7 @@ parse_any_element_tree(ElementRegistry ®istry, const ParseContext &context, {"a:txBody", create_default_tree_parser(ElementType::group)}, {"a:t", parse_text_element}, {"a:tab", parse_text_element}, + {"a:br", create_default_tree_parser(ElementType::line_break)}, {"a:p", create_default_tree_parser(ElementType::paragraph)}, {"a:r", create_default_tree_parser(ElementType::span)}, {"a:tbl", parse_table_element}, diff --git a/test/data.cmake b/test/data.cmake index dc08524d6..15940c390 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -12,14 +12,14 @@ odr_test_data( odr_test_data( PATH "input/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.git" - REVISION "112cf18f0ef246dfa31ac21f95f5167d0aaa2bcc") + REVISION "b71cec155018311c130b98c07ddee5e853db9e26") odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "7dd2d881f7d832d119ab1369d8725c1bbdb9819e") + REVISION "9b4bfd1ecc57c2e0abd6e006be360d1adb34db24") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "a1150488e8e2b1ab0d6351373946a6be36858afb") + REVISION "5fc72ac725c2ab2c0be79bdc119203d7cda429e4")