From 0babee164fb841a664d64373e1c2b00a9b808fb8 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 23 Aug 2026 18:06:15 +0200 Subject: [PATCH 1/2] feat(odf): decode flat xml OpenDocument files `.fodt`, `.fodp`, `.fods` and `.fodg` were only extension and mimetype aliases on the zip-backed rows, so the table promised `open`, `translate_html`, `edit` and `save` for a file that in fact fell through to the xml source view - or, when a host trusted the extension and named the type, threw `NoOpenDocumentFile`. A flat document's one `office:document` root carries what `content.xml` and `styles.xml` carry between them, so `odf::Document` gains a constructor that hands that root in as both roots; parse, styles, list numbering and the adapter are the existing ones. Recognition rides on the parse `XmlFile` already did, next to svg's - the root element is all that tells either from any other xml. Without a package there is no filesystem, so two things differ. Images come base64 encoded in `office:binary-data`, decoded lazily by the adapter, which also picks them up in a package where they are equally legal. And `save` re-serialises the one tree rather than rebuilding a zip. Verified against LibreOffice's own flat export of eleven files across the four types: the rendered html matches the packaged render line for line, with byte-identical image payloads, and what differs is what LibreOffice itself writes differently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LUmsvGsbfXFgubV2RcpR4P --- CHANGELOG.md | 5 + CMakeLists.txt | 1 + README.md | 2 +- src/odr/internal/odf/AGENTS.md | 26 ++- src/odr/internal/odf/odf_document.cpp | 53 ++++- src/odr/internal/odf/odf_document.hpp | 6 + src/odr/internal/odf/odf_flat_file.cpp | 71 ++++++ src/odr/internal/odf/odf_flat_file.hpp | 61 ++++++ src/odr/internal/odf/odf_meta.cpp | 58 +++++ src/odr/internal/odf/odf_meta.hpp | 5 + src/odr/internal/open_strategy.cpp | 54 ++++- src/odr/internal/xml/AGENTS.md | 17 +- test/CMakeLists.txt | 1 + test/src/internal/odf/odf_flat_file_test.cpp | 218 +++++++++++++++++++ 14 files changed, 545 insertions(+), 33 deletions(-) create mode 100644 src/odr/internal/odf/odf_flat_file.cpp create mode 100644 src/odr/internal/odf/odf_flat_file.hpp create mode 100644 test/src/internal/odf/odf_flat_file_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc85b981..f7a8b7e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- Flat OpenDocument files decode as documents rather than as an xml source + view: `.fodt`, `.fodp`, `.fods` and `.fodg` open, render, edit and save like + their packaged counterparts. Their images ride in the markup, and an + `office:binary-data` image now decodes in a package too. + ## v6.10.1 - 2026-08-21 - A linked image in a docx or xlsx (`embed_images = false`) is named relative diff --git a/CMakeLists.txt b/CMakeLists.txt index 04feb2197..55115e5eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/odf/odf_document.cpp" "src/odr/internal/odf/odf_element_registry.cpp" "src/odr/internal/odf/odf_file.cpp" + "src/odr/internal/odf/odf_flat_file.cpp" "src/odr/internal/odf/odf_list.cpp" "src/odr/internal/odf/odf_manifest.cpp" "src/odr/internal/odf/odf_meta.cpp" diff --git a/README.md b/README.md index b796950be..3837e1475 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ C++ library to visualize files, especially documents, in HTML. ## Supported files -- [odt](https://github.com/opendocument-app/OpenDocument.core/issues/92), [odp](https://github.com/opendocument-app/OpenDocument.core/issues/93), [ods](https://github.com/opendocument-app/OpenDocument.core/issues/94), [odg](https://github.com/opendocument-app/OpenDocument.core/issues/96) ([OpenOffice / LibreOffice](https://github.com/opendocument-app/OpenDocument.core/issues/111)) +- [odt](https://github.com/opendocument-app/OpenDocument.core/issues/92), [odp](https://github.com/opendocument-app/OpenDocument.core/issues/93), [ods](https://github.com/opendocument-app/OpenDocument.core/issues/94), [odg](https://github.com/opendocument-app/OpenDocument.core/issues/96) ([OpenOffice / LibreOffice](https://github.com/opendocument-app/OpenDocument.core/issues/111)), including the flat xml form (fodt / fodp / fods / fodg) - [docx](https://github.com/opendocument-app/OpenDocument.core/issues/86), [pptx](https://github.com/opendocument-app/OpenDocument.core/issues/85), [xlsx](https://github.com/opendocument-app/OpenDocument.core/issues/87) ([Microsoft Office Open XML](https://github.com/opendocument-app/OpenDocument.core/issues/112)) - [csv](https://github.com/opendocument-app/OpenDocument.core/issues/107) - [doc](https://github.com/opendocument-app/OpenDocument.core/issues/104), [ppt](https://github.com/opendocument-app/OpenDocument.core/issues/106), [xls](https://github.com/opendocument-app/OpenDocument.core/issues/105) diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index 2addb109e..a697ebc08 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -7,10 +7,11 @@ element-adapter pattern, build/test loop, conventions) is in the top-level **Scope.** Reading **all four ODF document types** — text (`.odt`), presentation (`.odp`), spreadsheet (`.ods`), graphics -(`.odg`) — plus their template and legacy StarOffice variants, through the -abstract model. Reader + style resolver + a **partial editor** (text-content -edits, save). Relies on [ZIP](../zip/) (container) and [SVM](../svm/) (embedded -StarView metafile images). +(`.odg`) — plus their template, flat-xml (`.fodt` and friends) and legacy +StarOffice variants, through the abstract model. Reader + style resolver + a +**partial editor** (text-content edits, save). Relies on [ZIP](../zip/) +(container), [XML](../xml/) (what recognises a flat document) and +[SVM](../svm/) (embedded StarView metafile images). ## The load-bearing decision: a pugixml-node-backed registry @@ -68,6 +69,17 @@ of `style:text-position`) multiplies the inherited size; percent line-height passes through (the HTML renderer emits it as a unitless CSS ratio); percent margins are currently **dropped** (open work). +**A flat document is the same `Document`, minus the package.** The one +`office:document` root carries what `content.xml` and `styles.xml` carry +between them, so `Document`'s flat constructor hands that root in as *both* +roots and everything downstream is shared. Two things follow from having no +filesystem: images are the `office:binary-data` ones, base64 decoded lazily by +the adapter (legal in a package too, and now read there); and `save` +re-serialises the one tree instead of rebuilding a zip. Recognition needs the +xml parse `XmlFile` already did — `office:document` plus an `office:mimetype` +we know — so `odf_flat_file.cpp` reads the root off that tree and reparses with +the *document* parse options, which a source view's cannot substitute for. + **Decryption is manifest-driven, two layouts.** `odf_crypto.cpp` supports either a single `encrypted-package` blob (decrypt → inflate → new ZIP filesystem) or **lazy per-file** decryption (a `DecryptedFilesystem` wrapper decrypting on @@ -87,6 +99,7 @@ are **tolerated** to keep rendering best-effort. | File (`odf/`) | Role | |---|---| | `odf_file.{hpp,cpp}` | `OpenDocumentFile`: entry point; type/encryption; `decrypt()`; builds `Document` per type | +| `odf_flat_file.{hpp,cpp}` | `FlatOpenDocumentFile`: the same for a flat xml document, plus the root-element recogniser | | `odf_meta.cpp` | `parse_file_meta`: mimetype→FileType, doc type, page/table counts, encryption flag | | `odf_manifest.{hpp,cpp}` | `META-INF/manifest.xml` → per-file crypto entries, smallest-file tracking | | `odf_crypto.cpp` | Decryption: hashing, key derivation, cipher dispatch, package-vs-lazy filesystem | @@ -118,5 +131,6 @@ The structural/foundational gaps, roughly by value: `transparent`/alpha colours → `nullopt`; the Style-vs-element cascade layering is provisional (`// TODO use override?`). Outline numbering is indexed but not applied to headings. -7. **StarOffice/template mimetypes** are aliased onto the four base types; they - may deserve distinct `FileType`s (`odf_meta.cpp`). +7. **StarOffice/template/flat mimetypes** are aliased onto the four base types; + they may deserve distinct `FileType`s (`odf_meta.cpp`). A flat document is + read twice as a result — once to recognise it, once to build the tree. diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index 1660bd74a..bc742f54f 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -37,12 +38,23 @@ Document::Document(const FileType file_type, const DocumentType document_type, m_styles_xml = util::xml::parse(*m_files, AbsPath("/styles.xml")); } - m_root_element = parse_tree( - m_element_registry, - m_content_xml.document_element().child("office:body").first_child()); + init_(m_content_xml.document_element(), m_styles_xml.document_element()); +} + +Document::Document(const FileType file_type, const DocumentType document_type, + pugi::xml_document flat_xml) + : internal::Document(file_type, document_type, nullptr), + m_content_xml{std::move(flat_xml)} { + // both roots are the same node: indexing it twice re-reads the same styles + init_(m_content_xml.document_element(), m_content_xml.document_element()); +} - m_style_registry = StyleRegistry(*this, m_content_xml.document_element(), - m_styles_xml.document_element()); +void Document::init_(const pugi::xml_node content_root, + const pugi::xml_node styles_root) { + m_root_element = parse_tree(m_element_registry, + content_root.child("office:body").first_child()); + + m_style_registry = StyleRegistry(*this, content_root, styles_root); resolve_list_numbering(m_element_registry, m_style_registry, m_root_element); @@ -73,6 +85,14 @@ bool Document::is_savable(const bool encrypted) const noexcept { } void Document::save(const Path &path) const { + // a flat document is the one tree, with no package to rebuild around it; + // `save` over `print` so the declaration the parse dropped comes back + if (m_files == nullptr) { + std::ofstream out = util::file::create(path.string()); + m_content_xml.save(out, "", pugi::format_raw); + return; + } + // TODO this would decrypt/inflate and encrypt/deflate again zip::ZipArchive archive; @@ -913,6 +933,9 @@ class ElementAdapter final : public abstract::ElementAdapter, [[nodiscard]] bool image_is_internal(const ElementIdentifier element_id) const override { + if (image_data(element_id)) { + return true; + } if (m_document->as_filesystem() == nullptr) { return false; } @@ -925,6 +948,10 @@ class ElementAdapter final : public abstract::ElementAdapter, } [[nodiscard]] std::optional image_file(const ElementIdentifier element_id) const override { + if (const pugi::xml_node data = image_data(element_id)) { + return File(std::make_shared( + crypto::util::base64_decode(data.text().get()))); + } if (m_document->as_filesystem() == nullptr) { return std::nullopt; } @@ -934,7 +961,12 @@ class ElementAdapter final : public abstract::ElementAdapter, [[nodiscard]] std::string image_href(const ElementIdentifier element_id) const override { const pugi::xml_node node = get_node(element_id); - return node.attribute("xlink:href").value(); + if (const pugi::xml_attribute href = node.attribute("xlink:href")) { + return href.value(); + } + // an embedded image has no path of its own, and the renderer names the + // resource it writes after this + return "image" + std::to_string(element_id); } private: @@ -946,6 +978,15 @@ class ElementAdapter final : public abstract::ElementAdapter, return m_registry->element_at(element_id).node; } + /// The image's bytes where the markup carries them itself, base64 encoded - + /// the only way a flat document can hold an image, and legal in a package. + [[nodiscard]] pugi::xml_node + image_data(const ElementIdentifier element_id) const { + const pugi::xml_node data = + get_node(element_id).child("office:binary-data"); + return data.text().empty() ? pugi::xml_node() : data; + } + [[nodiscard]] static std::string get_text(const pugi::xml_node node) { if (node.type() == pugi::node_pcdata) { return node.value(); diff --git a/src/odr/internal/odf/odf_document.hpp b/src/odr/internal/odf/odf_document.hpp index cc172a14e..91323797a 100644 --- a/src/odr/internal/odf/odf_document.hpp +++ b/src/odr/internal/odf/odf_document.hpp @@ -14,6 +14,10 @@ class Document final : public internal::Document { public: Document(FileType file_type, DocumentType document_type, std::shared_ptr files); + /// A flat document: one tree holding both content and styles, and no + /// filesystem behind it. + Document(FileType file_type, DocumentType document_type, + pugi::xml_document flat_xml); ElementRegistry &element_registry(); StyleRegistry &style_registry(); @@ -28,6 +32,8 @@ class Document final : public internal::Document { void save(const Path &path, const char *password) const override; private: + void init_(pugi::xml_node content_root, pugi::xml_node styles_root); + pugi::xml_document m_content_xml; pugi::xml_document m_styles_xml; diff --git a/src/odr/internal/odf/odf_flat_file.cpp b/src/odr/internal/odf/odf_flat_file.cpp new file mode 100644 index 000000000..9c7b00e45 --- /dev/null +++ b/src/odr/internal/odf/odf_flat_file.cpp @@ -0,0 +1,71 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace odr::internal::odf { + +bool is_flat_opendocument_file(const xml::XmlFile &file) { + if (file.root_name() != "office:document") { + return false; + } + try { + parse_flat_file_meta(file.document().document_element()); + } catch (...) { + return false; + } + return true; +} + +FlatOpenDocumentFile::FlatOpenDocumentFile(const xml::XmlFile &file) + : m_file{std::make_shared(file.file(), file.encoding())}, + m_file_meta{parse_flat_file_meta(file.document().document_element())} {} + +std::shared_ptr FlatOpenDocumentFile::file() const noexcept { + return m_file->file(); +} + +FileType FlatOpenDocumentFile::file_type() const noexcept { + return m_file_meta.type; +} + +std::string_view FlatOpenDocumentFile::mimetype() const noexcept { + return m_file_meta.mimetype; +} + +FileMeta FlatOpenDocumentFile::file_meta() const noexcept { + return m_file_meta; +} + +DocumentType FlatOpenDocumentFile::document_type() const { + return m_file_meta.document_type; +} + +bool FlatOpenDocumentFile::password_encrypted() const noexcept { return false; } + +EncryptionState FlatOpenDocumentFile::encryption_state() const noexcept { + return EncryptionState::not_encrypted; +} + +std::shared_ptr +FlatOpenDocumentFile::decrypt(const std::string & /*password*/) const { + throw NotEncryptedError(); +} + +bool FlatOpenDocumentFile::is_decodable() const noexcept { return true; } + +std::shared_ptr FlatOpenDocumentFile::document() const { + // the tree the recogniser parsed keeps what a source view needs and is a + // parse the document model cannot use; this one is its own + return std::make_shared(m_file_meta.type, m_file_meta.document_type, + util::xml::parse(m_file->text())); +} + +} // namespace odr::internal::odf diff --git a/src/odr/internal/odf/odf_flat_file.hpp b/src/odr/internal/odf/odf_flat_file.hpp new file mode 100644 index 000000000..85dbd16b0 --- /dev/null +++ b/src/odr/internal/odf/odf_flat_file.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include + +#include + +#include +#include + +namespace odr::internal::abstract { +class Document; +} // namespace odr::internal::abstract + +namespace odr::internal::text { +class TextFile; +} // namespace odr::internal::text + +namespace odr::internal::xml { +class XmlFile; +} // namespace odr::internal::xml + +namespace odr::internal::odf { + +/// Whether @p file is a flat OpenDocument: an `office:document` root whose +/// `office:mimetype` names a document type we decode. Reading the root is all +/// that tells one from any other xml - and what @ref FlatOpenDocumentFile's +/// constructor throws on. +[[nodiscard]] bool is_flat_opendocument_file(const xml::XmlFile &file); + +/// An OpenDocument written as one xml file rather than a zip package +/// (`.fodt`, `.fodp`, `.fods`, `.fodg`), decoding to the same @ref Document as +/// the packaged form. Without a package there is no filesystem: images come +/// base64 encoded in the markup, and the file is never encrypted. +class FlatOpenDocumentFile final : public virtual abstract::DocumentFile { +public: + /// @throws NoOpenDocumentFile if @p file is not a flat OpenDocument. + explicit FlatOpenDocumentFile(const xml::XmlFile &file); + + [[nodiscard]] std::shared_ptr file() const noexcept override; + + [[nodiscard]] FileType file_type() const noexcept override; + [[nodiscard]] std::string_view mimetype() const noexcept override; + [[nodiscard]] FileMeta file_meta() const noexcept override; + + [[nodiscard]] DocumentType document_type() const override; + + [[nodiscard]] bool password_encrypted() const noexcept override; + [[nodiscard]] EncryptionState encryption_state() const noexcept override; + [[nodiscard]] std::shared_ptr + decrypt(const std::string &password) const override; + + [[nodiscard]] bool is_decodable() const noexcept override; + + [[nodiscard]] std::shared_ptr document() const override; + +private: + std::shared_ptr m_file; + FileMeta m_file_meta; +}; + +} // namespace odr::internal::odf diff --git a/src/odr/internal/odf/odf_meta.cpp b/src/odr/internal/odf/odf_meta.cpp index b6d8504c4..2b3bee81d 100644 --- a/src/odr/internal/odf/odf_meta.cpp +++ b/src/odr/internal/odf/odf_meta.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -9,6 +10,7 @@ #include #include +#include #include #include @@ -52,6 +54,16 @@ void lookup_file_type(const std::string &mimetype_in, FileType &file_type, FileType::opendocument_spreadsheet}, {"application/vnd.sun.xml.draw.template", FileType::opendocument_graphics}, + // a flat document names the packaged mimetype in `office:mimetype`; the + // `-flat-xml` ones are what a caller handing us the file may name it + {"application/vnd.oasis.opendocument.text-flat-xml", + FileType::opendocument_text}, + {"application/vnd.oasis.opendocument.presentation-flat-xml", + FileType::opendocument_presentation}, + {"application/vnd.oasis.opendocument.spreadsheet-flat-xml", + FileType::opendocument_spreadsheet}, + {"application/vnd.oasis.opendocument.graphics-flat-xml", + FileType::opendocument_graphics}, }; if (const auto it = MIME_TYPES.find(mimetype_in); it != MIME_TYPES.end()) { file_type = it->second; @@ -62,6 +74,21 @@ void lookup_file_type(const std::string &mimetype_in, FileType &file_type, } } +std::string_view flat_mimetype(const FileType file_type) { + switch (file_type) { + case FileType::opendocument_text: + return "application/vnd.oasis.opendocument.text-flat-xml"; + case FileType::opendocument_presentation: + return "application/vnd.oasis.opendocument.presentation-flat-xml"; + case FileType::opendocument_spreadsheet: + return "application/vnd.oasis.opendocument.spreadsheet-flat-xml"; + case FileType::opendocument_graphics: + return "application/vnd.oasis.opendocument.graphics-flat-xml"; + default: + return "application/octet-stream"; + } +} + } // namespace FileMeta parse_file_meta(const abstract::ReadableFilesystem &filesystem, @@ -140,4 +167,35 @@ FileMeta parse_file_meta(const abstract::ReadableFilesystem &filesystem, return result; } +FileMeta parse_flat_file_meta(const pugi::xml_node root) { + if (std::string_view(root.name()) != "office:document") { + throw NoOpenDocumentFile(); + } + + FileMeta result; + lookup_file_type(root.attribute("office:mimetype").value(), result.type, + result.mimetype); + if (result.type == FileType::unknown) { + throw NoOpenDocumentFile(); + } + result.mimetype = flat_mimetype(result.type); + result.document_type = document_type_by_file_type(result.type); + + const pugi::xml_node statistics = + root.child("office:meta").child("meta:document-statistic"); + if (result.type == FileType::opendocument_text) { + if (const pugi::xml_attribute page_count = + statistics.attribute("meta:page-count")) { + result.entry_count = page_count.as_uint(); + } + } else if (result.type == FileType::opendocument_spreadsheet) { + if (const pugi::xml_attribute table_count = + statistics.attribute("meta:table-count")) { + result.entry_count = table_count.as_uint(); + } + } + + return result; +} + } // namespace odr::internal::odf diff --git a/src/odr/internal/odf/odf_meta.hpp b/src/odr/internal/odf/odf_meta.hpp index 888463b48..3ad805bee 100644 --- a/src/odr/internal/odf/odf_meta.hpp +++ b/src/odr/internal/odf/odf_meta.hpp @@ -18,4 +18,9 @@ namespace odr::internal::odf { FileMeta parse_file_meta(const abstract::ReadableFilesystem &filesystem, const pugi::xml_document *manifest, bool decrypted); +/// Reads the meta of a flat xml document off its `office:document` root. +/// @throws NoOpenDocumentFile unless @p root is one whose `office:mimetype` +/// names a document type we decode. +FileMeta parse_flat_file_meta(pugi::xml_node root); + } // namespace odr::internal::odf diff --git a/src/odr/internal/open_strategy.cpp b/src/odr/internal/open_strategy.cpp index 68a4e2606..645312afb 100644 --- a/src/odr/internal/open_strategy.cpp +++ b/src/odr/internal/open_strategy.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +64,18 @@ open_file_as(const std::shared_ptr &file, const FileType as, } catch (...) { ODR_VERBOSE(logger, "failed to open as odf"); } + + try { + ODR_VERBOSE(logger, "try open as flat odf"); + const xml::XmlFile xml_file(std::make_shared(file)); + auto flat_file = std::make_unique(xml_file); + if (flat_file->file_type() == as) { + return flat_file; + } + ODR_VERBOSE(logger, "flat odf is a different document type"); + } catch (...) { + ODR_VERBOSE(logger, "failed to open as flat odf"); + } throw NoOpenDocumentFile(); } @@ -314,8 +327,8 @@ open_strategy::list_file_types(const std::shared_ptr &file, ODR_VERBOSE(logger, "failed to open as json"); } - // an svg has no signature; only the xml root element tells it from plain - // xml, so both are reported + // neither an svg nor a flat odf has a signature; only the xml root + // element tells them from plain xml, so both readings are reported try { ODR_VERBOSE(logger, "try open as xml"); auto xml_file = std::make_shared(text); @@ -324,6 +337,9 @@ open_strategy::list_file_types(const std::shared_ptr &file, if (svg::is_svg_file(*xml_file)) { ODR_VERBOSE(logger, "open as svg"); result.push_back(svg::SvgFile(xml_file).file_type()); + } else if (odf::is_flat_opendocument_file(*xml_file)) { + ODR_VERBOSE(logger, "open as flat odf"); + result.push_back(odf::FlatOpenDocumentFile(*xml_file).file_type()); } } catch (...) { ODR_VERBOSE(logger, "failed to open as xml"); @@ -438,22 +454,27 @@ open_strategy::open_file(const std::shared_ptr &file, ODR_VERBOSE(logger, "failed to open as json"); } - // svg is read off the parse xml already did: it is the more specific - // reading of the same bytes, and xml is the last resort before the line - // list + // svg and flat odf are read off the parse xml already did: they are the + // more specific readings of the same bytes, and xml is the last resort + // before the line list try { ODR_VERBOSE(logger, "try open as xml"); auto xml_file = std::make_unique(text); - if (!svg::is_svg_file(*xml_file)) { - ODR_VERBOSE(logger, "not an svg"); - // handed on as it is, so the parse is not repeated - return xml_file; + if (svg::is_svg_file(*xml_file)) { + ODR_VERBOSE(logger, "open as svg"); + return std::make_unique( + std::shared_ptr(std::move(xml_file))); } - ODR_VERBOSE(logger, "open as svg"); - return std::make_unique( - std::shared_ptr(std::move(xml_file))); + if (odf::is_flat_opendocument_file(*xml_file)) { + ODR_VERBOSE(logger, "open as flat odf"); + return std::make_unique(*xml_file); + } + + ODR_VERBOSE(logger, "neither an svg nor a flat odf"); + // handed on as it is, so the parse is not repeated + return xml_file; } catch (...) { ODR_VERBOSE(logger, "failed to open as xml"); } @@ -565,6 +586,15 @@ open_strategy::open_document_file(const std::shared_ptr &file, } catch (...) { ODR_VERBOSE(logger, "failed to open as ooxml"); } + } else if (file_type == FileType::unknown) { + // a flat odf carries no signature, so magic cannot name it + try { + ODR_VERBOSE(logger, "try open as flat odf"); + const xml::XmlFile xml_file(std::make_shared(file)); + return std::make_unique(xml_file); + } catch (...) { + ODR_VERBOSE(logger, "failed to open as flat odf"); + } } ODR_ERROR(logger, "unsupported file type for document file " diff --git a/src/odr/internal/xml/AGENTS.md b/src/odr/internal/xml/AGENTS.md index bb52f0157..5c6812557 100644 --- a/src/odr/internal/xml/AGENTS.md +++ b/src/odr/internal/xml/AGENTS.md @@ -121,11 +121,12 @@ that number. ## Detection -Xml is the **last resort** in `open_file`'s unknown-type path, after csv, json -and svg. - -Two consequences. A flat-xml ODF (`.fods` and friends) has no detection — the -flat mimetypes are only aliases on the zip-backed rows — so it decodes as xml. -Better than text, **not** flat-ODF support. Likewise `.xhtml`, `.rels`, -`.plist` and rss feeds become source views: correct for a source viewer, and -correct that we do not try to *render* xhtml. +Xml is the **last resort** in `open_file`'s unknown-type path, after csv, json, +svg and flat-xml ODF. + +Svg and flat ODF are read off this parse — the root element is the only thing +that tells either from any other xml, so `is_svg_file` and +`is_flat_opendocument_file` inspect the tree `XmlFile` already built, and the +more specific reading wins. What is left over becomes a source view: `.xhtml`, +`.rels`, `.plist` and rss feeds, which is correct for a source viewer, and +correct in that we do not try to *render* xhtml. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8b516952c..bbf373502 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -59,6 +59,7 @@ add_executable(odr_test "src/internal/svg/svg_file_test.cpp" "src/internal/xml/xml_file_test.cpp" + "src/internal/odf/odf_flat_file_test.cpp" "src/internal/odf/odf_table_test.cpp" "src/internal/oldms/doc_test.cpp" diff --git a/test/src/internal/odf/odf_flat_file_test.cpp b/test/src/internal/odf/odf_flat_file_test.cpp new file mode 100644 index 000000000..8700b4b91 --- /dev/null +++ b/test/src/internal/odf/odf_flat_file_test.cpp @@ -0,0 +1,218 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +using namespace odr; + +namespace { + +/// A flat document needs no namespace declarations to parse - prefixes are +/// part of the tag name and nothing resolves them. +std::string flat_document(const std::string &mimetype, const std::string &body, + const std::string &styles = "") { + return R"()" + R"()" + styles + "" + body + + ""; +} + +std::string flat_text(const std::string &body, const std::string &styles = "") { + return flat_document("application/vnd.oasis.opendocument.text", + "" + body + "", styles); +} + +Element first_of_type(const Element root, const ElementType type) { + for (const Element child : root.children()) { + if (child.type() == type) { + return child; + } + if (const Element match = first_of_type(child, type)) { + return match; + } + } + return {}; +} + +// 1x1 png +constexpr const char *png_base64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGA" + "hKmMIQAAAABJRU5ErkJggg=="; + +} // namespace + +TEST(FlatOpenDocumentFile, the_root_mimetype_names_the_document_type) { + const struct { + const char *mimetype; + FileType file_type; + DocumentType document_type; + } cases[]{ + {"application/vnd.oasis.opendocument.text", FileType::opendocument_text, + DocumentType::text}, + {"application/vnd.oasis.opendocument.presentation", + FileType::opendocument_presentation, DocumentType::presentation}, + {"application/vnd.oasis.opendocument.spreadsheet", + FileType::opendocument_spreadsheet, DocumentType::spreadsheet}, + {"application/vnd.oasis.opendocument.graphics", + FileType::opendocument_graphics, DocumentType::drawing}, + }; + + for (const auto &[mimetype, file_type, document_type] : cases) { + const DecodedFile file( + File::from_memory(flat_document(mimetype, ""))); + + EXPECT_EQ(file.file_type(), file_type); + EXPECT_EQ(file.file_category(), FileCategory::document); + EXPECT_TRUE(file.is_document_file()); + EXPECT_EQ(file.as_document_file().document_type(), document_type); + } +} + +/// The flat mimetypes are what a caller names the file, not what the root +/// carries - both have to open. +TEST(FlatOpenDocumentFile, the_flat_mimetype_is_read_too_and_reported_back) { + const DecodedFile file(File::from_memory( + flat_document("application/vnd.oasis.opendocument.spreadsheet-flat-xml", + ""))); + + EXPECT_EQ(file.file_type(), FileType::opendocument_spreadsheet); + EXPECT_EQ(file.file_meta().mimetype, + "application/vnd.oasis.opendocument.spreadsheet-flat-xml"); +} + +TEST(FlatOpenDocumentFile, other_xml_is_left_to_the_source_view) { + EXPECT_EQ(DecodedFile(File::from_memory("")).file_type(), + FileType::xml); + EXPECT_EQ( + DecodedFile(File::from_memory( + R"()")) + .file_type(), + FileType::xml); + EXPECT_EQ(DecodedFile(File::from_memory("")).file_type(), FileType::xml); +} + +TEST(FlatOpenDocumentFile, opening_it_as_a_document_file_works) { + const DocumentFile file = + DocumentFile::from_memory(flat_text("Hello")); + + EXPECT_EQ(file.file_type(), FileType::opendocument_text); + EXPECT_FALSE(file.password_encrypted()); +} + +TEST(FlatOpenDocumentFile, opening_it_as_a_named_type_works) { + const std::string source = flat_text("Hello"); + + EXPECT_EQ(DecodedFile(File::from_memory(source), FileType::opendocument_text) + .file_type(), + FileType::opendocument_text); + EXPECT_THROW(std::ignore = DecodedFile(File::from_memory(source), + FileType::opendocument_graphics), + UnknownFileType); +} + +TEST(FlatOpenDocumentFile, the_body_decodes_to_the_same_tree_as_a_package) { + const Document document = + DocumentFile::from_memory( + flat_text("Hello flat")) + .document(); + + EXPECT_EQ(document.document_type(), DocumentType::text); + + const Element paragraph = + first_of_type(document.root_element(), ElementType::paragraph); + ASSERT_TRUE(paragraph); + EXPECT_EQ(paragraph.first_child().as_text().content(), "Hello "); +} + +/// A package splits automatic and named styles over two files; a flat document +/// has both under its one root, and they have to resolve just the same. +TEST(FlatOpenDocumentFile, styles_resolve_from_the_single_root) { + const Document document = + DocumentFile::from_memory( + flat_text( + R"(Hello)", + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()")) + .document(); + + const Element paragraph = + first_of_type(document.root_element(), ElementType::paragraph); + ASSERT_TRUE(paragraph); + + const TextStyle style = paragraph.as_paragraph().text_style(); + EXPECT_EQ(style.font_size, Measure("24pt")); + EXPECT_EQ(style.font_weight, FontWeight::bold); +} + +/// Without a package there is nowhere to put an image but the markup. +TEST(FlatOpenDocumentFile, an_embedded_image_is_internal_and_decodes) { + const Document document = + DocumentFile::from_memory( + flat_text(std::string("" + "") + + png_base64 + + "")) + .document(); + + const Element element = + first_of_type(document.root_element(), ElementType::image); + ASSERT_TRUE(element); + + const Image image = element.as_image(); + EXPECT_TRUE(image.is_internal()); + EXPECT_FALSE(image.href().empty()); + + const std::optional file = image.file(); + ASSERT_TRUE(file.has_value()); + EXPECT_EQ(DecodedFile(*file).file_type(), + FileType::portable_network_graphics); +} + +/// An `xlink:href` still names a file in a package - a flat document has none, +/// so the image is external and stays a plain link. +TEST(FlatOpenDocumentFile, a_linked_image_is_not_internal) { + const Document document = + DocumentFile::from_memory( + flat_text(R"()" + R"()")) + .document(); + + const Image image = + first_of_type(document.root_element(), ElementType::image).as_image(); + EXPECT_FALSE(image.is_internal()); + EXPECT_EQ(image.href(), "https://example.org/a.png"); +} + +TEST(FlatOpenDocumentFile, it_saves_back_as_one_xml_file) { + const Document document = + DocumentFile::from_memory(flat_text("Hello")).document(); + + const std::string path = + (std::filesystem::current_path() / "flat_save_test.fodt").string(); + ASSERT_TRUE(document.is_savable()); + document.save(path); + + const DocumentFile saved(path); + EXPECT_EQ(saved.file_type(), FileType::opendocument_text); + EXPECT_EQ(first_of_type(saved.document().root_element(), ElementType::text) + .as_text() + .content(), + "Hello"); +} From 3092a5acaf300a4b0565f752b5dd5f9471d1f62d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 23 Aug 2026 20:18:52 +0200 Subject: [PATCH 2/2] fix(odf): keep an embedded image's href out of the resource path `office:binary-data` and `xlink:href` can sit on the same `draw:image`. The short-circuit that reads the markup bytes returned `is_internal()` before the `make_absolute()` check that rejects an escaping path, while `image_href` still answered with the raw attribute - so `bring_offline` wrote the decoded bytes to wherever the href pointed. The bytes in the markup are the image, so an href beside them names no file of ours and never reaches the renderer now. Also from the same review: - `Document::as_filesystem` answers with an empty filesystem where the impl has none instead of throwing `std::invalid_argument`, which every flat document did - `odr-cli server foo.fodt` died on it after having built its html service. The internal null stays null: `save` and the image lookups pick the flat path off it. - `parse_file_meta` shares `read_entry_count` and `document_type_by_file_type` with the flat parse rather than writing both out by hand. - Eight tests: `office:binary-data` in a package, markup bytes beating an href in both encodings, the entry counts including a negative, `list_file_types`, the empty filesystem, distinct hrefs, and a real `html::translate` of a flat document embedded and linked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015E5WrxdYxfoBE7JUotBtqh --- CHANGELOG.md | 2 + src/odr/document.cpp | 11 +- src/odr/document.hpp | 2 + src/odr/internal/odf/AGENTS.md | 4 +- src/odr/internal/odf/odf_document.cpp | 12 +- src/odr/internal/odf/odf_meta.cpp | 53 ++-- test/src/internal/odf/odf_flat_file_test.cpp | 240 ++++++++++++++++++- 7 files changed, 281 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a8b7e55..43b05b5ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The release run heads these entries with the version and opens a fresh view: `.fodt`, `.fodp`, `.fods` and `.fodg` open, render, edit and save like their packaged counterparts. Their images ride in the markup, and an `office:binary-data` image now decodes in a package too. +- `Document::as_filesystem` answers with an empty filesystem for a document + that is one file rather than a package. ## v6.10.1 - 2026-08-21 diff --git a/src/odr/document.cpp b/src/odr/document.cpp index 37398a1e7..3c0ed91f1 100644 --- a/src/odr/document.cpp +++ b/src/odr/document.cpp @@ -6,8 +6,11 @@ #include #include +#include #include +#include + namespace odr { Document::Document(std::shared_ptr impl) @@ -43,7 +46,13 @@ Element Document::root_element() const { } Filesystem Document::as_filesystem() const { - return Filesystem(m_impl->as_filesystem()); + if (std::shared_ptr files = + m_impl->as_filesystem()) { + return Filesystem(std::move(files)); + } + // a document that is not a package - a flat xml one - has no files of its + // own rather than no answer + return Filesystem(std::make_shared()); } } // namespace odr diff --git a/src/odr/document.hpp b/src/odr/document.hpp index 6f15461c1..0c5bcfa41 100644 --- a/src/odr/document.hpp +++ b/src/odr/document.hpp @@ -30,6 +30,8 @@ class Document final { [[nodiscard]] Element root_element() const; + /// The files the document is packaged from, empty for a document that is + /// one file - a flat xml one. [[nodiscard]] Filesystem as_filesystem() const; private: diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index a697ebc08..b1fb973d0 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -74,7 +74,9 @@ margins are currently **dropped** (open work). between them, so `Document`'s flat constructor hands that root in as *both* roots and everything downstream is shared. Two things follow from having no filesystem: images are the `office:binary-data` ones, base64 decoded lazily by -the adapter (legal in a package too, and now read there); and `save` +the adapter (legal in a package too, and now read there) and named after their +element id rather than by an `xlink:href` that names no file of ours — the +renderer writes a resource to the path it is handed; and `save` re-serialises the one tree instead of rebuilding a zip. Recognition needs the xml parse `XmlFile` already did — `office:document` plus an `office:mimetype` we know — so `odf_flat_file.cpp` reads the root off that tree and reparses with diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index bc742f54f..3bd980b53 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -960,13 +960,13 @@ class ElementAdapter final : public abstract::ElementAdapter, } [[nodiscard]] std::string image_href(const ElementIdentifier element_id) const override { - const pugi::xml_node node = get_node(element_id); - if (const pugi::xml_attribute href = node.attribute("xlink:href")) { - return href.value(); - } // an embedded image has no path of its own, and the renderer names the - // resource it writes after this - return "image" + std::to_string(element_id); + // resource it writes after this - an `xlink:href` next to the bytes does + // not name them, and must not reach the renderer as a path + if (image_data(element_id)) { + return "image" + std::to_string(element_id); + } + return get_node(element_id).attribute("xlink:href").value(); } private: diff --git a/src/odr/internal/odf/odf_meta.cpp b/src/odr/internal/odf/odf_meta.cpp index 2b3bee81d..350ac1648 100644 --- a/src/odr/internal/odf/odf_meta.cpp +++ b/src/odr/internal/odf/odf_meta.cpp @@ -89,6 +89,22 @@ std::string_view flat_mimetype(const FileType file_type) { } } +/// `meta:document-statistic` counts what the document type makes countable. +void read_entry_count(const pugi::xml_node statistics, FileMeta &result) { + const char *attribute = nullptr; + if (result.type == FileType::opendocument_text) { + attribute = "meta:page-count"; + } else if (result.type == FileType::opendocument_spreadsheet) { + attribute = "meta:table-count"; + } else { + return; + } + + if (const pugi::xml_attribute count = statistics.attribute(attribute)) { + result.entry_count = count.as_uint(); + } +} + } // namespace FileMeta parse_file_meta(const abstract::ReadableFilesystem &filesystem, @@ -132,15 +148,7 @@ FileMeta parse_file_meta(const abstract::ReadableFilesystem &filesystem, } } - if (result.type == FileType::opendocument_text) { - result.document_type = DocumentType::text; - } else if (result.type == FileType::opendocument_presentation) { - result.document_type = DocumentType::presentation; - } else if (result.type == FileType::opendocument_spreadsheet) { - result.document_type = DocumentType::spreadsheet; - } else if (result.type == FileType::opendocument_graphics) { - result.document_type = DocumentType::drawing; - } + result.document_type = document_type_by_file_type(result.type); if (result.password_encrypted == decrypted && filesystem.is_file(AbsPath("/meta.xml"))) { @@ -151,17 +159,7 @@ FileMeta parse_file_meta(const abstract::ReadableFilesystem &filesystem, .child("office:meta") .child("meta:document-statistic"); - if (result.type == FileType::opendocument_text) { - if (const pugi::xml_attribute page_count = - statistics.attribute("meta:page-count")) { - result.entry_count = page_count.as_uint(); - } - } else if (result.type == FileType::opendocument_spreadsheet) { - if (const pugi::xml_attribute table_count = - statistics.attribute("meta:table-count")) { - result.entry_count = table_count.as_uint(); - } - } + read_entry_count(statistics, result); } return result; @@ -181,19 +179,8 @@ FileMeta parse_flat_file_meta(const pugi::xml_node root) { result.mimetype = flat_mimetype(result.type); result.document_type = document_type_by_file_type(result.type); - const pugi::xml_node statistics = - root.child("office:meta").child("meta:document-statistic"); - if (result.type == FileType::opendocument_text) { - if (const pugi::xml_attribute page_count = - statistics.attribute("meta:page-count")) { - result.entry_count = page_count.as_uint(); - } - } else if (result.type == FileType::opendocument_spreadsheet) { - if (const pugi::xml_attribute table_count = - statistics.attribute("meta:table-count")) { - result.entry_count = table_count.as_uint(); - } - } + read_entry_count(root.child("office:meta").child("meta:document-statistic"), + result); return result; } diff --git a/test/src/internal/odf/odf_flat_file_test.cpp b/test/src/internal/odf/odf_flat_file_test.cpp index 8700b4b91..5972fd7ce 100644 --- a/test/src/internal/odf/odf_flat_file_test.cpp +++ b/test/src/internal/odf/odf_flat_file_test.cpp @@ -2,13 +2,24 @@ #include #include #include +#include +#include #include #include +#include +#include +#include + #include +#include #include +#include +#include +#include #include +#include using namespace odr; @@ -46,6 +57,28 @@ constexpr const char *png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGA" "hKmMIQAAAABJRU5ErkJggg=="; +/// A packaged odt holding @p body, written to @p name in the working +/// directory - the same markup a flat document carries, minus the flatness. +std::string packaged_text(const std::string &name, const std::string &body) { + const std::string content = + R"()" + R"()" + + body + R"()"; + + odr::internal::zip::ZipArchive zip; + zip.insert_file(std::end(zip), odr::internal::RelPath("mimetype"), + std::make_shared( + "application/vnd.oasis.opendocument.text"), + 0); + zip.insert_file(std::end(zip), odr::internal::RelPath("content.xml"), + std::make_shared(content)); + + const std::string path = (std::filesystem::current_path() / name).string(); + std::ofstream out(path, std::ios::binary); + zip.save(out); + return path; +} + } // namespace TEST(FlatOpenDocumentFile, the_root_mimetype_names_the_document_type) { @@ -194,12 +227,215 @@ TEST(FlatOpenDocumentFile, a_linked_image_is_not_internal) { R"()")) .document(); - const Image image = - first_of_type(document.root_element(), ElementType::image).as_image(); + const Element element = + first_of_type(document.root_element(), ElementType::image); + ASSERT_TRUE(element); + + const Image image = element.as_image(); EXPECT_FALSE(image.is_internal()); EXPECT_EQ(image.href(), "https://example.org/a.png"); } +/// The counts the meta block carries are what a caller reads off `file_meta` +/// without decoding the body. +TEST(FlatOpenDocumentFile, the_statistics_give_the_entry_count) { + const auto meta = [](const std::string &statistic) { + return ""; + }; + + const DecodedFile text(File::from_memory( + flat_document("application/vnd.oasis.opendocument.text", "", + meta(R"(meta:page-count="7")")))); + EXPECT_EQ(text.file_meta().entry_count, 7); + + const DecodedFile spreadsheet(File::from_memory( + flat_document("application/vnd.oasis.opendocument.spreadsheet", + "", meta(R"(meta:table-count="3")")))); + EXPECT_EQ(spreadsheet.file_meta().entry_count, 3); + + // the statistic a text document does not count + const DecodedFile mismatched(File::from_memory( + flat_document("application/vnd.oasis.opendocument.text", "", + meta(R"(meta:table-count="3")")))); + EXPECT_FALSE(mismatched.file_meta().entry_count.has_value()); +} + +/// `list_file_types` reports every reading of the bytes, and a flat document +/// is well formed xml whichever way it is read. +TEST(FlatOpenDocumentFile, it_is_listed_next_to_the_source_view) { + const std::vector types = DecodedFile::list_file_types( + File::from_memory(flat_text("Hello"))); + + EXPECT_NE(std::ranges::find(types, FileType::xml), std::end(types)); + EXPECT_NE(std::ranges::find(types, FileType::opendocument_text), + std::end(types)); +} + +/// There is no package behind a flat document; asking for one answers empty +/// rather than throwing. +TEST(FlatOpenDocumentFile, it_has_an_empty_filesystem) { + const Document document = + DocumentFile::from_memory(flat_text("Hello")).document(); + + const Filesystem filesystem = document.as_filesystem(); + EXPECT_FALSE(filesystem.exists("/content.xml")); + EXPECT_TRUE(filesystem.file_walker("/").end()); +} + +/// The renderer names the resource it writes after the href, so two embedded +/// images must not answer with the same one. +TEST(FlatOpenDocumentFile, embedded_images_get_distinct_hrefs) { + const std::string image = std::string("" + "") + + png_base64 + + ""; + const Document document = + DocumentFile::from_memory( + flat_text("" + image + image + "")) + .document(); + + std::vector hrefs; + for (const Element child : + first_of_type(document.root_element(), ElementType::paragraph) + .children()) { + if (const Element image_element = + first_of_type(child, ElementType::image)) { + hrefs.push_back(image_element.as_image().href()); + } + } + + ASSERT_EQ(hrefs.size(), 2); + EXPECT_NE(hrefs[0], hrefs[1]); +} + +/// The bytes are in the markup, so an `xlink:href` beside them names no file +/// of ours - and must not reach the renderer, which writes a resource to the +/// path it gets. +TEST(FlatOpenDocumentFile, + an_embedded_image_does_not_take_its_href_from_the_markup) { + const Document document = + DocumentFile::from_memory( + flat_text(std::string(R"()") + .append(R"()") + .append("") + .append(png_base64) + .append("") + .append(""))) + .document(); + + const Element element = + first_of_type(document.root_element(), ElementType::image); + ASSERT_TRUE(element); + + const Image image = element.as_image(); + EXPECT_TRUE(image.is_internal()); + EXPECT_EQ(image.href().find(".."), std::string::npos); + EXPECT_TRUE(internal::Path(image.href()).relative()); +} + +/// `office:binary-data` is legal in a package too, and is read there now. +TEST(FlatOpenDocumentFile, a_packaged_embedded_image_decodes_as_well) { + const std::string path = packaged_text( + "packaged_binary_data.odt", + std::string("") + .append(png_base64) + .append("")); + + const Document document = DocumentFile(path).document(); + + const Element element = + first_of_type(document.root_element(), ElementType::image); + ASSERT_TRUE(element); + + const Image image = element.as_image(); + EXPECT_TRUE(image.is_internal()); + + const std::optional file = image.file(); + ASSERT_TRUE(file.has_value()); + EXPECT_EQ(DecodedFile(*file).file_type(), + FileType::portable_network_graphics); +} + +/// The markup bytes are the image; an `xlink:href` naming a file in the +/// package does not override them. +TEST(FlatOpenDocumentFile, packaged_markup_bytes_beat_the_href) { + const std::string path = packaged_text( + "packaged_binary_data_and_href.odt", + std::string(R"()") + .append(R"()") + .append("") + .append(png_base64) + .append("") + .append("")); + + // the element holds a bare pointer into the document, so the document has + // to outlive it + const Document document = DocumentFile(path).document(); + + const Element element = + first_of_type(document.root_element(), ElementType::image); + ASSERT_TRUE(element); + + const Image image = element.as_image(); + EXPECT_TRUE(image.is_internal()); + EXPECT_NE(image.href(), "Pictures/absent.png"); + ASSERT_TRUE(image.file().has_value()); +} + +/// The one thing the change promises a consumer: a flat document renders. +TEST(FlatOpenDocumentFile, it_renders_its_embedded_image_embedded_or_linked) { + const std::string source = + flat_text(std::string("" + "") + .append(png_base64) + .append("" + "")); + + { + HtmlConfig config( + (std::filesystem::current_path() / "flat_embed").string()); + config.embed_images = true; + + std::ostringstream out; + html::translate(DecodedFile(File::from_memory(source)), config) + .list_views() + .at(0) + .write_html(out); + + EXPECT_NE(out.str().find("data:image/png;base64,"), std::string::npos); + } + + { + HtmlConfig config((std::filesystem::current_path() / "flat_link").string()); + config.embed_images = false; + + const HtmlService service = + html::translate(DecodedFile(File::from_memory(source)), config); + + std::ostringstream out; + const HtmlResources resources = service.list_views().at(0).write_html(out); + + std::size_t images = 0; + for (const auto &[resource, location] : resources) { + if (resource.type() != HtmlResourceType::image || + !resource.is_accessible()) { + continue; + } + ++images; + + ASSERT_TRUE(location.has_value()) << resource.name(); + EXPECT_TRUE(internal::Path(*location).relative()) << *location; + EXPECT_TRUE(service.exists(*location)) << *location; + + std::ostringstream served; + service.write(*location, served); + EXPECT_FALSE(served.str().empty()) << *location; + } + EXPECT_EQ(images, 1); + } +} + TEST(FlatOpenDocumentFile, it_saves_back_as_one_xml_file) { const Document document = DocumentFile::from_memory(flat_text("Hello")).document();