From 7e0f0f537755d1e1e483a39000164fbc666bf6e9 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 26 Aug 2026 09:44:58 +0200 Subject: [PATCH 1/3] feat(pptx): resolve theme colour, and paint the ground it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pptx states most of its colour as `a:schemeClr` — a slot name, not a value — so reading only the literal `a:srgbClr` sees almost nothing: the deck that prompted this carries 1066 scheme references and not one literal. A slot now resolves along slide → layout → master → theme, folding the theme's `a:clrScheme` together with the master's `p:clrMap` into a `ColorScheme`. Colour had been held back because a run colour is unsafe until something is painted behind it: a deck puts white text on a coloured master, and on our white page it simply vanished. So the ground arrives with it — `p:bg` from the slide, its layout or its master onto the new `PageLayout::background_color`, and a shape's own `p:spPr/a:solidFill` onto the frame. `a:bodyPr` anchor, `a:lnSpc` line height, `a:spcBef`/`a:spcAft` and `@baseline` come along. Painting a frame's fill exposed an odf bug it had been hiding: `draw:fill` and `draw:fill-color` cascade independently, and the colour outlives the fill, so boxes the file leaves blank were about to come out coloured. The fill state now rides in the resolved colour's alpha. Master and layout shape trees are still not drawn, so a deck whose only ground is a shape on its master keeps its white text unreadable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G6qVWb1ky168K4FrBpyZeA --- CHANGELOG.md | 12 + python/src/bind_style.cpp | 3 +- src/odr/internal/html/common.cpp | 9 + src/odr/internal/html/document_element.cpp | 9 +- src/odr/internal/html/document_style.cpp | 7 + src/odr/internal/odf/AGENTS.md | 9 + src/odr/internal/odf/odf_style.cpp | 16 +- src/odr/internal/ooxml/ooxml_util.cpp | 30 ++ src/odr/internal/ooxml/ooxml_util.hpp | 4 + src/odr/internal/ooxml/presentation/AGENTS.md | 55 +++- src/odr/internal/ooxml/presentation/README.md | 10 +- .../ooxml_presentation_document.cpp | 274 +++++++++++++++++- .../ooxml_presentation_document.hpp | 31 ++ src/odr/style.hpp | 2 + test/data.cmake | 4 +- 15 files changed, 437 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebac4cfec..3a2955c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,18 @@ The release run heads these entries with the version and opens a fresh 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. +- New `PageLayout::background_color`, the ground a page or slide is painted on. + A `.pptx` slide fills it from `p:bg`, taken from the slide, its layout or its + master; nothing else sets it yet. +- A `.pptx` renders its colour: runs and highlights resolve `a:schemeClr` + through the slide's theme and its master's `p:clrMap`, a shape paints its + `a:solidFill`, and a text body honours its `a:bodyPr` anchor. Line height + (`a:lnSpc`), space before and after (`a:spcPts`) and sub/superscript arrive + with them. Master and layout **shapes** are still not drawn, so text a deck + puts on one stays unreadable where that shape was its only ground. +- An odf shape or frame no longer paints a `draw:fill-color` that `draw:fill` + does not call for — ODF leaves the colour behind on a box it stopped filling, + and boxes the file leaves blank were coming out coloured. ## v6.10.1 - 2026-08-21 diff --git a/python/src/bind_style.cpp b/python/src/bind_style.cpp index 009f5edfc..d6ae325ed 100644 --- a/python/src/bind_style.cpp +++ b/python/src/bind_style.cpp @@ -184,5 +184,6 @@ void odr_python::bind_style(py::module_ &m) { .def_readwrite("width", &odr::PageLayout::width) .def_readwrite("height", &odr::PageLayout::height) .def_readwrite("print_orientation", &odr::PageLayout::print_orientation) - .def_readwrite("margin", &odr::PageLayout::margin); + .def_readwrite("margin", &odr::PageLayout::margin) + .def_readwrite("background_color", &odr::PageLayout::background_color); } diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index ddf328a70..85e9516ce 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -204,6 +205,14 @@ html::fill_path_variables(const std::string &path, } std::string html::color(const Color &color) { + if (color.alpha != 255) { + std::stringstream ss; + ss << "rgba(" << static_cast(color.red) << "," + << static_cast(color.green) << "," + << static_cast(color.blue) << "," + << (static_cast(color.alpha) / 255.0) << ")"; + return ss.str(); + } std::stringstream ss; ss << "#"; ss << std::setw(6) << std::setfill('0') << std::hex << color.rgb(); diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index 52452f119..510ed5dfd 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -537,9 +537,16 @@ void html::translate_frame(const Element &element, const WritingState &state) { const Frame frame = element.as_frame(); const GraphicStyle style = frame.style(); + // A frame is a plain box, so its fill has to be a background - the `fill` + // that `translate_drawing_style` writes only reaches the svg a shape carries. + std::string background; + if (style.fill_color.has_value() && style.fill_color->alpha != 0) { + background = "background-color:" + color(*style.fill_color) + ";"; + } state.out().write_element_begin( "div", HtmlElementOptions().set_style(translate_frame_properties(frame) + - translate_drawing_style(style))); + translate_drawing_style(style) + + background)); translate_children(frame.children(), state); state.out().write_element_end("div"); } diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 5d4e3d0bd..28e290ac9 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -94,6 +94,13 @@ std::string html::translate_outer_page_style(const PageLayout &page_layout) { height.has_value()) { result.append("height:").append(height->to_string()).append(";"); } + if (const std::optional background_color = + page_layout.background_color; + background_color.has_value()) { + result.append("background-color:") + .append(color(*background_color)) + .append(";"); + } return result; } diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index 6919aea20..473cf3a28 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -69,6 +69,15 @@ of `style:text-position`) multiplies the inherited size; percent line-height passes through (the HTML renderer emits it as a unitless CSS ratio); percent margins are currently **dropped** (open work). +**`draw:fill` decides whether `draw:fill-color` is paint.** The two cascade +independently, and the colour outlives the fill it belonged to — a frame that is +not filled keeps the colour of one that was, and LibreOffice writes exactly that +(`` with no `draw:fill` +in sight). Reading the colour on its own painted boxes the file leaves blank, so +the fill state rides in the resolved colour's **alpha**: `draw:fill` sets it, +`draw:fill-color` sets the rgb and keeps it, and `draw:fill` defaults to none, so +a colour with no fill anywhere in the chain paints nothing. + **A flat document is the same `Document`, minus the package.** Its one `office:document` root carries what `content.xml` and `styles.xml` carry between them, so the flat constructor hands that root in as *both* roots. diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index 1e77108ec..1d9d67ff4 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -519,9 +519,23 @@ void Style::resolve_graphic_style_(const pugi::xml_node node, read_color(graphic_properties.attribute("svg:stroke-color"))) { result.stroke_color = stroke_color; } + // `draw:fill` and `draw:fill-color` cascade independently, and the colour + // outlives the fill it belonged to: a frame that is not filled keeps the + // colour of one that was. Carry the fill state in the alpha channel so the + // two resolve together — `draw:fill` defaults to none, so a colour alone + // paints nothing. + if (const pugi::xml_attribute fill = + graphic_properties.attribute("draw:fill")) { + const Color previous = result.fill_color.value_or(Color()); + result.fill_color = + Color(previous.red, previous.green, previous.blue, + std::strcmp("solid", fill.value()) == 0 ? 255 : 0); + } if (const std::optional fill_color = read_color(graphic_properties.attribute("draw:fill-color"))) { - result.fill_color = fill_color; + result.fill_color = + Color(fill_color->red, fill_color->green, fill_color->blue, + result.fill_color.has_value() ? result.fill_color->alpha : 0); } if (const std::optional vertical_align = read_vertical_align( graphic_properties.attribute("draw:textarea-vertical-align"))) { diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index ae6389b71..91d209a92 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -334,4 +334,34 @@ ooxml::parse_relationships(const abstract::ReadableFilesystem &filesystem, return parse_relationships(relationships); } +/// The target of the first relationship whose type ends in @p type +/// (`slideLayout`, `slideMaster`, `theme`, …), resolved against the directory +/// the part itself lives in. +std::optional +ooxml::parse_relationship_target(const abstract::ReadableFilesystem &filesystem, + const AbsPath &path, + const std::string_view type) { + const AbsPath rel_path = path.parent() + .join(RelPath("_rels")) + .join(RelPath(path.basename() + ".rels")); + if (!filesystem.is_file(rel_path)) { + return {}; + } + + const pugi::xml_document relationships = + util::xml::parse(filesystem, rel_path); + for (const pugi::xpath_node e : + relationships.select_nodes("//Relationship")) { + const std::string_view relation_type = + e.node().attribute("Type").as_string(); + if (!relation_type.ends_with(type) || relation_type.size() == type.size() || + relation_type[relation_type.size() - type.size() - 1] != '/') { + continue; + } + return path.parent().join( + RelPath(e.node().attribute("Target").as_string())); + } + return {}; +} + } // namespace odr::internal diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index e4204fe57..d5315d649 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -58,5 +59,8 @@ parse_relationships(const pugi::xml_document &relations); std::unordered_map parse_relationships(const abstract::ReadableFilesystem &filesystem, const AbsPath &path); +std::optional +parse_relationship_target(const abstract::ReadableFilesystem &filesystem, + const AbsPath &path, std::string_view type); } // namespace odr::internal::ooxml diff --git a/src/odr/internal/ooxml/presentation/AGENTS.md b/src/odr/internal/ooxml/presentation/AGENTS.md index f8310055b..5c60801d9 100644 --- a/src/odr/internal/ooxml/presentation/AGENTS.md +++ b/src/odr/internal/ooxml/presentation/AGENTS.md @@ -27,19 +27,40 @@ from `hMerge`/`vMerge`). **Styles are resolved inline — there is no `StyleRegistry`.** Free functions in 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 +size in hundredth-points, bold/italic/underline/strike/shadow, sub/superscript +from `@baseline`; align from `@algn` ([ECMA-376] 20.1.10.59 `ST_TextAlignType`, +which spells the values differently than wordprocessingml does), `@marL`/`@marR` +margins in EMUs, `a:lnSpc` line height, `a:spcBef`/`a:spcAft` as top and bottom +margins. **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. `a:spcBef`/`a:spcAft` +are taken only in their absolute `a:spcPts` form: the percent form is of the text +size, which css would resolve against the width instead. 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. +**Colour goes through the theme, and never lands without a ground.** A pptx +states most of its colour as `a:schemeClr`, a *slot* name — `tx1`, `bg1`, +`accent1` — so reading only the literal `a:srgbClr` sees almost nothing: the file +that motivated this work carries 1066 scheme references and not one literal. A +slot resolves along **slide → layout → master → theme**: the theme's +`a:clrScheme` holds the colours, the master's `p:clrMap` says which slot each +name stands for, and `ColorScheme` is the two folded together. Masters are +shared, so one is read once rather than once per slide. + +The reason colour waited for this: **a run colour is only safe once something +paints behind it.** Text a deck puts on a coloured master is white, and on our +white page it simply vanished — 36 runs in `tuesday_d6.pptx`, every footer in the +Google Slides export. So the ground is read too: `p:bg` from the slide, else its +layout, else its master, onto `PageLayout::background_color`, and a shape's own +`p:spPr/a:solidFill` onto the frame. What is still missing is a master's or +layout's **shapes** — `tuesday_d6.pptx` puts its white titles on a gradient-filled +`custGeom` banner living in the master, and neither custom geometry nor gradients +nor master shape trees are modelled, so those titles stay invisible. That is the +same gap as (1) below, and the last thing standing between this deck and a +correct render. + **Frame positioning is EMU-based.** `p:spPr/a:xfrm/a:off` + `a:ext` (`p:xfrm` for `p:graphicFrame`) give `x/y/width/height` in EMUs; anchor type is always `at_page`. Slide size comes from `p:presentation/p:sldSz` (ECMA-376 default @@ -49,7 +70,7 @@ for `p:graphicFrame`) give `x/y/width/height` in EMUs; anchor type is always | File (`presentation/`) | Role | |---|---| -| `ooxml_presentation_document.{hpp,cpp}` | `Document` (loads XML + relationships) + `ElementAdapter`; inline style resolution | +| `ooxml_presentation_document.{hpp,cpp}` | `Document` (loads XML + relationships) + `ColorScheme` (theme × `p:clrMap`) + `ElementAdapter`; inline style resolution | | `ooxml_presentation_parser.{hpp,cpp}` | `ParseContext` (slides map) + tag dispatch; presentation.xml → slides → spTree | | `ooxml_presentation_element_registry.{hpp,cpp}` | Flat element store + Table/Text side maps | @@ -59,9 +80,13 @@ for `p:graphicFrame`) give `x/y/width/height` in EMUs; anchor type is always Coverage is in [`README.md`](README.md). Foundational gaps, roughly by value: -1. **No master/layout inheritance.** `slide_master_page` returns empty; master - and layout parts are loaded into the relationship map but never consulted - (no inherited placeholders, backgrounds, or styles). +1. **No master/layout inheritance beyond colour.** `slide_master_page` returns + empty and neither shape tree is walked, so a placeholder's font, size and + position, and every shape a master or layout draws — banners, logos, rules — + are missing. The chain *is* walked now, but only for the theme's colours and + the background fill. Custom geometry (`a:custGeom`) and gradients + (`a:gradFill`) are unmodelled, so some grounds cannot be painted even once + the trees are walked. 2. **Images not modelled** — no `p:pic`/`a:blip` parser entry; `image_href` reads ODF-style `xlink:href` (wrong for pptx `r:embed`). 3. **Table cell styles unresolved.** Tables are wired (grid, spans, covered diff --git a/src/odr/internal/ooxml/presentation/README.md b/src/odr/internal/ooxml/presentation/README.md index 5a8c2dfd8..9f96cabc8 100644 --- a/src/odr/internal/ooxml/presentation/README.md +++ b/src/odr/internal/ooxml/presentation/README.md @@ -20,7 +20,8 @@ Roughly ordered by importance. - [x] slides - [x] shapes (`p:sp`), text bodies - [x] slide size (`p:sldSz`) and slide names - - [ ] slide master / layout inheritance + - [x] slide background (`p:bg`, inherited from layout / master) + - [ ] slide master / layout inheritance (beyond theme colors + background) - [x] text extraction - [ ] edit - [ ] save @@ -42,13 +43,14 @@ Roughly ordered by importance. - [x] size - [x] italic, bold - [x] underline, strike through - - [ ] color, background (highlight) — waits on background fill, see - [`AGENTS.md`](AGENTS.md) + - [x] color, background (highlight), incl. theme colors (`a:schemeClr`) - [x] shadow - - [ ] superscript, subscript + - [x] superscript, subscript (`@baseline`) - [x] paragraph - [x] alignment (`a:pPr/@algn`) - [x] indentation / left & right margins (`@marL` / `@marR`) + - [x] line height (`a:lnSpc`), space before / after (`a:spcPts` only) +- [x] shape fill (`p:spPr/a:solidFill`) and text anchor (`a:bodyPr/@anchor`) - [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 fd5db12e6..42b16a120 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -13,6 +13,7 @@ #include #include +#include #include namespace odr::internal::ooxml::presentation { @@ -20,8 +21,43 @@ namespace odr::internal::ooxml::presentation { namespace { std::unique_ptr create_element_adapter(const Document &document, ElementRegistry ®istry); + +/// A theme slot holds a literal colour or a system colour that names the value +/// it last resolved to. [ECMA-376] 20.1.2.3.32, 20.1.2.3.33 +std::optional read_theme_color_(const pugi::xml_node slot) { + if (const std::optional color = + read_color_attribute(slot.child("a:srgbClr").attribute("val"))) { + return color; + } + return read_color_attribute(slot.child("a:sysClr").attribute("lastClr")); } +/// A drawingml colour choice: a literal, a theme slot, or a system colour. +/// [ECMA-376] 20.1.2.3 +std::optional read_drawing_color_(const pugi::xml_node parent, + const ColorScheme *color_scheme) { + if (const std::optional color = read_theme_color_(parent)) { + return color; + } + if (const pugi::xml_attribute scheme_color = + parent.child("a:schemeClr").attribute("val"); + scheme_color && color_scheme != nullptr) { + return color_scheme->resolve(scheme_color.value()); + } + return {}; +} + +/// The ground a slide, layout or master states. A `p:bgRef` into the theme's +/// fill styles is not modelled. [ECMA-376] 19.3.1.1 +std::optional read_background_color_(const pugi::xml_node slide_like, + const ColorScheme *color_scheme) { + return read_drawing_color_( + slide_like.child("p:cSld").child("p:bg").child("p:bgPr").child( + "a:solidFill"), + color_scheme); +} +} // namespace + Document::Document(std::shared_ptr files) : internal::Document(FileType::office_open_xml_presentation, DocumentType::presentation, std::move(files)) { @@ -31,12 +67,14 @@ Document::Document(std::shared_ptr files) // all to the presentation, and a Google Slides export relates a protobuf. const Relations relations = parse_relationships(*m_files, AbsPath("/ppt/presentation.xml")); + std::vector> slides; 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)))); + std::string id = slide_id.attribute("r:id").value(); + AbsPath slide_path = AbsPath("/ppt").join(RelPath(relations.at(id))); + m_slides_xml[id] = util::xml::parse(*m_files, slide_path); + slides.emplace_back(std::move(id), std::move(slide_path)); } // ECMA-376 default slide size when p:sldSz is absent. @@ -58,9 +96,129 @@ Document::Document(std::shared_ptr files) m_root_element = parse_tree(m_element_registry, parse_context, m_document_xml.document_element()); + load_slide_styles_(slides); + m_element_adapter = create_element_adapter(*this, m_element_registry); } +namespace { +/// What a slide master decides for every slide that hangs off it. +struct MasterStyle { + ColorScheme color_scheme; + std::optional background; +}; +} // namespace + +/// slide → layout → master → theme, and the master's `p:clrMap` says which slot +/// each name stands for. Masters are shared, so a master is read once rather +/// than once per slide; the ground comes from the slide, else its layout, else +/// its master. +void Document::load_slide_styles_( + const std::vector> &slides) { + std::unordered_map by_master; + + // `parse_presentation_children` appends one slide per `p:sldId`, so the + // root's children carry the same order `slides` was built in. + ElementIdentifier slide_id = + m_element_registry.element_at(m_root_element).first_child_id; + for (const auto &[relation_id, slide_path] : slides) { + if (slide_id == null_element_id) { + break; + } + const ElementIdentifier current_id = slide_id; + slide_id = m_element_registry.element_at(slide_id).next_sibling_id; + + const std::optional layout_path = + parse_relationship_target(*m_files, slide_path, "slideLayout"); + if (!layout_path.has_value()) { + continue; + } + const std::optional master_path = + parse_relationship_target(*m_files, *layout_path, "slideMaster"); + if (!master_path.has_value()) { + continue; + } + + const auto master_it = by_master.find(master_path->string()); + if (master_it == std::end(by_master)) { + const std::optional theme_path = + parse_relationship_target(*m_files, *master_path, "theme"); + const pugi::xml_document master = + util::xml::parse(*m_files, *master_path); + + MasterStyle master_style; + if (theme_path.has_value()) { + const pugi::xml_document theme = + util::xml::parse(*m_files, *theme_path); + master_style.color_scheme = + ColorScheme(theme.document_element() + .child("a:themeElements") + .child("a:clrScheme"), + master.document_element().child("p:clrMap")); + } + master_style.background = read_background_color_( + master.document_element(), &master_style.color_scheme); + by_master[master_path->string()] = std::move(master_style); + } + const MasterStyle &master_style = by_master.at(master_path->string()); + m_slide_color_schemes[current_id] = master_style.color_scheme; + + const pugi::xml_document layout = util::xml::parse(*m_files, *layout_path); + std::optional background = + read_background_color_(m_slides_xml.at(relation_id).document_element(), + &master_style.color_scheme); + if (!background.has_value()) { + background = read_background_color_(layout.document_element(), + &master_style.color_scheme); + } + if (!background.has_value()) { + background = master_style.background; + } + if (background.has_value()) { + m_slide_backgrounds[current_id] = *background; + } + } +} + +const ColorScheme * +Document::slide_color_scheme(const ElementIdentifier element_id) const { + const auto it = m_slide_color_schemes.find(element_id); + return it == std::end(m_slide_color_schemes) ? nullptr : &it->second; +} + +PageLayout +Document::slide_page_layout(const ElementIdentifier element_id) const { + PageLayout result = m_slide_layout; + if (const auto it = m_slide_backgrounds.find(element_id); + it != std::end(m_slide_backgrounds)) { + result.background_color = it->second; + } + return result; +} + +ColorScheme::ColorScheme(const pugi::xml_node color_scheme, + const pugi::xml_node color_map) { + for (const pugi::xml_node slot : color_scheme.children()) { + if (const std::optional color = read_theme_color_(slot)) { + // the slot names are `a:dk1`, `a:lt1`, `a:accent1`, … + m_colors[std::string(slot.name()).substr(2)] = *color; + } + } + for (const pugi::xml_attribute mapping : color_map.attributes()) { + const auto it = m_colors.find(mapping.value()); + if (it == std::end(m_colors)) { + continue; + } + const Color color = it->second; // the insert below may rehash + m_colors[mapping.name()] = color; + } +} + +std::optional ColorScheme::resolve(const char *name) const { + const auto it = m_colors.find(name); + return it == std::end(m_colors) ? std::optional() : it->second; +} + const PageLayout &Document::slide_layout() const { return m_slide_layout; } const ElementRegistry &Document::element_registry() const { @@ -83,7 +241,35 @@ void Document::save(const Path & /*path*/, const char * /*password*/) const { namespace { -void resolve_text_style_(const pugi::xml_node node, TextStyle &result) { +/// [ECMA-376] 20.1.10.60 ST_TextAnchoringType +std::optional +read_text_anchor_(const pugi::xml_attribute attribute) { + const char *val = attribute.value(); + if (std::strcmp("t", val) == 0) { + return VerticalAlign::top; + } + if (std::strcmp("ctr", val) == 0) { + return VerticalAlign::middle; + } + if (std::strcmp("b", val) == 0) { + return VerticalAlign::bottom; + } + return {}; +} + +/// `a:lnSpc` states a percent of the line — `a:spcPct` in thousandths — or an +/// absolute `a:spcPts` in hundredths of a point. [ECMA-376] 21.1.2.2.12 +std::optional read_line_spacing_(const pugi::xml_node node) { + if (const pugi::xml_attribute percent = + node.child("a:spcPct").attribute("val")) { + return Measure(percent.as_double() * 1e-3, DynamicUnit("%")); + } + return read_hundredth_point_attribute( + node.child("a:spcPts").attribute("val")); +} + +void resolve_text_style_(const pugi::xml_node node, + const ColorScheme *color_scheme, TextStyle &result) { const pugi::xml_node run_properties = node.child("a:rPr"); if (const pugi::xml_attribute font_name = @@ -114,8 +300,21 @@ void resolve_text_style_(const pugi::xml_node node, TextStyle &result) { read_shadow_attribute(run_properties.attribute("shadow"))) { result.font_shadow = font_shadow; } - // `a:solidFill` colour is left unread on purpose until backgrounds are - // painted — see AGENTS.md. + if (const std::optional font_color = read_drawing_color_( + run_properties.child("a:solidFill"), color_scheme)) { + result.font_color = font_color; + } + if (const std::optional background_color = read_drawing_color_( + run_properties.child("a:highlight"), color_scheme)) { + result.background_color = background_color; + } + // `baseline` is a percent of the font size, and its sign the direction. + if (const pugi::xml_attribute baseline = + run_properties.attribute("baseline")) { + result.font_position = baseline.as_int() > 0 ? FontPosition::super + : baseline.as_int() < 0 ? FontPosition::sub + : FontPosition::normal; + } } void resolve_paragraph_style_(const pugi::xml_node node, @@ -135,6 +334,24 @@ void resolve_paragraph_style_(const pugi::xml_node node, read_emus_attribute(paragraph_properties.attribute("marR"))) { result.margin.right = margin_right; } + if (const std::optional line_height = + read_line_spacing_(paragraph_properties.child("a:lnSpc"))) { + result.line_height = line_height; + } + // Only the absolute form: a percent here is of the text size, which css + // would resolve against the width instead. + if (const std::optional margin_top = + read_hundredth_point_attribute(paragraph_properties.child("a:spcBef") + .child("a:spcPts") + .attribute("val"))) { + result.margin.top = margin_top; + } + if (const std::optional margin_bottom = + read_hundredth_point_attribute(paragraph_properties.child("a:spcAft") + .child("a:spcPts") + .attribute("val"))) { + result.margin.bottom = margin_bottom; + } } class ElementAdapter final : public abstract::ElementAdapter, @@ -259,9 +476,9 @@ class ElementAdapter final : public abstract::ElementAdapter, return element_type(element_id) == ElementType::image ? this : nullptr; } - [[nodiscard]] PageLayout slide_page_layout( - [[maybe_unused]] const ElementIdentifier element_id) const override { - return m_document->slide_layout(); + [[nodiscard]] PageLayout + slide_page_layout(const ElementIdentifier element_id) const override { + return m_document->slide_page_layout(element_id); } [[nodiscard]] ElementIdentifier slide_master_page( [[maybe_unused]] const ElementIdentifier element_id) const override { @@ -479,9 +696,24 @@ class ElementAdapter final : public abstract::ElementAdapter, [[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 { + const pugi::xml_node node = get_node(element_id); + const pugi::xml_node shape_properties = node.child("p:spPr"); + + GraphicStyle result; + if (shape_properties.child("a:noFill")) { + result.fill_color = Color(0, 0, 0, 0); + } else if (const std::optional fill_color = + read_drawing_color_(shape_properties.child("a:solidFill"), + get_color_scheme(element_id))) { + result.fill_color = fill_color; + } + if (const std::optional vertical_align = read_text_anchor_( + node.child("p:txBody").child("a:bodyPr").attribute("anchor"))) { + result.vertical_align = vertical_align; + } + return result; } [[nodiscard]] bool @@ -512,6 +744,18 @@ class ElementAdapter final : public abstract::ElementAdapter, return m_registry->element_at(element_id).node; } + /// The scheme of the slide the element sits on. + [[nodiscard]] const ColorScheme * + get_color_scheme(const ElementIdentifier element_id) const { + for (ElementIdentifier id = element_id; id != null_element_id; + id = element_parent(id)) { + if (element_type(id) == ElementType::slide) { + return m_document->slide_color_scheme(id); + } + } + return nullptr; + } + /// `p:sp` carries its transform in `p:spPr/a:xfrm`, `p:graphicFrame` in /// `p:xfrm`. [[nodiscard]] pugi::xml_node @@ -542,13 +786,15 @@ class ElementAdapter final : public abstract::ElementAdapter, m_registry->element_at(element_id); if (element.type == ElementType::paragraph) { ResolvedStyle result; - resolve_text_style_(element.node, result.text_style); + resolve_text_style_(element.node, get_color_scheme(element_id), + result.text_style); resolve_paragraph_style_(element.node, result.paragraph_style); return result; } if (element.type == ElementType::span) { ResolvedStyle result; - resolve_text_style_(element.node, result.text_style); + resolve_text_style_(element.node, get_color_scheme(element_id), + result.text_style); return result; } return {}; diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp index d0a901f82..bd9d4ea10 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp @@ -3,20 +3,46 @@ #include #include +#include #include +#include +#include #include +#include +#include #include namespace odr::internal::ooxml::presentation { +/// The theme's `a:clrScheme` seen through the slide master's `p:clrMap`, so a +/// slide's `a:schemeClr` names a colour. [ECMA-376] 20.1.6.2 +class ColorScheme final { +public: + ColorScheme() = default; + ColorScheme(pugi::xml_node color_scheme, pugi::xml_node color_map); + + [[nodiscard]] std::optional resolve(const char *name) const; + +private: + std::unordered_map m_colors; +}; + class Document final : public internal::Document { public: explicit Document(std::shared_ptr files); [[nodiscard]] const ElementRegistry &element_registry() const; [[nodiscard]] const PageLayout &slide_layout() const; + /// The scheme of the slide @p element_id, or null where the chain to a theme + /// is broken. + [[nodiscard]] const ColorScheme * + slide_color_scheme(ElementIdentifier element_id) const; + /// The layout of the slide @p element_id: the shared one, plus the ground the + /// slide inherits from its layout or master. + [[nodiscard]] PageLayout + slide_page_layout(ElementIdentifier element_id) const; [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; @@ -28,8 +54,13 @@ class Document final : public internal::Document { pugi::xml_document m_document_xml; std::unordered_map m_slides_xml; PageLayout m_slide_layout; + std::unordered_map m_slide_color_schemes; + std::unordered_map m_slide_backgrounds; ElementRegistry m_element_registry; + + void load_slide_styles_( + const std::vector> &slides); }; } // namespace odr::internal::ooxml::presentation diff --git a/src/odr/style.hpp b/src/odr/style.hpp index 764f73d07..bd4f7d6c7 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -213,6 +213,8 @@ struct PageLayout final { std::optional height; std::optional print_orientation; DirectionalStyle margin; + /// The ground the page is painted on; unset leaves it to the viewer. + std::optional background_color; }; } // namespace odr diff --git a/test/data.cmake b/test/data.cmake index 15940c390..9bcaea825 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 "9b4bfd1ecc57c2e0abd6e006be360d1adb34db24") + REVISION "f211f7544b400cb69b13e277a6c280e131a05c65") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "5fc72ac725c2ab2c0be79bdc119203d7cda429e4") + REVISION "eacdb50e9cd6791538708c6be6b288e077cc4a0b") From 78968f97e275f4b7188dbf9073b10d2c7abe1c38 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 26 Aug 2026 09:59:13 +0200 Subject: [PATCH 2/3] fix: complete the PageLayout initialisers the new field broke `-Wmissing-field-initializers` is an error on the linux builds, and both `ppt_document` and `iwork_document` spell every member out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G6qVWb1ky168K4FrBpyZeA --- src/odr/internal/iwork/iwork_document.cpp | 1 + src/odr/internal/oldms/presentation/ppt_document.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/odr/internal/iwork/iwork_document.cpp b/src/odr/internal/iwork/iwork_document.cpp index 530b762d4..909b2aa49 100644 --- a/src/odr/internal/iwork/iwork_document.cpp +++ b/src/odr/internal/iwork/iwork_document.cpp @@ -214,6 +214,7 @@ class ElementAdapter final : public abstract::ElementAdapter, .height = points(size->height), .print_orientation = {}, .margin = {}, + .background_color = {}, }; } [[nodiscard]] ElementIdentifier slide_master_page( diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index 4597300f5..a8d95cd2f 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -164,6 +164,7 @@ class ElementAdapter final : public abstract::ElementAdapter, Measure(size->second / master_units_per_inch, DynamicUnit("in")), .print_orientation = {}, .margin = {}, + .background_color = {}, }; } return { @@ -171,6 +172,7 @@ class ElementAdapter final : public abstract::ElementAdapter, .height = Measure("7.5in"), .print_orientation = {}, .margin = {}, + .background_color = {}, }; } [[nodiscard]] ElementIdentifier slide_master_page( From aa5c1b6a3a883eb8dedf21cf622800efa3f79fef Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 26 Aug 2026 19:58:08 +0200 Subject: [PATCH 3/3] fix(pptx): make the colour resolution total, and pin what the corpus misses The style resolution moves out of the document into `ooxml_presentation_style.{hpp,cpp}`, the way `ooxml/text` already keeps it, so the layout/master/theme walk and the `a:rPr`/`a:pPr` readers are drivable from inline xml and an in-memory filesystem. - every layout, master and theme part is read behind `is_file` and a catch, so a missing or non-xml part leaves a slide unstyled instead of failing the open - a relationship target that is absolute, empty or escaping resolves rather than throwing - a `p:bg` we do not model ends the inheritance walk instead of falling through to the master's colour - `p:clrMap` resolves against the theme, not against itself - xlsx drops the argb alpha byte again: excel ignores it, producers write `00`, and `html::color` started honouring it - a layout is read once, not once per slide, and its master's scheme is shared rather than copied per slide - `PageLayout::background_color` reaches the jni and apple bindings Reference output is byte-identical across both corpora. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VR6FrcYaNnr5vi9kt9qUD7 --- CHANGELOG.md | 22 +- CMakeLists.txt | 1 + apple/include/OdrCoreObjC/ODRStyle.h | 2 + apple/src/ODRStyle.mm | 1 + apple/swift/Style+Optionals.swift | 1 + .../app/opendocument/core/PageLayout.java | 9 +- jni/src/jni_style.cpp | 6 +- src/odr/internal/odf/AGENTS.md | 13 +- src/odr/internal/odf/odf_style.cpp | 7 +- src/odr/internal/ooxml/ooxml_util.cpp | 62 ++- src/odr/internal/ooxml/ooxml_util.hpp | 2 + src/odr/internal/ooxml/presentation/AGENTS.md | 36 +- src/odr/internal/ooxml/presentation/README.md | 3 +- .../ooxml_presentation_document.cpp | 279 ++------------ .../ooxml_presentation_document.hpp | 30 +- .../presentation/ooxml_presentation_style.cpp | 245 ++++++++++++ .../presentation/ooxml_presentation_style.hpp | 66 ++++ .../spreadsheet/ooxml_spreadsheet_style.cpp | 4 +- test/CMakeLists.txt | 2 + test/src/internal/html/common_test.cpp | 13 + .../src/internal/html/document_style_test.cpp | 14 + .../ooxml/ooxml_presentation_style_test.cpp | 355 ++++++++++++++++++ test/src/internal/ooxml/ooxml_util_test.cpp | 108 ++++++ 23 files changed, 963 insertions(+), 318 deletions(-) create mode 100644 src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp create mode 100644 src/odr/internal/ooxml/presentation/ooxml_presentation_style.hpp create mode 100644 test/src/internal/ooxml/ooxml_presentation_style_test.cpp create mode 100644 test/src/internal/ooxml/ooxml_util_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2955c89..7847b6190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,18 +67,16 @@ The release run heads these entries with the version and opens a fresh 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. -- New `PageLayout::background_color`, the ground a page or slide is painted on. - A `.pptx` slide fills it from `p:bg`, taken from the slide, its layout or its - master; nothing else sets it yet. -- A `.pptx` renders its colour: runs and highlights resolve `a:schemeClr` - through the slide's theme and its master's `p:clrMap`, a shape paints its - `a:solidFill`, and a text body honours its `a:bodyPr` anchor. Line height - (`a:lnSpc`), space before and after (`a:spcPts`) and sub/superscript arrive - with them. Master and layout **shapes** are still not drawn, so text a deck - puts on one stays unreadable where that shape was its only ground. -- An odf shape or frame no longer paints a `draw:fill-color` that `draw:fill` - does not call for — ODF leaves the colour behind on a box it stopped filling, - and boxes the file leaves blank were coming out coloured. +- New `PageLayout::background_color`, the ground a page is painted on. A `.pptx` + slide takes it from the slide, its layout or its master; nothing else sets it + yet. +- A `.pptx` renders in colour: text, highlights and shape fills, including the + ones its theme names. Line height, space before and after, sub/superscript and + vertical text alignment arrive with them. Tinted and shaded theme colours + still come out at full strength, and shapes a master or layout draws are still + missing. +- An odf shape or frame that is not filled no longer paints the colour of one + that was. ## v6.10.1 - 2026-08-21 diff --git a/CMakeLists.txt b/CMakeLists.txt index 79a738658..ab129a0d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -204,6 +204,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp" "src/odr/internal/ooxml/presentation/ooxml_presentation_element_registry.cpp" "src/odr/internal/ooxml/presentation/ooxml_presentation_parser.cpp" + "src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp" "src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp" "src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_parser.cpp" "src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.cpp" diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index 47baa4174..99f8c7a8c 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -223,6 +223,8 @@ NS_SWIFT_NAME(PageLayout) /// `ODRPrintOrientation`, boxed. @property(nonatomic, readonly, nullable) NSNumber *printOrientation; @property(nonatomic, readonly) ODRDirectionalMeasure *margin; +/// `ODRColor`, boxed in an `NSValue`. +@property(nonatomic, readonly, nullable) NSValue *backgroundColor; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index 2ac9b0c4d..b233407ad 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -246,6 +246,7 @@ + (instancetype)layoutWithHandle:(const odr::PageLayout &)handle { result->_height = box(handle.height); result->_printOrientation = box_enum(handle.print_orientation); result->_margin = [ODRDirectionalMeasure directionalWithHandle:handle.margin]; + result->_backgroundColor = box(handle.background_color); return result; } diff --git a/apple/swift/Style+Optionals.swift b/apple/swift/Style+Optionals.swift index 3e1599c41..199c940ad 100644 --- a/apple/swift/Style+Optionals.swift +++ b/apple/swift/Style+Optionals.swift @@ -63,6 +63,7 @@ extension PageLayout { public var orientation: PrintOrientation? { printOrientation?.asEnum(PrintOrientation.self) } + public var background: Color? { backgroundColor?.asColor } } extension Frame { diff --git a/jni/java/app/opendocument/core/PageLayout.java b/jni/java/app/opendocument/core/PageLayout.java index 9a320b6ce..d6447f3be 100644 --- a/jni/java/app/opendocument/core/PageLayout.java +++ b/jni/java/app/opendocument/core/PageLayout.java @@ -6,11 +6,18 @@ public final class PageLayout { public final Measure height; public final PrintOrientation printOrientation; public final DirectionalMeasure margin; + public final Color backgroundColor; - PageLayout(Measure width, Measure height, int printOrientation, DirectionalMeasure margin) { + PageLayout( + Measure width, + Measure height, + int printOrientation, + DirectionalMeasure margin, + Color backgroundColor) { this.width = width; this.height = height; this.printOrientation = PrintOrientation.fromNative(printOrientation); this.margin = margin; + this.backgroundColor = backgroundColor; } } diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 436475b01..a8fd74fff 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -256,10 +256,12 @@ jobject make_page_layout(JNIEnv *env, const odr::PageLayout &layout) { return new_object( env, "app/opendocument/core/PageLayout", "(Lapp/opendocument/core/Measure;Lapp/opendocument/core/Measure;I" - "Lapp/opendocument/core/DirectionalMeasure;)V", + "Lapp/opendocument/core/DirectionalMeasure;" + "Lapp/opendocument/core/Color;)V", make_measure(env, layout.width), make_measure(env, layout.height), enum_code(layout.print_orientation), - make_directional_measure(env, layout.margin)); + make_directional_measure(env, layout.margin), + make_color(env, layout.background_color)); } jobject make_table_dimensions(JNIEnv *env, diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index 473cf3a28..5092e886c 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -70,13 +70,12 @@ passes through (the HTML renderer emits it as a unitless CSS ratio); percent margins are currently **dropped** (open work). **`draw:fill` decides whether `draw:fill-color` is paint.** The two cascade -independently, and the colour outlives the fill it belonged to — a frame that is -not filled keeps the colour of one that was, and LibreOffice writes exactly that -(`` with no `draw:fill` -in sight). Reading the colour on its own painted boxes the file leaves blank, so -the fill state rides in the resolved colour's **alpha**: `draw:fill` sets it, -`draw:fill-color` sets the rgb and keeps it, and `draw:fill` defaults to none, so -a colour with no fill anywhere in the chain paints nothing. +independently, and the colour outlives the fill it belonged to — LibreOffice +writes `` with no +`draw:fill` in sight. So the fill state rides in the resolved colour's +**alpha**: `draw:fill` sets it, `draw:fill-color` sets the rgb and keeps it, and +`draw:fill` defaults to none, so a colour with no fill anywhere in the chain +paints nothing. **A flat document is the same `Document`, minus the package.** Its one `office:document` root carries what `content.xml` and `styles.xml` carry diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index 1d9d67ff4..d3d975bd2 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -519,11 +519,8 @@ void Style::resolve_graphic_style_(const pugi::xml_node node, read_color(graphic_properties.attribute("svg:stroke-color"))) { result.stroke_color = stroke_color; } - // `draw:fill` and `draw:fill-color` cascade independently, and the colour - // outlives the fill it belonged to: a frame that is not filled keeps the - // colour of one that was. Carry the fill state in the alpha channel so the - // two resolve together — `draw:fill` defaults to none, so a colour alone - // paints nothing. + // `draw:fill` and `draw:fill-color` cascade independently, so the fill state + // rides in the alpha — see AGENTS.md if (const pugi::xml_attribute fill = graphic_properties.attribute("draw:fill")) { const Color previous = result.fill_color.value_or(Color()); diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 91d209a92..f42aced82 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace odr::internal { @@ -285,6 +286,23 @@ ooxml::read_vertical_align_attribute(const pugi::xml_attribute attribute) { return {}; } +/// [ECMA-376] 20.1.10.60 ST_TextAnchoringType — drawingml spells the same +/// values differently than wordprocessingml does. +std::optional ooxml::read_drawing_vertical_align_attribute( + const pugi::xml_attribute attribute) { + const char *val = attribute.value(); + if (std::strcmp("t", val) == 0) { + return VerticalAlign::top; + } + if (std::strcmp("ctr", val) == 0) { + return VerticalAlign::middle; + } + if (std::strcmp("b", val) == 0) { + return VerticalAlign::bottom; + } + return {}; +} + std::optional ooxml::read_border_node(const pugi::xml_node node) { if (!node) { return {}; @@ -319,12 +337,38 @@ ooxml::parse_relationships(const pugi::xml_document &relations) { return result; } +namespace { + +AbsPath relationships_path(const AbsPath &path) { + return path.parent() + .join(RelPath("_rels")) + .join(RelPath(path.basename() + ".rels")); +} + +/// [ECMA-376] 15.2.4: a target is relative to the part that states it, unless +/// it names a part from the package root. One that is empty, or that climbs out +/// of the package, names nothing. +std::optional resolve_relationship_target(const AbsPath &path, + const char *target) { + if (target == nullptr || *target == '\0') { + return {}; + } + if (*target == '/') { + return AbsPath(target); + } + try { + return path.parent().join(RelPath(target)); + } catch (const std::invalid_argument &) { + return {}; + } +} + +} // namespace + std::unordered_map ooxml::parse_relationships(const abstract::ReadableFilesystem &filesystem, const AbsPath &path) { - const AbsPath rel_path = path.parent() - .join(RelPath("_rels")) - .join(RelPath(path.basename() + ".rels")); + const AbsPath rel_path = relationships_path(path); if (!filesystem.is_file(rel_path)) { return {}; } @@ -335,15 +379,12 @@ ooxml::parse_relationships(const abstract::ReadableFilesystem &filesystem, } /// The target of the first relationship whose type ends in @p type -/// (`slideLayout`, `slideMaster`, `theme`, …), resolved against the directory -/// the part itself lives in. +/// (`slideLayout`, `slideMaster`, `theme`, …), resolved against the part. std::optional ooxml::parse_relationship_target(const abstract::ReadableFilesystem &filesystem, const AbsPath &path, const std::string_view type) { - const AbsPath rel_path = path.parent() - .join(RelPath("_rels")) - .join(RelPath(path.basename() + ".rels")); + const AbsPath rel_path = relationships_path(path); if (!filesystem.is_file(rel_path)) { return {}; } @@ -352,14 +393,15 @@ ooxml::parse_relationship_target(const abstract::ReadableFilesystem &filesystem, util::xml::parse(filesystem, rel_path); for (const pugi::xpath_node e : relationships.select_nodes("//Relationship")) { + // the type is a uri, so `type` has to match a whole trailing segment const std::string_view relation_type = e.node().attribute("Type").as_string(); if (!relation_type.ends_with(type) || relation_type.size() == type.size() || relation_type[relation_type.size() - type.size() - 1] != '/') { continue; } - return path.parent().join( - RelPath(e.node().attribute("Target").as_string())); + return resolve_relationship_target( + path, e.node().attribute("Target").as_string()); } return {}; } diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index d5315d649..a029b04d7 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -47,6 +47,8 @@ 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_drawing_vertical_align_attribute(pugi::xml_attribute); std::optional read_border_node(pugi::xml_node); using Relations = std::unordered_map; diff --git a/src/odr/internal/ooxml/presentation/AGENTS.md b/src/odr/internal/ooxml/presentation/AGENTS.md index 5c60801d9..100eaa64f 100644 --- a/src/odr/internal/ooxml/presentation/AGENTS.md +++ b/src/odr/internal/ooxml/presentation/AGENTS.md @@ -26,7 +26,7 @@ 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` directly: font from `a:latin/@typeface`, +`ooxml_presentation_style` read `a:rPr` / `a:pPr` directly: font from `a:latin/@typeface`, size in hundredth-points, bold/italic/underline/strike/shadow, sub/superscript from `@baseline`; align from `@algn` ([ECMA-376] 20.1.10.59 `ST_TextAlignType`, which spells the values differently than wordprocessingml does), `@marL`/`@marR` @@ -42,24 +42,23 @@ contribution. **Colour goes through the theme, and never lands without a ground.** A pptx states most of its colour as `a:schemeClr`, a *slot* name — `tx1`, `bg1`, -`accent1` — so reading only the literal `a:srgbClr` sees almost nothing: the file -that motivated this work carries 1066 scheme references and not one literal. A -slot resolves along **slide → layout → master → theme**: the theme's -`a:clrScheme` holds the colours, the master's `p:clrMap` says which slot each -name stands for, and `ColorScheme` is the two folded together. Masters are -shared, so one is read once rather than once per slide. +`accent1` — so reading only the literal `a:srgbClr` sees almost nothing. A slot +resolves along **slide → layout → master → theme**: the theme's `a:clrScheme` +holds the colours, the master's `p:clrMap` says which slot each name stands for, +and `ColorScheme` is the two folded together. Layouts are shared, so a layout, +its master and its theme are read once rather than once per slide. Colour +*transforms* — `a:lumMod`, `a:lumOff`, `a:tint`, `a:shade`, `a:alpha` +([ECMA-376] 20.1.2.3) — are dropped, so a tinted slot renders at full strength. -The reason colour waited for this: **a run colour is only safe once something -paints behind it.** Text a deck puts on a coloured master is white, and on our -white page it simply vanished — 36 runs in `tuesday_d6.pptx`, every footer in the -Google Slides export. So the ground is read too: `p:bg` from the slide, else its +**A run colour is only safe once something paints behind it**, which is why it +lands with the ground and not before: white text on a coloured master would +otherwise vanish on our white page. So `p:bg` is read from the slide, else its layout, else its master, onto `PageLayout::background_color`, and a shape's own -`p:spPr/a:solidFill` onto the frame. What is still missing is a master's or -layout's **shapes** — `tuesday_d6.pptx` puts its white titles on a gradient-filled -`custGeom` banner living in the master, and neither custom geometry nor gradients -nor master shape trees are modelled, so those titles stay invisible. That is the -same gap as (1) below, and the last thing standing between this deck and a -correct render. +`p:spPr/a:solidFill` onto the frame. A `p:bg` we do not model — `p:bgRef`, +`a:gradFill`, `a:blipFill` — ends that walk rather than falling through to the +part behind it. Master and layout **shapes** are still not drawn (gap (1) +below), so text a deck puts on one stays unreadable where that shape was its +only ground. **Frame positioning is EMU-based.** `p:spPr/a:xfrm/a:off` + `a:ext` (`p:xfrm` for `p:graphicFrame`) give `x/y/width/height` in EMUs; anchor type is always @@ -70,7 +69,8 @@ for `p:graphicFrame`) give `x/y/width/height` in EMUs; anchor type is always | File (`presentation/`) | Role | |---|---| -| `ooxml_presentation_document.{hpp,cpp}` | `Document` (loads XML + relationships) + `ColorScheme` (theme × `p:clrMap`) + `ElementAdapter`; inline style resolution | +| `ooxml_presentation_document.{hpp,cpp}` | `Document` (loads XML + relationships) + `ElementAdapter` | +| `ooxml_presentation_style.{hpp,cpp}` | `ColorScheme` (theme × `p:clrMap`), the layout/master walk, and the `a:rPr`/`a:pPr` resolution | | `ooxml_presentation_parser.{hpp,cpp}` | `ParseContext` (slides map) + tag dispatch; presentation.xml → slides → spTree | | `ooxml_presentation_element_registry.{hpp,cpp}` | Flat element store + Table/Text side maps | diff --git a/src/odr/internal/ooxml/presentation/README.md b/src/odr/internal/ooxml/presentation/README.md index 9f96cabc8..7527bc545 100644 --- a/src/odr/internal/ooxml/presentation/README.md +++ b/src/odr/internal/ooxml/presentation/README.md @@ -43,7 +43,8 @@ Roughly ordered by importance. - [x] size - [x] italic, bold - [x] underline, strike through - - [x] color, background (highlight), incl. theme colors (`a:schemeClr`) + - [x] color, background (highlight), incl. theme colors (`a:schemeClr`; no + `a:lumMod` / `a:tint` / `a:shade` transforms) - [x] shadow - [x] superscript, subscript (`@baseline`) - [x] paragraph diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp index 42b16a120..33bf009d9 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -10,10 +10,10 @@ #include #include #include +#include #include #include -#include #include namespace odr::internal::ooxml::presentation { @@ -22,40 +22,6 @@ namespace { std::unique_ptr create_element_adapter(const Document &document, ElementRegistry ®istry); -/// A theme slot holds a literal colour or a system colour that names the value -/// it last resolved to. [ECMA-376] 20.1.2.3.32, 20.1.2.3.33 -std::optional read_theme_color_(const pugi::xml_node slot) { - if (const std::optional color = - read_color_attribute(slot.child("a:srgbClr").attribute("val"))) { - return color; - } - return read_color_attribute(slot.child("a:sysClr").attribute("lastClr")); -} - -/// A drawingml colour choice: a literal, a theme slot, or a system colour. -/// [ECMA-376] 20.1.2.3 -std::optional read_drawing_color_(const pugi::xml_node parent, - const ColorScheme *color_scheme) { - if (const std::optional color = read_theme_color_(parent)) { - return color; - } - if (const pugi::xml_attribute scheme_color = - parent.child("a:schemeClr").attribute("val"); - scheme_color && color_scheme != nullptr) { - return color_scheme->resolve(scheme_color.value()); - } - return {}; -} - -/// The ground a slide, layout or master states. A `p:bgRef` into the theme's -/// fill styles is not modelled. [ECMA-376] 19.3.1.1 -std::optional read_background_color_(const pugi::xml_node slide_like, - const ColorScheme *color_scheme) { - return read_drawing_color_( - slide_like.child("p:cSld").child("p:bg").child("p:bgPr").child( - "a:solidFill"), - color_scheme); -} } // namespace Document::Document(std::shared_ptr files) @@ -67,14 +33,14 @@ Document::Document(std::shared_ptr files) // all to the presentation, and a Google Slides export relates a protobuf. const Relations relations = parse_relationships(*m_files, AbsPath("/ppt/presentation.xml")); - std::vector> slides; + std::vector slides; for (const pugi::xml_node slide_id : m_document_xml.document_element() .child("p:sldIdLst") .children("p:sldId")) { - std::string id = slide_id.attribute("r:id").value(); + const std::string id = slide_id.attribute("r:id").value(); AbsPath slide_path = AbsPath("/ppt").join(RelPath(relations.at(id))); m_slides_xml[id] = util::xml::parse(*m_files, slide_path); - slides.emplace_back(std::move(id), std::move(slide_path)); + slides.push_back(std::move(slide_path)); } // ECMA-376 default slide size when p:sldSz is absent. @@ -101,78 +67,50 @@ Document::Document(std::shared_ptr files) m_element_adapter = create_element_adapter(*this, m_element_registry); } -namespace { -/// What a slide master decides for every slide that hangs off it. -struct MasterStyle { - ColorScheme color_scheme; - std::optional background; -}; -} // namespace +/// slide → layout → master → theme; the master's `p:clrMap` says which slot +/// each name stands for. Layouts are shared, so a layout, its master and its +/// theme are read once rather than once per slide. +void Document::load_slide_styles_(const std::vector &slides) { + if (m_root_element == null_element_id) { + return; + } -/// slide → layout → master → theme, and the master's `p:clrMap` says which slot -/// each name stands for. Masters are shared, so a master is read once rather -/// than once per slide; the ground comes from the slide, else its layout, else -/// its master. -void Document::load_slide_styles_( - const std::vector> &slides) { - std::unordered_map by_master; + std::unordered_map by_layout; // `parse_presentation_children` appends one slide per `p:sldId`, so the // root's children carry the same order `slides` was built in. ElementIdentifier slide_id = m_element_registry.element_at(m_root_element).first_child_id; - for (const auto &[relation_id, slide_path] : slides) { + for (const AbsPath &slide_path : slides) { if (slide_id == null_element_id) { break; } const ElementIdentifier current_id = slide_id; - slide_id = m_element_registry.element_at(slide_id).next_sibling_id; + const pugi::xml_node slide_node = + m_element_registry.element_at(current_id).node; + slide_id = m_element_registry.element_at(current_id).next_sibling_id; const std::optional layout_path = parse_relationship_target(*m_files, slide_path, "slideLayout"); if (!layout_path.has_value()) { continue; } - const std::optional master_path = - parse_relationship_target(*m_files, *layout_path, "slideMaster"); - if (!master_path.has_value()) { - continue; + const auto [layout_it, inserted] = + by_layout.try_emplace(layout_path->string()); + if (inserted) { + layout_it->second = + load_layout_style(*m_files, *layout_path, m_color_schemes); } + const LayoutStyle &layout_style = layout_it->second; - const auto master_it = by_master.find(master_path->string()); - if (master_it == std::end(by_master)) { - const std::optional theme_path = - parse_relationship_target(*m_files, *master_path, "theme"); - const pugi::xml_document master = - util::xml::parse(*m_files, *master_path); - - MasterStyle master_style; - if (theme_path.has_value()) { - const pugi::xml_document theme = - util::xml::parse(*m_files, *theme_path); - master_style.color_scheme = - ColorScheme(theme.document_element() - .child("a:themeElements") - .child("a:clrScheme"), - master.document_element().child("p:clrMap")); - } - master_style.background = read_background_color_( - master.document_element(), &master_style.color_scheme); - by_master[master_path->string()] = std::move(master_style); - } - const MasterStyle &master_style = by_master.at(master_path->string()); - m_slide_color_schemes[current_id] = master_style.color_scheme; - - const pugi::xml_document layout = util::xml::parse(*m_files, *layout_path); - std::optional background = - read_background_color_(m_slides_xml.at(relation_id).document_element(), - &master_style.color_scheme); - if (!background.has_value()) { - background = read_background_color_(layout.document_element(), - &master_style.color_scheme); + if (layout_style.color_scheme != nullptr) { + m_slide_color_schemes[current_id] = layout_style.color_scheme; } - if (!background.has_value()) { - background = master_style.background; + + std::optional background = layout_style.background; + if (const std::optional> stated = + read_background_color(slide_node, layout_style.color_scheme)) { + background = *stated; } if (background.has_value()) { m_slide_backgrounds[current_id] = *background; @@ -183,7 +121,7 @@ void Document::load_slide_styles_( const ColorScheme * Document::slide_color_scheme(const ElementIdentifier element_id) const { const auto it = m_slide_color_schemes.find(element_id); - return it == std::end(m_slide_color_schemes) ? nullptr : &it->second; + return it == std::end(m_slide_color_schemes) ? nullptr : it->second; } PageLayout @@ -196,31 +134,6 @@ Document::slide_page_layout(const ElementIdentifier element_id) const { return result; } -ColorScheme::ColorScheme(const pugi::xml_node color_scheme, - const pugi::xml_node color_map) { - for (const pugi::xml_node slot : color_scheme.children()) { - if (const std::optional color = read_theme_color_(slot)) { - // the slot names are `a:dk1`, `a:lt1`, `a:accent1`, … - m_colors[std::string(slot.name()).substr(2)] = *color; - } - } - for (const pugi::xml_attribute mapping : color_map.attributes()) { - const auto it = m_colors.find(mapping.value()); - if (it == std::end(m_colors)) { - continue; - } - const Color color = it->second; // the insert below may rehash - m_colors[mapping.name()] = color; - } -} - -std::optional ColorScheme::resolve(const char *name) const { - const auto it = m_colors.find(name); - return it == std::end(m_colors) ? std::optional() : it->second; -} - -const PageLayout &Document::slide_layout() const { return m_slide_layout; } - const ElementRegistry &Document::element_registry() const { return m_element_registry; } @@ -241,119 +154,6 @@ void Document::save(const Path & /*path*/, const char * /*password*/) const { namespace { -/// [ECMA-376] 20.1.10.60 ST_TextAnchoringType -std::optional -read_text_anchor_(const pugi::xml_attribute attribute) { - const char *val = attribute.value(); - if (std::strcmp("t", val) == 0) { - return VerticalAlign::top; - } - if (std::strcmp("ctr", val) == 0) { - return VerticalAlign::middle; - } - if (std::strcmp("b", val) == 0) { - return VerticalAlign::bottom; - } - return {}; -} - -/// `a:lnSpc` states a percent of the line — `a:spcPct` in thousandths — or an -/// absolute `a:spcPts` in hundredths of a point. [ECMA-376] 21.1.2.2.12 -std::optional read_line_spacing_(const pugi::xml_node node) { - if (const pugi::xml_attribute percent = - node.child("a:spcPct").attribute("val")) { - return Measure(percent.as_double() * 1e-3, DynamicUnit("%")); - } - return read_hundredth_point_attribute( - node.child("a:spcPts").attribute("val")); -} - -void resolve_text_style_(const pugi::xml_node node, - const ColorScheme *color_scheme, TextStyle &result) { - const pugi::xml_node run_properties = node.child("a:rPr"); - - if (const pugi::xml_attribute font_name = - run_properties.child("a:latin").attribute("typeface")) { - result.font_name = font_name.value(); - } - if (const std::optional font_size = - read_hundredth_point_attribute(run_properties.attribute("sz"))) { - result.font_size = font_size; - } - if (const std::optional font_weight = - read_font_weight_attribute(run_properties.attribute("b"))) { - result.font_weight = font_weight; - } - if (const std::optional font_style = - read_font_style_attribute(run_properties.attribute("i"))) { - result.font_style = font_style; - } - if (const bool font_underline = - read_line_attribute(run_properties.attribute("u"))) { - result.font_underline = font_underline; - } - if (const bool font_line_through = - read_line_attribute(run_properties.attribute("strike"))) { - result.font_line_through = font_line_through; - } - if (const std::optional font_shadow = - read_shadow_attribute(run_properties.attribute("shadow"))) { - result.font_shadow = font_shadow; - } - if (const std::optional font_color = read_drawing_color_( - run_properties.child("a:solidFill"), color_scheme)) { - result.font_color = font_color; - } - if (const std::optional background_color = read_drawing_color_( - run_properties.child("a:highlight"), color_scheme)) { - result.background_color = background_color; - } - // `baseline` is a percent of the font size, and its sign the direction. - if (const pugi::xml_attribute baseline = - run_properties.attribute("baseline")) { - result.font_position = baseline.as_int() > 0 ? FontPosition::super - : baseline.as_int() < 0 ? FontPosition::sub - : FontPosition::normal; - } -} - -void resolve_paragraph_style_(const pugi::xml_node node, - ParagraphStyle &result) { - const pugi::xml_node paragraph_properties = node.child("a:pPr"); - - if (const std::optional text_align = - read_drawing_text_align_attribute( - paragraph_properties.attribute("algn"))) { - result.text_align = text_align; - } - 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_emus_attribute(paragraph_properties.attribute("marR"))) { - result.margin.right = margin_right; - } - if (const std::optional line_height = - read_line_spacing_(paragraph_properties.child("a:lnSpc"))) { - result.line_height = line_height; - } - // Only the absolute form: a percent here is of the text size, which css - // would resolve against the width instead. - if (const std::optional margin_top = - read_hundredth_point_attribute(paragraph_properties.child("a:spcBef") - .child("a:spcPts") - .attribute("val"))) { - result.margin.top = margin_top; - } - if (const std::optional margin_bottom = - read_hundredth_point_attribute(paragraph_properties.child("a:spcAft") - .child("a:spcPts") - .attribute("val"))) { - result.margin.bottom = margin_bottom; - } -} - class ElementAdapter final : public abstract::ElementAdapter, public abstract::SlideAdapter, public abstract::LineBreakAdapter, @@ -705,12 +505,13 @@ class ElementAdapter final : public abstract::ElementAdapter, if (shape_properties.child("a:noFill")) { result.fill_color = Color(0, 0, 0, 0); } else if (const std::optional fill_color = - read_drawing_color_(shape_properties.child("a:solidFill"), - get_color_scheme(element_id))) { + read_drawing_color(shape_properties.child("a:solidFill"), + get_color_scheme(element_id))) { result.fill_color = fill_color; } - if (const std::optional vertical_align = read_text_anchor_( - node.child("p:txBody").child("a:bodyPr").attribute("anchor"))) { + if (const std::optional vertical_align = + read_drawing_vertical_align_attribute( + node.child("p:txBody").child("a:bodyPr").attribute("anchor"))) { result.vertical_align = vertical_align; } return result; @@ -786,15 +587,15 @@ class ElementAdapter final : public abstract::ElementAdapter, m_registry->element_at(element_id); if (element.type == ElementType::paragraph) { ResolvedStyle result; - resolve_text_style_(element.node, get_color_scheme(element_id), - result.text_style); - resolve_paragraph_style_(element.node, result.paragraph_style); + resolve_text_style(element.node, get_color_scheme(element_id), + result.text_style); + resolve_paragraph_style(element.node, result.paragraph_style); return result; } if (element.type == ElementType::span) { ResolvedStyle result; - resolve_text_style_(element.node, get_color_scheme(element_id), - result.text_style); + resolve_text_style(element.node, get_color_scheme(element_id), + result.text_style); return result; } return {}; diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp index bd9d4ea10..44eea7f8d 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp @@ -5,42 +5,26 @@ #include #include #include +#include #include #include #include -#include #include #include namespace odr::internal::ooxml::presentation { -/// The theme's `a:clrScheme` seen through the slide master's `p:clrMap`, so a -/// slide's `a:schemeClr` names a colour. [ECMA-376] 20.1.6.2 -class ColorScheme final { -public: - ColorScheme() = default; - ColorScheme(pugi::xml_node color_scheme, pugi::xml_node color_map); - - [[nodiscard]] std::optional resolve(const char *name) const; - -private: - std::unordered_map m_colors; -}; - class Document final : public internal::Document { public: explicit Document(std::shared_ptr files); [[nodiscard]] const ElementRegistry &element_registry() const; - [[nodiscard]] const PageLayout &slide_layout() const; - /// The scheme of the slide @p element_id, or null where the chain to a theme - /// is broken. + /// The scheme of the slide @p element_id, or null where it relates no master. [[nodiscard]] const ColorScheme * slide_color_scheme(ElementIdentifier element_id) const; - /// The layout of the slide @p element_id: the shared one, plus the ground the - /// slide inherits from its layout or master. + /// The shared slide layout, plus the ground this slide states or inherits. [[nodiscard]] PageLayout slide_page_layout(ElementIdentifier element_id) const; @@ -54,13 +38,15 @@ class Document final : public internal::Document { pugi::xml_document m_document_xml; std::unordered_map m_slides_xml; PageLayout m_slide_layout; - std::unordered_map m_slide_color_schemes; + /// by slide master path; a slide points into this, so it has to outlive them + std::unordered_map m_color_schemes; + std::unordered_map + m_slide_color_schemes; std::unordered_map m_slide_backgrounds; ElementRegistry m_element_registry; - void load_slide_styles_( - const std::vector> &slides); + void load_slide_styles_(const std::vector &slides); }; } // namespace odr::internal::ooxml::presentation diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp new file mode 100644 index 000000000..2829b6bc3 --- /dev/null +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace odr::internal::ooxml::presentation { + +namespace { + +/// A literal colour, or a system colour that names the value it last resolved +/// to. [ECMA-376] 20.1.2.3.32, 20.1.2.3.33 +std::optional read_theme_color_(const pugi::xml_node slot) { + if (const std::optional color = + read_color_attribute(slot.child("a:srgbClr").attribute("val"))) { + return color; + } + return read_color_attribute(slot.child("a:sysClr").attribute("lastClr")); +} + +/// `a:spcPct` in thousandths of a percent, or `a:spcPts` in hundredths of a +/// point. [ECMA-376] 21.1.2.2.12 +std::optional read_line_spacing(const pugi::xml_node node) { + if (const pugi::xml_attribute percent = + node.child("a:spcPct").attribute("val")) { + return Measure(percent.as_double() * 1e-3, DynamicUnit("%")); + } + return read_hundredth_point_attribute( + node.child("a:spcPts").attribute("val")); +} + +/// Styling is optional, so a part that is missing or is not xml leaves a slide +/// without it rather than failing the open. +bool parse_optional_part(const abstract::ReadableFilesystem &files, + const AbsPath &path, pugi::xml_document &result) { + if (!files.is_file(path)) { + return false; + } + try { + result = util::xml::parse(files, path); + } catch (const std::exception &) { + return false; + } + return true; +} + +} // namespace + +} // namespace odr::internal::ooxml::presentation + +namespace odr::internal::ooxml { + +presentation::ColorScheme::ColorScheme(const pugi::xml_node color_scheme, + const pugi::xml_node color_map) { + for (const pugi::xml_node slot : color_scheme.children()) { + if (const std::optional color = read_theme_color_(slot)) { + // the slot names are `a:dk1`, `a:lt1`, `a:accent1`, … + const std::string_view name = slot.name(); + const std::size_t colon = name.find(':'); + m_colors[std::string( + colon == std::string_view::npos ? name : name.substr(colon + 1))] = + *color; + } + } + // a mapping's source may be another's target, so every one of them resolves + // against the theme rather than against a map it is being written to + std::unordered_map mapped = m_colors; + for (const pugi::xml_attribute mapping : color_map.attributes()) { + if (const auto it = m_colors.find(mapping.value()); + it != std::end(m_colors)) { + mapped[mapping.name()] = it->second; + } + } + m_colors = std::move(mapped); +} + +std::optional +presentation::ColorScheme::resolve(const char *name) const { + const auto it = m_colors.find(name); + return it == std::end(m_colors) ? std::optional() : it->second; +} + +std::optional +presentation::read_drawing_color(const pugi::xml_node parent, + const ColorScheme *color_scheme) { + if (const std::optional color = read_theme_color_(parent)) { + return color; + } + if (const pugi::xml_attribute scheme_color = + parent.child("a:schemeClr").attribute("val"); + scheme_color && color_scheme != nullptr) { + return color_scheme->resolve(scheme_color.value()); + } + return {}; +} + +std::optional> +presentation::read_background_color(const pugi::xml_node slide_like, + const ColorScheme *color_scheme) { + const pugi::xml_node background = slide_like.child("p:cSld").child("p:bg"); + if (!background) { + return {}; + } + return read_drawing_color(background.child("p:bgPr").child("a:solidFill"), + color_scheme); +} + +presentation::LayoutStyle presentation::load_layout_style( + const abstract::ReadableFilesystem &files, const AbsPath &layout_path, + std::unordered_map &color_schemes) { + LayoutStyle result; + + pugi::xml_document layout; + if (!parse_optional_part(files, layout_path, layout)) { + return result; + } + + pugi::xml_document master; + if (const std::optional master_path = + parse_relationship_target(files, layout_path, "slideMaster"); + master_path.has_value() && + parse_optional_part(files, *master_path, master)) { + // `unordered_map` keeps its elements put, so the pointer outlives the + // inserts that follow + const auto [master_it, inserted] = + color_schemes.try_emplace(master_path->string()); + if (inserted) { + pugi::xml_document theme; + if (const std::optional theme_path = + parse_relationship_target(files, *master_path, "theme"); + theme_path.has_value() && + parse_optional_part(files, *theme_path, theme)) { + master_it->second = + ColorScheme(theme.document_element() + .child("a:themeElements") + .child("a:clrScheme"), + master.document_element().child("p:clrMap")); + } + } + result.color_scheme = &master_it->second; + result.background = + read_background_color(master.document_element(), result.color_scheme) + .value_or(std::optional()); + } + + if (const std::optional> stated = read_background_color( + layout.document_element(), result.color_scheme)) { + result.background = *stated; + } + return result; +} + +void presentation::resolve_text_style(const pugi::xml_node node, + const ColorScheme *color_scheme, + TextStyle &result) { + const pugi::xml_node run_properties = node.child("a:rPr"); + + if (const pugi::xml_attribute font_name = + run_properties.child("a:latin").attribute("typeface")) { + result.font_name = font_name.value(); + } + if (const std::optional font_size = + read_hundredth_point_attribute(run_properties.attribute("sz"))) { + result.font_size = font_size; + } + if (const std::optional font_weight = + read_font_weight_attribute(run_properties.attribute("b"))) { + result.font_weight = font_weight; + } + if (const std::optional font_style = + read_font_style_attribute(run_properties.attribute("i"))) { + result.font_style = font_style; + } + if (const bool font_underline = + read_line_attribute(run_properties.attribute("u"))) { + result.font_underline = font_underline; + } + if (const bool font_line_through = + read_line_attribute(run_properties.attribute("strike"))) { + result.font_line_through = font_line_through; + } + if (const std::optional font_shadow = + read_shadow_attribute(run_properties.attribute("shadow"))) { + result.font_shadow = font_shadow; + } + if (const std::optional font_color = read_drawing_color( + run_properties.child("a:solidFill"), color_scheme)) { + result.font_color = font_color; + } + if (const std::optional background_color = read_drawing_color( + run_properties.child("a:highlight"), color_scheme)) { + result.background_color = background_color; + } + // the sign carries the direction + if (const pugi::xml_attribute baseline = + run_properties.attribute("baseline")) { + result.font_position = baseline.as_int() > 0 ? FontPosition::super + : baseline.as_int() < 0 ? FontPosition::sub + : FontPosition::normal; + } +} + +void presentation::resolve_paragraph_style(const pugi::xml_node node, + ParagraphStyle &result) { + const pugi::xml_node paragraph_properties = node.child("a:pPr"); + + if (const std::optional text_align = + read_drawing_text_align_attribute( + paragraph_properties.attribute("algn"))) { + result.text_align = text_align; + } + 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_emus_attribute(paragraph_properties.attribute("marR"))) { + result.margin.right = margin_right; + } + if (const std::optional line_height = + read_line_spacing(paragraph_properties.child("a:lnSpc"))) { + result.line_height = line_height; + } + // absolute only: a percent here is of the text size, which css would + // resolve against the width instead + if (const std::optional margin_top = + read_hundredth_point_attribute(paragraph_properties.child("a:spcBef") + .child("a:spcPts") + .attribute("val"))) { + result.margin.top = margin_top; + } + if (const std::optional margin_bottom = + read_hundredth_point_attribute(paragraph_properties.child("a:spcAft") + .child("a:spcPts") + .attribute("val"))) { + result.margin.bottom = margin_bottom; + } +} + +} // namespace odr::internal::ooxml diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_style.hpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.hpp new file mode 100644 index 000000000..6bfece221 --- /dev/null +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include +#include +#include + +#include + +namespace odr::internal::abstract { +class ReadableFilesystem; +} // namespace odr::internal::abstract + +namespace odr::internal { +class AbsPath; +} // namespace odr::internal + +namespace odr::internal::ooxml::presentation { + +/// The theme's `a:clrScheme` seen through the slide master's `p:clrMap`, so a +/// slide's `a:schemeClr` names a colour. [ECMA-376] 20.1.6.2 +class ColorScheme final { +public: + ColorScheme() = default; + ColorScheme(pugi::xml_node color_scheme, pugi::xml_node color_map); + + [[nodiscard]] std::optional resolve(const char *name) const; + +private: + std::unordered_map m_colors; +}; + +/// What a slide layout, and the master behind it, decide for every slide that +/// hangs off them. +struct LayoutStyle final { + const ColorScheme *color_scheme{nullptr}; + std::optional background; +}; + +/// A drawingml colour choice: a literal, a theme slot, or a system colour. +/// [ECMA-376] 20.1.2.3 +std::optional read_drawing_color(pugi::xml_node parent, + const ColorScheme *color_scheme); + +/// Whether the part states a ground at all, and the colour where it states one +/// we model: a `p:bgRef`, gradient or image ends the inheritance walk rather +/// than falling through to the part behind it. [ECMA-376] 19.3.1.1 +std::optional> +read_background_color(pugi::xml_node slide_like, + const ColorScheme *color_scheme); + +/// The scheme of the master behind @p layout_path, cached in @p color_schemes +/// by master path, and the ground the layout states, else its master's. A part +/// that is missing or is not xml leaves the slide unstyled rather than failing +/// the open. +LayoutStyle +load_layout_style(const abstract::ReadableFilesystem &filesystem, + const AbsPath &layout_path, + std::unordered_map &color_schemes); + +void resolve_text_style(pugi::xml_node node, const ColorScheme *color_scheme, + TextStyle &result); +void resolve_paragraph_style(pugi::xml_node node, ParagraphStyle &result); + +} // namespace odr::internal::ooxml::presentation diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp index 97d632a20..9aca012ca 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp @@ -54,8 +54,10 @@ std::optional read_color(const pugi::xml_node node) { if (const pugi::xml_attribute rgb = node.attribute("rgb")) { const char *value = rgb.value(); if (std::strlen(value) == 8) { + // the alpha byte is not one: excel ignores it and producers routinely + // write `00`, which would paint nothing at all const std::uint32_t color = std::strtoull(value, nullptr, 16); - return Color::from_argb(color); + return Color::from_rgb(color); } if (std::strlen(value) == 6) { const std::uint32_t color = std::strtoull(value, nullptr, 16); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 542ebdd83..3a896cf05 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -81,6 +81,8 @@ add_executable(odr_test "src/internal/ooxml/ooxml_crypto_test.cpp" "src/internal/ooxml/ooxml_text_style_test.cpp" + "src/internal/ooxml/ooxml_util_test.cpp" + "src/internal/ooxml/ooxml_presentation_style_test.cpp" "src/internal/pdf/pdf_cid.cpp" "src/internal/pdf/pdf_cmap.cpp" diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index 9abf9dbd8..5975eda2f 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -215,3 +216,15 @@ TEST(html_common, a_zoom_pinned_to_actual_size_is_still_a_pin) { EXPECT_EQ(emit_zoom(config, ihtml::WidthFit::browser, 800), styled(":root{--odr-fit:0.5;--odr-zoom:0.5}body{zoom:0.5}")); } + +TEST(html_common, an_opaque_color_is_a_hex_triplet) { + EXPECT_EQ(ihtml::color(Color(1, 2, 3)), "#010203"); + EXPECT_EQ(ihtml::color(Color(1, 2, 3, 255)), "#010203"); + EXPECT_EQ(ihtml::color(Color(0xff, 0xff, 0xff)), "#ffffff"); +} + +TEST(html_common, a_color_that_does_not_fully_cover_states_its_alpha) { + EXPECT_EQ(ihtml::color(Color(1, 2, 3, 0)), "rgba(1,2,3,0)"); + EXPECT_EQ(ihtml::color(Color(1, 2, 3, 128)), "rgba(1,2,3,0.501961)"); + EXPECT_EQ(ihtml::color(Color(1, 2, 3, 1)), "rgba(1,2,3,0.00392157)"); +} diff --git a/test/src/internal/html/document_style_test.cpp b/test/src/internal/html/document_style_test.cpp index e2d10f109..eaf1aedb7 100644 --- a/test/src/internal/html/document_style_test.cpp +++ b/test/src/internal/html/document_style_test.cpp @@ -24,11 +24,25 @@ TEST(html_document_style, outer_page_style_fixes_both_dimensions) { "width:21cm;height:29.7cm;"); } +TEST(html_document_style, outer_page_style_paints_the_ground) { + PageLayout page_layout = a4_page_layout(); + page_layout.background_color = Color(0x12, 0x34, 0x56); + EXPECT_EQ(ihtml::translate_outer_page_style(page_layout), + "width:21cm;height:29.7cm;background-color:#123456;"); +} + TEST(html_document_style, outer_flowing_page_style_floors_the_height) { EXPECT_EQ(ihtml::translate_outer_flowing_page_style(a4_page_layout()), "width:21cm;min-height:29.7cm;"); } +TEST(html_document_style, outer_flowing_page_style_paints_the_ground) { + PageLayout page_layout = a4_page_layout(); + page_layout.background_color = Color(0x12, 0x34, 0x56); + EXPECT_EQ(ihtml::translate_outer_flowing_page_style(page_layout), + "width:21cm;background-color:#123456;min-height:29.7cm;"); +} + TEST(html_document_style, outer_flowing_page_style_without_height) { PageLayout page_layout = a4_page_layout(); page_layout.height = {}; diff --git a/test/src/internal/ooxml/ooxml_presentation_style_test.cpp b/test/src/internal/ooxml/ooxml_presentation_style_test.cpp new file mode 100644 index 000000000..4c19b1762 --- /dev/null +++ b/test/src/internal/ooxml/ooxml_presentation_style_test.cpp @@ -0,0 +1,355 @@ +#include + +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include + +using namespace odr; +using namespace odr::internal; +using namespace odr::internal::ooxml::presentation; + +namespace { + +using Part = std::pair; + +pugi::xml_node node_of(const std::string &xml, pugi::xml_document &document) { + EXPECT_TRUE(document.load_string(xml.c_str())); + return document.first_child(); +} + +std::string slot(const std::string &name, const std::string &value) { + return "<" + name + R"(>"; +} + +/// The two theme slots the `p:clrMap` cases map from. +const std::string light_and_dark = + slot("a:dk1", "111111") + slot("a:lt1", "eeeeee"); + +ColorScheme scheme_of(const std::string &colors, const std::string &map, + pugi::xml_document &document) { + const pugi::xml_node root = node_of("" + colors + + "" + map + "", + document); + return ColorScheme(root.child("a:clrScheme"), root.child("p:clrMap")); +} + +std::string slide_like(const std::string &background) { + return "" + background + ""; +} + +std::string solid_background(const std::string &fill) { + return "" + fill + + ""; +} + +constexpr const char *layout_path = "/ppt/slideLayouts/slideLayout1.xml"; +constexpr const char *layout_rels_path = + "/ppt/slideLayouts/_rels/slideLayout1.xml.rels"; +constexpr const char *master_path = "/ppt/slideMasters/slideMaster1.xml"; +constexpr const char *master_rels_path = + "/ppt/slideMasters/_rels/slideMaster1.xml.rels"; +constexpr const char *theme_path = "/ppt/theme/theme1.xml"; + +std::string relationship(const std::string &type, const std::string &target) { + return R"()" + R"()"; +} + +VirtualFilesystem filesystem_of(const std::vector &parts) { + VirtualFilesystem result; + for (const auto &[path, content] : parts) { + result.copy(std::make_shared(content), AbsPath(path)); + } + return result; +} + +/// A layout on a master on a theme, each stating what the case gives it. +std::vector package(const std::string &layout, const std::string &master, + const std::string &theme_colors) { + return { + {layout_path, layout}, + {layout_rels_path, + relationship("slideMaster", "../slideMasters/slideMaster1.xml")}, + {master_path, master}, + {master_rels_path, relationship("theme", "../theme/theme1.xml")}, + {theme_path, "" + theme_colors + + ""}, + }; +} + +/// @p color_schemes owns what the returned style points at, so it outlives it. +LayoutStyle +layout_style_of(const std::vector &parts, + std::unordered_map &color_schemes) { + const VirtualFilesystem filesystem = filesystem_of(parts); + return load_layout_style(filesystem, AbsPath(layout_path), color_schemes); +} + +} // namespace + +TEST(ooxml_presentation_style, a_scheme_maps_a_slot_name_onto_a_theme_color) { + pugi::xml_document document; + const ColorScheme scheme = + scheme_of(light_and_dark, R"()", document); + + ASSERT_TRUE(scheme.resolve("bg1").has_value()); + EXPECT_EQ(0xffeeeeeeu, scheme.resolve("bg1")->argb()); + ASSERT_TRUE(scheme.resolve("tx1").has_value()); + EXPECT_EQ(0xff111111u, scheme.resolve("tx1")->argb()); + // the theme's own slots stay reachable under their own names + EXPECT_EQ(0xffeeeeeeu, scheme.resolve("lt1")->argb()); + EXPECT_FALSE(scheme.resolve("accent1").has_value()); +} + +TEST(ooxml_presentation_style, a_scheme_maps_every_slot_against_the_theme) { + pugi::xml_document document; + // each accent is the other's source, so a map written in place would resolve + // the second against the first's new value + const ColorScheme scheme = + scheme_of(slot("a:accent1", "aa0000") + slot("a:accent2", "00bb00"), + R"()", document); + + EXPECT_EQ(0xff00bb00u, scheme.resolve("accent1")->argb()); + EXPECT_EQ(0xffaa0000u, scheme.resolve("accent2")->argb()); +} + +TEST(ooxml_presentation_style, a_theme_slot_named_without_a_prefix) { + pugi::xml_document document; + const ColorScheme scheme = scheme_of(slot("x", "abcdef"), "", document); + + ASSERT_TRUE(scheme.resolve("x").has_value()); + EXPECT_EQ(0xffabcdefu, scheme.resolve("x")->argb()); +} + +TEST(ooxml_presentation_style, a_system_color_resolves_to_its_last_value) { + pugi::xml_document document; + const pugi::xml_node fill = node_of( + R"()", + document); + + ASSERT_TRUE(read_drawing_color(fill, nullptr).has_value()); + EXPECT_EQ(0xff123456u, read_drawing_color(fill, nullptr)->argb()); +} + +TEST(ooxml_presentation_style, a_scheme_color_needs_a_scheme_to_resolve) { + pugi::xml_document scheme_document; + const ColorScheme scheme = + scheme_of(light_and_dark, R"()", scheme_document); + + pugi::xml_document document; + const pugi::xml_node fill = node_of( + R"()", document); + + EXPECT_FALSE(read_drawing_color(fill, nullptr).has_value()); + ASSERT_TRUE(read_drawing_color(fill, &scheme).has_value()); + EXPECT_EQ(0xff111111u, read_drawing_color(fill, &scheme)->argb()); +} + +TEST(ooxml_presentation_style, a_part_that_states_no_ground_inherits_one) { + pugi::xml_document document; + EXPECT_FALSE(read_background_color(node_of(slide_like(""), document), nullptr) + .has_value()); +} + +TEST(ooxml_presentation_style, a_ground_we_cannot_read_ends_the_walk) { + pugi::xml_document document; + const std::optional> background = read_background_color( + node_of(slide_like(""), + document), + nullptr); + + // it states one, so nothing behind it applies - but we paint nothing + ASSERT_TRUE(background.has_value()); + EXPECT_FALSE(background->has_value()); +} + +TEST(ooxml_presentation_style, a_ground_stated_as_a_literal) { + pugi::xml_document document; + const std::optional> background = read_background_color( + node_of(slide_like(solid_background(R"()")), + document), + nullptr); + + ASSERT_TRUE(background.has_value()); + ASSERT_TRUE(background->has_value()); + EXPECT_EQ(0xfffedcbau, (*background)->argb()); +} + +TEST(ooxml_presentation_style, a_layout_takes_its_masters_scheme_and_ground) { + std::unordered_map color_schemes; + const LayoutStyle style = layout_style_of( + package("", + R"()" + + solid_background(R"()") + + "", + light_and_dark), + color_schemes); + + ASSERT_NE(nullptr, style.color_scheme); + ASSERT_TRUE(style.color_scheme->resolve("tx1").has_value()); + EXPECT_EQ(0xff111111u, style.color_scheme->resolve("tx1")->argb()); + ASSERT_TRUE(style.background.has_value()); + EXPECT_EQ(0xffeeeeeeu, style.background->argb()); +} + +TEST(ooxml_presentation_style, a_layouts_own_ground_beats_its_masters) { + std::unordered_map color_schemes; + const LayoutStyle style = layout_style_of( + package("" + + solid_background(R"()") + + "", + "" + + solid_background(R"()") + + "", + light_and_dark), + color_schemes); + + ASSERT_TRUE(style.background.has_value()); + EXPECT_EQ(0xff00ff00u, style.background->argb()); +} + +TEST(ooxml_presentation_style, a_layout_whose_ground_we_cannot_read) { + std::unordered_map color_schemes; + const LayoutStyle style = layout_style_of( + package("" + "", + "" + + solid_background(R"()") + + "", + light_and_dark), + color_schemes); + + EXPECT_FALSE(style.background.has_value()); +} + +TEST(ooxml_presentation_style, one_scheme_per_master_however_many_layouts) { + std::vector parts = + package("", "", light_and_dark); + parts.emplace_back("/ppt/slideLayouts/slideLayout2.xml", ""); + parts.emplace_back( + "/ppt/slideLayouts/_rels/slideLayout2.xml.rels", + relationship("slideMaster", "../slideMasters/slideMaster1.xml")); + const VirtualFilesystem filesystem = filesystem_of(parts); + + std::unordered_map color_schemes; + const LayoutStyle first = + load_layout_style(filesystem, AbsPath(layout_path), color_schemes); + const LayoutStyle second = load_layout_style( + filesystem, AbsPath("/ppt/slideLayouts/slideLayout2.xml"), color_schemes); + + EXPECT_EQ(1u, color_schemes.size()); + EXPECT_EQ(first.color_scheme, second.color_scheme); +} + +TEST(ooxml_presentation_style, a_layout_relating_no_master_stays_unstyled) { + std::unordered_map color_schemes; + const LayoutStyle style = + layout_style_of({{layout_path, ""}}, color_schemes); + + EXPECT_EQ(nullptr, style.color_scheme); + EXPECT_FALSE(style.background.has_value()); +} + +TEST(ooxml_presentation_style, a_layout_part_that_is_not_xml_stays_unstyled) { + std::unordered_map color_schemes; + const LayoutStyle style = layout_style_of( + {{layout_path, "not xml at all"}, + {layout_rels_path, + relationship("slideMaster", "../slideMasters/slideMaster1.xml")}}, + color_schemes); + + EXPECT_EQ(nullptr, style.color_scheme); + EXPECT_FALSE(style.background.has_value()); +} + +TEST(ooxml_presentation_style, a_run_paints_and_highlights_through_the_theme) { + pugi::xml_document scheme_document; + const ColorScheme scheme = scheme_of( + light_and_dark, R"()", scheme_document); + + pugi::xml_document document; + const pugi::xml_node run = node_of( + R"()" + R"()", + document); + + TextStyle style; + resolve_text_style(run, &scheme, style); + + ASSERT_TRUE(style.font_color.has_value()); + EXPECT_EQ(0xff111111u, style.font_color->argb()); + ASSERT_TRUE(style.background_color.has_value()); + EXPECT_EQ(0xffffff00u, style.background_color->argb()); +} + +TEST(ooxml_presentation_style, the_baseline_sign_carries_the_direction) { + pugi::xml_document document; + TextStyle style; + + resolve_text_style( + node_of(R"()", document), nullptr, + style); + EXPECT_EQ(FontPosition::super, style.font_position); + + pugi::xml_document sub_document; + resolve_text_style( + node_of(R"()", sub_document), + nullptr, style); + EXPECT_EQ(FontPosition::sub, style.font_position); + + pugi::xml_document normal_document; + resolve_text_style( + node_of(R"()", normal_document), nullptr, + style); + EXPECT_EQ(FontPosition::normal, style.font_position); +} + +TEST(ooxml_presentation_style, line_spacing_is_a_percent_or_a_length) { + pugi::xml_document percent_document; + ParagraphStyle percent; + resolve_paragraph_style( + node_of( + R"()", + percent_document), + percent); + ASSERT_TRUE(percent.line_height.has_value()); + EXPECT_EQ(Measure(150, DynamicUnit("%")), *percent.line_height); + + pugi::xml_document points_document; + ParagraphStyle points; + resolve_paragraph_style( + node_of( + R"()", + points_document), + points); + ASSERT_TRUE(points.line_height.has_value()); + EXPECT_EQ(Measure(18, DynamicUnit("pt")), *points.line_height); +} + +TEST(ooxml_presentation_style, paragraph_spacing_is_taken_absolute_only) { + pugi::xml_document document; + ParagraphStyle style; + resolve_paragraph_style( + node_of(R"()" + R"()", + document), + style); + + ASSERT_TRUE(style.margin.top.has_value()); + EXPECT_EQ(Measure(6, DynamicUnit("pt")), *style.margin.top); + EXPECT_FALSE(style.margin.bottom.has_value()); +} diff --git a/test/src/internal/ooxml/ooxml_util_test.cpp b/test/src/internal/ooxml/ooxml_util_test.cpp new file mode 100644 index 000000000..a0beac4e3 --- /dev/null +++ b/test/src/internal/ooxml/ooxml_util_test.cpp @@ -0,0 +1,108 @@ +#include + +#include +#include +#include + +#include + +#include +#include +#include + +using namespace odr::internal; +using namespace odr::internal::ooxml; + +namespace { + +constexpr const char *slide_path = "/ppt/slides/slide1.xml"; +constexpr const char *rels_path = "/ppt/slides/_rels/slide1.xml.rels"; +constexpr const char *layout_type = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/" + "slideLayout"; + +VirtualFilesystem filesystem_of(const std::string &relationships) { + VirtualFilesystem result; + result.copy(std::make_shared(std::string()), AbsPath(slide_path)); + if (!relationships.empty()) { + result.copy(std::make_shared(relationships), + AbsPath(rels_path)); + } + return result; +} + +std::string relationship(const std::string &type, const std::string &target) { + return R"()"; +} + +std::string relationships_of(const std::string &children) { + return R"()" + + children + ""; +} + +std::optional layout_of(const std::string &relationships) { + const VirtualFilesystem filesystem = filesystem_of(relationships); + const std::optional result = + parse_relationship_target(filesystem, AbsPath(slide_path), "slideLayout"); + if (!result.has_value()) { + return {}; + } + return result->string(); +} + +} // namespace + +TEST(ooxml_util, relationship_target_resolves_against_the_part) { + EXPECT_EQ(layout_of(relationships_of( + relationship(layout_type, "../slideLayouts/slideLayout1.xml"))), + "/ppt/slideLayouts/slideLayout1.xml"); +} + +TEST(ooxml_util, relationship_target_of_a_part_without_relationships) { + EXPECT_FALSE(layout_of("").has_value()); +} + +TEST(ooxml_util, relationship_type_matches_a_whole_trailing_segment) { + // ends with the type, but not on a `/` boundary + EXPECT_FALSE(layout_of(relationships_of(relationship( + "http://example.com/relationships/notSlideLayout", + "layout.xml"))) + .has_value()); + // the type is the whole uri, so there is no segment before it + EXPECT_FALSE( + layout_of(relationships_of(relationship("slideLayout", "layout.xml"))) + .has_value()); + EXPECT_EQ(layout_of(relationships_of( + relationship("http://example.com/rel/slideLayout", "l.xml"))), + "/ppt/slides/l.xml"); +} + +TEST(ooxml_util, relationship_target_takes_the_first_of_its_type) { + EXPECT_EQ(layout_of(relationships_of( + relationship("http://example.com/rel/theme", "theme.xml") + + relationship(layout_type, "first.xml") + + relationship(layout_type, "second.xml"))), + "/ppt/slides/first.xml"); +} + +TEST(ooxml_util, relationship_target_of_a_type_nothing_relates) { + EXPECT_FALSE(layout_of(relationships_of(relationship( + "http://example.com/rel/theme", "theme.xml"))) + .has_value()); +} + +TEST(ooxml_util, an_absolute_relationship_target_names_a_part_from_the_root) { + EXPECT_EQ(layout_of(relationships_of(relationship( + layout_type, "/ppt/slideLayouts/slideLayout1.xml"))), + "/ppt/slideLayouts/slideLayout1.xml"); +} + +TEST(ooxml_util, a_relationship_target_that_names_nothing_resolves_to_nothing) { + EXPECT_FALSE( + layout_of(relationships_of(relationship(layout_type, ""))).has_value()); + // climbing out of the package + EXPECT_FALSE( + layout_of(relationships_of(relationship(layout_type, "../../../x.xml"))) + .has_value()); +}