diff --git a/.gitignore b/.gitignore index 5401bf5f5..19f5ee2d3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ ## OpenDocument.core build/ +build-wasm/ cmake-build-*/ jni/target/ jni/.flattened-pom.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 76061a0ae..f6999a1d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- New `Document::save(std::ostream &)` and `Document::save_to_memory()`, which + returns the saved document as an in-memory `File`. The path overloads are + unchanged. + +- The wasm binding gained `edit(diff)`, `save()`, `isEditable()` and + `isSavable()`, so a browser can save an edit back. + +- Memory saves in the other bindings: `save_to_memory()` returns `bytes` in + python, `saveToMemory()` `byte[]` in java, `-saveToMemoryWithError:` `NSData` + in Objective-C. + - Two entries of the same zip document can be read at once; nothing serialises on a single lock any more. diff --git a/apple/include/OdrCoreObjC/ODRDocument.h b/apple/include/OdrCoreObjC/ODRDocument.h index 576e49cbf..6907f9e17 100644 --- a/apple/include/OdrCoreObjC/ODRDocument.h +++ b/apple/include/OdrCoreObjC/ODRDocument.h @@ -28,6 +28,13 @@ NS_SWIFT_NAME(Document) password:(NSString *)password error:(NSError **)error; +/// The saved document as bytes. +- (nullable NSData *)saveToMemoryWithError:(NSError **)error + NS_SWIFT_NAME(saveToMemory()); +- (nullable NSData *)saveToMemoryWithPassword:(NSString *)password + error:(NSError **)error + NS_SWIFT_NAME(saveToMemory(password:)); + /// The document's parts as a filesystem. - (nullable ODRFilesystem *)filesystemWithError:(NSError **)error NS_SWIFT_NAME(filesystem()); diff --git a/apple/src/ODRDocument.mm b/apple/src/ODRDocument.mm index 2a0081ed8..7897f494a 100644 --- a/apple/src/ODRDocument.mm +++ b/apple/src/ODRDocument.mm @@ -6,6 +6,8 @@ #include #include +#include +#include using odr::apple::guarded; using odr::apple::guarded_value; @@ -63,6 +65,25 @@ - (BOOL)saveTo:(NSString *)path }); } +- (nullable NSData *)saveToMemoryWithError:(NSError **)error { + return guarded(error, [&]() -> NSData * { + std::ostringstream out; + _handle->save(out); + const std::string bytes = out.str(); + return [NSData dataWithBytes:bytes.data() length:bytes.size()]; + }); +} + +- (nullable NSData *)saveToMemoryWithPassword:(NSString *)password + error:(NSError **)error { + return guarded(error, [&]() -> NSData * { + std::ostringstream out; + _handle->save(out, to_string(password)); + const std::string bytes = out.str(); + return [NSData dataWithBytes:bytes.data() length:bytes.size()]; + }); +} + - (nullable ODRElement *)rootElementWithError:(NSError **)error { return guarded(error, [&]() -> ODRElement * { return [ODRElement elementWithHandle:_handle->root_element() owner:self]; diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index eccdee02f..f4514c8d7 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -247,6 +247,35 @@ final class ElementTreeTests: XCTestCase { } } +final class DocumentSaveTests: XCTestCase { + private func document() throws -> Document { + try DecodedFile.decode(path: try Fixture.odt()) + .asDocumentFile().document() + } + + func testSaveToMemoryCarriesAnEdit() throws { + let document = try self.document() + XCTAssertTrue(document.isSavable) + + let root = try XCTUnwrap(try document.rootElement()) + let text = try XCTUnwrap(root.firstDescendant(ofType: Text.self)) + try text.setContent("saved to memory") + + let saved = try XCTUnwrap(try document.saveToMemory()) + XCTAssertFalse(saved.isEmpty) + + let path = URL(fileURLWithPath: try temporaryDirectory()) + .appendingPathComponent("from-memory.odt") + try saved.write(to: path) + + let reloaded = try DecodedFile.decode(path: path.path) + .asDocumentFile().document() + let reloadedRoot = try XCTUnwrap(try reloaded.rootElement()) + XCTAssertTrue( + reloadedRoot.descendants(ofType: Text.self).contains { $0.content == "saved to memory" }) + } +} + final class TableAddressTests: XCTestCase { func testRoundTrips() throws { XCTAssertEqual(TableAddress.columnNumber(from: "C"), 2) diff --git a/jni/java/app/opendocument/core/Document.java b/jni/java/app/opendocument/core/Document.java index ba4c4a502..e55d4a1b3 100644 --- a/jni/java/app/opendocument/core/Document.java +++ b/jni/java/app/opendocument/core/Document.java @@ -26,6 +26,15 @@ public void save(String path, String password) { saveEncryptedNative(handle(), path, password); } + /** The saved document as bytes. */ + public byte[] saveToMemory() { + return saveToMemoryNative(handle()); + } + + public byte[] saveToMemory(String password) { + return saveToMemoryEncryptedNative(handle(), password); + } + public FileType fileType() { return FileType.fromNative(fileTypeNative(handle())); } @@ -60,6 +69,10 @@ void edit(String diff) { private native void saveEncryptedNative(long handle, String path, String password); + private native byte[] saveToMemoryNative(long handle); + + private native byte[] saveToMemoryEncryptedNative(long handle, String password); + private native int fileTypeNative(long handle); private native int documentTypeNative(long handle); diff --git a/jni/src/jni_document.cpp b/jni/src/jni_document.cpp index 58243642e..47533f536 100644 --- a/jni/src/jni_document.cpp +++ b/jni/src/jni_document.cpp @@ -7,6 +7,7 @@ #include #include +#include #include namespace { @@ -16,6 +17,7 @@ using odr_jni::from_handle; using odr_jni::guarded; using odr_jni::HandleGuard; using odr_jni::make_handle; +using odr_jni::to_jbytes; using odr_jni::to_jstring; using odr_jni::to_string; @@ -105,6 +107,26 @@ Java_app_opendocument_core_Document_saveEncryptedNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jbyteArray JNICALL +Java_app_opendocument_core_Document_saveToMemoryNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + std::ostringstream out; + from_handle(handle)->save(out); + return to_jbytes(env, out.str()); + }); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_app_opendocument_core_Document_saveToMemoryEncryptedNative( + JNIEnv *env, jobject, jlong handle, jstring password) { + return guarded(env, [&] { + std::ostringstream out; + from_handle(handle)->save(out, to_string(env, password)); + return to_jbytes(env, out.str()); + }); +} + extern "C" JNIEXPORT jint JNICALL Java_app_opendocument_core_Document_fileTypeNative(JNIEnv *env, jobject, jlong handle) { diff --git a/jni/tests/app/opendocument/core/DocumentTest.java b/jni/tests/app/opendocument/core/DocumentTest.java index 534767107..5d63fb305 100644 --- a/jni/tests/app/opendocument/core/DocumentTest.java +++ b/jni/tests/app/opendocument/core/DocumentTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -103,4 +104,22 @@ void editAppliesADiff() throws IOException { assertTrue(walkText(document.rootElement()).contains("edited by the diff")); } + + @Test + void saveToMemoryRoundTripsAnEdit() throws IOException { + Document document = openDocument(); + + Element paragraph = document.rootElement().firstChild(); + DocumentPath text = paragraph.firstChild().documentPath(); + Html.edit(document, "{\"modifiedText\":{\"" + text + "\":\"saved to memory\"}}"); + + byte[] saved = document.saveToMemory(); + assertTrue(saved.length > 0); + + Path reloadedPath = tempDir.resolve("from-memory.odt"); + Files.write(reloadedPath, saved); + Document reloaded = Odr.open(reloadedPath.toString()).asDocumentFile().document(); + + assertTrue(walkText(reloaded.rootElement()).contains("saved to memory")); + } } diff --git a/python/src/bind_document.cpp b/python/src/bind_document.cpp index b03932c88..456c19f60 100644 --- a/python/src/bind_document.cpp +++ b/python/src/bind_document.cpp @@ -11,6 +11,7 @@ #include +#include #include namespace py = pybind11; @@ -333,6 +334,29 @@ void odr_python::bind_document(py::module_ &m) { &odr::Document::save, py::const_), py::arg("path"), py::arg("password"), py::call_guard()) + .def( + "save_to_memory", + [](const odr::Document &document) { + std::ostringstream out; + { + py::gil_scoped_release release; + document.save(out); + } + return py::bytes(out.str()); + }, + "Save the document and return its bytes.") + .def( + "save_to_memory", + [](const odr::Document &document, const std::string &password) { + std::ostringstream out; + { + py::gil_scoped_release release; + document.save(out, password); + } + return py::bytes(out.str()); + }, + py::arg("password"), + "Save the document encrypted and return its bytes.") .def("file_type", &odr::Document::file_type) .def("document_type", &odr::Document::document_type) .def("root_element", &odr::Document::root_element, keep_self_alive) diff --git a/python/tests/test_document.py b/python/tests/test_document.py index 15c7de379..7083f6745 100644 --- a/python/tests/test_document.py +++ b/python/tests/test_document.py @@ -117,3 +117,30 @@ def items(element): assert [item.marker() for item in items(lists[1])] == ["1.", "2."] assert [item.number() for item in items(lists[1])] == [1, 2] + + +def test_save_to_memory_round_trips(odt_path, tmp_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + assert document.is_savable() + + saved = document.save_to_memory() + assert isinstance(saved, bytes) + assert saved[:2] == b"PK" + + path = tmp_path / "from_memory.odt" + path.write_bytes(saved) + reloaded = pyodr.open(str(path)).as_document_file().document() + assert walk_text(reloaded.root_element()) == walk_text(document.root_element()) + + +def test_save_to_memory_carries_an_edit(odt_path, tmp_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + + diff = '{"modifiedText":{"/child:0/child:0":"edited in python"}}' + pyodr.html.edit(document, diff) + + path = tmp_path / "edited.odt" + path.write_bytes(document.save_to_memory()) + reloaded = pyodr.open(str(path)).as_document_file().document() + + assert "edited in python" in walk_text(reloaded.root_element()) diff --git a/src/odr/document.cpp b/src/odr/document.cpp index b0895aa9a..6deff47bb 100644 --- a/src/odr/document.cpp +++ b/src/odr/document.cpp @@ -7,9 +7,12 @@ #include #include -#include +#include +#include #include +#include +#include namespace odr { @@ -26,13 +29,40 @@ bool Document::is_savable(const bool encrypted) const noexcept { return m_impl->is_savable(encrypted); } +// Checked here so an unsavable format leaves no empty file behind. void Document::save(const std::string &path) const { - m_impl->save(internal::Path(path)); + if (!m_impl->is_savable(false)) { + throw UnsupportedOperation(); + } + std::ofstream out = internal::util::file::create(path); + m_impl->save(out); } void Document::save(const std::string &path, const std::string &password) const { - m_impl->save(internal::Path(path), password.c_str()); + if (!m_impl->is_savable(true)) { + throw UnsupportedOperation(); + } + std::ofstream out = internal::util::file::create(path); + m_impl->save(out, password.c_str()); +} + +void Document::save(std::ostream &out) const { m_impl->save(out); } + +void Document::save(std::ostream &out, const std::string &password) const { + m_impl->save(out, password.c_str()); +} + +File Document::save_to_memory() const { + std::ostringstream out; + m_impl->save(out); + return File::from_memory(std::move(out).str()); +} + +File Document::save_to_memory(const std::string &password) const { + std::ostringstream out; + m_impl->save(out, password.c_str()); + return File::from_memory(std::move(out).str()); } FileType Document::file_type() const noexcept { return m_impl->file_type(); } diff --git a/src/odr/document.hpp b/src/odr/document.hpp index 9033acdb9..8c9895d31 100644 --- a/src/odr/document.hpp +++ b/src/odr/document.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -12,6 +13,7 @@ enum class FileType; enum class DocumentType; class DocumentFile; class Element; +class File; class Filesystem; /// @brief Represents a document. @@ -25,6 +27,13 @@ class Document final { void save(const std::string &path) const; void save(const std::string &path, const std::string &password) const; + void save(std::ostream &out) const; + void save(std::ostream &out, const std::string &password) const; + + /// @brief The saved document as a file in memory. + [[nodiscard]] File save_to_memory() const; + [[nodiscard]] File save_to_memory(const std::string &password) const; + [[nodiscard]] FileType file_type() const noexcept; [[nodiscard]] DocumentType document_type() const noexcept; diff --git a/src/odr/internal/abstract/document.hpp b/src/odr/internal/abstract/document.hpp index 0712d8bba..a24139762 100644 --- a/src/odr/internal/abstract/document.hpp +++ b/src/odr/internal/abstract/document.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -29,10 +30,6 @@ struct ParagraphStyle; struct GraphicStyle; } // namespace odr -namespace odr::internal { -class Path; -} // namespace odr::internal - namespace odr::internal::abstract { class ReadableFilesystem; class ElementAdapter; @@ -71,8 +68,8 @@ class Document { /// Savable, @p encrypted to ask for an encrypted save. [[nodiscard]] virtual bool is_savable(bool encrypted) const noexcept = 0; - virtual void save(const Path &path) const = 0; - virtual void save(const Path &path, const char *password) const = 0; + virtual void save(std::ostream &out) const = 0; + virtual void save(std::ostream &out, const char *password) const = 0; [[nodiscard]] virtual FileType file_type() const noexcept = 0; [[nodiscard]] virtual DocumentType document_type() const noexcept = 0; diff --git a/src/odr/internal/csv/csv_document.cpp b/src/odr/internal/csv/csv_document.cpp index f64c03139..fc4f86d07 100644 --- a/src/odr/internal/csv/csv_document.cpp +++ b/src/odr/internal/csv/csv_document.cpp @@ -313,12 +313,12 @@ bool CsvDocument::is_savable( return false; } -void CsvDocument::save([[maybe_unused]] const Path &path) const { +void CsvDocument::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void CsvDocument::save([[maybe_unused]] const Path &path, - [[maybe_unused]] const char *password) const { +void CsvDocument::save(std::ostream & /*out*/, + const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/csv/csv_document.hpp b/src/odr/internal/csv/csv_document.hpp index 124fdfafd..26bc142fe 100644 --- a/src/odr/internal/csv/csv_document.hpp +++ b/src/odr/internal/csv/csv_document.hpp @@ -31,8 +31,8 @@ class CsvDocument final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; /// The cell's text, empty where a row stops short. [[nodiscard]] std::string_view cell(std::uint32_t column, diff --git a/src/odr/internal/iwork/iwork_document.cpp b/src/odr/internal/iwork/iwork_document.cpp index 909b2aa49..404c3a885 100644 --- a/src/odr/internal/iwork/iwork_document.cpp +++ b/src/odr/internal/iwork/iwork_document.cpp @@ -60,14 +60,11 @@ bool Document::is_savable(const bool encrypted) const noexcept { return false; } -void Document::save(const Path &path) const { - (void)path; +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path &path, const char *password) const { - (void)path; - (void)password; +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/iwork/iwork_document.hpp b/src/odr/internal/iwork/iwork_document.hpp index 5759ad546..75da7f178 100644 --- a/src/odr/internal/iwork/iwork_document.hpp +++ b/src/odr/internal/iwork/iwork_document.hpp @@ -22,8 +22,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: ElementRegistry m_element_registry; diff --git a/src/odr/internal/markdown/markdown_document.cpp b/src/odr/internal/markdown/markdown_document.cpp index 735ffc3d6..6e3d32587 100644 --- a/src/odr/internal/markdown/markdown_document.cpp +++ b/src/odr/internal/markdown/markdown_document.cpp @@ -41,14 +41,11 @@ bool Document::is_savable(const bool encrypted) const noexcept { return false; } -void Document::save(const Path &path) const { - (void)path; +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path &path, const char *password) const { - (void)path; - (void)password; +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/markdown/markdown_document.hpp b/src/odr/internal/markdown/markdown_document.hpp index 10aaa71fe..a92401fd8 100644 --- a/src/odr/internal/markdown/markdown_document.hpp +++ b/src/odr/internal/markdown/markdown_document.hpp @@ -21,8 +21,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: ElementRegistry m_element_registry; diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index cb05d94eb..ecb46cea7 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -13,13 +13,12 @@ #include #include #include -#include #include #include #include #include -#include +#include #include namespace odr::internal::odf { @@ -84,11 +83,10 @@ bool Document::is_savable(const bool encrypted) const noexcept { return !encrypted; } -void Document::save(const Path &path) const { +void Document::save(std::ostream &out) const { // no package to rebuild: a flat document is the one tree, and `save` puts // back the declaration the parse dropped if (m_files == nullptr) { - std::ofstream out = util::file::create(path.string()); m_content_xml.save(out, "", pugi::format_raw); return; } @@ -115,9 +113,9 @@ void Document::save(const Path &path) const { } if (abs_path == Path("/content.xml")) { // TODO stream - std::stringstream out; - m_content_xml.print(out, "", pugi::format_raw); - auto tmp = std::make_shared(out.str()); + std::stringstream content; + m_content_xml.print(content, "", pugi::format_raw); + auto tmp = std::make_shared(content.str()); archive.insert_file(std::end(archive), rel_path, tmp); continue; } @@ -130,9 +128,9 @@ void Document::save(const Path &path) const { node.node().parent().remove_child(node.node()); } - std::stringstream out; - manifest.print(out, "", pugi::format_raw); - auto tmp = std::make_shared(out.str()); + std::stringstream content; + manifest.print(content, "", pugi::format_raw); + auto tmp = std::make_shared(content.str()); archive.insert_file(std::end(archive), rel_path, tmp); continue; @@ -140,11 +138,10 @@ void Document::save(const Path &path) const { archive.insert_file(std::end(archive), rel_path, m_files->open(abs_path)); } - std::ofstream ostream = util::file::create(path.string()); - archive.save(ostream); + archive.save(out); } -void Document::save(const Path & /*path*/, const char * /*password*/) const { +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { // TODO throw if not savable throw UnsupportedOperation(); } diff --git a/src/odr/internal/odf/odf_document.hpp b/src/odr/internal/odf/odf_document.hpp index 8e126e58a..cc6dd43f2 100644 --- a/src/odr/internal/odf/odf_document.hpp +++ b/src/odr/internal/odf/odf_document.hpp @@ -27,8 +27,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: void init_(pugi::xml_node content_root, pugi::xml_node styles_root); diff --git a/src/odr/internal/oldms/presentation/ppt_document.cpp b/src/odr/internal/oldms/presentation/ppt_document.cpp index a8d95cd2f..d6a1a1154 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.cpp +++ b/src/odr/internal/oldms/presentation/ppt_document.cpp @@ -48,14 +48,11 @@ bool Document::is_savable(const bool encrypted) const noexcept { return false; } -void Document::save(const Path &path) const { - (void)path; +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path &path, const char *password) const { - (void)path; - (void)password; +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/oldms/presentation/ppt_document.hpp b/src/odr/internal/oldms/presentation/ppt_document.hpp index 5567d1622..6e2ed6395 100644 --- a/src/odr/internal/oldms/presentation/ppt_document.hpp +++ b/src/odr/internal/oldms/presentation/ppt_document.hpp @@ -21,8 +21,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: ElementRegistry m_element_registry; diff --git a/src/odr/internal/oldms/spreadsheet/xls_document.cpp b/src/odr/internal/oldms/spreadsheet/xls_document.cpp index 6184507b4..6798a9395 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_document.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_document.cpp @@ -44,14 +44,11 @@ bool Document::is_savable(const bool encrypted) const noexcept { return false; } -void Document::save(const Path &path) const { - (void)path; +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path &path, const char *password) const { - (void)path; - (void)password; +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/oldms/spreadsheet/xls_document.hpp b/src/odr/internal/oldms/spreadsheet/xls_document.hpp index b03b1da0a..e14168127 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_document.hpp +++ b/src/odr/internal/oldms/spreadsheet/xls_document.hpp @@ -21,8 +21,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: ElementRegistry m_element_registry; diff --git a/src/odr/internal/oldms/text/doc_document.cpp b/src/odr/internal/oldms/text/doc_document.cpp index def730114..31dec33ee 100644 --- a/src/odr/internal/oldms/text/doc_document.cpp +++ b/src/odr/internal/oldms/text/doc_document.cpp @@ -43,14 +43,11 @@ bool Document::is_savable(const bool encrypted) const noexcept { return false; } -void Document::save(const Path &path) const { - (void)path; +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path &path, const char *password) const { - (void)path; - (void)password; +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/oldms/text/doc_document.hpp b/src/odr/internal/oldms/text/doc_document.hpp index 66f653894..19f9943ac 100644 --- a/src/odr/internal/oldms/text/doc_document.hpp +++ b/src/odr/internal/oldms/text/doc_document.hpp @@ -21,8 +21,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: ElementRegistry m_element_registry; diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp index 33bf009d9..fa2755200 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.cpp @@ -144,11 +144,11 @@ bool Document::is_savable(const bool /*encrypted*/) const noexcept { return false; } -void Document::save(const Path & /*path*/) const { +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path & /*path*/, const char * /*password*/) const { +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp index 44eea7f8d..e25f04bc6 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_document.hpp @@ -31,8 +31,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: pugi::xml_document m_document_xml; diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp index d51ed26a9..3f52c6a14 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp @@ -75,11 +75,11 @@ bool Document::is_savable(const bool /*encrypted*/) const noexcept { return false; } -void Document::save(const Path & /*path*/) const { +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path & /*path*/, const char * /*password*/) const { +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp index b157dd55c..d8f6bda81 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp @@ -24,8 +24,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: XmlDocumentsAndRelations m_xml_documents_and_relations; diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 1ec69490d..f42bda52f 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -9,13 +9,12 @@ #include #include #include -#include #include #include #include -#include #include +#include #include namespace odr::internal::ooxml::text { @@ -117,7 +116,7 @@ bool Document::is_savable(const bool encrypted) const noexcept { return !encrypted; } -void Document::save(const Path &path) const { +void Document::save(std::ostream &out) const { // TODO this would decrypt/inflate and encrypt/deflate again zip::ZipArchive archive; @@ -131,20 +130,19 @@ void Document::save(const Path &path) const { } if (abs_path == AbsPath("/word/document.xml")) { // TODO stream - std::stringstream out; - m_document_xml.print(out, "", pugi::format_raw); - auto tmp = std::make_shared(out.str()); + std::stringstream content; + m_document_xml.print(content, "", pugi::format_raw); + auto tmp = std::make_shared(content.str()); archive.insert_file(std::end(archive), rel_path, tmp); continue; } archive.insert_file(std::end(archive), rel_path, m_files->open(abs_path)); } - std::ofstream ostream = util::file::create(path.string()); - archive.save(ostream); + archive.save(out); } -void Document::save(const Path & /*path*/, const char * /*password*/) const { +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.hpp b/src/odr/internal/ooxml/text/ooxml_text_document.hpp index 8b3ff0d68..2435dde6a 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.hpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.hpp @@ -30,8 +30,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: pugi::xml_document m_document_xml; diff --git a/src/odr/internal/rtf/rtf_document.cpp b/src/odr/internal/rtf/rtf_document.cpp index 21588ef05..595a4103a 100644 --- a/src/odr/internal/rtf/rtf_document.cpp +++ b/src/odr/internal/rtf/rtf_document.cpp @@ -40,14 +40,11 @@ bool Document::is_savable(const bool encrypted) const noexcept { return false; } -void Document::save(const Path &path) const { - (void)path; +void Document::save(std::ostream & /*out*/) const { throw UnsupportedOperation(); } -void Document::save(const Path &path, const char *password) const { - (void)path; - (void)password; +void Document::save(std::ostream & /*out*/, const char * /*password*/) const { throw UnsupportedOperation(); } diff --git a/src/odr/internal/rtf/rtf_document.hpp b/src/odr/internal/rtf/rtf_document.hpp index 62b6c5712..163f8e7dc 100644 --- a/src/odr/internal/rtf/rtf_document.hpp +++ b/src/odr/internal/rtf/rtf_document.hpp @@ -20,8 +20,8 @@ class Document final : public internal::Document { [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; - void save(const Path &path) const override; - void save(const Path &path, const char *password) const override; + void save(std::ostream &out) const override; + void save(std::ostream &out, const char *password) const override; private: ElementRegistry m_element_registry; diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index 50825825e..5b3df990e 100644 --- a/test/src/document_test.cpp +++ b/test/src/document_test.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include @@ -10,6 +12,7 @@ #include #include +#include #include using namespace odr; @@ -327,3 +330,60 @@ TEST(Document, edit_docx_diff) { "Colorasdfasdfasdfed Line"); expect_text_at(document, "/child:6/child:0/child:0", "Text hello world!"); } + +namespace { + +/// The one document `save` writes as xml rather than as a zip. +constexpr const char *flat_odt = + R"()" + R"()" + R"(hello)" + R"()"; + +} // namespace + +TEST(Document, save_to_memory_round_trips_a_flat_document) { + const Document document = DocumentFile::from_memory(flat_odt).document(); + + set_every_text(document.root_element(), "hello world!"); + + const File saved = document.save_to_memory(); + EXPECT_EQ(FileLocation::memory, saved.location()); + + const Document reloaded = DocumentFile(saved).document(); + expect_every_text(reloaded.root_element(), "hello world!"); +} + +TEST(Document, save_to_a_stream_writes_what_save_to_memory_holds) { + const Document document = DocumentFile::from_memory(flat_odt).document(); + + std::ostringstream out; + document.save(out); + + EXPECT_EQ(out.str(), document.save_to_memory().memory_data().value()); +} + +TEST(Document, save_to_memory_round_trips_a_package) { + const Document document = + DocumentFile(TestData::test_file_path("odr-public/odt/about.odt")) + .document(); + + set_every_text(document.root_element(), "hello world!"); + + const Document reloaded = DocumentFile(document.save_to_memory()).document(); + expect_every_text(reloaded.root_element(), "hello world!"); +} + +TEST(Document, saving_an_unsavable_format_leaves_no_file) { + const Document document = + DocumentFile( + TestData::test_file_path("odr-public/pptx/style-various-1.pptx")) + .document(); + ASSERT_FALSE(document.is_savable()); + + const std::string path = + (std::filesystem::current_path() / "unsavable_save.pptx").string(); + EXPECT_THROW(document.save(path), UnsupportedOperation); + EXPECT_FALSE(std::filesystem::exists(path)); +} diff --git a/wasm/AGENTS.md b/wasm/AGENTS.md index d090835e6..4ad1f1f6e 100644 --- a/wasm/AGENTS.md +++ b/wasm/AGENTS.md @@ -39,7 +39,10 @@ Worker**, where every value that crosses is structured-cloned. view an index within its session. This also dissolves the keep-alive problem the other bindings hand-built: `HtmlView` holds a bare pointer into its service and `Element` into the document adapter, and here neither is handed - out — `Session` owns file, service and views together. Handle `0` is never + out — `Session` owns file, document, service and views together. The + document is the *one* tree the render, the edit and the save all go through: + `DocumentFile::document()` decodes a fresh one per call, so a `save` that + opened its own would write the document nobody edited. Handle `0` is never issued, so a zeroed handle is always invalid. - **Config crosses as a plain object.** `to_html_config` reads known keys and leaves the rest defaulted. Never bind a mutable config: it could not cross diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index 103e914c0..532042cc8 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -32,6 +32,7 @@ endif () add_executable(odr_wasm "src/odr_wasm.cpp" "src/wasm_core.cpp" + "src/wasm_document.cpp" "src/wasm_file.cpp" "src/wasm_html.cpp" "src/wasm_logger.cpp" diff --git a/wasm/README.md b/wasm/README.md index 64cdecf26..b2d6b5906 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -59,6 +59,22 @@ for (const view of doc.listViews()) { } ``` +Editing is a round trip through the rendered page: + +```js +const doc = odr.open(bytes, { editable: true }); +const { html } = doc.render(0); +// ... the reader edits the page in the iframe ... +doc.edit(JSON.stringify(iframe.contentWindow.odr.generateDiff())); + +const saved = doc.save(); // the document, not the html +download(new Blob([saved])); +``` + +`isEditable()` and `isSavable()` answer for this document, where +`capabilities()` answers for the format. Only ODF and docx can be saved so far; +anything else throws `UnsupportedOperation`. + Encrypted documents: ```js diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 507715ae4..3104d1543 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -150,6 +150,16 @@ export declare class Document { render(index?: number): Rendered; read(path: string): Content; + /** `capabilities()` narrowed to this document. */ + isEditable(): boolean; + isSavable(encrypted?: boolean): boolean; + /** Applies what the rendered page's `odr.generateDiff()` collected. + * @throws OdrError `NoDocumentFile` */ + edit(diff: string): this; + /** The document's bytes, not the rendered html. + * @throws OdrError `UnsupportedOperation` where the format cannot be saved */ + save(password?: string): Uint8Array; + /** Idempotent; returns whether it released anything. */ close(): boolean; [Symbol.dispose](): void; diff --git a/wasm/js/index.js b/wasm/js/index.js index 97e24026a..bfca3432e 100644 --- a/wasm/js/index.js +++ b/wasm/js/index.js @@ -71,6 +71,28 @@ export class Document { return unwrap(this.#core.readPath(this.#handle, path)); } + // `capabilities()` narrowed to this document. + isEditable() { + return unwrap(this.#core.isEditable(this.#handle)); + } + + isSavable(encrypted = false) { + return unwrap(this.#core.isSavable(this.#handle, encrypted)); + } + + // Applies what the rendered page's `odr.generateDiff()` collected. + edit(diff) { + unwrap(this.#core.edit(this.#handle, diff)); + return this; + } + + // The document's bytes, not the rendered html. + save(password) { + return password === undefined + ? unwrap(this.#core.save(this.#handle)) + : unwrap(this.#core.saveEncrypted(this.#handle, password)); + } + close() { return unwrap(this.#core.close(this.#handle)); } diff --git a/wasm/src/odr_wasm.cpp b/wasm/src/odr_wasm.cpp index 4cc983a5e..53ef0dc79 100644 --- a/wasm/src/odr_wasm.cpp +++ b/wasm/src/odr_wasm.cpp @@ -38,6 +38,13 @@ Session &session(const Handle handle) { return it->second; } +Document &document_of(Session &session) { + if (!session.document.has_value()) { + session.document = session.file.as_document_file().document(); + } + return *session.document; +} + Handle add_session(Session session) { const Handle handle = next_handle()++; sessions().emplace(handle, std::move(session)); diff --git a/wasm/src/odr_wasm.hpp b/wasm/src/odr_wasm.hpp index 3e8a942dd..279aede1c 100644 --- a/wasm/src/odr_wasm.hpp +++ b/wasm/src/odr_wasm.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -24,6 +25,9 @@ struct Session final { DecodedFile file; Logger logger; HtmlConfig config; + /// The one tree render, edit and save share; `DocumentFile::document()` + /// decodes a fresh one per call. + std::optional document; std::optional service; HtmlViews views; }; @@ -32,6 +36,8 @@ Logger &default_logger(); /// @throws std::out_of_range if @p handle is unknown. Session &session(Handle handle); +/// @throws NoDocumentFile if the session's file is not a document. +Document &document_of(Session &session); Handle add_session(Session session); bool remove_session(Handle handle) noexcept; void clear_sessions() noexcept; diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp new file mode 100644 index 000000000..445044b1a --- /dev/null +++ b/wasm/src/wasm_document.cpp @@ -0,0 +1,56 @@ +#include + +#include +#include + +#include + +#include +#include + +namespace odr::wasm { + +namespace { + +/// `capabilities()` narrowed to this document. +emscripten::val is_editable(const Handle handle) { + return guarded([&] { + return ok(emscripten::val(document_of(session(handle)).is_editable())); + }); +} + +emscripten::val is_savable(const Handle handle, const bool encrypted) { + return guarded([&] { + return ok( + emscripten::val(document_of(session(handle)).is_savable(encrypted))); + }); +} + +/// The document's bytes; there is no filesystem to save to. +emscripten::val save(const Handle handle) { + return guarded([&] { + std::ostringstream out; + document_of(session(handle)).save(out); + return ok(to_uint8_array(out.str())); + }); +} + +emscripten::val save_encrypted(const Handle handle, + const std::string &password) { + return guarded([&] { + std::ostringstream out; + document_of(session(handle)).save(out, password); + return ok(to_uint8_array(out.str())); + }); +} + +} // namespace + +} // namespace odr::wasm + +EMSCRIPTEN_BINDINGS(odr_document) { + emscripten::function("isEditable", &odr::wasm::is_editable); + emscripten::function("isSavable", &odr::wasm::is_savable); + emscripten::function("save", &odr::wasm::save); + emscripten::function("saveEncrypted", &odr::wasm::save_encrypted); +} diff --git a/wasm/src/wasm_file.cpp b/wasm/src/wasm_file.cpp index 2fa4fc133..e89c19133 100644 --- a/wasm/src/wasm_file.cpp +++ b/wasm/src/wasm_file.cpp @@ -23,6 +23,7 @@ emscripten::val opened(DecodedFile file, const emscripten::val &config) { Session s{.file = std::move(file), .logger = default_logger(), .config = to_html_config(config), + .document = {}, .service = {}, .views = {}}; return ok(emscripten::val(add_session(std::move(s)))); @@ -87,7 +88,8 @@ emscripten::val decrypt(const Handle handle, const std::string &password) { return guarded([&] { Session &s = session(handle); s.file = s.file.decrypt(password); - // whatever was translated came from the encrypted file + // whatever was decoded or translated came from the encrypted file + s.document.reset(); s.service.reset(); s.views.clear(); return ok(); diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 891d8d2ec..723ab9960 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -51,7 +51,10 @@ void read_measure(const emscripten::val &value, const char *key, Session &warm(const Handle handle) { Session &s = session(handle); if (!s.service.has_value()) { - s.service = html::translate(s.file, s.config, s.logger); + // from the session's tree, not the file, which would decode a second one + s.service = s.file.is_document_file() + ? html::translate(document_of(s), s.config, s.logger) + : html::translate(s.file, s.config, s.logger); s.views = s.service->list_views(); } return s; @@ -135,6 +138,15 @@ emscripten::val read_path(const Handle handle, const std::string &path) { }); } +/// Applies what the rendered page's `odr.generateDiff()` collected. +emscripten::val edit(const Handle handle, const std::string &diff) { + return guarded([&] { + Session &s = session(handle); + html::edit(document_of(s), diff, s.logger); + return ok(); + }); +} + } // namespace HtmlConfig to_html_config(const emscripten::val &value) { @@ -207,4 +219,5 @@ EMSCRIPTEN_BINDINGS(odr_html) { emscripten::function("listViews", &odr::wasm::list_views); emscripten::function("renderView", &odr::wasm::render_view); emscripten::function("readPath", &odr::wasm::read_path); + emscripten::function("edit", &odr::wasm::edit); } diff --git a/wasm/tests/edit.test.mjs b/wasm/tests/edit.test.mjs new file mode 100644 index 000000000..4efa6fb2e --- /dev/null +++ b/wasm/tests/edit.test.mjs @@ -0,0 +1,96 @@ +// The round trip: render editable, apply the page's diff, save the bytes back. + +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; + +import { Odr, OdrError, minimalOdt } from './helper.mjs'; + +// Read out of the html rather than spelled, as the browser does. +function firstEditablePath(html) { + const match = html.match(/data-odr-path="([^"]+)"/); + assert.ok(match, 'the editable render carries no data-odr-path'); + return match[1]; +} + +describe('edit', () => { + let odr; + before(async () => { + odr = await Odr(); + }); + after(() => odr.closeAll()); + + it('reports what this document can do', () => { + const doc = odr.open(minimalOdt()); + try { + assert.equal(doc.isEditable(), true); + assert.equal(doc.isSavable(), true); + assert.equal(doc.isSavable(true), false); + } finally { + doc.close(); + } + }); + + it('applies a diff and saves the document it renders', () => { + const doc = odr.open(minimalOdt('hello'), { editable: true }); + try { + const { html } = doc.render(0); + assert.match(html, /contenteditable/); + + const path = firstEditablePath(html); + doc.edit(JSON.stringify({ modifiedText: { [path]: 'edited in the browser' } })); + + // the edit is in the document, so the same service renders it + assert.match(doc.render(0).html, /edited in the browser/); + + const saved = doc.save(); + assert.ok(saved instanceof Uint8Array); + // a zip, i.e. the document rather than the rendered page + assert.deepEqual(Array.from(saved.subarray(0, 2)), [0x50, 0x4b]); + + const reopened = odr.open(saved); + try { + assert.equal(reopened.fileType, odr.enums.FileType.odt); + assert.match(reopened.render(0).html, /edited in the browser/); + } finally { + reopened.close(); + } + } finally { + doc.close(); + } + }); + + it('saves without a render having happened', () => { + const doc = odr.open(minimalOdt('untouched')); + try { + assert.match(new TextDecoder().decode(doc.save()), /^PK/); + } finally { + doc.close(); + } + }); + + it('refuses a file that is not a document', () => { + const doc = odr.open(new TextEncoder().encode('lorem ipsum dolor sit amet')); + try { + assert.throws(() => doc.save(), (error) => { + assert.ok(error instanceof OdrError); + assert.equal(error.name, 'NoDocumentFile'); + return true; + }); + assert.throws(() => doc.edit('{"modifiedText":{}}'), OdrError); + } finally { + doc.close(); + } + }); + + it('refuses an encrypted save, which no format supports yet', () => { + const doc = odr.open(minimalOdt()); + try { + assert.throws(() => doc.save('secret'), (error) => { + assert.equal(error.name, 'UnsupportedOperation'); + return true; + }); + } finally { + doc.close(); + } + }); +});