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..8b4d99b8 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,25 @@ 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, 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 / master_units_per_inch, DynamicUnit("in")), + .height = + Measure(size->second / master_units_per_inch, DynamicUnit("in")), + .print_orientation = {}, + .margin = {}, + }; + } return { .width = Measure("10in"), .height = Measure("7.5in"), @@ -159,9 +177,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 +255,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(const ElementIdentifier element_id) const override { + return m_registry->image_element_at(element_id).href; + } + private: /// The character style stored for a paragraph or span element. [[nodiscard]] TextStyle @@ -237,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, @@ -248,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_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..590b4c1f 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,11 @@ class ElementRegistry final { std::optional anchor; }; + 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; [[nodiscard]] std::size_t size() const noexcept; @@ -41,14 +47,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 +66,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..1328ce56 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,20 +231,31 @@ 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. -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 + /// into the BLIP store. + 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}; }; -/// 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) { @@ -255,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( @@ -327,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, @@ -379,50 +391,95 @@ 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)); + 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 + // 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 || + (property.opid == office_art_property_fill_blip && + !shape.blip_ref.has_value())) { + shape.blip_ref = property.op; + } + } } - // Other children (shapeProp, FOPT, clientData, …): skipped by the cursor. + // 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) { - // SlideContainer → DrawingContainer → OfficeArtDgContainer → - // OfficeArtSpgrContainer (all mandatory), then iterate the shapes. +/// 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 + // 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 + std::vector shapes; + const auto keep = [&shapes](Shape shape) { + if (!shape.text.empty() || shape.blip_ref.has_value()) { + shapes.push_back(std::move(shape)); } - TextBox box = read_shape(in, *shape, outline_texts, context); - shapes.consume(shape->recLen); - if (!box.text.empty()) { - 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 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)); + group.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; + return shapes; } /// Maps a persist object identifier to its offset in the PowerPoint Document @@ -515,6 +572,105 @@ 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); + + 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"); + } + 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 std::uint32_t prefix = blip_prefix_size(*child); + 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. @@ -561,11 +717,12 @@ 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> collect_slides(std::istream ¤t_user, - std::istream &document, - StyleContext &context) { +/// 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) { // 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 +779,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); @@ -637,10 +819,10 @@ std::vector> collect_slides(std::istream ¤t_user, 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); @@ -655,7 +837,48 @@ std::vector> collect_slides(std::istream ¤t_user, 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" + // (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 &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[*shape.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) + } + shape.image = slot.data; + if (!shape.image.empty()) { + // Only JPEG and PNG BLIPs are modelled; tell them apart by magic. + 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 (Shape &shape : shapes) { + if (shape.is_background && !shape.anchor.has_value()) { + const auto size = registry.slide_size().value_or( + std::pair{5760, 4320}); + shape.anchor = Anchor{0, 0, size.first, size.second}; + } + } + std::ranges::stable_partition( + shapes, [](const Shape &shape) { return shape.is_background; }); } return slides; } @@ -686,17 +909,24 @@ 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 &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 text box; the box's paragraphs hang off the frame. - for (const TextBox &box : boxes) { + // One frame per shape; a picture and/or the shape's paragraphs hang off + // the frame. + 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); - build_paragraphs(registry, frame_id, box.text); + if (!shape.image.empty()) { + auto [image_id, image_element, image] = registry.create_image_element(); + 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, shape.text); } } diff --git a/src/odr/internal/oldms/presentation/ppt_structs.hpp b/src/odr/internal/oldms/presentation/ppt_structs.hpp index a41a3098..3693b5f8 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,12 +33,41 @@ 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 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 / @@ -90,8 +121,40 @@ 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) +/// 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 { 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 diff --git a/test/src/internal/oldms/ppt_test.cpp b/test/src/internal/oldms/ppt_test.cpp index 594ea24d..90331f1c 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,40 @@ 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"); + // 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"); + // 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");