Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion src/odr/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
#include <odr/filesystem.hpp>

#include <odr/internal/abstract/document.hpp>
#include <odr/internal/common/filesystem.hpp>
#include <odr/internal/common/path.hpp>

#include <memory>

namespace odr {

Document::Document(std::shared_ptr<internal::abstract::Document> impl)
Expand Down Expand Up @@ -43,7 +46,13 @@ Element Document::root_element() const {
}

Filesystem Document::as_filesystem() const {
return Filesystem(m_impl->as_filesystem());
if (std::shared_ptr<internal::abstract::ReadableFilesystem> 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<internal::VirtualFilesystem>());
}

} // namespace odr
2 changes: 2 additions & 0 deletions src/odr/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 22 additions & 6 deletions src/odr/internal/odf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand Down Expand Up @@ -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.
55 changes: 48 additions & 7 deletions src/odr/internal/odf/odf_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <odr/internal/abstract/filesystem.hpp>
#include <odr/internal/common/file.hpp>
#include <odr/internal/common/table_cursor.hpp>
#include <odr/internal/crypto/crypto_util.hpp>
#include <odr/internal/odf/odf_element_registry.hpp>
#include <odr/internal/odf/odf_list.hpp>
#include <odr/internal/odf/odf_parser.hpp>
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand All @@ -925,6 +948,10 @@ class ElementAdapter final : public abstract::ElementAdapter,
}
[[nodiscard]] std::optional<File>
image_file(const ElementIdentifier element_id) const override {
if (const pugi::xml_node data = image_data(element_id)) {
return File(std::make_shared<MemoryFile>(
crypto::util::base64_decode(data.text().get())));
}
if (m_document->as_filesystem() == nullptr) {
return std::nullopt;
}
Expand All @@ -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:
Expand All @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/odr/internal/odf/odf_document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class Document final : public internal::Document {
public:
Document(FileType file_type, DocumentType document_type,
std::shared_ptr<abstract::ReadableFilesystem> 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();
Expand All @@ -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;

Expand Down
71 changes: 71 additions & 0 deletions src/odr/internal/odf/odf_flat_file.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#include <odr/internal/odf/odf_flat_file.hpp>

#include <odr/exceptions.hpp>

#include <odr/internal/odf/odf_document.hpp>
#include <odr/internal/odf/odf_meta.hpp>
#include <odr/internal/text/text_file.hpp>
#include <odr/internal/util/xml_util.hpp>
#include <odr/internal/xml/xml_file.hpp>

#include <pugixml.hpp>

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<text::TextFile>(file.file(), file.encoding())},
m_file_meta{parse_flat_file_meta(file.document().document_element())} {}

std::shared_ptr<abstract::File> 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<abstract::DecodedFile>
FlatOpenDocumentFile::decrypt(const std::string & /*password*/) const {
throw NotEncryptedError();
}

bool FlatOpenDocumentFile::is_decodable() const noexcept { return true; }

std::shared_ptr<abstract::Document> 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<Document>(m_file_meta.type, m_file_meta.document_type,
util::xml::parse(m_file->text()));
}

} // namespace odr::internal::odf
61 changes: 61 additions & 0 deletions src/odr/internal/odf/odf_flat_file.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#pragma once

#include <odr/file.hpp>

#include <odr/internal/abstract/file.hpp>

#include <memory>
#include <string>

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<abstract::File> 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<DecodedFile>
decrypt(const std::string &password) const override;

[[nodiscard]] bool is_decodable() const noexcept override;

[[nodiscard]] std::shared_ptr<abstract::Document> document() const override;

private:
std::shared_ptr<text::TextFile> m_file;
FileMeta m_file_meta;
};

} // namespace odr::internal::odf
Loading
Loading