From 683c8f95c5e43b250b4495f313633a377c3960e4 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 21:23:28 +0200 Subject: [PATCH 1/3] fix(wasm): a plain text file saves through edit and save The wasm Document sent isEditable, isSavable, edit and save to the document of the session. A txt has no document, so each call threw NoDocumentFile, and a .txt could not be saved from the browser. Python, Java and Objective-C were not affected, because they bind TextFile::write_edited. For a text file, the four calls now use the TextFile. The edit puts the edited bytes in place of the session's file, so the next render shows them. The save writes the file as UTF-8. TextFile::is_savable now also requires FileType::text_file. JsonFile is a TextFile too, so a json view was editable and write_edited wrote it, but the table declares json unsaved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018ZehrLdwNTE8sBLxNZZYpW --- CHANGELOG.md | 8 ++++ src/odr/file.cpp | 4 ++ src/odr/file.hpp | 5 +- test/src/internal/text/text_file_test.cpp | 12 +++++ wasm/AGENTS.md | 6 ++- wasm/README.md | 3 +- wasm/js/index.d.ts | 8 ++-- wasm/src/wasm_document.cpp | 35 +++++++++++--- wasm/src/wasm_html.cpp | 14 ++++++ wasm/tests/edit.test.mjs | 58 ++++++++++++++++++++++- 10 files changed, 136 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96380c327..7ea9c2bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- The wasm package's `Document.edit`, `save`, `isEditable` and `isSavable` + work for a plain text file. They threw `NoDocumentFile` before, so a `.txt` + could not be saved from the browser. A txt saves as UTF-8. + +- `TextFile::is_savable` is false for a json file, as every binding's doc + already said. It was true, so a json view was editable and `write_edited` + wrote it. + ## v7.0.0 - 2026-09-13 - **Breaking**: `DocumentPath`, `Element::document_path()` and diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 195ef93e6..109ca6c63 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -291,6 +291,10 @@ std::string TextFile::text() const { } bool TextFile::is_savable() const noexcept { + // json is a text file too, and one this library does not write + if (file_type() != FileType::text_file) { + return false; + } const TextEncoding encoding = this->encoding(); return encoding == TextEncoding::unknown || text_encoding_is_decodable(encoding); diff --git a/src/odr/file.hpp b/src/odr/file.hpp index f0eeb75ac..7ef674400 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -439,8 +439,9 @@ class TextFile final : public DecodedFile { /// the raw bytes where it is not. [[nodiscard]] std::string text() const; - /// False where @ref encoding cannot be decoded: the view hands those bytes - /// to the browser as they are, so what comes back cannot be put back. + /// False where the file type is not `text_file`, and where @ref encoding + /// cannot be decoded: the view hands those bytes to the browser as they are, + /// so what comes back cannot be put back. [[nodiscard]] bool is_savable() const noexcept; /// Applies @p operations - `{"version": 2, "ops": [{"op": "setContent", diff --git a/test/src/internal/text/text_file_test.cpp b/test/src/internal/text/text_file_test.cpp index 39e5c42bf..71e2ac9ae 100644 --- a/test/src/internal/text/text_file_test.cpp +++ b/test/src/internal/text/text_file_test.cpp @@ -168,6 +168,18 @@ TEST(TextFile, a_file_we_cannot_decode_is_not_savable) { UnsupportedOperation); } +/// json reads as a text file, and the table declares it unsaved. +TEST(TextFile, json_is_not_savable) { + const DecodedFile json = + open(File::from_memory(std::string(R"({"a": 1})")), + DecodeOptions::as(FileType::javascript_object_notation)); + ASSERT_TRUE(json.is_text_file()); + EXPECT_FALSE(json.as_text_file().is_savable()); + std::ostringstream out; + EXPECT_THROW(json.as_text_file().write_edited(set_content("x"), out), + UnsupportedOperation); +} + /// A decodable encoding that is not utf-8 saves, and saves as utf-8. TEST(TextFile, a_decodable_encoding_saves_as_utf8) { const odr::TextFile latin1(std::make_shared( diff --git a/wasm/AGENTS.md b/wasm/AGENTS.md index f2054a013..4bd7e3f04 100644 --- a/wasm/AGENTS.md +++ b/wasm/AGENTS.md @@ -42,8 +42,10 @@ Worker**, where every value that crosses is structured-cloned. 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. + opened its own would write the document nobody edited. A plain text file + has no tree, so its `edit` makes the edited bytes the session's file, and + the same calls answer for it. Handle `0` is never issued, so a zeroed handle + is always invalid. This is why the structural edits are addressed **by element id**, not by an element: an id is a plain number, and the render already writes it into the diff --git a/wasm/README.md b/wasm/README.md index 2594c78eb..b235e869b 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -95,7 +95,8 @@ refuses the rest with code 1010, `outOfScope`. `isEditable()` and `isSavable()` answer for this document, where `capabilities()` answers for the format. ODF, docx, pptx, xlsx and txt can be -saved; anything else throws `UnsupportedOperation`. +saved; anything else throws `UnsupportedOperation`. A txt saves as UTF-8, +whatever encoding it was read in. Encrypted documents: diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 5f8d725f4..a20106256 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -176,8 +176,9 @@ export declare class Document { /** `capabilities()` narrowed to this document. */ isEditable(): boolean; isSavable(encrypted?: boolean): boolean; - /** Applies what the rendered page's `odr.generateDiff()` collected. - * @throws OdrError `NoDocumentFile` */ + /** Applies what the rendered page's `odr.generateDiff()` collected. A plain + * text file takes its `setContent` envelope, and the next render shows it. + * @throws OdrError `NoDocumentFile` for a file that is neither */ edit(diff: string): this; /** @@ -217,7 +218,8 @@ export declare class Document { * `odr.annotation.getAnnotations()` collected. */ annotate(annotations: string): Uint8Array; - /** The document's bytes, not the rendered html. + /** The document's bytes, not the rendered html. A plain text file saves as + * UTF-8, whatever its source encoding. * @throws OdrError `UnsupportedOperation` where the format cannot be saved */ save(password?: string): Uint8Array; diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp index 9bb8422eb..db64d3e22 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -14,17 +15,26 @@ namespace odr::wasm { namespace { -/// `capabilities()` narrowed to this document. +/// `capabilities()` narrowed to this document. A plain text file has no +/// document, and `TextFile::is_savable` answers both for it. emscripten::val is_editable(const Handle handle) { return guarded([&] { - return ok(emscripten::val(document_of(session(handle)).is_editable())); + Session &s = session(handle); + if (s.file.is_text_file()) { + return ok(emscripten::val(s.file.as_text_file().is_savable())); + } + return ok(emscripten::val(document_of(s).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))); + Session &s = session(handle); + if (s.file.is_text_file()) { + return ok( + emscripten::val(!encrypted && s.file.as_text_file().is_savable())); + } + return ok(emscripten::val(document_of(s).is_savable(encrypted))); }); } @@ -128,11 +138,18 @@ emscripten::val insert_paragraph_after(const Handle handle, }); } -/// The document's bytes; there is no filesystem to save to. +/// The document's bytes; there is no filesystem to save to. A plain text file +/// saves as UTF-8, whatever its source encoding. emscripten::val save(const Handle handle) { return guarded([&] { + Session &s = session(handle); std::ostringstream out; - document_of(session(handle)).save(out); + if (s.file.is_text_file()) { + s.file.as_text_file().write_edited(R"({"version":2,"ops":[]})", out, + s.logger); + } else { + document_of(s).save(out); + } return ok(to_uint8_array(out.str())); }); } @@ -140,8 +157,12 @@ emscripten::val save(const Handle handle) { emscripten::val save_encrypted(const Handle handle, const std::string &password) { return guarded([&] { + Session &s = session(handle); + if (s.file.is_text_file()) { + throw UnsupportedOperation(); + } std::ostringstream out; - document_of(session(handle)).save(out, password); + document_of(s).save(out, password); return ok(to_uint8_array(out.str())); }); } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 41431c962..dc6101d4d 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include @@ -144,6 +145,19 @@ emscripten::val read_path(const Handle handle, const std::string &path) { emscripten::val edit(const Handle handle, const std::string &diff) { return guarded([&] { Session &s = session(handle); + if (s.file.is_text_file()) { + // no tree holds the edit, so the edited bytes become the session's file + // and the next render translates them + std::ostringstream out; + s.file.as_text_file().write_edited(diff, out, s.logger); + DecodedFile edited = odr::open( + File::from_memory(std::move(out).str(), s.file.file().name()), + DecodeOptions::as(FileType::text_file), s.logger); + s.views.clear(); + s.service.reset(); + s.file = std::move(edited); + return ok(); + } document_of(s).edit(diff, s.logger); return ok(); }); diff --git a/wasm/tests/edit.test.mjs b/wasm/tests/edit.test.mjs index ee1e732ec..677c7aa4f 100644 --- a/wasm/tests/edit.test.mjs +++ b/wasm/tests/edit.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; -import { Odr, OdrError, minimalOds, minimalOdt } from './helper.mjs'; +import { Odr, OdrError, minimalOds, minimalOdt, minimalPdf } from './helper.mjs'; // Read out of the html rather than spelled, as the browser does. The runs // carry the ids an op names; a paragraph carries one too, so the tag counts. @@ -168,8 +168,62 @@ describe('edit', () => { } }); + it('edits and saves a plain text file through the same calls', () => { + const doc = odr.open(new TextEncoder().encode('lorem ipsum'), { + editable: true, + name: 'notes.txt', + }); + try { + assert.equal(doc.isEditable(), true); + assert.equal(doc.isSavable(), true); + assert.equal(doc.isSavable(true), false); + assert.equal(new TextDecoder().decode(doc.save()), 'lorem ipsum'); + + assert.match(doc.render(0).html, /lorem ipsum/); + doc.edit(JSON.stringify({ + version: 2, + ops: [{ op: 'setContent', text: 'edited in the browser' }], + })); + assert.equal(doc.fileName, 'notes.txt'); + assert.match(doc.render(0).html, /edited in the browser/); + + const saved = doc.save(); + assert.equal(new TextDecoder().decode(saved), 'edited in the browser'); + const reopened = odr.open(saved); + try { + assert.equal(reopened.fileType, odr.enums.FileType.txt); + } finally { + reopened.close(); + } + + assert.throws(() => doc.save('secret'), (error) => { + assert.equal(error.name, 'UnsupportedOperation'); + return true; + }); + } finally { + doc.close(); + } + }); + + it('refuses a text file of a type it does not write', () => { + const doc = odr.open(new TextEncoder().encode('{"a": 1}'), { editable: true }); + try { + assert.equal(doc.isEditable(), false); + assert.equal(doc.isSavable(), false); + assert.match(doc.render(0).html, /data-odr-editable="readOnly"/); + for (const call of [() => doc.save(), () => doc.edit('{"version":2,"ops":[]}')]) { + assert.throws(call, (error) => { + assert.equal(error.name, 'UnsupportedOperation'); + return true; + }); + } + } finally { + doc.close(); + } + }); + it('refuses a file that is not a document', () => { - const doc = odr.open(new TextEncoder().encode('lorem ipsum dolor sit amet')); + const doc = odr.open(minimalPdf()); try { assert.throws(() => doc.save(), (error) => { assert.ok(error instanceof OdrError); From 865a43c465c4cf8f0608a54d0af6d560bd9ffc7b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 21:34:54 +0200 Subject: [PATCH 2/3] feat(api): TextFile has the edit and save names of Document A host saved a document with edit and save, but a plain text file with write_edited. So each binding had to tell a txt from a document before a save. TextFile now has edit, save and save_to_memory. The edit keeps the text in the file of the text engine, so every handle over the file and the next render see it. The save writes the text as UTF-8. Python, Java, Objective-C and Swift bind the new names, and the wasm binding uses them instead of a reopen of the edited bytes. write_edited stays in core and in every binding, and its doc marks it deprecated, so no caller breaks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018ZehrLdwNTE8sBLxNZZYpW --- CHANGELOG.md | 4 ++ apple/include/OdrCoreObjC/ODRFile.h | 13 +++- apple/src/ODRFile.mm | 23 +++++++ apple/tests/OdrCoreTests.swift | 14 +++++ docs/design/editing.md | 4 +- docs/design/txt-editing.md | 26 +++++--- jni/java/app/opendocument/core/TextFile.java | 31 +++++++++- jni/src/jni_file.cpp | 26 ++++++++ jni/tests/app/opendocument/core/FileTest.java | 17 +++++ python/src/bind_file.cpp | 28 ++++++++- python/tests/test_file.py | 13 ++++ src/odr/file.cpp | 62 +++++++++++++++++-- src/odr/file.hpp | 23 ++++++- src/odr/internal/text/text_file.cpp | 5 ++ src/odr/internal/text/text_file.hpp | 3 + test/src/internal/text/text_file_test.cpp | 20 ++++++ wasm/AGENTS.md | 4 +- wasm/src/wasm_document.cpp | 3 +- wasm/src/wasm_html.cpp | 16 +---- 19 files changed, 293 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea9c2bb3..9ead1e84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ The release run heads these entries with the version and opens a fresh already said. It was true, so a json view was editable and `write_edited` wrote it. +- `TextFile` has the names of `Document`: `edit` keeps the edit in the file, + and `save` and `save_to_memory` write it. Python, Java, Objective-C and + Swift bind them. `write_edited` stays, but is deprecated. + ## v7.0.0 - 2026-09-13 - **Breaking**: `DocumentPath`, `Element::document_path()` and diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index 6d3ae4bde..2b5f75035 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -382,8 +382,19 @@ NS_SWIFT_NAME(TextFile) /// `NO` where the file type is one this library does not write, or the /// encoding cannot be decoded. @property(nonatomic, readonly) BOOL isSavable; +/// Applies the operations our browser-side editor produces, in order. The text +/// is UTF-8 afterwards, whatever the source encoding was. +- (BOOL)edit:(NSString *)operations + error:(NSError **)error NS_SWIFT_NAME(edit(operations:)); +/// Writes the text, with every edit applied, as UTF-8. +- (BOOL)saveTo:(NSString *)path error:(NSError **)error; +/// The saved file as bytes. +- (nullable NSData *)saveToMemoryWithError:(NSError **)error + NS_SWIFT_NAME(saveToMemory()); /// Applies the operations and returns the result, as UTF-8 whatever the source -/// encoding was. +/// encoding was. The file stays as it is. +/// +/// Deprecated: `edit(operations:)`, then `saveToMemory()`. - (nullable NSData *)writeEdited:(NSString *)operations error:(NSError **)error NS_SWIFT_NAME(writeEdited(operations:)); diff --git a/apple/src/ODRFile.mm b/apple/src/ODRFile.mm index db8eab719..185f216a1 100644 --- a/apple/src/ODRFile.mm +++ b/apple/src/ODRFile.mm @@ -603,6 +603,29 @@ - (BOOL)isSavable { [&] { return self.handle.as_text_file().is_savable() ? YES : NO; }, NO); } +- (BOOL)edit:(NSString *)operations error:(NSError **)error { + return guarded(error, [&] { + self.handle.as_text_file().edit(to_string(operations)); + return YES; + }); +} + +- (BOOL)saveTo:(NSString *)path error:(NSError **)error { + return guarded(error, [&] { + self.handle.as_text_file().save(to_string(path)); + return YES; + }); +} + +- (nullable NSData *)saveToMemoryWithError:(NSError **)error { + return guarded(error, [&]() -> NSData * { + std::ostringstream out; + self.handle.as_text_file().save(out); + const std::string bytes = out.str(); + return [NSData dataWithBytes:bytes.data() length:bytes.size()]; + }); +} + - (nullable NSData *)writeEdited:(NSString *)operations error:(NSError **)error { return guarded(error, [&]() -> NSData * { diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index c9243b9d9..5412b68a6 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -526,6 +526,20 @@ final class TextFileEditTests: XCTestCase { XCTAssertEqual(String(data: edited, encoding: .utf8), "rewritten") } + + func testEditsAndSavesLikeADocument() throws { + let path = try write("hello text file\n", as: "note.txt") + let file = try DecodedFile.decode(path: path).asTextFile() + + try file.edit( + operations: #"{"version":2,"ops":[{"op":"setContent","text":"rewritten"}]}"#) + + XCTAssertEqual(try file.text(), "rewritten") + XCTAssertEqual(String(data: try file.saveToMemory(), encoding: .utf8), "rewritten") + let saved = (path as NSString).deletingLastPathComponent + "/saved.txt" + try file.save(to: saved) + XCTAssertEqual(try String(contentsOfFile: saved, encoding: .utf8), "rewritten") + } } final class PdfAnnotationTests: XCTestCase { diff --git a/docs/design/editing.md b/docs/design/editing.md index 095f0ce6c..679bb2461 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -32,8 +32,8 @@ file — with no live connection between the browser and C++. - `Document::edit` replays the envelope: `setCell` for a sheet, and `setText`, `setTextStyle`, `insertText`, `removeElement`, `splitParagraph`, `mergeParagraph` and `insertParagraph` for a document. - `TextFile::write_edited` is the plain-text counterpart, since a `.txt` is - not a document. + `TextFile::edit` is the plain-text counterpart, with the same name, since a + `.txt` is not a document. - `back_translate` CLI replays an envelope onto a source document and `save`s it. Inline formatting — bold, italic, underline, strikethrough, highlight, colour diff --git a/docs/design/txt-editing.md b/docs/design/txt-editing.md index f07006c21..1fc8cafdc 100644 --- a/docs/design/txt-editing.md +++ b/docs/design/txt-editing.md @@ -64,27 +64,35 @@ does not have. UTF-16 code units and `std::string` counts bytes. **What it costs:** the whole file crosses the bridge on every save. Less than -it looks, because `write_edited` produces the complete bytes either way, so a +it looks, because `TextFile::save` writes the complete bytes either way, so a finer log would only be reassembled before writing; the saving would be one hop. Where it does bite is a large file, and the answer there is **one** `replaceLines {from, to, text}` computed as a single diff hunk at emit time — still one operation, still applied to the file as it was, so still nothing positional to go stale. That needs no schema change to reach. -### 3. The write path is `PdfFile::annotate`'s shape, not `Document::save`'s +### 3. The write path has `Document`'s names ```cpp [[nodiscard]] bool TextFile::is_savable() const noexcept; -void TextFile::write_edited(std::string_view operations, std::ostream &out, - const Logger & = Logger::null()) const; +void TextFile::edit(std::string_view operations, + const Logger & = Logger::null()) const; +void TextFile::save(std::ostream &out) const; ``` -One call taking the envelope and a stream, leaving the handle unchanged. +`edit` keeps the edit in the file, so every handle over it and the next render +see it. `save` writes the text. -**Why:** a `TextFile` is an immutable handle over bytes, and there is no -document to mutate and later serialise. `PdfFile::annotate` is the precedent — -the other non-document file with a write path of its own — and the shape suits -for the same reason: nothing is held between the edit and the write. +**Why:** a host saves a `.txt` with the calls it already makes for a document, +and learns no second API because of what the file turned out to be — decision 9 +of [`editing.md`](editing.md) again. The first shape was `PdfFile::annotate`'s: +`TextFile::write_edited` took the envelope and a stream and left the file as it +was. Each binding then had to tell a txt from a document before a save. It +stays, deprecated. + +**What it costs:** a `TextFile` handle is no longer immutable. The edit lives in +the text engine's file, which every handle shares, as a document's edit lives +in its shared tree. ### 4. What it writes is UTF-8, whatever the source was diff --git a/jni/java/app/opendocument/core/TextFile.java b/jni/java/app/opendocument/core/TextFile.java index fb04cab90..edf1efb08 100644 --- a/jni/java/app/opendocument/core/TextFile.java +++ b/jni/java/app/opendocument/core/TextFile.java @@ -35,15 +35,42 @@ public boolean isSavable() { } /** - * Applies the operations and returns the result, as UTF-8 whatever the source - * encoding was. + * Applies the operations our browser-side editor produces. The text is UTF-8 afterwards, + * whatever the source encoding was. */ + public void edit(String operations) { + editNative(handle(), operations); + } + + /** Writes the text, with every edit applied, as UTF-8. */ + public void save(String path) { + saveNative(handle(), path); + } + + /** The saved file as bytes. */ + public byte[] saveToMemory() { + return saveToMemoryNative(handle()); + } + + /** + * Applies the operations and returns the result, as UTF-8 whatever the source encoding was. The + * file stays as it is. + * + * @deprecated use {@link #edit(String)}, then {@link #saveToMemory()} + */ + @Deprecated public byte[] writeEdited(String operations) { return writeEditedNative(handle(), operations); } private native int encodingNative(long handle); + private native void editNative(long handle, String operations); + + private native void saveNative(long handle, String path); + + private native byte[] saveToMemoryNative(long handle); + private native boolean isSavableNative(long handle); private native byte[] writeEditedNative(long handle, String operations); diff --git a/jni/src/jni_file.cpp b/jni/src/jni_file.cpp index 5c8dd89d1..4f3205608 100644 --- a/jni/src/jni_file.cpp +++ b/jni/src/jni_file.cpp @@ -385,6 +385,32 @@ Java_app_opendocument_core_TextFile_writeEditedNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT void JNICALL +Java_app_opendocument_core_TextFile_editNative(JNIEnv *env, jobject, + jlong handle, + jstring operations) { + guarded(env, [&] { + decoded(handle).as_text_file().edit(to_string(env, operations)); + }); +} + +extern "C" JNIEXPORT void JNICALL +Java_app_opendocument_core_TextFile_saveNative(JNIEnv *env, jobject, + jlong handle, jstring path) { + guarded(env, + [&] { decoded(handle).as_text_file().save(to_string(env, path)); }); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_app_opendocument_core_TextFile_saveToMemoryNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + std::ostringstream out; + decoded(handle).as_text_file().save(out); + return to_jbytes(env, out.str()); + }); +} + // app.opendocument.core.ImageFile extern "C" JNIEXPORT jbyteArray JNICALL diff --git a/jni/tests/app/opendocument/core/FileTest.java b/jni/tests/app/opendocument/core/FileTest.java index 4f506770f..5a98a2aba 100644 --- a/jni/tests/app/opendocument/core/FileTest.java +++ b/jni/tests/app/opendocument/core/FileTest.java @@ -69,6 +69,7 @@ void openCsv() throws IOException { } @Test + @SuppressWarnings("deprecation") void textFileWritesAnEditBack() throws IOException { Path txt = TestFiles.txtFile(tempDir); try (DecodedFile file = Odr.open(txt.toString())) { @@ -83,6 +84,22 @@ void textFileWritesAnEditBack() throws IOException { } } + @Test + void textFileEditsAndSavesLikeADocument() throws IOException { + Path txt = TestFiles.txtFile(tempDir); + try (DecodedFile file = Odr.open(txt.toString())) { + TextFile text = file.asTextFile(); + text.edit("{\"version\":2,\"ops\":[{\"op\":\"setContent\",\"text\":\"rewritten\"}]}"); + + assertEquals("rewritten", text.text()); + assertEquals( + "rewritten", new String(text.saveToMemory(), java.nio.charset.StandardCharsets.UTF_8)); + Path saved = tempDir.resolve("saved.txt"); + text.save(saved.toString()); + assertEquals("rewritten", Files.readString(saved)); + } + } + @Test void fileReadMatchesSize() throws IOException { Path odt = TestFiles.odtFile(tempDir); diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index 1bb02647a..14264df8b 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -317,6 +317,29 @@ void odr_python::bind_file(py::module_ &m) { .def("is_savable", &odr::TextFile::is_savable, "False where the file type is one this library does not write, or " "the encoding cannot be decoded.") + .def( + "edit", + [](const odr::TextFile &file, const std::string &operations) { + file.edit(operations); + }, + py::arg("operations"), + "Apply the operations our browser-side editor produces. The text is " + "UTF-8 afterwards, whatever the source encoding was.") + .def("save", + py::overload_cast(&odr::TextFile::save, + py::const_), + py::arg("path"), py::call_guard()) + .def( + "save_to_memory", + [](const odr::TextFile &file) { + std::ostringstream out; + { + py::gil_scoped_release release; + file.save(out); + } + return py::bytes(out.str()); + }, + "Save the file and return its bytes.") .def( "write_edited", [](const odr::TextFile &file, const std::string &operations) { @@ -328,8 +351,9 @@ void odr_python::bind_file(py::module_ &m) { return py::bytes(out.str()); }, py::arg("operations"), - "Apply the operations and return the result, as UTF-8 whatever the " - "source encoding was."); + "Deprecated: use edit, then save_to_memory. Apply the operations and " + "return the result, as UTF-8 whatever the source encoding was. The " + "file stays as it is."); py::class_(m, "ImageFile") .def("read", [](const odr::ImageFile &file) { diff --git a/python/tests/test_file.py b/python/tests/test_file.py index e614c3aa8..0894f7240 100644 --- a/python/tests/test_file.py +++ b/python/tests/test_file.py @@ -251,6 +251,19 @@ def test_text_file_writes_an_edit_back(txt_path): assert edited == b"rewritten\n" +def test_text_file_edits_and_saves_like_a_document(txt_path, tmp_path): + text_file = pyodr.open(str(txt_path)).as_text_file() + text_file.edit( + '{"version":2,"ops":[{"op":"setContent","text":"rewritten\\n"}]}' + ) + + assert text_file.text() == "rewritten\n" + assert text_file.save_to_memory() == b"rewritten\n" + saved = tmp_path / "saved.txt" + text_file.save(str(saved)) + assert saved.read_bytes() == b"rewritten\n" + + def test_a_csv_holds_a_text_file_rather_than_being_one(csv_path): file = pyodr.open(str(csv_path)) assert not file.is_text_file() diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 109ca6c63..3b45d24ea 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -11,13 +11,16 @@ #include #include #include +#include #include #include #include +#include #include #include +#include #include namespace odr { @@ -300,13 +303,10 @@ bool TextFile::is_savable() const noexcept { text_encoding_is_decodable(encoding); } -void TextFile::write_edited(const std::string_view operations, - std::ostream &out, - const Logger & /*logger*/) const { - if (!is_savable()) { - throw UnsupportedOperation(); - } +namespace { +/// The text @p operations set, or none where they state no operation. +std::optional content_of(const std::string_view operations) { const nlohmann::json json = nlohmann::json::parse(operations); if (json.value("version", 0) != 2) { throw std::invalid_argument("unsupported edit version"); @@ -320,6 +320,56 @@ void TextFile::write_edited(const std::string_view operations, } content = operation.at("text").get(); } + return content; +} + +} // namespace + +void TextFile::edit(const std::string_view operations, + const Logger & /*logger*/) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + std::optional content = content_of(operations); + if (!content.has_value()) { + return; + } + const auto text_file = + std::dynamic_pointer_cast(m_impl); + if (text_file == nullptr) { + throw UnsupportedOperation(); + } + text_file->set_text(std::move(*content)); +} + +void TextFile::save(const std::string &path) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + std::ofstream out = internal::util::file::create(path); + out << text(); +} + +void TextFile::save(std::ostream &out) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + out << text(); +} + +File TextFile::save_to_memory() const { + std::ostringstream out; + save(out); + return File::from_memory(std::move(out).str()); +} + +void TextFile::write_edited(const std::string_view operations, + std::ostream &out, + const Logger & /*logger*/) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + const std::optional content = content_of(operations); // an envelope stating any operation replaces every byte, so the file is // only read where it states none diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 7ef674400..7b129614e 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -444,9 +444,26 @@ class TextFile final : public DecodedFile { /// so what comes back cannot be put back. [[nodiscard]] bool is_savable() const noexcept; - /// Applies @p operations - `{"version": 2, "ops": [{"op": "setContent", - /// "text": "…"}]}` - and writes the result to @p out, as UTF-8 whatever - /// @ref encoding the source was. See `docs/design/txt-editing.md`. + /// @brief Applies @p operations to the file, in order, as @ref Document::edit + /// does to a document. + /// + /// The envelope is `{"version": 2, "ops": [{"op": "setContent", "text": + /// "…"}]}`. The file is UTF-8 afterwards, whatever @ref encoding the source + /// was, and every handle over it sees the edit. See + /// `docs/design/txt-editing.md`. + /// @throws UnsupportedOperation where @ref is_savable is false. + void edit(std::string_view operations, + const Logger &logger = Logger::null()) const; + + /// Writes the text as UTF-8, with every @ref edit applied. + /// @throws UnsupportedOperation where @ref is_savable is false. + void save(const std::string &path) const; + void save(std::ostream &out) const; + /// The saved file in memory. + [[nodiscard]] File save_to_memory() const; + + /// Deprecated: @ref edit, then @ref save. Writes what @p operations make of + /// the file to @p out, and leaves the file as it is. /// @throws UnsupportedOperation where @ref is_savable is false. void write_edited(std::string_view operations, std::ostream &out, const Logger &logger = Logger::null()) const; diff --git a/src/odr/internal/text/text_file.cpp b/src/odr/internal/text/text_file.cpp index 9f703e2e7..6ae32cc04 100644 --- a/src/odr/internal/text/text_file.cpp +++ b/src/odr/internal/text/text_file.cpp @@ -55,4 +55,9 @@ std::string TextFile::text() const { return encoding::to_utf8(util::stream::read(*in), m_encoding); } +void TextFile::set_text(std::string utf8) { + m_file = File::from_memory(std::move(utf8), m_file->name()).impl(); + m_encoding = TextEncoding::utf8; +} + } // namespace odr::internal::text diff --git a/src/odr/internal/text/text_file.hpp b/src/odr/internal/text/text_file.hpp index 4d55564bf..c5868e304 100644 --- a/src/odr/internal/text/text_file.hpp +++ b/src/odr/internal/text/text_file.hpp @@ -29,6 +29,9 @@ class TextFile final : public abstract::TextFile { /// @throws UnsupportedTextEncoding if the encoding cannot be decoded. [[nodiscard]] std::string text() const; + /// Replaces the bytes with @p utf8, keeping the file's name. + void set_text(std::string utf8); + private: std::shared_ptr m_file; TextEncoding m_encoding{TextEncoding::unknown}; diff --git a/test/src/internal/text/text_file_test.cpp b/test/src/internal/text/text_file_test.cpp index 71e2ac9ae..ac8040811 100644 --- a/test/src/internal/text/text_file_test.cpp +++ b/test/src/internal/text/text_file_test.cpp @@ -166,6 +166,26 @@ TEST(TextFile, a_file_we_cannot_decode_is_not_savable) { std::ostringstream out; EXPECT_THROW(shift_jis.write_edited(set_content("x"), out), UnsupportedOperation); + EXPECT_THROW(shift_jis.edit(set_content("x")), UnsupportedOperation); + EXPECT_THROW(shift_jis.save(out), UnsupportedOperation); +} + +TEST(TextFile, an_edit_stays_in_the_file_until_it_is_saved) { + const DecodedFile file = + open(File::from_memory(std::string("one\ntwo\n"), "notes.txt"), + DecodeOptions::as(FileType::text_file)); + file.as_text_file().edit(set_content("one\nTWO\n")); + + // a second handle over the same file sees the edit + const odr::TextFile text = file.as_text_file(); + EXPECT_EQ(text.text(), "one\nTWO\n"); + EXPECT_EQ(text.encoding(), TextEncoding::utf8); + EXPECT_EQ(text.file().name(), "notes.txt"); + + std::ostringstream out; + text.save(out); + EXPECT_EQ(std::move(out).str(), "one\nTWO\n"); + EXPECT_EQ(text.save_to_memory().size(), 8U); } /// json reads as a text file, and the table declares it unsaved. diff --git a/wasm/AGENTS.md b/wasm/AGENTS.md index 4bd7e3f04..4a8063df1 100644 --- a/wasm/AGENTS.md +++ b/wasm/AGENTS.md @@ -43,8 +43,8 @@ Worker**, where every value that crosses is structured-cloned. 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. A plain text file - has no tree, so its `edit` makes the edited bytes the session's file, and - the same calls answer for it. Handle `0` is never issued, so a zeroed handle + has no tree, and `TextFile::edit` keeps the edit in the file itself, so the + same calls answer for it. Handle `0` is never issued, so a zeroed handle is always invalid. This is why the structural edits are addressed **by element id**, not by an diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp index db64d3e22..9a6ca175a 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -145,8 +145,7 @@ emscripten::val save(const Handle handle) { Session &s = session(handle); std::ostringstream out; if (s.file.is_text_file()) { - s.file.as_text_file().write_edited(R"({"version":2,"ops":[]})", out, - s.logger); + s.file.as_text_file().save(out); } else { document_of(s).save(out); } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index dc6101d4d..21dbf94e1 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include @@ -146,19 +145,10 @@ emscripten::val edit(const Handle handle, const std::string &diff) { return guarded([&] { Session &s = session(handle); if (s.file.is_text_file()) { - // no tree holds the edit, so the edited bytes become the session's file - // and the next render translates them - std::ostringstream out; - s.file.as_text_file().write_edited(diff, out, s.logger); - DecodedFile edited = odr::open( - File::from_memory(std::move(out).str(), s.file.file().name()), - DecodeOptions::as(FileType::text_file), s.logger); - s.views.clear(); - s.service.reset(); - s.file = std::move(edited); - return ok(); + s.file.as_text_file().edit(diff, s.logger); + } else { + document_of(s).edit(diff, s.logger); } - document_of(s).edit(diff, s.logger); return ok(); }); } From 409584761865bd4d11e06e59ec63ea1e299070f5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 14 Sep 2026 07:49:25 +0200 Subject: [PATCH 3/3] chore: black formats the test, and the notes are shorter Black joined the edit call in test_file.py, so the format job passes. The changelog entries, the TextFile::edit doc and decision 3 of txt-editing.md no longer tell what the code did before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018ZehrLdwNTE8sBLxNZZYpW --- CHANGELOG.md | 14 +++++--------- docs/design/txt-editing.md | 5 +---- python/tests/test_file.py | 4 +--- src/odr/file.hpp | 10 +++------- test/src/internal/text/text_file_test.cpp | 1 - wasm/src/wasm_document.cpp | 6 ++---- 6 files changed, 12 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ead1e84d..e9b665bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,17 +16,13 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- The wasm package's `Document.edit`, `save`, `isEditable` and `isSavable` - work for a plain text file. They threw `NoDocumentFile` before, so a `.txt` - could not be saved from the browser. A txt saves as UTF-8. +- The wasm `Document.edit`, `save`, `isEditable` and `isSavable` work for a + plain text file, which saves as UTF-8. -- `TextFile::is_savable` is false for a json file, as every binding's doc - already said. It was true, so a json view was editable and `write_edited` - wrote it. +- `TextFile::is_savable` is false for a json file. -- `TextFile` has the names of `Document`: `edit` keeps the edit in the file, - and `save` and `save_to_memory` write it. Python, Java, Objective-C and - Swift bind them. `write_edited` stays, but is deprecated. +- `TextFile::edit`, `save` and `save_to_memory`, also in Python, Java, + Objective-C and Swift. `write_edited` is deprecated. ## v7.0.0 - 2026-09-13 diff --git a/docs/design/txt-editing.md b/docs/design/txt-editing.md index 1fc8cafdc..f8efeb083 100644 --- a/docs/design/txt-editing.md +++ b/docs/design/txt-editing.md @@ -85,10 +85,7 @@ see it. `save` writes the text. **Why:** a host saves a `.txt` with the calls it already makes for a document, and learns no second API because of what the file turned out to be — decision 9 -of [`editing.md`](editing.md) again. The first shape was `PdfFile::annotate`'s: -`TextFile::write_edited` took the envelope and a stream and left the file as it -was. Each binding then had to tell a txt from a document before a save. It -stays, deprecated. +of [`editing.md`](editing.md) again. `TextFile::write_edited` stays, deprecated. **What it costs:** a `TextFile` handle is no longer immutable. The edit lives in the text engine's file, which every handle shares, as a document's edit lives diff --git a/python/tests/test_file.py b/python/tests/test_file.py index 0894f7240..c3b68ee48 100644 --- a/python/tests/test_file.py +++ b/python/tests/test_file.py @@ -253,9 +253,7 @@ def test_text_file_writes_an_edit_back(txt_path): def test_text_file_edits_and_saves_like_a_document(txt_path, tmp_path): text_file = pyodr.open(str(txt_path)).as_text_file() - text_file.edit( - '{"version":2,"ops":[{"op":"setContent","text":"rewritten\\n"}]}' - ) + text_file.edit('{"version":2,"ops":[{"op":"setContent","text":"rewritten\\n"}]}') assert text_file.text() == "rewritten\n" assert text_file.save_to_memory() == b"rewritten\n" diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 7b129614e..319a66406 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -444,13 +444,9 @@ class TextFile final : public DecodedFile { /// so what comes back cannot be put back. [[nodiscard]] bool is_savable() const noexcept; - /// @brief Applies @p operations to the file, in order, as @ref Document::edit - /// does to a document. - /// - /// The envelope is `{"version": 2, "ops": [{"op": "setContent", "text": - /// "…"}]}`. The file is UTF-8 afterwards, whatever @ref encoding the source - /// was, and every handle over it sees the edit. See - /// `docs/design/txt-editing.md`. + /// Applies @p operations - `{"version": 2, "ops": [{"op": "setContent", + /// "text": "…"}]}` - to the file, and every handle over it sees the edit. The + /// text is UTF-8 afterwards. See `docs/design/txt-editing.md`. /// @throws UnsupportedOperation where @ref is_savable is false. void edit(std::string_view operations, const Logger &logger = Logger::null()) const; diff --git a/test/src/internal/text/text_file_test.cpp b/test/src/internal/text/text_file_test.cpp index ac8040811..a6b91311e 100644 --- a/test/src/internal/text/text_file_test.cpp +++ b/test/src/internal/text/text_file_test.cpp @@ -188,7 +188,6 @@ TEST(TextFile, an_edit_stays_in_the_file_until_it_is_saved) { EXPECT_EQ(text.save_to_memory().size(), 8U); } -/// json reads as a text file, and the table declares it unsaved. TEST(TextFile, json_is_not_savable) { const DecodedFile json = open(File::from_memory(std::string(R"({"a": 1})")), diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp index 9a6ca175a..129dbf266 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -15,8 +15,7 @@ namespace odr::wasm { namespace { -/// `capabilities()` narrowed to this document. A plain text file has no -/// document, and `TextFile::is_savable` answers both for it. +/// `capabilities()` narrowed to this document, or `TextFile::is_savable`. emscripten::val is_editable(const Handle handle) { return guarded([&] { Session &s = session(handle); @@ -138,8 +137,7 @@ emscripten::val insert_paragraph_after(const Handle handle, }); } -/// The document's bytes; there is no filesystem to save to. A plain text file -/// saves as UTF-8, whatever its source encoding. +/// The document's bytes; there is no filesystem to save to. emscripten::val save(const Handle handle) { return guarded([&] { Session &s = session(handle);