From 1b1e6b1c34512da1a5c1cf10650248b4b0a9f85d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 10 Sep 2026 14:41:09 +0200 Subject: [PATCH] feat(ooxml): edit and save a pptx A `.pptx` was the one document format that could be read and not written. It already kept its slide dom resident and already had `text_set_content`; what it lacked was `save`, the two flags and a capability row. `save` re-serialises the slide parts and copies the rest of the package through as bytes, so a part we never parsed survives untouched. The slides are held by the `r:id` the slide-id list names them by, so the document keeps the other direction as well - path to `r:id` - to know which part it is writing. Every text operation lands here too, over `a:p` and `a:r`. The dom half is `xml::TreeEditor`, the same code odf and ooxml text run; only the tag names differ, and drawingml states them with the `a:` prefix rather than `w:`, which is now the one argument `write_text_nodes` takes. `saving_an_unsavable_format_leaves_no_file` moves to `.doc`, which throws its source away as it parses and so has nothing to write back. Verified with headless LibreOffice: it renders both the run edit and the paragraph split out of the saved deck. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW --- CHANGELOG.md | 4 + docs/design/document-editing.md | 9 +- src/odr/internal/file_type_table.cpp | 4 +- src/odr/internal/ooxml/AGENTS.md | 4 +- src/odr/internal/ooxml/ooxml_util.cpp | 14 +- src/odr/internal/ooxml/ooxml_util.hpp | 9 +- src/odr/internal/ooxml/presentation/AGENTS.md | 17 +- src/odr/internal/ooxml/presentation/README.md | 4 +- .../ooxml_presentation_document.cpp | 169 ++++++++++++------ .../ooxml_presentation_document.hpp | 10 ++ .../ooxml/text/ooxml_text_document.cpp | 7 +- test/src/document_test.cpp | 80 ++++++++- test/src/internal/ooxml/ooxml_util_test.cpp | 2 +- 13 files changed, 254 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4082b4a2..70089f267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A `.pptx` can be edited and saved: every text operation a `.docx` takes, and + a save that writes the slide parts back into the package. Its + `FileTypeCapabilities` now states `edit` and `save`. + - A text document is edited the way a reader expects: typing, replacing and deleting across runs and paragraphs, Enter, Backspace at a paragraph start, and a plain-text paste that opens a paragraph per line. diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index 2f5d2c830..48f85daad 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -6,9 +6,8 @@ mode frame in [`editing.md`](editing.md), and the decisions that are its own. the sheet view. Text documents, presentations and drawings share this one: what it edits is runs and paragraphs, wherever the format puts them. -Status: **the schema and the replay are landing; the browser editor follows.** -This document is written ahead of the code, and each section says what is in -and what is not. +Status: **landed.** The schema, the replay, the browser editor and the pptx +write side are all in the code; each section says what is in and what is not. Scope of this work: an edit that spans several runs, a new paragraph, and a delete or a replace that reaches across both. Inline formatting (bold, italic, @@ -274,7 +273,7 @@ reading forward. |---|---|---| | `.odt`, `.odp`, `.ods`, `.odg` | `odf` | edits and saves today; the new ops land here | | `.docx` | `ooxml/text` | edits and saves today; the new ops land here | -| `.pptx` | `ooxml/presentation` | **read-only today.** It already has `text_set_content` and keeps its slide DOMs resident; what it lacks is `save`, the two flags and the capability row | +| `.pptx` | `ooxml/presentation` | edits and saves; the same operations over `a:p` / `a:r` | | everything else | — | read-only, and says so by decision 7 | `.odp` needs nothing of its own: a presentation is the same odf `Document` as a @@ -295,7 +294,7 @@ Each step is a pull request that builds and tests on its own. 4. **The browser editor.** Owns the DOM mutation, records the ops, and carries undo/redo (decisions 6 and 6b). **Landed.** 5. **pptx writes.** `save`, `is_editable`, `is_savable`, the capability row and - the new hooks over `a:p` / `a:r`. + the new hooks over `a:p` / `a:r`. **Landed.** ## What a split does to what is around it diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index 7c8a9ce2b..0da05d0f6 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -356,7 +356,9 @@ constexpr std::array table{ .open = true, .decrypt = true, .translate_html = true, - .color_scheme = true}}, + .color_scheme = true, + .edit = true, + .save = true}}, Row{FileType::office_open_xml_workbook, "xlsx"sv, xlsx_extensions, diff --git a/src/odr/internal/ooxml/AGENTS.md b/src/odr/internal/ooxml/AGENTS.md index fc4c26b1f..e828eb2e0 100644 --- a/src/odr/internal/ooxml/AGENTS.md +++ b/src/odr/internal/ooxml/AGENTS.md @@ -13,8 +13,8 @@ detection**; each is a self-contained module with its own `AGENTS.md`: | Module | Format | Editable | Agent doc | |---|---|:--:|---| | [`text/`](text/) | `.docx` (Word) | text + save | [text/AGENTS.md](text/AGENTS.md) | -| [`presentation/`](presentation/) | `.pptx` (PowerPoint) | read-only | [presentation/AGENTS.md](presentation/AGENTS.md) | -| [`spreadsheet/`](spreadsheet/) | `.xlsx` (Excel) | read-only | [spreadsheet/AGENTS.md](spreadsheet/AGENTS.md) | +| [`presentation/`](presentation/) | `.pptx` (PowerPoint) | text + save | [presentation/AGENTS.md](presentation/AGENTS.md) | +| [`spreadsheet/`](spreadsheet/) | `.xlsx` (Excel) | cell values + save | [spreadsheet/AGENTS.md](spreadsheet/AGENTS.md) | ## Shared element model (same as ODF) diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 21bee22b0..94c7d027a 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace odr::internal { @@ -39,10 +40,13 @@ std::optional font_style_from_value(const char *value) { xml::NodeSpan ooxml::write_text_nodes(pugi::xml_node parent, const pugi::xml_node before, - const std::string &text) { + const std::string &text, + const std::string_view prefix) { xml::NodeSpan span; + const std::string text_tag = std::string(prefix) + ":t"; + const std::string tab_tag = std::string(prefix) + ":tab"; - const auto insert = [&](const char *name) { + const auto insert = [&](const std::string &name) { const pugi::xml_node node = before ? parent.insert_child_before(name, before) : parent.append_child(name); @@ -56,7 +60,7 @@ xml::NodeSpan ooxml::write_text_nodes(pugi::xml_node parent, // space at either end of a `w:t`, and a lone space is part of a `string` // token - so the text says whether one is there, not the token type. const auto insert_text = [&](const std::string &token) { - pugi::xml_node node = insert("w:t"); + pugi::xml_node node = insert(text_tag); if (token.starts_with(' ') || token.ends_with(' ')) { node.append_attribute("xml:space").set_value("preserve"); } @@ -75,14 +79,14 @@ xml::NodeSpan ooxml::write_text_nodes(pugi::xml_node parent, break; case xml::StringToken::Type::tabs: for (std::size_t i = 0; i < token.string.size(); ++i) { - insert("w:tab"); + insert(tab_tag); } break; } } if (!span.first) { - insert("w:t"); + insert(text_tag); } return span; } diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index aaba5561d..ecd182c07 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -28,10 +28,13 @@ class AbsPath; namespace odr::internal::ooxml { -/// Writes @p text as `w:t` / `w:tab` nodes before @p before, or at the end of -/// @p parent where that is null. Empty text still gets a node to anchor to. +/// Writes @p text as `<@p prefix>:t` / `<@p prefix>:tab` nodes before +/// @p before, or at the end of @p parent where that is null. The prefix is the +/// whole difference between the two markup languages: `w:` and `a:`. Empty +/// text still gets a node to anchor to. xml::NodeSpan write_text_nodes(pugi::xml_node parent, pugi::xml_node before, - const std::string &text); + const std::string &text, + std::string_view prefix); std::optional read_string_attribute(pugi::xml_attribute); std::optional read_color_attribute(pugi::xml_attribute); diff --git a/src/odr/internal/ooxml/presentation/AGENTS.md b/src/odr/internal/ooxml/presentation/AGENTS.md index 100eaa64f..af2878c8b 100644 --- a/src/odr/internal/ooxml/presentation/AGENTS.md +++ b/src/odr/internal/ooxml/presentation/AGENTS.md @@ -2,7 +2,7 @@ The **why**; the feature checklist is in [`README.md`](README.md), the shared OOXML mechanics (registry/adapter pattern, OPC relationships, encryption) in -[`../AGENTS.md`](../AGENTS.md). **Read-only.** +[`../AGENTS.md`](../AGENTS.md). **Reader + text editor + save.** **Scope.** Read `ppt/presentation.xml` and each slide's shape tree into the abstract model so the generic renderer lays out positioned frames. Paragraphs, @@ -92,7 +92,16 @@ Coverage is in [`README.md`](README.md). Foundational gaps, roughly by value: 3. **Table cell styles unresolved.** Tables are wired (grid, spans, covered cells, column widths/row heights), but `a:tcPr` (fills, borders, margins) is not translated. -4. **Read-only.** `text_set_content` machinery exists but is dormant - (`element_is_editable` → false); wiring edit + save (mirroring docx) is a - natural next step. +4. **Editing is text-content and the structure a text edit needs**, the same + surface `.docx` has: set a run's text, put a run beside one, remove an + element, and split, merge or insert a paragraph. The dom half is + `xml::TreeEditor`, shared with odf and ooxml text — only the tag names + differ, and those come from the nodes. No style editing, and no editing of + a shape, a picture or a table's furniture. + + `save` re-serialises the slide parts and copies the rest of the package + through as bytes, so a part we never parsed survives untouched. The slides + are held by their `r:id`, which is how the slide-id list names them, so + `save` keeps the other direction — path to `r:id` — to know which part it + is writing. 5. **Listings, comments/annotations** not modelled. diff --git a/src/odr/internal/ooxml/presentation/README.md b/src/odr/internal/ooxml/presentation/README.md index 2644a34b1..9ed884672 100644 --- a/src/odr/internal/ooxml/presentation/README.md +++ b/src/odr/internal/ooxml/presentation/README.md @@ -23,8 +23,8 @@ Roughly ordered by importance. - [x] slide background (`p:bg`, inherited from layout / master) - [ ] slide master / layout inheritance (beyond theme colors + background) - [x] text extraction -- [ ] edit -- [ ] save +- [x] edit (text, and the structure a text edit needs) +- [x] save ### Content diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp index 44c905ad9..f15e7eb5f 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -1,18 +1,24 @@ #include +#include #include #include #include #include +#include #include #include #include #include #include +#include #include +#include #include +#include +#include namespace odr::internal::ooxml::presentation { @@ -38,6 +44,7 @@ Document::Document(std::shared_ptr files) const std::string id = slide_id.attribute("r:id").value(); AbsPath slide_path = AbsPath("/ppt").join(RelPath(relations.at(id))); m_slides_xml[id] = xml::parse(*m_files, slide_path); + m_slide_ids_by_path[slide_path] = id; slides.push_back(std::move(slide_path)); } @@ -65,6 +72,49 @@ Document::Document(std::shared_ptr files) m_element_adapter = create_element_adapter(*this, m_element_registry); } +bool Document::is_editable() const noexcept { return true; } + +bool Document::is_savable(const bool encrypted) const noexcept { + return !encrypted && !is_decrypted(); +} + +/// Only the slides are re-serialised; everything else is copied through as +/// bytes, so a part we never parsed survives untouched. +void Document::save(std::ostream &out) const { + if (!is_savable(false)) { + throw UnsupportedOperation(); + } + + // TODO this would decrypt/inflate and encrypt/deflate again + zip::ZipArchive archive; + + for (auto walker = m_files->file_walker(AbsPath("/")); !walker->end(); + walker->next()) { + const AbsPath &abs_path = walker->path(); + RelPath rel_path = walker->path().rebase(AbsPath("/")); + if (walker->is_directory()) { + archive.insert_directory(std::end(archive), rel_path); + continue; + } + if (const auto slide = m_slide_ids_by_path.find(abs_path); + slide != std::end(m_slide_ids_by_path)) { + // TODO stream + std::stringstream content; + m_slides_xml.at(slide->second).print(content, "", pugi::format_raw); + auto tmp = std::make_shared(content.str()); + archive.insert_file(std::end(archive), rel_path, tmp); + continue; + } + archive.insert_file(std::end(archive), rel_path, m_files->open(abs_path)); + } + + archive.save(out); +} + +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { + throw UnsupportedOperation(); +} + /// slide → layout → master → theme; the master's `p:clrMap` says which slot /// each name stands for. Layouts are shared, so a layout, its master and its /// theme are read once rather than once per slide. @@ -138,6 +188,9 @@ const ElementRegistry &Document::element_registry() const { namespace { +using TreeEditor = xml::TreeEditor; +using xml::NodeSpan; + using AdapterBase = internal::RegistryElementAdapter< ElementRegistry, abstract::SlideAdapter, abstract::LineBreakAdapter, abstract::ParagraphAdapter, abstract::SpanAdapter, abstract::TextAdapter, @@ -150,6 +203,11 @@ class ElementAdapter final : public AdapterBase { ElementAdapter(const Document &document, ElementRegistry ®istry) : AdapterBase(registry), m_document(&document) {} + [[nodiscard]] bool element_is_editable( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return true; + } + [[nodiscard]] PageLayout slide_page_layout(const ElementIdentifier element_id) const override { return m_document->slide_page_layout(element_id); @@ -203,65 +261,74 @@ class ElementAdapter final : public AdapterBase { ElementRegistry::Text &text_element = m_registry->text_element_at(element_id); - const pugi::xml_node first = get_node(element_id); - const pugi::xml_node last = text_element.last; + const NodeSpan old_span{element.node, text_element.last}; + pugi::xml_node parent = old_span.first.parent(); + const NodeSpan new_span = + write_text_nodes(parent, old_span.first, text, "a"); + + element.node = new_span.first; + text_element.last = new_span.last; + + xml::remove_nodes(old_span); + } + [[nodiscard]] ElementIdentifier + text_insert(const ElementIdentifier element_id, const Placement where, + const std::string &text) const override { + const ElementRegistry::Text &anchor = + m_registry->text_element_at(element_id); + const pugi::xml_node first = get_node(element_id); pugi::xml_node parent = first.parent(); - const pugi::xml_node old_first = first; - const pugi::xml_node old_last = last; - pugi::xml_node new_first = old_first; - pugi::xml_node new_last = last; - - const auto insert_node = [&](const char *node) { - const pugi::xml_node new_node = - parent.insert_child_before(node, old_first); - if (new_first == old_first) { - new_first = new_node; - } - new_last = new_node; - return new_node; - }; - - for (const xml::StringToken &token : xml::tokenize_text(text)) { - switch (token.type) { - case xml::StringToken::Type::none: - break; - case xml::StringToken::Type::string: { - auto text_node = insert_node("a:t"); - text_node.append_child(pugi::xml_node_type::node_pcdata) - .text() - .set(token.string.c_str()); - } break; - case xml::StringToken::Type::spaces: { - auto text_node = insert_node("a:t"); - text_node.append_attribute("xml:space").set_value("preserve"); - text_node.append_child(pugi::xml_node_type::node_pcdata) - .text() - .set(token.string.c_str()); - } break; - case xml::StringToken::Type::tabs: { - for (std::size_t i = 0; i < token.string.size(); ++i) { - insert_node("a:tab"); - } - } break; - } + // a run beside this one in the same `a:r` carries the same `a:rPr` + const pugi::xml_node before = + where == Placement::after ? anchor.last.next_sibling() : first; + + const NodeSpan span = write_text_nodes(parent, before, text, "a"); + const auto &[new_id, unused_element, unused_text] = + m_registry->create_text_element(span.first, span.last); + if (where == Placement::after) { + m_registry->insert_sibling_after(element_id, new_id); + } else { + m_registry->insert_sibling_before(element_id, new_id); } + return new_id; + } - if (new_first == old_first) { - // empty text still needs a live node to anchor the element to, or the - // removal below would leave the registry pointing at freed nodes - insert_node("a:t"); - } + [[nodiscard]] ElementIdentifier + element_append_text(const ElementIdentifier element_id, + const std::string &text) const override { + pugi::xml_node node = get_node(element_id); + const NodeSpan span = write_text_nodes(node, {}, text, "a"); + const auto &[new_id, unused_element, unused_text] = + m_registry->create_text_element(span.first, span.last); + m_registry->append_child(element_id, new_id); + return new_id; + } - element.node = new_first; - text_element.last = new_last; + void element_remove(const ElementIdentifier element_id) const override { + TreeEditor(*m_registry).remove(element_id); + } + + [[nodiscard]] ElementIdentifier + paragraph_split(const ElementIdentifier element_id, + const ElementIdentifier after_id) const override { + return TreeEditor(*m_registry).split(element_id, after_id); + } - for (pugi::xml_node node = old_first; node != old_last.next_sibling();) { - const pugi::xml_node next = node.next_sibling(); - parent.remove_child(node); - node = next; + void paragraph_merge_next(const ElementIdentifier element_id) const override { + const ElementIdentifier next_id = element_next_sibling(element_id); + if (next_id == null_element_id || + element_type(next_id) != ElementType::paragraph) { + throw std::invalid_argument("no paragraph follows the one to merge into"); } + TreeEditor(*m_registry).merge_next(element_id); } + + [[nodiscard]] ElementIdentifier + paragraph_insert_after(const ElementIdentifier element_id) const override { + return TreeEditor(*m_registry).insert_sibling_after(element_id); + } + [[nodiscard]] TextStyle text_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).text_style; diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp index 3b99ef3ee..5d31c5c43 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,12 @@ class Document final : public internal::Document { public: explicit Document(std::shared_ptr files); + [[nodiscard]] bool is_editable() const noexcept override; + [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; + + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; + [[nodiscard]] const ElementRegistry &element_registry() const; /// The scheme of the slide @p element_id, or null where it relates no master. [[nodiscard]] const ColorScheme * @@ -30,7 +37,10 @@ class Document final : public internal::Document { private: pugi::xml_document m_document_xml; + /// by the `r:id` the slide-id list names, which is how a slide is reached std::unordered_map m_slides_xml; + /// the other way round, which is what `save` walks + std::map m_slide_ids_by_path; PageLayout m_slide_layout; /// by slide master path; a slide points into this, so it has to outlive them std::unordered_map m_color_schemes; diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 0ccaeb489..e6a1587e5 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -246,7 +246,8 @@ class ElementAdapter final : public AdapterBase { const NodeSpan old_span{element.node, text_element.last}; pugi::xml_node parent = old_span.first.parent(); - const NodeSpan new_span = write_text_nodes(parent, old_span.first, text); + const NodeSpan new_span = + write_text_nodes(parent, old_span.first, text, "w"); element.node = new_span.first; text_element.last = new_span.last; @@ -265,7 +266,7 @@ class ElementAdapter final : public AdapterBase { const pugi::xml_node before = where == Placement::after ? anchor.last.next_sibling() : first; - const NodeSpan span = write_text_nodes(parent, before, text); + const NodeSpan span = write_text_nodes(parent, before, text, "w"); const auto &[new_id, unused_element, unused_text] = m_registry->create_text_element(span.first, span.last); if (where == Placement::after) { @@ -280,7 +281,7 @@ class ElementAdapter final : public AdapterBase { element_append_text(const ElementIdentifier element_id, const std::string &text) const override { pugi::xml_node node = get_node(element_id); - const NodeSpan span = write_text_nodes(node, {}, text); + const NodeSpan span = write_text_nodes(node, {}, text, "w"); const auto &[new_id, unused_element, unused_text] = m_registry->create_text_element(span.first, span.last); m_registry->append_child(element_id, new_id); diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index 4875f9986..ad159f431 100644 --- a/test/src/document_test.cpp +++ b/test/src/document_test.cpp @@ -580,6 +580,80 @@ TEST(Document, edit_docx_splits_a_paragraph) { EXPECT_TRUE(tail.starts_with("tail ")) << tail; } +namespace { + +/// The first paragraph of at least three runs, found anywhere in the tree - a +/// presentation puts its text inside frames rather than under the root. +Element slide_paragraph_of_several_runs(const Document &document) { + const auto walk = [](this auto &&self, const Element element) -> Element { + if (element.type() == ElementType::paragraph && + runs_of(element).size() >= 3) { + return element; + } + for (const Element child : element.children()) { + if (const Element found = self(child)) { + return found; + } + } + return {}; + }; + return walk(document.root_element()); +} + +} // namespace + +// Reopening is what proves the package the engine wrote is sound. +TEST(Document, edit_pptx_across_runs) { + const std::string path = "odr-public/pptx/2025-09-11.15_35_15.pptx"; + std::string paragraph_path; + const Document document = edit_and_reload( + path, + [&](const Document &opened) { + EXPECT_TRUE(opened.is_editable()); + EXPECT_TRUE(opened.is_savable()); + const Element paragraph = slide_paragraph_of_several_runs(opened); + EXPECT_TRUE(paragraph) << path << " holds no paragraph of three runs"; + paragraph_path = paragraph.document_path().to_string(); + return rewrite_paragraph_ops(runs_of(paragraph)); + }, + "pptx_edit_runs.pptx"); + + EXPECT_EQ(text_of(document.root_element().navigate_path( + DocumentPath(paragraph_path))), + "head tail and more"); +} + +// The same split as a text document, over `a:p` and `a:r`. +TEST(Document, edit_pptx_splits_a_paragraph) { + const std::string path = "odr-public/pptx/2025-09-11.15_35_15.pptx"; + std::string paragraph_path; + const Document document = edit_and_reload( + path, + [&](const Document &opened) { + const Element paragraph = slide_paragraph_of_several_runs(opened); + EXPECT_TRUE(paragraph) << path << " holds no paragraph of three runs"; + paragraph_path = paragraph.document_path().to_string(); + const std::vector runs = runs_of(paragraph); + return nlohmann::json{{"version", 2}, + {"ops", + {{{"op", "setText"}, + {"id", runs.front().identifier()}, + {"text", "head"}}, + {{"op", "splitParagraph"}, + {"paragraph", paragraph.identifier()}, + {"after", runs.front().identifier()}, + {"id", -1}}}}} + .dump(); + }, + "pptx_edit_split.pptx"); + + const Element head = + document.root_element().navigate_path(DocumentPath(paragraph_path)); + EXPECT_EQ(text_of(head), "head"); + EXPECT_EQ(head.next_sibling().type(), ElementType::paragraph); + EXPECT_FALSE(text_of(head.next_sibling()).empty()); +} + TEST(Document, edit_docx_diff) { const Document document = edit_and_reload( "odr-public/docx/style-various-1.docx", @@ -642,15 +716,17 @@ TEST(Document, save_to_memory_round_trips_a_package) { expect_every_text(reloaded.root_element(), "hello world!"); } +// `.doc` throws its source away as it parses, so there is nothing to write +// back. TEST(Document, saving_an_unsavable_format_leaves_no_file) { const Document document = - open(TestData::test_file_path("odr-public/pptx/style-various-1.pptx")) + open(TestData::test_file_path("odr-public/doc/11KB.doc")) .as_document_file() .document(); ASSERT_FALSE(document.is_savable()); const std::string path = - (std::filesystem::current_path() / "unsavable_save.pptx").string(); + (std::filesystem::current_path() / "unsavable_save.doc").string(); EXPECT_THROW(document.save(path), UnsupportedOperation); EXPECT_FALSE(std::filesystem::exists(path)); diff --git a/test/src/internal/ooxml/ooxml_util_test.cpp b/test/src/internal/ooxml/ooxml_util_test.cpp index 6722b9ccc..aa394bbbf 100644 --- a/test/src/internal/ooxml/ooxml_util_test.cpp +++ b/test/src/internal/ooxml/ooxml_util_test.cpp @@ -117,7 +117,7 @@ namespace { std::string written(const std::string &text) { pugi::xml_document document; pugi::xml_node run = document.append_child("w:r"); - write_text_nodes(run, {}, text); + write_text_nodes(run, {}, text, "w"); std::ostringstream out; document.print(out, "", pugi::format_raw);