From 43863a237fd451d54fcbde071374b0963f661b5e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 20:27:26 +0200 Subject: [PATCH 1/2] 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 | 17 ++ .../oldms/presentation/ppt_parser.cpp | 261 ++++++++++++++++-- .../oldms/presentation/ppt_structs.hpp | 54 +++- test/src/internal/oldms/oldms_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 494fee96..8d3f5669 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 / @@ -87,12 +88,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) @@ -115,7 +117,19 @@ 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/` @@ -135,8 +149,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). @@ -201,9 +216,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** @@ -214,9 +226,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). @@ -234,5 +247,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 664950d4..602ade2a 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 @@ -59,7 +61,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) : m_document(&document), m_registry(®istry) {} @@ -136,10 +139,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"), @@ -151,9 +167,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( @@ -221,6 +245,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 0539f938..1854550e 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.cpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.cpp @@ -9,8 +9,10 @@ void ElementRegistry::clear() noexcept { m_elements.clear(); m_texts.clear(); m_frames.clear(); + m_images.clear(); m_styles.clear(); m_font_names.clear(); + m_slide_size.reset(); } [[nodiscard]] std::size_t ElementRegistry::size() const noexcept { @@ -41,6 +43,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); @@ -77,6 +87,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); @@ -100,6 +122,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; +} + const char *ElementRegistry::intern_font_name(const std::string &name) { if (const auto it = std::ranges::find(m_font_names, name); it != m_font_names.end()) { @@ -144,4 +176,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 4c0ab6ac..e21f416e 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp @@ -6,11 +6,13 @@ #include +#include #include #include #include #include #include +#include #include namespace odr::internal::oldms::presentation { @@ -35,6 +37,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; @@ -42,14 +48,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); @@ -61,17 +70,25 @@ class ElementRegistry final { void set_element_style(ElementIdentifier id, TextStyle style); [[nodiscard]] const TextStyle *element_style(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_styles; + std::optional> m_slide_size; /// Deque: elements never move, so interned `c_str()`s stay valid. std::deque m_font_names; 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 11f073dd..1efb7413 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -263,11 +264,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`. @@ -421,11 +430,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; } @@ -437,23 +472,43 @@ std::vector read_slide_text_boxes(std::istream &in, const RecordHeader &slide, const std::vector &outline_texts, const 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; } @@ -548,6 +603,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 and /// interned in the registry. Stream at the DocumentContainer body. @@ -597,9 +749,10 @@ std::vector read_font_collection(std::istream &in, /// 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. -std::vector> collect_slides(std::istream ¤t_user, - std::istream &document, - ElementRegistry ®istry) { +std::vector> +collect_slides(std::istream ¤t_user, std::istream &document, + const abstract::ReadableFilesystem &files, + ElementRegistry ®istry) { // 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) { @@ -658,6 +811,31 @@ std::vector> collect_slides(std::istream ¤t_user, context.fonts = read_font_collection(document, doc_header, registry); } + // 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); @@ -693,6 +871,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; } @@ -720,16 +933,22 @@ presentation::parse_tree(ElementRegistry ®istry, const auto document_stream = document_file->stream(); const auto current_user_stream = current_user_file->stream(); - for (const std::vector &boxes : - collect_slides(*current_user_stream, *document_stream, registry)) { + for (std::vector &boxes : collect_slides( + *current_user_stream, *document_stream, files, registry)) { 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/oldms_test.cpp b/test/src/internal/oldms/oldms_test.cpp index 8bae34b5..e4cb0b3c 100644 --- a/test/src/internal/oldms/oldms_test.cpp +++ b/test/src/internal/oldms/oldms_test.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -375,15 +376,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 7fb049006a84d08ba377e055be04667c5b98f15a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 21:28:22 +0200 Subject: [PATCH 2/2] 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/oldms_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 602ade2a..8958b1e8 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -254,9 +254,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 e21f416e..b34c2ff9 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp @@ -39,6 +39,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 1efb7413..729650e9 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -275,6 +275,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}; }; @@ -892,6 +895,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 @@ -947,6 +956,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/oldms_test.cpp b/test/src/internal/oldms/oldms_test.cpp index e4cb0b3c..debbc0e8 100644 --- a/test/src/internal/oldms/oldms_test.cpp +++ b/test/src/internal/oldms/oldms_test.cpp @@ -404,6 +404,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");