From a4e2da770b6db66987ed9f29ca104b5336abf8b3 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 20:27:26 +0200 Subject: [PATCH 1/5] feat(ppt): pictures from the BLIP store, real slide size, slide names Resolve each shape's pib (picture shape) or fillBlip (picture fill, the LibreOffice pattern) property through the OfficeArtBStoreContainer and the /Pictures delay stream, and emit JPEG/PNG BLIPs as image elements under the shape's frame, served as in-memory files. Background shapes (OfficeArtFSP.fBackground) get a full-slide anchor and render below the other shapes. The drawing's optional non-grouped shape ([MS-ODRAW] 2.2.13) is now read too - picture fills live there. Also: slide size from the DocumentAtom instead of hardcoded 10x7.5in, and slide_name returns "Slide N". WMF/EMF/PICT/DIB/TIFF BLIPs and pictures on master slides remain open (documented in AGENTS.md). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9Z3kyUaHNkJacAEggevHH --- src/odr/internal/oldms/presentation/AGENTS.md | 50 ++-- .../oldms/presentation/ppt_document.cpp | 48 +++- .../presentation/ppt_element_registry.cpp | 40 +++ .../presentation/ppt_element_registry.hpp | 16 ++ .../oldms/presentation/ppt_parser.cpp | 262 ++++++++++++++++-- .../oldms/presentation/ppt_structs.hpp | 54 +++- test/src/internal/oldms/ppt_test.cpp | 29 +- 7 files changed, 448 insertions(+), 51 deletions(-) diff --git a/src/odr/internal/oldms/presentation/AGENTS.md b/src/odr/internal/oldms/presentation/AGENTS.md index ec448b09..f65c06a0 100644 --- a/src/odr/internal/oldms/presentation/AGENTS.md +++ b/src/odr/internal/oldms/presentation/AGENTS.md @@ -5,9 +5,10 @@ The **why** and the roadmap; what is concretely done lives in the code. Shared **Scope.** Extract the **visible text of each slide, positioned in its text boxes**, plus **direct character formatting** (font, size, bold, italic, -underline, color) as styled spans, through the abstract model so the generic -HTML renderer lays each slide out as positioned frames. No paragraph styles or -master-inherited formatting, master/notes pages, images, charts, tables, or +underline, color) as styled spans, plus **pictures** (JPEG/PNG BLIPs +referenced by slide shapes), through the abstract model so the generic HTML +renderer lays each slide out as positioned frames. No paragraph styles or +master-inherited formatting/pictures, master/notes pages, charts, tables, or animations. **Specs.** `[MS-PPT]` (the PowerPoint stream) + `[MS-ODRAW]` (the Office Art / @@ -88,12 +89,13 @@ unmodelled/optional: an absent slide list (0 slides), a shape with no anchor (unpositioned frame), nested groups and non-`Sp` records in a group, any unrecognised child. -**First cut: top-level shapes only.** Only direct children of the root -`OfficeArtSpgrContainer`, whose anchors are already in the slide's master-unit -system (master units = 1/576 inch → inches via `/576`). Nested-group transforms, -non-grouped shapes, and master-placeholder geometry inheritance are deferred -(open work §1). Shapes with no text are dropped, so the group shape and pictures -disappear. +**First cut: top-level shapes only.** Direct children of the root +`OfficeArtSpgrContainer` plus the drawing's optional non-grouped shape +([MS-ODRAW] 2.2.13), whose anchors are already in the slide's master-unit +system (master units = 1/576 inch → inches via `/576`). Nested-group +transforms and master-placeholder geometry inheritance are deferred (open +work §1). Shapes with neither text nor a picture are dropped, so the group +shape disappears. **Direct character formatting only, resolved to styled spans.** Each text atom is kept raw (undecoded) until the **`StyleTextPropAtom`** (0x0FA1, §2.9.44) @@ -119,7 +121,19 @@ for empty-paragraph height). The non-obvious bits: styles (`TxMasterStyleAtom`, open work). This replaces the old flat `11pt` placeholder. -**Slide size hardcoded 10"×7.5"** (`slide_page_layout`) — open work. +**Pictures resolve through the BLIP store.** A shape references its picture +via the `OfficeArtFOPT` `pib` property (real picture shapes) or `fillBlip` +(picture fills — how LibreOffice-exported `.ppt` places pictures), a one-based +index into the `OfficeArtBStoreContainer` of the document's drawing group. +Each `OfficeArtFBSE` locates the BLIP in the `/Pictures` (delay) stream at +`foDelay` (or embeds it); **JPEG and PNG** BLIPs become an `image` element +under the shape's frame, served to the renderer as an in-memory `odr::File`. +WMF/EMF/PICT/DIB/TIFF BLIPs are skipped (open work). A shape flagged +`fBackground` (`OfficeArtFSP`) gets a full-slide anchor when it has none and +is moved before the slide's other shapes so it renders underneath. + +**Slide size** comes from the `DocumentAtom` (master units), with 10"×7.5" as +fallback; `slide_name` returns "Slide N" in presentation order. `Document::is_editable()` → `false`; `save` throws. **Endianness.** Host byte order / LSB-first bit-fields assumed; shared `oldms/` @@ -139,8 +153,9 @@ break (split like `doc_parser`); `0x09` tab kept; other controls dropped color. - `ppt_empty` (`empty.ppt`): 1 slide. - `ppt_style_various` (`style-various-1.ppt`): 8 slides, positioned frames, - per-box text, plus style assertions (44pt Arial black title; underlined blue - 32pt link text). + per-box text, style assertions (44pt Arial black title; underlined blue + 32pt link text), and the slide-6 background picture (leading full-slide + frame with the PNG bytes from `/Pictures`). Fixture-commit / reference-HTML wiring / `OutlineTextRefAtom` fixture are open (§2 below). @@ -205,9 +220,6 @@ and is independent: and map each descendant's anchor from `[xLeft..xRight]×[yTop..yBottom]` onto the group shape's own anchor rect in the parent, composing transforms down the nesting, before the `/576` conversion. -- **1.2 Non-grouped shapes.** `OfficeArtDgContainer` (0xF002) also has an optional - direct `shape` (§2.2.13) for a shape not in a group — the current walk only - iterates the `OfficeArtSpgrContainer`. Rare; read that child too. - **1.3 Optional / inherited anchor.** A shape without an `OfficeArtClientAnchor` currently yields a frame with no position. PowerPoint placeholders often omit it and inherit geometry from the matching placeholder on the **master slide** @@ -218,9 +230,10 @@ and is independent: ## 2. Smaller shortcomings -- **2.1 Slide size hardcoded.** Real size is `DocumentAtom.slideSize` - (`RT_DocumentAtom` 0x03E9, first child of `DocumentContainer`) — a `PointStruct` - in master units. Read it; fall back to 10"×7.5" only if absent. +- **2.0 Picture formats and locations.** WMF/EMF/PICT/DIB/TIFF BLIPs are + skipped (no converter; DIB would need a synthesized BMP header). Pictures on + **master slides** are not rendered (masters are skipped entirely) — e.g. + LibreOffice photo decks put each photo on a per-slide master. - **2.2 Reference-output HTML not wired.** `html_output_test` has no `ppt` case; add reference HTML under `test/data/reference-output/…/output/ppt/` (needs the `OpenDocument.test.output` submodule). @@ -238,5 +251,4 @@ and is independent: styles + color scheme to resolve both. - **2.5 Auto-field metacharacters dropped.** Slide-number/date/header/footer placeholders (`RT_*MetaCharAtom`) are ignored. Low priority. -- **2.6 `slide_name` is empty.** Could return `"Slide N"` for the HTML page label. - **2.7 Endianness** — shared `oldms/` shortcoming; see [`../AGENTS.md`](../AGENTS.md). diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index 3d099506..d116a6cc 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -9,6 +10,7 @@ #include #include +#include #include #include @@ -65,7 +67,8 @@ class ElementAdapter final : public abstract::ElementAdapter, public abstract::LineBreakAdapter, public abstract::ParagraphAdapter, public abstract::SpanAdapter, - public abstract::TextAdapter { + public abstract::TextAdapter, + public abstract::ImageAdapter { public: ElementAdapter(const Document &document, ElementRegistry ®istry, const StyleRegistry &style_registry) @@ -144,10 +147,23 @@ class ElementAdapter final : public abstract::ElementAdapter, text_adapter(const ElementIdentifier element_id) const override { return element_type(element_id) == ElementType::text ? this : nullptr; } + [[nodiscard]] const ImageAdapter * + image_adapter(const ElementIdentifier element_id) const override { + return element_type(element_id) == ElementType::image ? this : nullptr; + } [[nodiscard]] PageLayout slide_page_layout( [[maybe_unused]] const ElementIdentifier element_id) const override { - // Default 4:3 slide so the renderer has non-empty page dimensions. + // The DocumentAtom's slide size (master units = 1/576 inch), with the + // default 4:3 slide as fallback so the renderer has page dimensions. + if (const auto size = m_registry->slide_size(); size.has_value()) { + return { + .width = Measure(size->first / 576.0, DynamicUnit("in")), + .height = Measure(size->second / 576.0, DynamicUnit("in")), + .print_orientation = {}, + .margin = {}, + }; + } return { .width = Measure("10in"), .height = Measure("7.5in"), @@ -159,9 +175,17 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return null_element_id; } - [[nodiscard]] std::string slide_name( - [[maybe_unused]] const ElementIdentifier element_id) const override { - return {}; + [[nodiscard]] std::string + slide_name(const ElementIdentifier element_id) const override { + // Slides carry no names in the file; number them in presentation order. + std::size_t index = 1; + for (ElementIdentifier id = + m_registry->element_at(element_id).previous_sibling_id; + id != null_element_id; + id = m_registry->element_at(id).previous_sibling_id) { + ++index; + } + return "Slide " + std::to_string(index); } [[nodiscard]] AnchorType frame_anchor_type( @@ -229,6 +253,20 @@ class ElementAdapter final : public abstract::ElementAdapter, return {}; } + [[nodiscard]] bool image_is_internal( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return true; + } + [[nodiscard]] std::optional + image_file(const ElementIdentifier element_id) const override { + return odr::File(std::make_shared( + m_registry->image_element_at(element_id).data)); + } + [[nodiscard]] std::string image_href( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return {}; + } + private: /// The character style stored for a paragraph or span element. [[nodiscard]] TextStyle diff --git a/src/odr/internal/oldms/presentation/ppt_element_registry.cpp b/src/odr/internal/oldms/presentation/ppt_element_registry.cpp index 24f815d7..eea1434b 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.cpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.cpp @@ -8,7 +8,9 @@ void ElementRegistry::clear() noexcept { m_elements.clear(); m_texts.clear(); m_frames.clear(); + m_images.clear(); m_style_indices.clear(); + m_slide_size.reset(); } [[nodiscard]] std::size_t ElementRegistry::size() const noexcept { @@ -39,6 +41,14 @@ ElementRegistry::create_frame_element() { return {element_id, element, it->second}; } +std::tuple +ElementRegistry::create_image_element() { + const auto &[element_id, element] = create_element(ElementType::image); + auto [it, success] = m_images.emplace(element_id, Image{}); + return {element_id, element, it->second}; +} + ElementRegistry::Element & ElementRegistry::element_at(const ElementIdentifier id) { check_element_id(id); @@ -75,6 +85,18 @@ ElementRegistry::frame_element_at(const ElementIdentifier id) const { return m_frames.at(id); } +ElementRegistry::Image & +ElementRegistry::image_element_at(const ElementIdentifier id) { + check_image_id(id); + return m_images.at(id); +} + +const ElementRegistry::Image & +ElementRegistry::image_element_at(const ElementIdentifier id) const { + check_image_id(id); + return m_images.at(id); +} + void ElementRegistry::append_child(const ElementIdentifier parent_id, const ElementIdentifier child_id) { check_element_id(parent_id); @@ -98,6 +120,16 @@ void ElementRegistry::append_child(const ElementIdentifier parent_id, element_at(parent_id).last_child_id = child_id; } +void ElementRegistry::set_slide_size(const std::int32_t width, + const std::int32_t height) { + m_slide_size = {width, height}; +} + +std::optional> +ElementRegistry::slide_size() const { + return m_slide_size; +} + void ElementRegistry::set_element_style_index(const ElementIdentifier id, const std::uint32_t index) { check_element_id(id); @@ -134,4 +166,12 @@ void ElementRegistry::check_frame_id(const ElementIdentifier id) const { } } +void ElementRegistry::check_image_id(const ElementIdentifier id) const { + check_element_id(id); + if (!m_images.contains(id)) { + throw std::out_of_range( + "ElementRegistry::check_id: image identifier not found"); + } +} + } // namespace odr::internal::oldms::presentation diff --git a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp index 7ac5a29c..1039730a 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace odr::internal::oldms::presentation { @@ -34,6 +35,10 @@ class ElementRegistry final { std::optional anchor; }; + struct Image final { + std::string data; //< the raw image file bytes (JPEG/PNG) + }; + void clear() noexcept; [[nodiscard]] std::size_t size() const noexcept; @@ -41,14 +46,17 @@ class ElementRegistry final { std::tuple create_element(ElementType type); std::tuple create_text_element(); std::tuple create_frame_element(); + std::tuple create_image_element(); [[nodiscard]] Element &element_at(ElementIdentifier id); [[nodiscard]] Text &text_element_at(ElementIdentifier id); [[nodiscard]] Frame &frame_element_at(ElementIdentifier id); + [[nodiscard]] Image &image_element_at(ElementIdentifier id); [[nodiscard]] const Element &element_at(ElementIdentifier id) const; [[nodiscard]] const Text &text_element_at(ElementIdentifier id) const; [[nodiscard]] const Frame &frame_element_at(ElementIdentifier id) const; + [[nodiscard]] const Image &image_element_at(ElementIdentifier id) const; void append_child(ElementIdentifier parent_id, ElementIdentifier child_id); @@ -57,15 +65,23 @@ class ElementRegistry final { void set_element_style_index(ElementIdentifier id, std::uint32_t index); [[nodiscard]] std::uint32_t element_style_index(ElementIdentifier id) const; + /// Slide dimensions from the DocumentAtom, in master units (1/576 inch). + void set_slide_size(std::int32_t width, std::int32_t height); + [[nodiscard]] std::optional> + slide_size() const; + private: std::vector m_elements; std::unordered_map m_texts; std::unordered_map m_frames; + std::unordered_map m_images; std::unordered_map m_style_indices; + std::optional> m_slide_size; void check_element_id(ElementIdentifier id) const; void check_text_id(ElementIdentifier id) const; void check_frame_id(ElementIdentifier id) const; + void check_image_id(ElementIdentifier id) const; }; } // namespace odr::internal::oldms::presentation diff --git a/src/odr/internal/oldms/presentation/ppt_parser.cpp b/src/odr/internal/oldms/presentation/ppt_parser.cpp index db5113dd..983e5e92 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -230,11 +231,19 @@ StyledText style_pending(const PendingText &pending, return result; } -/// A single text box on a slide: its styled text and, optionally, the -/// position and size from its OfficeArtClientAnchor. +/// A single text box or picture shape on a slide: its styled text, its +/// picture reference/bytes, and optionally the position and size from its +/// OfficeArtClientAnchor. struct TextBox final { std::optional anchor; StyledText text; + /// The shape's picture (pib) or picture fill (fillBlip): a one-based index + /// into the BLIP store. + std::optional blip_ref; + /// The picture's file bytes (JPEG/PNG), resolved from the BLIP store. + std::string image; + /// The shape is the slide background (OfficeArtFSP.fBackground). + bool is_background{false}; }; /// Builds the paragraph/span/text subtree of one text box under `parent_id`. @@ -388,11 +397,37 @@ TextBox read_shape(std::istream &in, const RecordHeader &shape, if (child->recType == RT_OfficeArtClientAnchor) { box.anchor = read_client_anchor(in, child->recLen); children.consume(child->recLen); + } else if (child->recType == RT_OfficeArtFSP && + child->recLen >= 2 * sizeof(std::uint32_t)) { + read_u32(in); // spid + const std::uint32_t flags = read_u32(in); + children.consume(2 * sizeof(std::uint32_t)); + box.is_background = (flags & office_art_fsp_background) != 0; } else if (child->recType == RT_OfficeArtClientTextbox) { gather_text(in, *child, box.text, outline_texts, context); children.consume(child->recLen); + } else if (child->recType == RT_OfficeArtFOPT) { + // rh.recInstance is the property count; complex data (skipped by the + // cursor) follows the array ([MS-ODRAW] 2.2.9). + const std::uint16_t count = child->recInstance; + for (std::uint16_t i = 0; + i < count && (i + 1) * sizeof(OfficeArtFopte) <= child->recLen; + ++i) { + const auto property = util::byte_stream::read(in); + children.consume(sizeof(OfficeArtFopte)); + if (property.fComplex != 0) { + continue; + } + // A real picture shape (pib) wins over a picture fill (fillBlip). + if (property.opid == office_art_property_pib) { + box.blip_ref = property.op; + } else if (property.opid == office_art_property_fill_blip && + !box.blip_ref.has_value()) { + box.blip_ref = property.op; + } + } } - // Other children (shapeProp, FOPT, clientData, …): skipped by the cursor. + // Other children (shapeProp, clientData, …): skipped by the cursor. } return box; } @@ -404,23 +439,43 @@ std::vector read_slide_text_boxes(std::istream &in, const RecordHeader &slide, const std::vector &outline_texts, StyleContext &context) { - // SlideContainer → DrawingContainer → OfficeArtDgContainer → - // OfficeArtSpgrContainer (all mandatory), then iterate the shapes. + // SlideContainer → DrawingContainer → OfficeArtDgContainer (mandatory). + // The drawing holds a mandatory OfficeArtSpgrContainer plus optionally a + // direct shape not in any group ([MS-ODRAW] 2.2.13); both are read in + // stream (z) order. const RecordHeader drawing = require_child(in, slide, RT_Drawing); const RecordHeader dg = require_child(in, drawing, RT_OfficeArtDgContainer); - const RecordHeader spgr = require_child(in, dg, RT_OfficeArtSpgrContainer); std::vector boxes; - ChildCursor shapes(in, spgr); - while (const std::optional shape = shapes.next()) { - if (shape->recType != RT_OfficeArtSpContainer) { - continue; // not a shape; the cursor skips it - } - TextBox box = read_shape(in, *shape, outline_texts, context); - shapes.consume(shape->recLen); - if (!box.text.empty()) { + const auto keep = [&boxes](TextBox box) { + if (!box.text.empty() || box.blip_ref.has_value()) { boxes.push_back(std::move(box)); } + }; + + bool seen_group = false; + ChildCursor children(in, dg); + while (const std::optional child = children.next()) { + if (child->recType == RT_OfficeArtSpgrContainer) { + seen_group = true; + ChildCursor shapes(in, *child); + while (const std::optional shape = shapes.next()) { + if (shape->recType != RT_OfficeArtSpContainer) { + continue; // not a shape; the cursor skips it + } + keep(read_shape(in, *shape, outline_texts, context)); + shapes.consume(shape->recLen); + } + children.consume(child->recLen); + } else if (child->recType == RT_OfficeArtSpContainer) { + keep(read_shape(in, *child, outline_texts, context)); + children.consume(child->recLen); + } + // Other children: skipped by the cursor. + } + if (!seen_group) { + throw std::runtime_error("ppt: missing required record type " + + std::to_string(RT_OfficeArtSpgrContainer)); } return boxes; } @@ -515,6 +570,103 @@ SlideListText read_slide_list_text(std::istream &in, return result; } +/// Reads an OfficeArt BLIP record ([MS-ODRAW] 2.2.23) at the stream position +/// and returns its image file bytes; empty for BLIP types not modelled +/// (WMF/EMF/PICT/DIB/TIFF). +std::string read_blip_record(std::istream &in) { + const RecordHeader header = read_record_header(in); + + // JPEG/PNG: rgbUid1, optional rgbUid2 (odd data recInstance), tag, data + // ([MS-ODRAW] 2.2.27/2.2.28). + std::uint32_t prefix; + if (header.recType == RT_OfficeArtBlipJPEG) { + const bool two_uids = + header.recInstance == 0x46B || header.recInstance == 0x6E3; + prefix = 16 + (two_uids ? 16 : 0) + 1; + } else if (header.recType == RT_OfficeArtBlipPNG) { + prefix = 16 + (header.recInstance == 0x6E1 ? 16 : 0) + 1; + } else { + in.ignore(header.recLen); + return {}; + } + if (header.recLen < prefix) { + throw std::runtime_error("ppt: truncated BLIP record"); + } + in.ignore(prefix); + return util::stream::read(in, header.recLen - prefix); +} + +/// One slot of the BLIP store: the offset of the BLIP in the "Pictures" +/// stream (0xFFFFFFFF = none), or its already-extracted bytes when the BLIP +/// is embedded in the store itself. +struct BlipSlot final { + std::uint32_t fo_delay{0xFFFFFFFF}; + std::string data; +}; + +/// Reads the BLIP store ([MS-ODRAW] 2.2.20) of the DocumentContainer's +/// drawing group; one slot per store entry, in order (pib is a one-based +/// index into these). Stream at the DocumentContainer body. +std::vector read_blip_store(std::istream &in, + const RecordHeader &document) { + std::vector slots; + const std::optional drawing_group = + find_child(in, document, RT_DrawingGroup); + if (!drawing_group.has_value()) { + return slots; + } + const std::optional dgg = + find_child(in, *drawing_group, RT_OfficeArtDggContainer); + if (!dgg.has_value()) { + return slots; + } + const std::optional store = + find_child(in, *dgg, RT_OfficeArtBStoreContainer); + if (!store.has_value()) { + return slots; + } + + ChildCursor entries(in, *store); + while (const std::optional child = entries.next()) { + BlipSlot &slot = slots.emplace_back(); + if (child->recType == RT_OfficeArtFBSE) { + const auto fbse = util::byte_stream::read(in); + entries.consume(sizeof(OfficeArtFbseFixed)); + in.ignore(fbse.cbName); + entries.consume(fbse.cbName); + // An embedded BLIP follows the name ([MS-ODRAW] 2.2.32); otherwise the + // BLIP lives in the delay ("Pictures") stream at foDelay. + if (child->recLen > + sizeof(OfficeArtFbseFixed) + std::uint32_t{fbse.cbName}) { + const std::uint32_t remaining = + child->recLen - sizeof(OfficeArtFbseFixed) - fbse.cbName; + slot.data = read_blip_record(in); + entries.consume(remaining); + } else { + slot.fo_delay = fbse.foDelay; + } + } else if (child->recType == RT_OfficeArtBlipJPEG || + child->recType == RT_OfficeArtBlipPNG) { + // A BLIP directly in the store occupies a slot of its own; rewind is + // impossible on this stream, so re-parse from the header we just read. + // The header was already consumed by the cursor; read body inline. + const bool two_uids = + child->recType == RT_OfficeArtBlipJPEG + ? (child->recInstance == 0x46B || child->recInstance == 0x6E3) + : (child->recInstance == 0x6E1); + const std::uint32_t prefix = 16 + (two_uids ? 16 : 0) + 1; + if (child->recLen < prefix) { + throw std::runtime_error("ppt: truncated BLIP record"); + } + in.ignore(prefix); + slot.data = util::stream::read(in, child->recLen - prefix); + entries.consume(child->recLen); + } + // Other record types leave an empty slot; the cursor skips them. + } + return slots; +} + /// Reads the document's font names from the FontCollection /// ([MS-PPT] 2.9.8/2.9.10), indexed by each FontEntityAtom's recInstance /// (empty strings mark the gaps). Stream at the DocumentContainer body. @@ -563,9 +715,10 @@ std::vector read_font_collection(std::istream &in, /// from the live records, ignoring stale copies left by incremental saves. /// Malformed records throw. Returns each slide's text boxes in shape order; /// the boxes' styles accumulate in `context`. -std::vector> collect_slides(std::istream ¤t_user, - std::istream &document, - StyleContext &context) { +std::vector> +collect_slides(std::istream ¤t_user, std::istream &document, + const abstract::ReadableFilesystem &files, + ElementRegistry ®istry, StyleContext &context) { // Newest user edit offset, from the Current User stream. const CurrentUserAtomHead head = read_current_user_atom_head(current_user); if (head.rh.recType != RT_CurrentUserAtom) { @@ -622,6 +775,31 @@ std::vector> collect_slides(std::istream ¤t_user, context.fonts = read_font_collection(document, doc_header); } + // Slide size from the DocumentAtom ([MS-PPT] 2.4.2): a PointStruct in + // master units. + { + document.clear(); + document.seekg(doc_offset); + const RecordHeader doc_header = read_header(document, RT_DocumentContainer); + if (const std::optional document_atom = + find_child(document, doc_header, RT_DocumentAtom); + document_atom.has_value() && + document_atom->recLen >= 2 * sizeof(std::int32_t)) { + const auto width = static_cast(read_u32(document)); + const auto height = static_cast(read_u32(document)); + registry.set_slide_size(width, height); + } + } + + // The BLIP store, for picture shapes. + std::vector blip_store; + { + document.clear(); + document.seekg(doc_offset); + const RecordHeader doc_header = read_header(document, RT_DocumentContainer); + blip_store = read_blip_store(document, doc_header); + } + document.clear(); document.seekg(doc_offset); const RecordHeader doc_header = read_header(document, RT_DocumentContainer); @@ -657,6 +835,41 @@ std::vector> collect_slides(std::istream ¤t_user, slides.push_back( read_slide_text_boxes(document, slide_header, outline_texts, context)); } + + // Resolve picture references against the BLIP store and the "Pictures" + // (delay) stream; unsupported/unresolvable pictures leave `image` empty. + const auto pictures_file = files.open(AbsPath("/Pictures")); + const auto pictures_stream = + pictures_file != nullptr ? pictures_file->stream() : nullptr; + for (std::vector &boxes : slides) { + for (TextBox &box : boxes) { + if (!box.blip_ref.has_value() || *box.blip_ref == 0 || + *box.blip_ref > blip_store.size()) { + continue; + } + BlipSlot &slot = blip_store[*box.blip_ref - 1]; + if (slot.data.empty() && slot.fo_delay != 0xFFFFFFFF && + pictures_stream != nullptr) { + pictures_stream->clear(); + pictures_stream->seekg(slot.fo_delay); + slot.data = read_blip_record(*pictures_stream); + slot.fo_delay = 0xFFFFFFFF; // resolved (possibly to unsupported/empty) + } + box.image = slot.data; + } + + // Background shapes cover the whole slide (they carry no anchor of their + // own) and must render below the other shapes. + for (TextBox &box : boxes) { + if (box.is_background && !box.anchor.has_value()) { + const auto size = registry.slide_size().value_or( + std::pair{5760, 4320}); + box.anchor = Anchor{0, 0, size.first, size.second}; + } + } + std::ranges::stable_partition( + boxes, [](const TextBox &box) { return box.is_background; }); + } return slides; } @@ -686,16 +899,23 @@ presentation::parse_tree(ElementRegistry ®istry, const auto current_user_stream = current_user_file->stream(); StyleContext context; - for (const std::vector &boxes : - collect_slides(*current_user_stream, *document_stream, context)) { + for (std::vector &boxes : + collect_slides(*current_user_stream, *document_stream, files, registry, + context)) { auto [slide_id, _] = registry.create_element(ElementType::slide); registry.append_child(root_id, slide_id); - // One frame per text box; the box's paragraphs hang off the frame. - for (const TextBox &box : boxes) { + // One frame per shape; a picture and/or the box's paragraphs hang off + // the frame. + for (TextBox &box : boxes) { auto [frame_id, frame_element, frame] = registry.create_frame_element(); frame.anchor = box.anchor; registry.append_child(slide_id, frame_id); + if (!box.image.empty()) { + auto [image_id, image_element, image] = registry.create_image_element(); + image.data = std::move(box.image); + registry.append_child(frame_id, image_id); + } build_paragraphs(registry, frame_id, box.text); } } diff --git a/src/odr/internal/oldms/presentation/ppt_structs.hpp b/src/odr/internal/oldms/presentation/ppt_structs.hpp index a41a3098..0fd4956a 100644 --- a/src/odr/internal/oldms/presentation/ppt_structs.hpp +++ b/src/odr/internal/oldms/presentation/ppt_structs.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include namespace odr::internal::oldms::presentation { @@ -12,6 +13,7 @@ namespace odr::internal::oldms::presentation { /// Record types relevant to text extraction. See [MS-PPT] 2.13.24 RecordType. enum RecordType : std::uint16_t { RT_DocumentContainer = 0x03E8, //< top-level document + RT_DocumentAtom = 0x03E9, //< slide size etc. [MS-PPT] 2.4.2 RT_SlideContainer = 0x03EE, //< a slide (drawing + placeholders) RT_Notes = 0x03F0, //< notes page (skipped) RT_Environment = 0x03F2, //< DocumentTextInfoContainer [MS-PPT] 2.4.5 @@ -31,14 +33,32 @@ enum RecordType : std::uint16_t { // Office Art (Escher) drawing records holding the slide's text boxes: // OfficeArt* are [MS-ODRAW], client records (textbox/anchor) are [MS-PPT]. - RT_Drawing = 0x040C, //< DrawingContainer [MS-PPT] 2.5.13 - RT_OfficeArtDgContainer = 0xF002, //< [MS-ODRAW] 2.2.13 - RT_OfficeArtSpgrContainer = 0xF003, //< shape group [MS-ODRAW] 2.2.16 + RT_Drawing = 0x040C, //< DrawingContainer [MS-PPT] 2.5.13 + RT_DrawingGroup = 0x040B, //< DrawingGroupContainer [MS-PPT] 2.4.3 + RT_OfficeArtDggContainer = 0xF000, //< drawing group data [MS-ODRAW] 2.2.12 + RT_OfficeArtBStoreContainer = 0xF001, //< the BLIP store [MS-ODRAW] 2.2.20 + RT_OfficeArtDgContainer = 0xF002, //< [MS-ODRAW] 2.2.13 + RT_OfficeArtSpgrContainer = 0xF003, //< shape group [MS-ODRAW] 2.2.16 RT_OfficeArtSpContainer = 0xF004, //< one shape (a text box) [MS-ODRAW] 2.2.14 + RT_OfficeArtFBSE = 0xF007, //< BLIP store entry [MS-ODRAW] 2.2.32 + RT_OfficeArtFSP = 0xF00A, //< shape id + flags [MS-ODRAW] 2.2.40 + RT_OfficeArtFOPT = 0xF00B, //< shape properties [MS-ODRAW] 2.2.9 RT_OfficeArtClientTextbox = 0xF00D, //< a shape's text [MS-PPT] 2.9.76 RT_OfficeArtClientAnchor = 0xF010, //< a shape's position [MS-PPT] 2.7.1 + RT_OfficeArtBlipJPEG = 0xF01D, //< JPEG BLIP [MS-ODRAW] 2.2.27 + RT_OfficeArtBlipPNG = 0xF01E, //< PNG BLIP [MS-ODRAW] 2.2.28 }; +/// The pib property of an OfficeArtFOPT: the shape's picture as a one-based +/// index into the BLIP store ([MS-ODRAW] 2.3.23.2). +constexpr std::uint16_t office_art_property_pib = 0x0104; +/// The fillBlip property: a picture fill's BLIP, same index semantics +/// ([MS-ODRAW] 2.3.7.2). LibreOffice-exported .ppt places pictures this way. +constexpr std::uint16_t office_art_property_fill_blip = 0x0186; +/// OfficeArtFSP.fBackground: the shape is the slide background +/// ([MS-ODRAW] 2.2.40). +constexpr std::uint32_t office_art_fsp_background = 0x400; + /// recInstance of a RT_SlideListWithText container: which list it is (slides / /// master / notes) inside the DocumentContainer ([MS-PPT] 2.4.14.1/3/6). enum SlideListInstance : std::uint16_t { @@ -90,6 +110,34 @@ struct UserEditAtomBody { static_assert(sizeof(UserEditAtomBody) == 28, "UserEditAtomBody should be 28 bytes"); +/// Fixed part of an OfficeArtFBSE ([MS-ODRAW] 2.2.32); an optional name and +/// an optional embedded BLIP follow. +struct OfficeArtFbseFixed { + std::uint8_t btWin32; //< MSOBLIPTYPE + std::uint8_t btMacOS; + std::array rgbUid; + std::uint16_t tag; + std::uint32_t size; //< BLIP size in the stream + std::uint32_t cRef; //< 0 = empty slot + std::uint32_t foDelay; //< offset in the delay ("Pictures") stream, or -1 + std::uint8_t unused1; + std::uint8_t cbName; + std::uint8_t unused2; + std::uint8_t unused3; +}; +static_assert(sizeof(OfficeArtFbseFixed) == 36, + "OfficeArtFbseFixed should be 36 bytes"); + +/// One property of an OfficeArtFOPT ([MS-ODRAW] 2.2.7/2.2.8); complex data +/// follows the property array. +struct OfficeArtFopte { + std::uint16_t opid : 14; + std::uint16_t fBid : 1; + std::uint16_t fComplex : 1; + std::uint32_t op; +}; +static_assert(sizeof(OfficeArtFopte) == 6, "OfficeArtFopte should be 6 bytes"); + #pragma pack(pop) /// A shape's position/size from an OfficeArtClientAnchor ([MS-PPT] 2.12.7/8), diff --git a/test/src/internal/oldms/ppt_test.cpp b/test/src/internal/oldms/ppt_test.cpp index 594ea24d..78b2b8ab 100644 --- a/test/src/internal/oldms/ppt_test.cpp +++ b/test/src/internal/oldms/ppt_test.cpp @@ -12,6 +12,7 @@ #include +#include #include #include #include @@ -126,15 +127,37 @@ TEST(OldMs, ppt_style_various) { EXPECT_TRUE(frame.height().has_value()); } } - EXPECT_EQ(total_frames, 12); + EXPECT_EQ(total_frames, 13); // 12 text boxes + 1 background picture - // The first frame of each slide is its title "titleN…", in order. + // The first text frame of each slide is its title "titleN…", in order. for (std::size_t i = 0; i < slides.size(); ++i) { - const std::string title = collect_text(slides[i].front()); + std::string title; + for (const Element &frame : slides[i]) { + title = collect_text(frame); + if (!title.empty()) { + break; + } + } EXPECT_EQ(title.rfind("title" + std::to_string(i + 1), 0), 0u) << "slide " << i << " title: " << title; } + // Slide 5 ("title6 - background image") carries a full-slide background + // picture: a leading frame holding an image element with the PNG bytes from + // the "Pictures" stream. + ASSERT_EQ(slides[5].size(), 2); + const Element background = slides[5][0]; + EXPECT_EQ(collect_text(background), ""); + ASSERT_EQ(background.first_child().type(), ElementType::image); + const Image image = background.first_child().as_image(); + EXPECT_TRUE(image.is_internal()); + ASSERT_TRUE(image.file().has_value()); + std::array magic{}; + image.file()->stream()->read(magic.data(), magic.size()); + EXPECT_EQ(std::string(magic.data() + 1, 3), "PNG"); + EXPECT_EQ(background.as_frame().x(), "0in"); + EXPECT_EQ(background.as_frame().y(), "0in"); + // Slide 0 is a title + subtitle box, at different vertical positions. ASSERT_EQ(slides[0].size(), 2); EXPECT_EQ(collect_text(slides[0][0]), "title1"); From 5a2296bf8e93b40e3b71868ace99a6a1a28dc3e5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 21:28:22 +0200 Subject: [PATCH 2/5] fix(ppt): give images a non-empty distinct href With HtmlConfig::embed_images = false the generic renderer uses image.href() as the resource path, so the empty href produced src="" and bring_offline could not write distinct image assets. Pictures have no container path in the file; synthesize one from the one-based BLIP store index plus the extension matching the image bytes ("Pictures/.png|.jpg"). Addresses review feedback on #610. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AEvWWwe1EyeWH7ANHoAvPH --- src/odr/internal/oldms/presentation/ppt_document.cpp | 6 +++--- .../oldms/presentation/ppt_element_registry.hpp | 1 + src/odr/internal/oldms/presentation/ppt_parser.cpp | 10 ++++++++++ test/src/internal/oldms/ppt_test.cpp | 3 +++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index d116a6cc..5f7d4d67 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -262,9 +262,9 @@ class ElementAdapter final : public abstract::ElementAdapter, return odr::File(std::make_shared( m_registry->image_element_at(element_id).data)); } - [[nodiscard]] std::string image_href( - [[maybe_unused]] const ElementIdentifier element_id) const override { - return {}; + [[nodiscard]] std::string + image_href(const ElementIdentifier element_id) const override { + return m_registry->image_element_at(element_id).href; } private: diff --git a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp index 1039730a..590b4c1f 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp @@ -37,6 +37,7 @@ class ElementRegistry final { struct Image final { std::string data; //< the raw image file bytes (JPEG/PNG) + std::string href; //< pseudo-path naming the BLIP (no real container path) }; void clear() noexcept; diff --git a/src/odr/internal/oldms/presentation/ppt_parser.cpp b/src/odr/internal/oldms/presentation/ppt_parser.cpp index 983e5e92..f9c660f7 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -242,6 +242,9 @@ struct TextBox final { std::optional blip_ref; /// The picture's file bytes (JPEG/PNG), resolved from the BLIP store. std::string image; + /// Distinct pseudo-path naming the picture (the file has no container + /// paths); derived from the BLIP store index. + std::string image_href; /// The shape is the slide background (OfficeArtFSP.fBackground). bool is_background{false}; }; @@ -856,6 +859,12 @@ collect_slides(std::istream ¤t_user, std::istream &document, slot.fo_delay = 0xFFFFFFFF; // resolved (possibly to unsupported/empty) } box.image = slot.data; + if (!box.image.empty()) { + // Only JPEG and PNG BLIPs are modelled; tell them apart by magic. + const bool is_png = box.image.starts_with("\x89PNG"); + box.image_href = "Pictures/" + std::to_string(*box.blip_ref) + + (is_png ? ".png" : ".jpg"); + } } // Background shapes cover the whole slide (they carry no anchor of their @@ -914,6 +923,7 @@ presentation::parse_tree(ElementRegistry ®istry, if (!box.image.empty()) { auto [image_id, image_element, image] = registry.create_image_element(); image.data = std::move(box.image); + image.href = std::move(box.image_href); registry.append_child(frame_id, image_id); } build_paragraphs(registry, frame_id, box.text); diff --git a/test/src/internal/oldms/ppt_test.cpp b/test/src/internal/oldms/ppt_test.cpp index 78b2b8ab..90331f1c 100644 --- a/test/src/internal/oldms/ppt_test.cpp +++ b/test/src/internal/oldms/ppt_test.cpp @@ -155,6 +155,9 @@ TEST(OldMs, ppt_style_various) { std::array magic{}; image.file()->stream()->read(magic.data(), magic.size()); EXPECT_EQ(std::string(magic.data() + 1, 3), "PNG"); + // A non-empty pseudo-path names the BLIP so the HTML renderer can emit a + // distinct resource per picture when images are not embedded. + EXPECT_EQ(image.href(), "Pictures/1.png"); EXPECT_EQ(background.as_frame().x(), "0in"); EXPECT_EQ(background.as_frame().y(), "0in"); From 29a12e013c0dc4107dc0357ac3ee091f6b12d730 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 12:19:50 +0200 Subject: [PATCH 3/5] formatting --- src/odr/internal/oldms/presentation/ppt_parser.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/odr/internal/oldms/presentation/ppt_parser.cpp b/src/odr/internal/oldms/presentation/ppt_parser.cpp index f9c660f7..5ff0eb44 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -908,9 +908,8 @@ presentation::parse_tree(ElementRegistry ®istry, const auto current_user_stream = current_user_file->stream(); StyleContext context; - for (std::vector &boxes : - collect_slides(*current_user_stream, *document_stream, files, registry, - context)) { + for (std::vector &boxes : collect_slides( + *current_user_stream, *document_stream, files, registry, context)) { auto [slide_id, _] = registry.create_element(ElementType::slide); registry.append_child(root_id, slide_id); From d3934cefa6c1a289e2ea2e0971972af179f19d28 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 14:03:57 +0200 Subject: [PATCH 4/5] refactor(ppt): name magic numbers, rename TextBox to Shape Review feedback on #610: - master_units_per_inch constant instead of bare 576.0 - BlipInstance enum for the BLIP recInstance values; the duplicated two-uid/prefix logic collapses into blip_prefix_size - TextBox held a picture too (pib or fillBlip), so call it Shape - fold the identical pib/fillBlip branches into one condition Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PeqF9H2EFKQ3TMVxvzmHvJ --- .../oldms/presentation/ppt_document.cpp | 17 +- .../oldms/presentation/ppt_parser.cpp | 173 +++++++++--------- .../oldms/presentation/ppt_structs.hpp | 15 ++ 3 files changed, 112 insertions(+), 93 deletions(-) diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index 5f7d4d67..8b4d99b8 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -154,12 +154,14 @@ class ElementAdapter final : public abstract::ElementAdapter, [[nodiscard]] PageLayout slide_page_layout( [[maybe_unused]] const ElementIdentifier element_id) const override { - // The DocumentAtom's slide size (master units = 1/576 inch), with the - // default 4:3 slide as fallback so the renderer has page dimensions. + // The DocumentAtom's slide size, with the default 4:3 slide as fallback + // so the renderer has page dimensions. if (const auto size = m_registry->slide_size(); size.has_value()) { return { - .width = Measure(size->first / 576.0, DynamicUnit("in")), - .height = Measure(size->second / 576.0, DynamicUnit("in")), + .width = + Measure(size->first / master_units_per_inch, DynamicUnit("in")), + .height = + Measure(size->second / master_units_per_inch, DynamicUnit("in")), .print_orientation = {}, .margin = {}, }; @@ -275,8 +277,8 @@ class ElementAdapter final : public abstract::ElementAdapter, m_registry->element_style_index(element_id)); } - // Converts one field of a frame's anchor (master units = 1/576 inch) to a - // Measure string, or nullopt when the frame has no anchor. + // Converts one field of a frame's anchor to a Measure string, or nullopt + // when the frame has no anchor. template [[nodiscard]] std::optional anchor_measure(const ElementIdentifier element_id, @@ -286,7 +288,8 @@ class ElementAdapter final : public abstract::ElementAdapter, if (!anchor.has_value()) { return std::nullopt; } - return Measure(select(*anchor) / 576.0, DynamicUnit("in")).to_string(); + return Measure(select(*anchor) / master_units_per_inch, DynamicUnit("in")) + .to_string(); } [[maybe_unused]] diff --git a/src/odr/internal/oldms/presentation/ppt_parser.cpp b/src/odr/internal/oldms/presentation/ppt_parser.cpp index 5ff0eb44..1328ce56 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -231,10 +231,10 @@ StyledText style_pending(const PendingText &pending, return result; } -/// A single text box or picture shape on a slide: its styled text, its -/// picture reference/bytes, and optionally the position and size from its -/// OfficeArtClientAnchor. -struct TextBox final { +/// One shape on a slide — a text box, a picture, or a picture-filled shape +/// with text on top: its styled text, its picture reference/bytes, and +/// optionally the position and size from its OfficeArtClientAnchor. +struct Shape final { std::optional anchor; StyledText text; /// The shape's picture (pib) or picture fill (fillBlip): a one-based index @@ -249,13 +249,13 @@ struct TextBox final { bool is_background{false}; }; -/// Builds the paragraph/span/text subtree of one text box under `parent_id`. -/// Paragraphs open lazily (the trailing paragraph mark adds no empty -/// paragraph); each span and paragraph stores its style so empty paragraphs -/// keep their height. +/// Builds the paragraph/span/text subtree of one shape's text under +/// `parent_id`. Paragraphs open lazily (the trailing paragraph mark adds no +/// empty paragraph); each span and paragraph stores its style so empty +/// paragraphs keep their height. void build_paragraphs(ElementRegistry ®istry, const ElementIdentifier parent_id, - const StyledText &box_text) { + const StyledText &shape_text) { ElementIdentifier paragraph_id = null_element_id; const auto ensure_paragraph = [&](const std::uint32_t style_index) { @@ -267,7 +267,7 @@ void build_paragraphs(ElementRegistry ®istry, } }; - for (const StyledRun &run : box_text) { + for (const StyledRun &run : shape_text) { std::size_t at = 0; while (at <= run.text.size()) { const std::size_t control = run.text.find_first_of( @@ -339,9 +339,9 @@ std::vector read_style_atom(std::istream &in, return parse_style_text_prop_atom(body, pending.char_count() + 1); } -/// Recursively concatenates a container's styled text in stream order. A box -/// holds inline TextChars/TextBytes atoms — each optionally followed by a -/// StyleTextPropAtom ([MS-PPT] 2.9.44) — or an OutlineTextRefAtom indexing +/// Recursively concatenates a container's styled text in stream order. A +/// text box holds inline TextChars/TextBytes atoms — each optionally followed +/// by a StyleTextPropAtom ([MS-PPT] 2.9.44) — or an OutlineTextRefAtom indexing /// the slide's `outline_texts` ([MS-PPT] 2.9.78). Stream at the container /// body. void gather_text(std::istream &in, const RecordHeader &container, @@ -391,23 +391,23 @@ void gather_text(std::istream &in, const RecordHeader &container, /// Reads one shape (OfficeArtSpContainer): its optional anchor and the text of /// its OfficeArtClientTextbox. Consumes the whole shape body. `outline_texts` /// resolves an OutlineTextRefAtom. Stream at the shape body. -TextBox read_shape(std::istream &in, const RecordHeader &shape, - const std::vector &outline_texts, - StyleContext &context) { - TextBox box; - ChildCursor children(in, shape); +Shape read_shape(std::istream &in, const RecordHeader &header, + const std::vector &outline_texts, + StyleContext &context) { + Shape shape; + ChildCursor children(in, header); while (const std::optional child = children.next()) { if (child->recType == RT_OfficeArtClientAnchor) { - box.anchor = read_client_anchor(in, child->recLen); + shape.anchor = read_client_anchor(in, child->recLen); children.consume(child->recLen); } else if (child->recType == RT_OfficeArtFSP && child->recLen >= 2 * sizeof(std::uint32_t)) { read_u32(in); // spid const std::uint32_t flags = read_u32(in); children.consume(2 * sizeof(std::uint32_t)); - box.is_background = (flags & office_art_fsp_background) != 0; + shape.is_background = (flags & office_art_fsp_background) != 0; } else if (child->recType == RT_OfficeArtClientTextbox) { - gather_text(in, *child, box.text, outline_texts, context); + gather_text(in, *child, shape.text, outline_texts, context); children.consume(child->recLen); } else if (child->recType == RT_OfficeArtFOPT) { // rh.recInstance is the property count; complex data (skipped by the @@ -422,26 +422,25 @@ TextBox read_shape(std::istream &in, const RecordHeader &shape, continue; } // A real picture shape (pib) wins over a picture fill (fillBlip). - if (property.opid == office_art_property_pib) { - box.blip_ref = property.op; - } else if (property.opid == office_art_property_fill_blip && - !box.blip_ref.has_value()) { - box.blip_ref = property.op; + if (property.opid == office_art_property_pib || + (property.opid == office_art_property_fill_blip && + !shape.blip_ref.has_value())) { + shape.blip_ref = property.op; } } } // Other children (shapeProp, clientData, …): skipped by the cursor. } - return box; + return shape; } -/// Reads a slide's text boxes in shape (z) order; empty boxes are dropped. -/// First cut: only top-level shapes, whose anchors are already in the slide's -/// master-unit coordinates. Stream at the SlideContainer body. -std::vector -read_slide_text_boxes(std::istream &in, const RecordHeader &slide, - const std::vector &outline_texts, - StyleContext &context) { +/// Reads a slide's shapes in z order; shapes with neither text nor a picture +/// are dropped. First cut: only top-level shapes, whose anchors are already +/// in the slide's master-unit coordinates. Stream at the SlideContainer body. +std::vector +read_slide_shapes(std::istream &in, const RecordHeader &slide, + const std::vector &outline_texts, + StyleContext &context) { // SlideContainer → DrawingContainer → OfficeArtDgContainer (mandatory). // The drawing holds a mandatory OfficeArtSpgrContainer plus optionally a // direct shape not in any group ([MS-ODRAW] 2.2.13); both are read in @@ -449,10 +448,10 @@ read_slide_text_boxes(std::istream &in, const RecordHeader &slide, const RecordHeader drawing = require_child(in, slide, RT_Drawing); const RecordHeader dg = require_child(in, drawing, RT_OfficeArtDgContainer); - std::vector boxes; - const auto keep = [&boxes](TextBox box) { - if (!box.text.empty() || box.blip_ref.has_value()) { - boxes.push_back(std::move(box)); + std::vector shapes; + const auto keep = [&shapes](Shape shape) { + if (!shape.text.empty() || shape.blip_ref.has_value()) { + shapes.push_back(std::move(shape)); } }; @@ -461,13 +460,13 @@ read_slide_text_boxes(std::istream &in, const RecordHeader &slide, while (const std::optional child = children.next()) { if (child->recType == RT_OfficeArtSpgrContainer) { seen_group = true; - ChildCursor shapes(in, *child); - while (const std::optional shape = shapes.next()) { + ChildCursor group(in, *child); + while (const std::optional shape = group.next()) { if (shape->recType != RT_OfficeArtSpContainer) { continue; // not a shape; the cursor skips it } keep(read_shape(in, *shape, outline_texts, context)); - shapes.consume(shape->recLen); + group.consume(shape->recLen); } children.consume(child->recLen); } else if (child->recType == RT_OfficeArtSpContainer) { @@ -480,7 +479,7 @@ read_slide_text_boxes(std::istream &in, const RecordHeader &slide, throw std::runtime_error("ppt: missing required record type " + std::to_string(RT_OfficeArtSpgrContainer)); } - return boxes; + return shapes; } /// Maps a persist object identifier to its offset in the PowerPoint Document @@ -573,25 +572,31 @@ SlideListText read_slide_list_text(std::istream &in, return result; } +/// Bytes between a JPEG/PNG BLIP record's header and its picture data: +/// rgbUid1, an optional rgbUid2 marked by the recInstance, and a 1-byte tag +/// ([MS-ODRAW] 2.2.27/2.2.28). +std::uint32_t blip_prefix_size(const RecordHeader &header) { + const bool two_uids = + header.recType == RT_OfficeArtBlipJPEG + ? (header.recInstance == BlipInstance_JpegRgbTwoUids || + header.recInstance == BlipInstance_JpegCmykTwoUids) + : (header.recInstance == BlipInstance_PngTwoUids); + constexpr std::uint32_t uid_size = 16; + return uid_size + (two_uids ? uid_size : 0) + 1; +} + /// Reads an OfficeArt BLIP record ([MS-ODRAW] 2.2.23) at the stream position /// and returns its image file bytes; empty for BLIP types not modelled /// (WMF/EMF/PICT/DIB/TIFF). std::string read_blip_record(std::istream &in) { const RecordHeader header = read_record_header(in); - // JPEG/PNG: rgbUid1, optional rgbUid2 (odd data recInstance), tag, data - // ([MS-ODRAW] 2.2.27/2.2.28). - std::uint32_t prefix; - if (header.recType == RT_OfficeArtBlipJPEG) { - const bool two_uids = - header.recInstance == 0x46B || header.recInstance == 0x6E3; - prefix = 16 + (two_uids ? 16 : 0) + 1; - } else if (header.recType == RT_OfficeArtBlipPNG) { - prefix = 16 + (header.recInstance == 0x6E1 ? 16 : 0) + 1; - } else { + if (header.recType != RT_OfficeArtBlipJPEG && + header.recType != RT_OfficeArtBlipPNG) { in.ignore(header.recLen); return {}; } + const std::uint32_t prefix = blip_prefix_size(header); if (header.recLen < prefix) { throw std::runtime_error("ppt: truncated BLIP record"); } @@ -653,11 +658,7 @@ std::vector read_blip_store(std::istream &in, // A BLIP directly in the store occupies a slot of its own; rewind is // impossible on this stream, so re-parse from the header we just read. // The header was already consumed by the cursor; read body inline. - const bool two_uids = - child->recType == RT_OfficeArtBlipJPEG - ? (child->recInstance == 0x46B || child->recInstance == 0x6E3) - : (child->recInstance == 0x6E1); - const std::uint32_t prefix = 16 + (two_uids ? 16 : 0) + 1; + const std::uint32_t prefix = blip_prefix_size(*child); if (child->recLen < prefix) { throw std::runtime_error("ppt: truncated BLIP record"); } @@ -716,9 +717,9 @@ std::vector read_font_collection(std::istream &in, /// UserEditAtom, whose chain builds the persist directory, which resolves the /// live DocumentContainer and each SlideContainer — so slides come out in order /// from the live records, ignoring stale copies left by incremental saves. -/// Malformed records throw. Returns each slide's text boxes in shape order; -/// the boxes' styles accumulate in `context`. -std::vector> +/// Malformed records throw. Returns each slide's shapes in z order; the +/// shapes' styles accumulate in `context`. +std::vector> collect_slides(std::istream ¤t_user, std::istream &document, const abstract::ReadableFilesystem &files, ElementRegistry ®istry, StyleContext &context) { @@ -818,10 +819,10 @@ collect_slides(std::istream ¤t_user, std::istream &document, read_slide_list_text(document, *slide_list, context); // Each SlidePersistAtom references a SlideContainer by persist id (which the - // spec requires the directory to resolve); read its text boxes, passing the - // slide's outline texts so OutlineTextRefAtom boxes can be resolved. + // spec requires the directory to resolve); read its shapes, passing the + // slide's outline texts so OutlineTextRefAtom shapes can be resolved. static const std::vector no_outline_texts; - std::vector> slides; + std::vector> slides; slides.reserve(slide_list_text.persist_ids.size()); for (const std::uint32_t persist_id : slide_list_text.persist_ids) { const auto it = directory.find(persist_id); @@ -836,7 +837,7 @@ collect_slides(std::istream ¤t_user, std::istream &document, ot != slide_list_text.outline_texts.end() ? ot->second : no_outline_texts; slides.push_back( - read_slide_text_boxes(document, slide_header, outline_texts, context)); + read_slide_shapes(document, slide_header, outline_texts, context)); } // Resolve picture references against the BLIP store and the "Pictures" @@ -844,13 +845,13 @@ collect_slides(std::istream ¤t_user, std::istream &document, const auto pictures_file = files.open(AbsPath("/Pictures")); const auto pictures_stream = pictures_file != nullptr ? pictures_file->stream() : nullptr; - for (std::vector &boxes : slides) { - for (TextBox &box : boxes) { - if (!box.blip_ref.has_value() || *box.blip_ref == 0 || - *box.blip_ref > blip_store.size()) { + for (std::vector &shapes : slides) { + for (Shape &shape : shapes) { + if (!shape.blip_ref.has_value() || *shape.blip_ref == 0 || + *shape.blip_ref > blip_store.size()) { continue; } - BlipSlot &slot = blip_store[*box.blip_ref - 1]; + BlipSlot &slot = blip_store[*shape.blip_ref - 1]; if (slot.data.empty() && slot.fo_delay != 0xFFFFFFFF && pictures_stream != nullptr) { pictures_stream->clear(); @@ -858,26 +859,26 @@ collect_slides(std::istream ¤t_user, std::istream &document, slot.data = read_blip_record(*pictures_stream); slot.fo_delay = 0xFFFFFFFF; // resolved (possibly to unsupported/empty) } - box.image = slot.data; - if (!box.image.empty()) { + shape.image = slot.data; + if (!shape.image.empty()) { // Only JPEG and PNG BLIPs are modelled; tell them apart by magic. - const bool is_png = box.image.starts_with("\x89PNG"); - box.image_href = "Pictures/" + std::to_string(*box.blip_ref) + - (is_png ? ".png" : ".jpg"); + const bool is_png = shape.image.starts_with("\x89PNG"); + shape.image_href = "Pictures/" + std::to_string(*shape.blip_ref) + + (is_png ? ".png" : ".jpg"); } } // Background shapes cover the whole slide (they carry no anchor of their // own) and must render below the other shapes. - for (TextBox &box : boxes) { - if (box.is_background && !box.anchor.has_value()) { + for (Shape &shape : shapes) { + if (shape.is_background && !shape.anchor.has_value()) { const auto size = registry.slide_size().value_or( std::pair{5760, 4320}); - box.anchor = Anchor{0, 0, size.first, size.second}; + shape.anchor = Anchor{0, 0, size.first, size.second}; } } std::ranges::stable_partition( - boxes, [](const TextBox &box) { return box.is_background; }); + shapes, [](const Shape &shape) { return shape.is_background; }); } return slides; } @@ -908,24 +909,24 @@ presentation::parse_tree(ElementRegistry ®istry, const auto current_user_stream = current_user_file->stream(); StyleContext context; - for (std::vector &boxes : collect_slides( + for (std::vector &shapes : collect_slides( *current_user_stream, *document_stream, files, registry, context)) { auto [slide_id, _] = registry.create_element(ElementType::slide); registry.append_child(root_id, slide_id); - // One frame per shape; a picture and/or the box's paragraphs hang off + // One frame per shape; a picture and/or the shape's paragraphs hang off // the frame. - for (TextBox &box : boxes) { + for (Shape &shape : shapes) { auto [frame_id, frame_element, frame] = registry.create_frame_element(); - frame.anchor = box.anchor; + frame.anchor = shape.anchor; registry.append_child(slide_id, frame_id); - if (!box.image.empty()) { + if (!shape.image.empty()) { auto [image_id, image_element, image] = registry.create_image_element(); - image.data = std::move(box.image); - image.href = std::move(box.image_href); + image.data = std::move(shape.image); + image.href = std::move(shape.image_href); registry.append_child(frame_id, image_id); } - build_paragraphs(registry, frame_id, box.text); + build_paragraphs(registry, frame_id, shape.text); } } diff --git a/src/odr/internal/oldms/presentation/ppt_structs.hpp b/src/odr/internal/oldms/presentation/ppt_structs.hpp index 0fd4956a..3693b5f8 100644 --- a/src/odr/internal/oldms/presentation/ppt_structs.hpp +++ b/src/odr/internal/oldms/presentation/ppt_structs.hpp @@ -59,6 +59,17 @@ constexpr std::uint16_t office_art_property_fill_blip = 0x0186; /// ([MS-ODRAW] 2.2.40). constexpr std::uint32_t office_art_fsp_background = 0x400; +/// recInstance of a JPEG/PNG BLIP record: the color space and whether one or +/// two 16-byte rgbUids precede the picture data ([MS-ODRAW] 2.2.27/2.2.28). +enum BlipInstance : std::uint16_t { + BlipInstance_JpegRgbOneUid = 0x46A, + BlipInstance_JpegRgbTwoUids = 0x46B, + BlipInstance_PngOneUid = 0x6E0, + BlipInstance_PngTwoUids = 0x6E1, + BlipInstance_JpegCmykOneUid = 0x6E2, + BlipInstance_JpegCmykTwoUids = 0x6E3, +}; + /// recInstance of a RT_SlideListWithText container: which list it is (slides / /// master / notes) inside the DocumentContainer ([MS-PPT] 2.4.14.1/3/6). enum SlideListInstance : std::uint16_t { @@ -140,6 +151,10 @@ static_assert(sizeof(OfficeArtFopte) == 6, "OfficeArtFopte should be 6 bytes"); #pragma pack(pop) +/// Slide coordinates — anchors and the DocumentAtom's slide size — are in +/// master units of 1/576 inch ([MS-PPT] 2.12.7). +constexpr double master_units_per_inch = 576.0; + /// A shape's position/size from an OfficeArtClientAnchor ([MS-PPT] 2.12.7/8), /// in master units (1/576 inch) of the slide's coordinate system. struct Anchor { From 95c1c1c7debe44e0b18360f3f1958fae6fdf5ef6 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 25 Jul 2026 14:08:20 +0200 Subject: [PATCH 5/5] ref update --- test/data/reference-output/odr-public | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index 274882da..9acd2e18 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit 274882da5e57e669db13d149be376752d76f5b8d +Subproject commit 9acd2e188ab01ce9f0bcec375285cecb848a28c9