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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- 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.

- `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

- **Breaking**: `DocumentPath`, `Element::document_path()` and
Expand Down
13 changes: 12 additions & 1 deletion apple/include/OdrCoreObjC/ODRFile.h
Original file line number Diff line number Diff line change
Expand Up @@ -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:));
Expand Down
23 changes: 23 additions & 0 deletions apple/src/ODRFile.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 * {
Expand Down
14 changes: 14 additions & 0 deletions apple/tests/OdrCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions docs/design/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions docs/design/txt-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,27 +64,32 @@ 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. `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
in its shared tree.

### 4. What it writes is UTF-8, whatever the source was

Expand Down
31 changes: 29 additions & 2 deletions jni/java/app/opendocument/core/TextFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions jni/src/jni_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions jni/tests/app/opendocument/core/FileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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())) {
Expand All @@ -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);
Expand Down
28 changes: 26 additions & 2 deletions python/src/bind_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const std::string &>(&odr::TextFile::save,
py::const_),
py::arg("path"), py::call_guard<py::gil_scoped_release>())
.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) {
Expand All @@ -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_<odr::ImageFile, odr::DecodedFile>(m, "ImageFile")
.def("read", [](const odr::ImageFile &file) {
Expand Down
11 changes: 11 additions & 0 deletions python/tests/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,17 @@ 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()
Expand Down
Loading
Loading