From 7a2bd37c0f71c454b875de927e967ab0cee4628b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 20:08:03 +0200 Subject: [PATCH] feat(ppt): per-run character formatting from StyleTextPropAtom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the StyleTextPropAtom ([MS-PPT] 2.9.44) that follows each text atom — in the drawing's ClientTextbox as well as the outline (SlideListWithText) — and split slide text into styled spans carrying bold, italic, underline, font size, font name (FontCollection) and explicit sRGB colors. Replaces the flat 11pt placeholder; unformatted text defaults to 18pt, approximating the unread master text styles. Paragraph-level runs are skipped field-by-field via their PFMasks; paragraphs store their first run's style so empty paragraphs keep their height. Scheme-indexed colors and master-inherited formatting remain open (documented in AGENTS.md). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9Z3kyUaHNkJacAEggevHH --- src/odr/internal/oldms/presentation/AGENTS.md | 54 ++- .../oldms/presentation/ppt_document.cpp | 32 +- .../presentation/ppt_element_registry.cpp | 23 + .../presentation/ppt_element_registry.hpp | 13 + .../internal/oldms/presentation/ppt_io.cpp | 206 +++++++++ .../internal/oldms/presentation/ppt_io.hpp | 34 ++ .../oldms/presentation/ppt_parser.cpp | 394 +++++++++++++++--- .../oldms/presentation/ppt_structs.hpp | 4 + test/src/internal/oldms/oldms_test.cpp | 78 ++++ 9 files changed, 750 insertions(+), 88 deletions(-) diff --git a/src/odr/internal/oldms/presentation/AGENTS.md b/src/odr/internal/oldms/presentation/AGENTS.md index 1c77c036..494fee96 100644 --- a/src/odr/internal/oldms/presentation/AGENTS.md +++ b/src/odr/internal/oldms/presentation/AGENTS.md @@ -4,9 +4,11 @@ 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, 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 +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 +19,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 @@ -91,8 +94,28 @@ 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. +**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. + +**Slide size hardcoded 10"×7.5"** (`slide_page_layout`) — open work. `Document::is_editable()` → `false`; `save` throws. **Endianness.** Host byte order / LSB-first bit-fields assumed; shared `oldms/` @@ -107,9 +130,13 @@ 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, plus style assertions (44pt Arial black title; underlined blue + 32pt link text). Fixture-commit / reference-HTML wiring / `OutlineTextRefAtom` fixture are open (§2 below). @@ -200,6 +227,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. diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index a28d70e8..664950d4 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -58,6 +58,7 @@ class ElementAdapter final : public abstract::ElementAdapter, public abstract::FrameAdapter, public abstract::LineBreakAdapter, public abstract::ParagraphAdapter, + public abstract::SpanAdapter, public abstract::TextAdapter { public: ElementAdapter(const Document &document, ElementRegistry ®istry) @@ -127,6 +128,10 @@ 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; @@ -191,12 +196,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 +217,18 @@ 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 {}; } 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..0539f938 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,8 @@ void ElementRegistry::clear() noexcept { m_elements.clear(); m_texts.clear(); m_frames.clear(); + m_styles.clear(); + m_font_names.clear(); } [[nodiscard]] std::size_t ElementRegistry::size() const noexcept { @@ -97,6 +100,26 @@ void ElementRegistry::append_child(const ElementIdentifier parent_id, element_at(parent_id).last_child_id = child_id; } +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"); diff --git a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp index fba86a87..4c0ab6ac 100644 --- a/src/odr/internal/oldms/presentation/ppt_element_registry.hpp +++ b/src/odr/internal/oldms/presentation/ppt_element_registry.hpp @@ -2,9 +2,11 @@ #include #include +#include #include +#include #include #include #include @@ -51,10 +53,21 @@ class ElementRegistry final { 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; + private: std::vector m_elements; std::unordered_map m_texts; std::unordered_map m_frames; + std::unordered_map m_styles; + /// 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; 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..11f073dd 100644 --- a/src/odr/internal/oldms/presentation/ppt_parser.cpp +++ b/src/odr/internal/oldms/presentation/ppt_parser.cpp @@ -1,18 +1,23 @@ #include +#include + #include #include #include #include #include #include +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -139,100 +144,277 @@ std::string clean_text(const std::string &in) { return out; } -/// A single text box on a slide: the text it holds and, optionally, the +/// 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 on a slide: its styled text and, optionally, the /// position and size from its OfficeArtClientAnchor. struct TextBox final { std::optional anchor; - std::string text; + StyledText text; }; -/// 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.insert(slide_text.end(), std::make_move_iterator(block.begin()), + std::make_move_iterator(block.end())); +} + +/// 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)); } - slide_text += text; } -/// 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 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()) { @@ -240,7 +422,7 @@ TextBox read_shape(std::istream &in, const RecordHeader &shape, box.anchor = read_client_anchor(in, child->recLen); children.consume(child->recLen); } 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); } // Other children (shapeProp, FOPT, clientData, …): skipped by the cursor. @@ -253,7 +435,8 @@ 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) { + const std::vector &outline_texts, + const StyleContext &context) { // SlideContainer → DrawingContainer → OfficeArtDgContainer → // OfficeArtSpgrContainer (all mandatory), then iterate the shapes. const RecordHeader drawing = require_child(in, slide, RT_Drawing); @@ -266,7 +449,7 @@ read_slide_text_boxes(std::istream &in, const RecordHeader &slide, if (shape->recType != RT_OfficeArtSpContainer) { continue; // not a shape; the cursor skips it } - TextBox box = read_shape(in, *shape, outline_texts); + TextBox box = read_shape(in, *shape, outline_texts, context); shapes.consume(shape->recLen); if (!box.text.empty()) { boxes.push_back(std::move(box)); @@ -301,50 +484,113 @@ 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 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 @@ -352,7 +598,8 @@ SlideListText read_slide_list_text(std::istream &in, /// from the live records, ignoring stale copies left by incremental saves. /// Malformed records throw. Returns each slide's text boxes in shape order. std::vector> collect_slides(std::istream ¤t_user, - std::istream &document) { + std::istream &document, + 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 +645,21 @@ 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); + } + 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 +670,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 +687,11 @@ 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)); } return slides; } @@ -461,7 +721,7 @@ presentation::parse_tree(ElementRegistry ®istry, const auto current_user_stream = current_user_file->stream(); for (const std::vector &boxes : - collect_slides(*current_user_stream, *document_stream)) { + collect_slides(*current_user_stream, *document_stream, registry)) { auto [slide_id, _] = registry.create_element(ElementType::slide); registry.append_child(root_id, slide_id); diff --git a/src/odr/internal/oldms/presentation/ppt_structs.hpp b/src/odr/internal/oldms/presentation/ppt_structs.hpp index 264c274e..a41a3098 100644 --- a/src/odr/internal/oldms/presentation/ppt_structs.hpp +++ b/src/odr/internal/oldms/presentation/ppt_structs.hpp @@ -14,12 +14,16 @@ enum RecordType : std::uint16_t { RT_DocumentContainer = 0x03E8, //< top-level document 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 diff --git a/test/src/internal/oldms/oldms_test.cpp b/test/src/internal/oldms/oldms_test.cpp index f414dcbe..8bae34b5 100644 --- a/test/src/internal/oldms/oldms_test.cpp +++ b/test/src/internal/oldms/oldms_test.cpp @@ -8,6 +8,9 @@ #include #include +#include + +#include #include #include @@ -259,6 +262,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); @@ -335,4 +389,28 @@ TEST(OldMs, ppt_style_various) { 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); }