diff --git a/src/odr/internal/oldms/presentation/AGENTS.md b/src/odr/internal/oldms/presentation/AGENTS.md index 1c77c036..8d3f5669 100644 --- a/src/odr/internal/oldms/presentation/AGENTS.md +++ b/src/odr/internal/oldms/presentation/AGENTS.md @@ -4,9 +4,12 @@ The **why** and the roadmap; what is concretely done lives in the code. Shared `oldms/` conventions are in [`../AGENTS.md`](../AGENTS.md). **Scope.** Extract the **visible text of each slide, positioned in its text -boxes**, through the abstract model so the generic HTML renderer lays each slide -out as positioned frames. No character/paragraph styles, master/notes pages, -images, charts, tables, or animations. +boxes**, plus **direct character formatting** (font, size, bold, italic, +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 / Escher drawing records) + `[MS-CFB]` container. Section numbers cited inline in @@ -17,13 +20,14 @@ code and in the drawing-tree map below. | File (`oldms/presentation/`) | Role | |---|---| | `ppt_structs.hpp` | `#pragma pack(1)` PODs (`RecordHeader`, atom bodies, `Anchor`) + `static_assert` sizes + `RecordType` / `SlideListInstance` enums | -| `ppt_io.{hpp,cpp}` | `read(...)` helpers over `std::istream` | +| `ppt_io.{hpp,cpp}` | `read(...)` helpers over `std::istream` + `parse_style_text_prop_atom` (StyleTextPropAtom → `TextCFRun`s) | | `ppt_parser.{hpp,cpp}` | `parse_tree(registry, files)` → walks the stream, builds the element tree | -| `ppt_element_registry.{hpp,cpp}` | Flat element store + text & frame side-payloads | +| `ppt_element_registry.{hpp,cpp}` | Flat element store + text & frame side-payloads + per-element `TextStyle` map + font-name intern store | | `ppt_document.{hpp,cpp}` | `internal::Document` subclass + the `ElementAdapter` | -Element tree shape: `root → slide → frame (one per text box) → paragraph (split on -0x0D) → text / line_break (0x0B)`. +Element tree shape: `root → slide → frame (one per text box) → paragraph (split +on 0x0D) → span (one per formatting run) → text`, with `line_break` (0x0B) +elements between spans. ## Design decisions @@ -84,15 +88,48 @@ 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. - -**Slide size hardcoded 10"×7.5"** (`slide_page_layout`) and the `font_size = 11pt` -in the adapters is a placeholder hack (same as `.doc`/`.xls`) — both open work. +**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) +that most closely follows it; its character runs (`TextCFRun`, counts in +UTF-16 units covering the text plus one implicit final paragraph mark) split +the text into spans. Each span and paragraph stores a resolved `TextStyle` in +a registry side-map (paragraphs keep their first run's style for +empty-paragraph height). The non-obvious bits: +- The **paragraph-level runs precede the character runs** in the atom and are + mask-skipped field by field (`TextPFException`, incl. the variable + `tabStops`). +- **Font names** come from the `FontCollection` (0x07D5, inside + `RT_Environment` 0x03F2), indexed by each `FontEntityAtom`'s `recInstance`, + interned in the registry (`TextStyle::font_name` is a `const char *`). +- **Colors** are `ColorIndexStruct`s: only explicit sRGB values (index `0xFE`) + are used; **scheme indexes are left unset** (they need the slide's color + scheme — open work). +- Text without a `StyleTextPropAtom`, or characters past the last run, get a + **default style of 18pt** — an approximation of the unread master text + styles (`TxMasterStyleAtom`, open work). This replaces the old flat `11pt` + placeholder. + +**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/` @@ -107,9 +144,14 @@ break (split like `doc_parser`); `0x09` tab kept; other controls dropped ## Tests +- `ppt_parse_style_text_prop_atom` — inline bytes: PF-run skipping (incl. + mask-dependent fields), CFStyle bold/italic, fontRef/size, explicit sRGB + color. - `ppt_empty` (`empty.ppt`): 1 slide. -- `ppt_slides` (`slides.ppt`): 2 slides, 2 positioned frames each (all `at_page` - with `x/y/width/height`), distinct positions, exact per-box text. +- `ppt_style_various` (`style-various-1.ppt`): 8 slides, positioned frames, + 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). @@ -174,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** @@ -187,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). @@ -200,7 +240,11 @@ and is independent: unexercised — all current `.ppt` files are LibreOffice-authored (empty outline). A PowerPoint-authored file using the outline indirection is needed. Pairs with §2.3. +- **2.5a Master-inherited formatting.** Placeholder text without direct + formatting falls back to the 18pt default instead of the master's + `TxMasterStyleAtom` styles; scheme-indexed colors (`ColorIndexStruct` + index < 0xFE) are dropped for the same reason. Read the main master's text + 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 a28d70e8..8958b1e8 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 @@ -58,7 +60,9 @@ class ElementAdapter final : public abstract::ElementAdapter, public abstract::FrameAdapter, public abstract::LineBreakAdapter, public abstract::ParagraphAdapter, - public abstract::TextAdapter { + public abstract::SpanAdapter, + public abstract::TextAdapter, + public abstract::ImageAdapter { public: ElementAdapter(const Document &document, ElementRegistry ®istry) : m_document(&document), m_registry(®istry) {} @@ -127,14 +131,31 @@ class ElementAdapter final : public abstract::ElementAdapter, paragraph_adapter(const ElementIdentifier element_id) const override { return element_type(element_id) == ElementType::paragraph ? this : nullptr; } + [[nodiscard]] const SpanAdapter * + span_adapter(const ElementIdentifier element_id) const override { + return element_type(element_id) == ElementType::span ? this : nullptr; + } [[nodiscard]] const TextAdapter * 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"), @@ -146,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( @@ -191,12 +220,14 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return {}; // TODO } - [[nodiscard]] TextStyle paragraph_text_style( - [[maybe_unused]] const ElementIdentifier element_id) const override { - // Set a font size so empty paragraphs still have height. - TextStyle style{}; - style.font_size = Measure("11pt"); - return style; + [[nodiscard]] TextStyle + paragraph_text_style(const ElementIdentifier element_id) const override { + return stored_style(element_id); + } + + [[nodiscard]] TextStyle + span_style(const ElementIdentifier element_id) const override { + return stored_style(element_id); } [[nodiscard]] std::string @@ -210,13 +241,32 @@ class ElementAdapter final : public abstract::ElementAdapter, } [[nodiscard]] TextStyle text_style( [[maybe_unused]] const ElementIdentifier element_id) const override { - // Set a font size so the text is not invisible. - TextStyle style{}; - style.font_size = Measure("11pt"); - return style; + // The enclosing span carries the character style. + 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 + stored_style(const ElementIdentifier element_id) const { + const TextStyle *style = m_registry->element_style(element_id); + return style != nullptr ? *style : TextStyle{}; + } + // 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. template diff --git a/src/odr/internal/oldms/presentation/ppt_element_registry.cpp b/src/odr/internal/oldms/presentation/ppt_element_registry.cpp index 9223e4a0..1854550e 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.cpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.cpp @@ -1,5 +1,6 @@ #include +#include #include namespace odr::internal::oldms::presentation { @@ -8,6 +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 { @@ -38,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); @@ -74,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); @@ -97,6 +122,36 @@ 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()) { + return it->c_str(); + } + return m_font_names.emplace_back(name).c_str(); +} + +void ElementRegistry::set_element_style(const ElementIdentifier id, + TextStyle style) { + check_element_id(id); + m_styles[id] = std::move(style); +} + +const TextStyle * +ElementRegistry::element_style(const ElementIdentifier id) const { + const auto it = m_styles.find(id); + return it != m_styles.end() ? &it->second : nullptr; +} + void ElementRegistry::check_element_id(const ElementIdentifier id) const { if (id == null_element_id) { throw std::out_of_range("ElementRegistry::check_id: null identifier"); @@ -121,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 fba86a87..b34c2ff9 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp @@ -2,13 +2,17 @@ #include #include +#include #include +#include +#include #include #include #include #include +#include #include namespace odr::internal::oldms::presentation { @@ -33,6 +37,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; @@ -40,25 +49,47 @@ 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); + /// Stores a font name and returns a pointer that stays valid for the + /// registry's lifetime (`TextStyle::font_name` is a `const char *`). + const char *intern_font_name(const std::string &name); + + /// Character style of a span or paragraph element. + 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_io.cpp b/src/odr/internal/oldms/presentation/ppt_io.cpp index c402df20..8b113794 100644 --- a/src/odr/internal/oldms/presentation/ppt_io.cpp +++ b/src/odr/internal/oldms/presentation/ppt_io.cpp @@ -3,10 +3,163 @@ #include #include +#include #include #include #include +namespace odr::internal::oldms::presentation { + +namespace { + +/// Bounds-checked reader over an in-memory record body. +class BodyCursor final { +public: + explicit BodyCursor(const std::string_view body) : m_body(body) {} + + [[nodiscard]] std::size_t remaining() const { return m_body.size() - m_at; } + + template T read() { + if (remaining() < sizeof(T)) { + throw std::runtime_error("ppt: truncated StyleTextPropAtom"); + } + T value; + std::memcpy(&value, m_body.data() + m_at, sizeof(T)); + m_at += sizeof(T); + return value; + } + + void skip(const std::size_t count) { + if (remaining() < count) { + throw std::runtime_error("ppt: truncated StyleTextPropAtom"); + } + m_at += count; + } + +private: + std::string_view m_body; + std::size_t m_at{0}; +}; + +/// Skips a TextPFException ([MS-PPT] 2.9.19): every optional field's size is +/// determined by its PFMasks bit; only tabStops is itself variable. +void skip_text_pf_exception(BodyCursor &cursor) { + const auto masks = cursor.read(); + const auto has = [masks](const int bit) { + return (masks & (1u << bit)) != 0; + }; + + // Field order per the spec: bulletFlags, bulletChar, bulletFontRef, + // bulletSize, bulletColor, textAlignment, lineSpacing, spaceBefore, + // spaceAfter, leftMargin, indent, defaultTabSize, tabStops, fontAlign, + // wrapFlags, textDirection. + if ((masks & 0xF) != 0) { // hasBullet, bulletHasFont/Color/Size + cursor.skip(2); // bulletFlags + } + if (has(7)) { + cursor.skip(2); // bulletChar + } + if (has(4)) { + cursor.skip(2); // bulletFontRef + } + if (has(6)) { + cursor.skip(2); // bulletSize + } + if (has(5)) { + cursor.skip(4); // bulletColor + } + if (has(11)) { + cursor.skip(2); // textAlignment + } + if (has(12)) { + cursor.skip(2); // lineSpacing + } + if (has(13)) { + cursor.skip(2); // spaceBefore + } + if (has(14)) { + cursor.skip(2); // spaceAfter + } + if (has(8)) { + cursor.skip(2); // leftMargin + } + if (has(10)) { + cursor.skip(2); // indent + } + if (has(15)) { + cursor.skip(2); // defaultTabSize + } + if (has(20)) { // tabStops: count + count * TabStop ([MS-PPT] 2.9.23) + const auto count = cursor.read(); + cursor.skip(std::size_t{count} * 4); + } + if (has(16)) { + cursor.skip(2); // fontAlign + } + if ((masks & 0xE0000) != 0) { // charWrap, wordWrap, overflow (bits 17-19) + cursor.skip(2); // wrapFlags + } + if (has(21)) { + cursor.skip(2); // textDirection + } +} + +/// Parses a TextCFException ([MS-PPT] 2.9.14) into a TextCFRun's style fields. +void read_text_cf_exception(BodyCursor &cursor, TextCFRun &run) { + const auto masks = cursor.read(); + const auto has = [masks](const int bit) { + return (masks & (1u << bit)) != 0; + }; + + // fontStyle exists if any style bit (bold 0, italic 1, underline 2, + // shadow 4, fehint 5, kumi 7, emboss 9) or fHasStyle (bits 10-13) is set. + if ((masks & 0x2B7) != 0 || (masks & 0x3C00) != 0) { + const auto font_style = cursor.read(); // CFStyle, 2.9.16 + if (has(0)) { + run.bold = (font_style & 0x1) != 0; + } + if (has(1)) { + run.italic = (font_style & 0x2) != 0; + } + if (has(2)) { + run.underline = (font_style & 0x4) != 0; + } + } + if (has(16)) { + run.font_ref = cursor.read(); + } + if (has(21)) { + cursor.skip(2); // oldEAFontRef + } + if (has(22)) { + cursor.skip(2); // ansiFontRef + } + if (has(23)) { + cursor.skip(2); // symbolFontRef + } + if (has(17)) { + run.font_size = cursor.read(); + } + if (has(18)) { + // ColorIndexStruct ([MS-PPT] 2.12.2): index 0xFE marks an explicit sRGB + // color; scheme indexes would need the slide's color scheme. + const auto red = cursor.read(); + const auto green = cursor.read(); + const auto blue = cursor.read(); + const auto index = cursor.read(); + if (index == 0xFE) { + run.color = Color(red, green, blue); + } + } + if (has(19)) { + cursor.skip(2); // position + } +} + +} // namespace + +} // namespace odr::internal::oldms::presentation + namespace odr::internal::oldms { presentation::RecordHeader presentation::read_record_header(std::istream &in) { @@ -81,4 +234,57 @@ presentation::read_client_anchor(std::istream &in, std::to_string(rec_len)); } +std::u16string presentation::read_raw_text_chars(std::istream &in, + const std::uint32_t rec_len) { + std::u16string buffer; + buffer.resize(rec_len / 2); + in.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(char16_t))); + return buffer; +} + +std::string presentation::read_raw_text_bytes(std::istream &in, + const std::uint32_t rec_len) { + std::string buffer; + buffer.resize(rec_len); + in.read(buffer.data(), static_cast(rec_len)); + buffer.resize(static_cast(in.gcount())); + return buffer; +} + +std::string presentation::decode_text_bytes(const std::string_view bytes) { + std::u16string buffer; + buffer.reserve(bytes.size()); + for (const char c : bytes) { + buffer.push_back(static_cast(c)); + } + return util::string::u16string_to_string(buffer); +} + +std::vector +presentation::parse_style_text_prop_atom(const std::string_view body, + const std::size_t char_count) { + BodyCursor cursor(body); + + // rgTextPFRun: skipped; the counts cover the whole corresponding text. + for (std::size_t covered = 0; + covered < char_count && cursor.remaining() > 0;) { + covered += cursor.read(); // count + cursor.skip(2); // indentLevel + skip_text_pf_exception(cursor); + } + + // rgTextCFRun. + std::vector runs; + for (std::size_t covered = 0; + covered < char_count && cursor.remaining() > 0;) { + TextCFRun run; + run.count = cursor.read(); + read_text_cf_exception(cursor, run); + covered += run.count; + runs.push_back(run); + } + return runs; +} + } // namespace odr::internal::oldms diff --git a/src/odr/internal/oldms/presentation/ppt_io.hpp b/src/odr/internal/oldms/presentation/ppt_io.hpp index f5fea82b..097593b9 100644 --- a/src/odr/internal/oldms/presentation/ppt_io.hpp +++ b/src/odr/internal/oldms/presentation/ppt_io.hpp @@ -1,10 +1,15 @@ #pragma once +#include + #include #include #include +#include #include +#include +#include namespace odr::internal::oldms::presentation { @@ -31,4 +36,33 @@ std::string read_text_bytes(std::istream &in, std::uint32_t rec_len); /// SmallRectStruct (int16), 16 → RectStruct (int32). Throws on any other. Anchor read_client_anchor(std::istream &in, std::uint32_t rec_len); +/// Reads a TextCharsAtom body without decoding (rec_len / 2 UTF-16 units). +std::u16string read_raw_text_chars(std::istream &in, std::uint32_t rec_len); + +/// Reads a TextBytesAtom body without decoding (rec_len characters). +std::string read_raw_text_bytes(std::istream &in, std::uint32_t rec_len); + +/// Decodes TextBytesAtom characters (each byte a code point 0x00-0xFF) to +/// UTF-8. +std::string decode_text_bytes(std::string_view bytes); + +/// One character-formatting run of a StyleTextPropAtom ([MS-PPT] 2.9.46); +/// each field is set only when its CFMasks bit is. +struct TextCFRun final { + std::uint32_t count{0}; //< characters this run covers + std::optional bold; + std::optional italic; + std::optional underline; + std::optional font_ref; //< index into the FontCollection + std::optional font_size; //< points + std::optional color; //< explicit sRGB only; scheme indexes are unset +}; + +/// Parses a StyleTextPropAtom body ([MS-PPT] 2.9.44): the paragraph-level runs +/// are skipped, the character-level runs returned. `char_count` is the +/// corresponding text's length in characters plus one (the runs also cover an +/// implicit final paragraph mark); fewer covered characters end the parse. +std::vector parse_style_text_prop_atom(std::string_view body, + std::size_t char_count); + } // 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 22c9db3f..729650e9 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -1,18 +1,24 @@ #include +#include + #include #include #include #include #include #include +#include +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -139,111 +145,325 @@ std::string clean_text(const std::string &in) { return out; } -/// A single text box on a slide: the text it holds and, optionally, the -/// position and size from its OfficeArtClientAnchor. +/// One run of uniformly formatted text within a text box. +struct StyledRun final { + std::string text; //< UTF-8 + TextStyle style; +}; +using StyledText = std::vector; + +/// What character-formatting resolution needs: the document's font names +/// (interned) and the style of unformatted text. +struct StyleContext final { + std::vector fonts; + TextStyle default_style; +}; + +/// The most recent text atom's raw (undecoded) content, kept until the +/// StyleTextPropAtom that most closely follows it ([MS-PPT] 2.9.44) — or +/// until the next text block shows there is none. +class PendingText final { +public: + void set_chars(std::u16string chars) { + m_chars = std::move(chars); + m_bytes.reset(); + m_set = true; + } + void set_bytes(std::string bytes) { + m_bytes = std::move(bytes); + m_chars.reset(); + m_set = true; + } + void reset() { + m_chars.reset(); + m_bytes.reset(); + m_set = false; + } + + [[nodiscard]] bool has_value() const { return m_set; } + + /// Length in characters (UTF-16 code units / bytes). + [[nodiscard]] std::size_t char_count() const { + return m_chars.has_value() ? m_chars->size() + : (m_bytes.has_value() ? m_bytes->size() : 0); + } + + /// Decodes the characters [begin, end) to UTF-8. + [[nodiscard]] std::string decode(const std::size_t begin, + const std::size_t end) const { + if (m_chars.has_value()) { + return util::string::u16string_to_string( + m_chars->substr(begin, end - begin)); + } + if (m_bytes.has_value()) { + return decode_text_bytes( + std::string_view(*m_bytes).substr(begin, end - begin)); + } + return {}; + } + +private: + std::optional m_chars; + std::optional m_bytes; + bool m_set{false}; +}; + +/// A character run's formatting on top of the default style. +TextStyle resolve_style(const TextCFRun &run, const StyleContext &context) { + TextStyle style = context.default_style; + if (run.bold.has_value()) { + style.font_weight = *run.bold ? FontWeight::bold : FontWeight::normal; + } + if (run.italic.has_value()) { + style.font_style = *run.italic ? FontStyle::italic : FontStyle::normal; + } + if (run.underline.has_value()) { + style.font_underline = *run.underline; + } + if (run.font_size.has_value()) { + style.font_size = + Measure(static_cast(*run.font_size), DynamicUnit("pt")); + } + if (run.color.has_value()) { + style.font_color = *run.color; + } + if (run.font_ref.has_value()) { + if (*run.font_ref >= context.fonts.size() || + context.fonts[*run.font_ref] == nullptr) { + throw std::runtime_error("ppt: font reference out of range"); + } + style.font_name = context.fonts[*run.font_ref]; + } + return style; +} + +/// Splits a pending text at its character-run boundaries; characters past the +/// last run (or all of them, without a StyleTextPropAtom) get the default +/// style. Empty runs are dropped. +StyledText style_pending(const PendingText &pending, + const std::vector &runs, + const StyleContext &context) { + StyledText result; + const std::size_t count = pending.char_count(); + std::size_t at = 0; + for (const TextCFRun &run : runs) { + if (at >= count) { + break; // the runs also cover the implicit final paragraph mark + } + const std::size_t end = std::min(count, at + run.count); + if (std::string text = pending.decode(at, end); !text.empty()) { + result.push_back({std::move(text), resolve_style(run, context)}); + } + at = end; + } + if (at < count) { + if (std::string text = pending.decode(at, count); !text.empty()) { + result.push_back({std::move(text), context.default_style}); + } + } + 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 { std::optional anchor; - std::string text; + 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/line_break/text subtree of one text box under -/// `parent_id`, mirroring the .doc parser's splitting. +/// 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. void build_paragraphs(ElementRegistry ®istry, const ElementIdentifier parent_id, - const std::string &box_text) { - auto paragraphs = - util::string::split(box_text, std::string(1, paragraph_mark)); - // Box text usually ends on a paragraph break; drop the trailing empty. - if (!paragraphs.empty() && paragraphs.back().empty()) { - paragraphs.pop_back(); - } - - for (const auto ¶graph : paragraphs) { - auto [paragraph_id, _] = registry.create_element(ElementType::paragraph); - registry.append_child(parent_id, paragraph_id); - - const auto lines = - util::string::split(paragraph, std::string(1, line_break_mark)); - for (std::size_t line_i = 0; line_i < lines.size(); ++line_i) { - if (line_i > 0) { - auto [line_id, _2] = registry.create_element(ElementType::line_break); - registry.append_child(paragraph_id, line_id); + const StyledText &box_text) { + ElementIdentifier paragraph_id = null_element_id; + + const auto ensure_paragraph = [&](const TextStyle &style) { + if (paragraph_id == null_element_id) { + auto [id, paragraph] = registry.create_element(ElementType::paragraph); + registry.append_child(parent_id, id); + registry.set_element_style(id, style); + paragraph_id = id; + } + }; + + for (const StyledRun &run : box_text) { + std::size_t at = 0; + while (at <= run.text.size()) { + const std::size_t control = run.text.find_first_of("\x0D\x0B", at); + const std::size_t segment_end = + control == std::string::npos ? run.text.size() : control; + + if (std::string cleaned = + clean_text(run.text.substr(at, segment_end - at)); + !cleaned.empty()) { + ensure_paragraph(run.style); + auto [span_id, span] = registry.create_element(ElementType::span); + registry.set_element_style(span_id, run.style); + registry.append_child(paragraph_id, span_id); + + auto [text_id, text_element, text_entry] = + registry.create_text_element(); + text_entry.text = std::move(cleaned); + registry.append_child(span_id, text_id); } - auto [text_id, _3, text_element] = registry.create_text_element(); - text_element.text = clean_text(lines[line_i]); - registry.append_child(paragraph_id, text_id); + if (control == std::string::npos) { + break; + } + if (run.text[control] == paragraph_mark) { + ensure_paragraph(run.style); + paragraph_id = null_element_id; + } else { // line_break_mark + ensure_paragraph(run.style); + auto [line_id, line] = registry.create_element(ElementType::line_break); + registry.append_child(paragraph_id, line_id); + } + at = control + 1; } } } -/// Reads the body of a text atom into a string, depending on its record type. -/// The stream must be positioned at the atom body; `header` is its header. -std::string read_text_atom(std::istream &in, const RecordHeader &header) { - if (header.recType == RT_TextBytesAtom) { - return read_text_bytes(in, header.recLen); - } - return read_text_chars(in, header.recLen); -} - -/// Appends a text atom to a slide's running text, separating consecutive text +/// Appends a text block to a text box's running text, separating consecutive /// blocks (e.g. title vs. body) with a paragraph break. -void append_text(std::string &slide_text, const std::string &text) { - if (text.empty()) { +void append_block(StyledText &slide_text, StyledText block, + const StyleContext &context) { + if (block.empty()) { return; } if (!slide_text.empty()) { - slide_text.push_back(paragraph_mark); + slide_text.push_back( + {std::string(1, paragraph_mark), context.default_style}); } - slide_text += text; + slide_text.insert(slide_text.end(), std::make_move_iterator(block.begin()), + std::make_move_iterator(block.end())); } -/// Recursively concatenates a container's text in stream order. A box holds -/// inline TextChars/TextBytes atoms or an OutlineTextRefAtom indexing the -/// slide's `outline_texts` ([MS-PPT] 2.9.78). Stream at the container body. +/// Reads a text atom's body without decoding into `pending`. The stream must +/// be positioned at the atom body; `header` is its header. +void read_pending_text(std::istream &in, const RecordHeader &header, + PendingText &pending) { + if (header.recType == RT_TextBytesAtom) { + pending.set_bytes(read_raw_text_bytes(in, header.recLen)); + } else { + pending.set_chars(read_raw_text_chars(in, header.recLen)); + } +} + +/// Reads a StyleTextPropAtom body and returns its character runs; the run +/// counts also cover the implicit final paragraph mark, hence the +1. +std::vector read_style_atom(std::istream &in, + const RecordHeader &header, + const PendingText &pending) { + const std::string body = util::stream::read(in, header.recLen); + 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 +/// the slide's `outline_texts` ([MS-PPT] 2.9.78). Stream at the container +/// body. void gather_text(std::istream &in, const RecordHeader &container, - std::string &slide_text, - const std::vector &outline_texts) { + StyledText &slide_text, + const std::vector &outline_texts, + const StyleContext &context) { + PendingText pending; + const auto flush = [&](const std::vector &runs) { + if (pending.has_value()) { + append_block(slide_text, style_pending(pending, runs, context), context); + pending.reset(); + } + }; + ChildCursor children(in, container); while (const std::optional child = children.next()) { if (child->recType == RT_TextCharsAtom || child->recType == RT_TextBytesAtom) { - append_text(slide_text, read_text_atom(in, *child)); + flush({}); + read_pending_text(in, *child, pending); + children.consume(child->recLen); + } else if (child->recType == RT_StyleTextPropAtom) { + const std::vector runs = read_style_atom(in, *child, pending); children.consume(child->recLen); + flush(runs); } else if (child->recType == RT_OutlineTextRefAtom && child->recLen >= sizeof(std::int32_t)) { // Box references this slide's index-th outline-text block; an // out-of-range index is ignored rather than aborting. const auto index = static_cast(read_u32(in)); children.consume(sizeof(std::int32_t)); + flush({}); if (index >= 0 && static_cast(index) < outline_texts.size()) { - append_text(slide_text, outline_texts[index]); + append_block(slide_text, outline_texts[index], context); } } else if (child->is_container()) { - gather_text(in, *child, slide_text, outline_texts); + flush({}); + gather_text(in, *child, slide_text, outline_texts, context); children.consume(child->recLen); } // Other atoms: left unconsumed; the cursor skips their bodies. } + flush({}); } /// 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) { + const std::vector &outline_texts, + const StyleContext &context) { TextBox box; ChildCursor children(in, shape); while (const std::optional child = children.next()) { 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); + 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; } @@ -253,24 +473,45 @@ TextBox read_shape(std::istream &in, const RecordHeader &shape, /// 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) { - // SlideContainer → DrawingContainer → OfficeArtDgContainer → - // OfficeArtSpgrContainer (all mandatory), then iterate the shapes. + const std::vector &outline_texts, + const 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 - } - TextBox box = read_shape(in, *shape, outline_texts); - 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; } @@ -301,58 +542,220 @@ void read_persist_directory(std::istream &in, const RecordHeader &header, } /// A SlideListWithText container's text: slides' persistIdRefs in presentation -/// order, plus per slide (by persistIdRef) the text of each TextHeaderAtom -/// block, indexed by OutlineTextRefAtom ([MS-PPT] 2.4.14.3 / 2.9.78). +/// order, plus per slide (by persistIdRef) the styled text of each +/// TextHeaderAtom block, indexed by OutlineTextRefAtom ([MS-PPT] 2.4.14.3 / +/// 2.9.78). struct SlideListText { std::vector persist_ids; - std::unordered_map> outline_texts; + std::unordered_map> outline_texts; }; /// Walks a SlideListWithText container once. Each SlidePersistAtom starts a /// slide; the following TextHeaderAtoms are its outline-text blocks, each -/// filled by its following TextChars/TextBytes atom. Stream at the container -/// body. +/// filled by its following TextChars/TextBytes atom plus the optional +/// StyleTextPropAtom after it. Stream at the container body. SlideListText read_slide_list_text(std::istream &in, - const RecordHeader &slide_list) { + const RecordHeader &slide_list, + const StyleContext &context) { constexpr std::uint32_t persist_ref_size = sizeof(std::uint32_t); SlideListText result; // Outline texts of the slide currently being read; valid until reassigned at // the next SlidePersistAtom (unordered_map keeps references stable on // insert). - std::vector *current = nullptr; + std::vector *current = nullptr; + PendingText pending; + const auto flush = [&](const std::vector &runs) { + if (pending.has_value()) { + if (current != nullptr && !current->empty()) { + current->back() = style_pending(pending, runs, context); + } + pending.reset(); + } + }; + ChildCursor children(in, slide_list); while (const std::optional child = children.next()) { if (child->recType == RT_SlidePersistAtom && child->recLen >= persist_ref_size) { + flush({}); const std::uint32_t persist_id = read_u32(in); // persistIdRef is first children.consume(persist_ref_size); result.persist_ids.push_back(persist_id); current = &result.outline_texts[persist_id]; } else if (child->recType == RT_TextHeaderAtom) { + flush({}); if (current != nullptr) { current->emplace_back(); // one block per header; text filled in below } } else if (child->recType == RT_TextCharsAtom || child->recType == RT_TextBytesAtom) { + flush({}); if (current != nullptr && !current->empty()) { - current->back() = read_text_atom(in, *child); + read_pending_text(in, *child, pending); children.consume(child->recLen); } + } else if (child->recType == RT_StyleTextPropAtom) { + const std::vector runs = read_style_atom(in, *child, pending); + children.consume(child->recLen); + flush(runs); } - // Everything else (style/meta atoms, …): left for the cursor to skip. + // Everything else (meta atoms, …): left for the cursor to skip. } + flush({}); 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. +std::vector read_font_collection(std::istream &in, + const RecordHeader &document, + ElementRegistry ®istry) { + std::vector fonts; + const std::optional environment = + find_child(in, document, RT_Environment); + if (!environment.has_value()) { + return fonts; + } + const std::optional collection = + find_child(in, *environment, RT_FontCollection); + if (!collection.has_value()) { + return fonts; + } + + ChildCursor entries(in, *collection); + while (const std::optional child = entries.next()) { + if (child->recType != RT_FontEntityAtom) { + continue; + } + // lfFaceName: 32 UTF-16 units, zero-terminated ([MS-PPT] 2.9.10). + if (child->recLen < 64) { + throw std::runtime_error("ppt: truncated FontEntityAtom"); + } + std::u16string name = read_raw_text_chars(in, 64); + entries.consume(64); + if (const std::size_t nul = name.find(u'\0'); nul != std::u16string::npos) { + name.resize(nul); + } + + const std::size_t index = child->recInstance; + if (fonts.size() <= index) { + fonts.resize(index + 1, nullptr); + } + fonts[index] = + registry.intern_font_name(util::string::u16string_to_string(name)); + } + return fonts; +} + /// Resolves the presentation slides via the [MS-PPT] reading algorithm (the /// only spec-defined path): the "Current User" stream points at the newest /// 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. -std::vector> collect_slides(std::istream ¤t_user, - std::istream &document) { +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) { @@ -398,8 +801,46 @@ std::vector> collect_slides(std::istream ¤t_user, if (doc_it == directory.end()) { throw std::runtime_error("ppt: document persist id not in directory"); } + const std::uint32_t doc_offset = doc_it->second; + + // Fonts and unformatted-text default. The default font size approximates + // the (unread) master text styles ([MS-PPT] 2.9.36 TextMasterStyleAtom). + StyleContext context; + context.default_style.font_size = Measure(18.0, DynamicUnit("pt")); + { + document.clear(); + document.seekg(doc_offset); + const RecordHeader doc_header = read_header(document, RT_DocumentContainer); + 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_it->second); + document.seekg(doc_offset); const RecordHeader doc_header = read_header(document, RT_DocumentContainer); // The presentation slide list (recInstance Slides), then its slides' persist @@ -410,12 +851,12 @@ std::vector> collect_slides(std::istream ¤t_user, return {}; // valid document, no presentation slides } const SlideListText slide_list_text = - read_slide_list_text(document, *slide_list); + 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. - static constexpr std::vector no_outline_texts; + static const std::vector no_outline_texts; std::vector> slides; slides.reserve(slide_list_text.persist_ids.size()); for (const std::uint32_t persist_id : slide_list_text.persist_ids) { @@ -427,11 +868,52 @@ std::vector> collect_slides(std::istream ¤t_user, document.seekg(it->second); const RecordHeader slide_header = read_header(document, RT_SlideContainer); const auto ot = slide_list_text.outline_texts.find(persist_id); - const std::vector &outline_texts = + const std::vector &outline_texts = ot != slide_list_text.outline_texts.end() ? ot->second : no_outline_texts; slides.push_back( - read_slide_text_boxes(document, slide_header, outline_texts)); + 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; + 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 + // 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; } @@ -460,16 +942,23 @@ 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)) { + 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); + image.href = std::move(box.image_href); + 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 264c274e..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,14 +13,19 @@ 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 RT_SlidePersistAtom = 0x03F3, //< delimits each slide's text in the list RT_MainMaster = 0x03F8, //< master slide (skipped) + RT_FontCollection = 0x07D5, //< document fonts [MS-PPT] 2.9.8 RT_OutlineTextRefAtom = 0x0F9E, //< box text lives in the slide list, by index RT_TextHeaderAtom = 0x0F9F, //< type of the text block that follows RT_TextCharsAtom = 0x0FA0, //< UTF-16 text (two bytes per code unit) + RT_StyleTextPropAtom = 0x0FA1, //< text formatting runs [MS-PPT] 2.9.44 RT_TextBytesAtom = 0x0FA8, //< "compressed" text: one byte per character + RT_FontEntityAtom = 0x0FB7, //< one font; recInstance = index RT_SlideListWithText = 0x0FF0, //< outline text for all slides RT_UserEditAtom = 0x0FF5, //< a user edit (offsets to dir + previous edit) RT_CurrentUserAtom = 0x0FF6, //< in the "Current User" stream @@ -27,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 { @@ -86,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 f414dcbe..debbc0e8 100644 --- a/test/src/internal/oldms/oldms_test.cpp +++ b/test/src/internal/oldms/oldms_test.cpp @@ -8,9 +8,13 @@ #include #include +#include + +#include #include #include +#include #include #include #include @@ -259,6 +263,57 @@ TEST(OldMs, xls_file_example_5000) { EXPECT_EQ(collect_text(sheet.cell(7, 5000)), "6125"); } +// A StyleTextPropAtom body ([MS-PPT] 2.9.44): the paragraph-level runs are +// skipped (including their mask-dependent fields), the character-level runs +// map masks/CFStyle/fontRef/size/color onto TextCFRun. +TEST(OldMs, ppt_parse_style_text_prop_atom) { + using internal::oldms::presentation::parse_style_text_prop_atom; + using internal::oldms::presentation::TextCFRun; + + const auto u16 = [](std::string &out, const std::uint16_t value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast(value >> 8)); + }; + const auto u32 = [&u16](std::string &out, const std::uint32_t value) { + u16(out, static_cast(value & 0xFFFF)); + u16(out, static_cast(value >> 16)); + }; + + std::string body; + // One paragraph run covering all 12 characters: count, indentLevel, then a + // TextPFException with a bullet flag so a mask-dependent field is skipped. + u32(body, 12); + u16(body, 0); // indentLevel + u32(body, 0x00000001); // PFMasks: hasBullet + u16(body, 0); // bulletFlags + // Character run 1: 6 characters, bold on + italic off. + u32(body, 6); + u32(body, 0x00000003); // CFMasks: bold | italic + u16(body, 0x0001); // CFStyle: bold set, italic clear + // Character run 2: 6 characters, font 1, 32pt, explicit red. + u32(body, 6); + u32(body, 0x00070000); // CFMasks: typeface | size | color + u16(body, 1); // fontRef + u16(body, 32); // fontSize + body += std::string("\xFF\x00\x00\xFE", 4); // ColorIndexStruct: sRGB red + + const std::vector runs = parse_style_text_prop_atom(body, 12); + ASSERT_EQ(runs.size(), 2); + + EXPECT_EQ(runs[0].count, 6); + EXPECT_EQ(runs[0].bold, true); + EXPECT_EQ(runs[0].italic, false); + EXPECT_FALSE(runs[0].underline.has_value()); + EXPECT_FALSE(runs[0].font_size.has_value()); + + EXPECT_EQ(runs[1].count, 6); + EXPECT_FALSE(runs[1].bold.has_value()); + EXPECT_EQ(runs[1].font_ref, 1); + EXPECT_EQ(runs[1].font_size, 32); + ASSERT_TRUE(runs[1].color.has_value()); + EXPECT_EQ(runs[1].color->rgb(), 0xFF0000u); +} + TEST(OldMs, ppt_empty) { const std::unique_ptr logger = Logger::create_stdio("odr-test", LogLevel::verbose); @@ -321,18 +376,67 @@ 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"); EXPECT_EQ(collect_text(slides[0][1]), "subtitle"); EXPECT_NE(slides[0][0].as_frame().y(), slides[0][1].as_frame().y()); + + // Character formatting from the StyleTextPropAtoms: each text run is a + // styled span under its paragraph. + const auto first_span = [](const Element frame) { + const Element span = frame.first_child().first_child(); + EXPECT_EQ(span.type(), ElementType::span); + return span.as_span(); + }; + + // The title is 44pt Arial with an explicit black color. + const TextStyle title = first_span(slides[0][0]).style(); + EXPECT_STREQ(title.font_name, "Arial"); + EXPECT_EQ(title.font_size, Measure("44pt")); + ASSERT_TRUE(title.font_color.has_value()); + EXPECT_EQ(title.font_color->rgb(), 0x000000u); + + // Slide 6 ("title7 - link") carries an underlined blue 32pt hyperlink text. + ASSERT_EQ(slides[6].size(), 2); + EXPECT_EQ(collect_text(slides[6][1]), "https://www.google.at/"); + const TextStyle link = first_span(slides[6][1]).style(); + EXPECT_EQ(link.font_size, Measure("32pt")); + EXPECT_EQ(link.font_underline, true); + ASSERT_TRUE(link.font_color.has_value()); + EXPECT_EQ(link.font_color->rgb(), 0x0000FFu); }