Skip to content

feat(document): save to a stream or into memory, and bind edit/save in wasm - #778

Merged
andiwand merged 1 commit into
mainfrom
feat/document-save-stream-memory
Aug 30, 2026
Merged

feat(document): save to a stream or into memory, and bind edit/save in wasm#778
andiwand merged 1 commit into
mainfrom
feat/document-save-stream-memory

Conversation

@andiwand

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Closes #777.

The core change

Document::save took a path, and a browser has none. It now writes to a
std::ostream.

abstract::Document::save takes the stream rather than an internal::Path:
no engine used the path for anything but opening an ofstream. Only two
implement save at all (odf::Document, ooxml::text::Document) and both
already ended at archive.save(ostream); the other eleven throw
UnsupportedOperation and ignored the argument. So the ofstream moves up one
layer instead of being repeated per engine, and the two savables in the library
are now stated the same way as Archive::save(std::ostream &).

void save(const std::string &path) const;                                // unchanged
void save(const std::string &path, const std::string &password) const;   // unchanged

void save(std::ostream &out) const;                                      // new
void save(std::ostream &out, const std::string &password) const;         // new

[[nodiscard]] File save_to_memory() const;                               // new
[[nodiscard]] File save_to_memory(const std::string &password) const;    // new

Two decisions worth stating:

  • save_to_memory returns File, not std::string. File is the
    library's own currency for "bytes with no path": the caller can take
    memory_data(), pipe them, or hand the result straight back to
    DecodedFile to reopen and check. It mirrors File::from_memory at the
    other end of the round trip, and costs one move.
  • It isn't spelled save(). save(const std::string &) is already the
    path overload, so a save(password) returning File would read as "save to
    a file named hunter2".

One behaviour change falls out of moving the ofstream up: the path overloads
would now create the file before the engine got to refuse the format, so they
check is_savable first. Every engine's is_savable already agrees exactly
with whether its save throws, so this restores the old "an unsavable format
leaves no file" precisely, and it is the honest check anyway.

wasm — the round trip #777 asks for

edit(diff) applies what the rendered page's odr.generateDiff() collected;
save() answers with the document's bytes. The stream overload means no MEMFS
detour and no second copy of the document in the heap — the ostringstream
is the buffer, and to_uint8_array is the one copy that has to happen.

The part the issue doesn't mention: Session held a DecodedFile, and
html::translate(const DecodedFile &) calls document_file.document(), which
decodes a fresh tree on every call. A save that opened its own Document
would have written the document nobody edited. So the session now owns the one
Document that the render, the edit and the save all go through. decrypt
drops it along with the service.

isEditable() and isSavable() are bound too — the issue's complaint that
capabilities() reports edit: true, save: true for a binding that could do
neither is now answerable per document rather than per format.

The other bindings

edit save save into memory
python already already save_to_memory() -> bytes
jni already already saveToMemory() -> byte[]
apple already already -saveToMemoryWithError:NSData
wasm new new new

Each follows its own binding's stated convention for a stream-based API —
bytes in python (as Archive.save() already does), byte[] in JNI, NSData
in ObjC.

Test

Everything green, all five suites run locally:

  • C++ 1258 passed. New: a flat .fodt built from an inline string round
    trips through save_to_memory (no test data, and it is the branch that
    writes xml rather than a zip), save(ostream) writes what save_to_memory
    holds, a package round trips, and an unsavable format leaves no file behind.
  • wasm 36 passed, 5 of them new: the whole round trip in memory — render
    editable, take the data-odr-path out of the html the way the browser does,
    edit, re-render, save, reopen the saved bytes and see the edit; plus saving
    without a render, a non-document refusing both, and the encrypted save.
  • python 65 passed, JNI 43 passed, Swift 22 passed, each with a new
    memory round trip.

clang-tidy is clean on the changed files.

…n wasm

`abstract::Document::save` takes a `std::ostream` rather than a path: no engine
used the path for anything but opening an `ofstream`, and both engines that
implement `save` already ended at `archive.save(ostream)`. The public
`save(path)` overloads are unchanged, except that they check `is_savable`
before opening the file so an unsavable format leaves none behind.
`save_to_memory` returns the saved document as an in-memory `File`.

That closes the wasm binding's editing round trip (#777): `edit(diff)`,
`save()`, plus `isEditable()`/`isSavable()`, which answer for the open document
where `capabilities()` answers for its format. The session now owns the one
`Document` render, edit and save share — `DocumentFile::document()` decodes a
fresh tree per call.

python, jni and apple get the memory save too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGQAJdFL17EKZZqsysu3jQ
@andiwand
andiwand force-pushed the feat/document-save-stream-memory branch from 8d207a7 to f48045e Compare August 30, 2026 06:59
@andiwand
andiwand merged commit 2d74eae into main Aug 30, 2026
24 of 25 checks passed
@andiwand
andiwand deleted the feat/document-save-stream-memory branch August 30, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wasm: editing has no way back — bind html::edit and Document::save

1 participant