diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc85b98..43b05b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ 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. +- `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 - A linked image in a docx or xlsx (`embed_images = false`) is named relative diff --git a/CMakeLists.txt b/CMakeLists.txt index 04feb219..55115e5e 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 b796950b..3837e147 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/document.cpp b/src/odr/document.cpp index 37398a1e..3c0ed91f 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 6f15461c..0c5bcfa4 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 2addb109..b1fb973d 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,19 @@ 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 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 +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 +101,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 +133,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 1660bd74..3bd980b5 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; } @@ -933,8 +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); - return node.attribute("xlink:href").value(); + // an embedded image has no path of its own, and the renderer names the + // 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: @@ -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 cc172a14..91323797 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 00000000..9c7b00e4 --- /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 00000000..85dbd16b --- /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 b6d8504c..350ac164 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,37 @@ 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"; + } +} + +/// `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, @@ -105,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"))) { @@ -124,18 +159,28 @@ 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; +} + +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); + + read_entry_count(root.child("office:meta").child("meta:document-statistic"), + result); return result; } diff --git a/src/odr/internal/odf/odf_meta.hpp b/src/odr/internal/odf/odf_meta.hpp index 888463b4..3ad805be 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 68a4e260..645312af 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 bb52f015..5c681255 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 8b516952..bbf37350 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 00000000..5972fd7c --- /dev/null +++ b/test/src/internal/odf/odf_flat_file_test.cpp @@ -0,0 +1,454 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#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=="; + +/// 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) { + 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 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(); + + 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"); +}