feat(wasm): render documents in the browser, with no server - #661
Merged
Conversation
andiwand
force-pushed
the
feat/wasm-groundwork
branch
6 times, most recently
from
August 8, 2026 17:41
aa61d0a to
2845bda
Compare
This was referenced Aug 8, 2026
andiwand
force-pushed
the
refactor/csv-in-tree
branch
from
August 8, 2026 18:03
07f6ef1 to
cd490a2
Compare
andiwand
force-pushed
the
feat/wasm-groundwork
branch
2 times, most recently
from
August 8, 2026 18:09
e6347c1 to
6298795
Compare
andiwand
force-pushed
the
refactor/csv-in-tree
branch
2 times, most recently
from
August 8, 2026 18:41
0e40b6c to
c9a9b42
Compare
andiwand
force-pushed
the
feat/wasm-groundwork
branch
2 times, most recently
from
August 8, 2026 19:04
3ef6254 to
b55a659
Compare
Groundwork for a WebAssembly build of the library, so it can back a client-side viewer in the browser. Nothing consumes the profile yet; this is the toolchain half, verified by resolving and building the full dependency graph for `os=Emscripten, arch=wasm`. Notably cryptopp builds unpatched. Its recipe has no Emscripten branch and it probes CPU features by compiling assembly, so `CRYPTOPP_DISABLE_ASM` was the expected escalation; it turned out not to be needed. Two things in the profile look like oversights and are not: - `compiler.threads` is absent rather than `null`. A profile value is a string, so `compiler.threads=null` reads as the literal "null" and conan rejects it against `settings.yml`. Omitting the line is how "unset" is spelled — and unset is what we want, because `-pthread` implies SharedArrayBuffer, which implies COOP/COEP response headers on whoever hosts the viewer, which rules out plain GitHub Pages. - The exception flags are `[conf]` rather than CMake flags. The EH mode is an ABI, so the dependencies have to be built with the same one as the core. Flat rather than a `.jinja` plus per-slice includers like `android`/`apple`: those split because they build one body for four ABIs and five platform-arch pairs, and there is exactly one wasm slice. Split it when wasm64 arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjFJ66sma1ye9ZnhM3tNeH
Renders a document to HTML in the browser: no upload, no server, and the bytes
never leave the machine. The library was already shaped for this — every
renderer writes to a stream, the css and js are compiled in, and one view comes
out as a complete self-contained document — so this is the binding and the
package, not new rendering.
Three things here are unlike python, jni and apple, and all three follow from
the binding being driven from a Web Worker, where everything that crosses is
structured-cloned:
Nothing throws across the boundary. Every entry point returns `{ok, value |
error}`. The worker protocol has to turn a failure into data regardless, so a
JS exception would only be converted back again — and an unconverted C++
exception reaches JS as an opaque pointer. `js/index.js` turns the envelope
back into a thrown `OdrError`, so only the wire carries envelopes.
Nothing escapes as an embind handle, because a bound wrapper cannot be cloned.
A document is a `uint32_t` into a registry and a view an index within it. That
also dissolves the keep-alive problem the other three each hand-built: a
`HtmlView` holds a bare pointer into its service and an `Element` into the
document adapter, and here neither is ever handed out — `Session` owns the
file, service and views together.
Config crosses as a plain object rather than a bound mutable type, for the
same cloning reason.
Enums are derived where the library has a runtime table for them — `FileType`,
`FileCategory`, `DocumentType` — so they cannot drift, and pinned by a
snapshot test where it does not. Appending stays silent and reordering goes
loud, which is what the headers ask for and what every binding so far has left
to hope.
The package is plain JavaScript with a hand-written `.d.ts`: a TypeScript
source tree would drag `tsc` and a build step into a C++ repository to produce
one file of declarations.
Tested under node from ctest, 26 cases. Inputs are built in memory — including
a hand-rolled zip writer for a minimal odt — following `python/AGENTS.md`; the
two fixtures on disk are a document with real layout and an encrypted one.
The lifetime cases are the point of the suite: use-after-close, double close,
and that a handle survives `structuredClone` while the wrapper silently does
not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjFJ66sma1ye9ZnhM3tNeH
Adds the only job that builds for Emscripten. It cannot share a cache with `build_test.yml`: no http server, no threads, and a different exception ABI make it a separate dependency set entirely. Two steps carry the weight. The native-vs-wasm diff renders the fixture with both builds and compares byte for byte. A difference there is endianness, float formatting, hash ordering or locale drift — the class of bug that is otherwise found by a user months later as "the web version renders it differently". It is currently identical across every format the spike tried, so this is a regression test with a known-good baseline rather than an aspiration. The size step reports raw and brotli'd bytes into the job summary and fails past 1.5 M brotli. That is a ratchet, not a target: the whole library — every engine, PDF, crypto — sits around 820 K, so crossing it means something got linked in that should not have been. Publishing follows `python.yml`'s posture, OIDC rather than a token, and carries over the rule `apple/AGENTS.md` states: install the packed tarball and render a document before publishing, because a merely well-formed package publishes happily and then fails at the consumer. `scripts/release_status.py` gains its `EXPECTED` entry, without which a failed npm publish would be invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjFJ66sma1ye9ZnhM3tNeH
Two separate faults, both presenting as a build failure wearing a test failure's clothes. `emsdk` pulls in `nodejs/16.3.0` as a tool requirement and CMakeToolchain prepends its `bin` to `CMAKE_PROGRAM_PATH`, so `find_program` picked a node from 2021 and the suite died as `node: bad option: --test`. Search outside the conan program path first, and check the version rather than trusting the name — the next tool requirement to drag in a node will not announce itself either. Then, passing the tests directory to `--test` works on node 20 and not on 22, which reads a bare path as a module to execute and fails with `Cannot find module`. Run from the directory instead and let node discover `*.test.mjs` itself, which is the behaviour every version since 18 shares. Verified on 20, 22, 24 and 25. A missing or too-old node stays a warning rather than an error, so a contributor without node can still build. That would make the CI job silently green, which is worse than red, so ctest now runs with `--no-tests=error`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjFJ66sma1ye9ZnhM3tNeH
andiwand
force-pushed
the
feat/wasm-groundwork
branch
from
August 8, 2026 19:17
b55a659 to
64b791c
Compare
andiwand
marked this pull request as ready for review
August 8, 2026 19:19
The npm tarball is an npm layout: `package/` prefix, resolved by a package manager. Someone putting a viewer on a static host has neither, and the CDN path needs a network the deployment may not have. So every release also carries `odr-core-browser-<version>.zip` — the same six files flat, plus `example.html`. Unzip where the pages are served from, import `./index.js`, done. `example.html` is `wasm/example/index.html` with its dev import repointed rather than a second copy of the demo, and the `grep` after the `sed` is the point: change that import and the build fails instead of shipping a bundle whose demo silently 404s. Uploaded with `gh release upload` rather than named `release-asset-*` for `release.yml` to sweep up. That sweep only sees artifacts from the release run, and this workflow is not part of it — it reacts to `release: published`, which is the pattern `release.yml` states for anything that merely follows a release. Hence `contents: write` on the job.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64b791c776
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Trusted publishing needs node >= 22.14 and npm >= 11.5.1. The job asked for node 22, which carries npm 10.9 — no OIDC at all, so the first release would have fallen back to token auth and failed as `ENEEDAUTH`. Node 24's bundled npm varies by minor and 11.3 is below the floor, so the CLI is upgraded outright. The build job stays on 22, where only `node --test` matters.
andiwand
force-pushed
the
feat/wasm-groundwork
branch
from
August 8, 2026 19:44
6552b89 to
3f5c30c
Compare
Measured from the CI-built artifact rather than an earlier local build: 2.9 M of wasm, 851372 bytes brotli'd at -q 11, 92 K of glue. The recorded 816 K and 77 K had drifted, and understating the number a ratchet is set against is the wrong direction to be wrong in.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
opendocument.app is a page that links to the apps. This makes it able to open
a document — rendered in the browser, no upload, no server, nothing to operate,
and the bytes never leave the machine.
Most of the work was already done. Every renderer writes to a stream, the css
and js are compiled into the library since #648, and one view already comes out
as a complete self-contained document with no
fetchin it. So this is thetoolchain, the binding and the package, not new rendering.
Draft because two things need a human decision — see the end.
What it looks like
A
blob:iframe keeps the same origin, so the page can still reachiframe.contentWindow.odrand drivesearch()/generateDiff()— the samehost API droid and ios use from their WebViews.
Measured, not argued about
Every dependency cross-compiles, cryptopp unpatched. This was the risk that
could have sunk the approach: the recipe has no Emscripten branch and probes CPU
features by compiling assembly.
CRYPTOPP_DISABLE_ASMturned out not to beneeded, so #655's removal of conan-odr-index stands.
Output is byte-identical to the native build across odt, docx, ods, xlsx,
odp, pptx, doc, xls, ppt, pdf, odg, csv and txt, and for encrypted docx/ods/odt
with their passwords — which exercises the cryptopp AES/PBKDF2 path and the
in-tree argon2id. One
diff -rcovers endianness, float formatting, hashordering and locale drift, so it is now a CI step with a known-good baseline
rather than an aspiration.
Size is a non-issue. 2.9 M raw, 831 K brotli, at
-O3before anytuning. That is the whole library — every engine, PDF, crypto. It retires the
contingency plan to split PDF into a lazily loaded second bundle, so no
per-format compile-time gating is introduced. CI ratchets at 1.5 M brotli.
-Ozand-fltoare still untried.The stack
Everything that is useful without wasm was split out and has now landed, so this
PR sits directly on
mainand what is left in it is the toolchain, the bindingand the package:
feat(api)open a file from memory, and take aFilewherever a path is takenfeat(html)translate without a cache pathrefactor(csv)scan for csv in-tree, dropping vincentlaucsb-csv-parserbuild(wasm)emscripten conan profile ·feat(wasm)embind bindings, packaged for npm ·ci(wasm)build, test and publishWhy each of those had to come first, since it is no longer visible from the
branch: #662 is what gives a browser a way in at all — it has an
ArrayBufferand no path, and
internal::MemoryFileexisted but binding the internals iswhat the bindings are told not to do. #663 makes "no renderer writes to disk"
structural rather than coincidental, which is what lets this build skip a MEMFS
mount entirely. #664 removes the last thread in the library —
CSVReader::begin()constructed a
std::threadand immediately joined it, with no way to switch itoff — and that matters here because
-pthreadimplies SharedArrayBuffer, whichimplies COOP/COEP headers on whoever hosts the viewer, which rules out plain
GitHub Pages.
feat(wasm)— the bindingThree things are unlike python/jni/apple, all following from the binding being
driven from a Web Worker where everything crossing is structured-cloned:
{ok, value | error}. The worker protocol has to serialise failures anyway,and an unconverted C++ exception reaches JS as an opaque pointer.
js/index.jsturns envelopes back into thrownOdrErrors, so only the wirecarries them.
cloned. A document is a
uint32_tinto a registry. This also dissolves thekeep-alive problem the other three each hand-built:
HtmlViewholds a barepointer into its service and
Elementinto the document adapter, and neitheris ever handed out —
Sessionowns file, service and views together.Enums are derived where the library has a runtime table (
FileType,FileCategory,DocumentType) so they cannot drift, and pinned by a snapshottest where it does not. Appending stays silent, reordering goes loud — the rule
the headers state and that every binding so far left to hope.
The package is plain JavaScript with a hand-written
.d.ts: a TypeScript sourcetree would drag
tscand a build step into a C++ repository to produce one fileof declarations.
Testing
hand-rolled zip writer for a minimal odt — following
python/AGENTS.md; thetwo fixtures on disk are a document with real layout and an encrypted one.
and that a handle survives
structuredClonewhile the wrapper silently doesnot.
Two of my own tests failed and were right to. Random bytes open as text, not
an error — text is the fallback, so a viewer shows junk rather than refusing.
And
structuredClone(document)does not throw, it yields{}— silentlyuseless, which is worse than throwing. Both are now pinned as documented
behaviour. A third failure was a real bug: the logger never reached the decode
path.
CI is green on the rebased branch: the wasm build, the node suite, the
byte-identical render comparison and the size ratchet all pass against the
merged
main.One decision before this leaves draft
conan.lockhas noemsdkentry, so the wasm profile needs--lockfile-partial. Adding it properly means a lockfile regen, which driftsunrelated pins — worth doing deliberately rather than in passing.
The npm scope is settled:
@opendocumentis ours, reserved by the npm accountof the same name, so
wasm/js/package.jsonneeds no change. Publishing isgated only on the first manual publish, which is what makes npm expose the
trusted-publisher settings a package needs.
Also worth a look:
wasm/AGENTS.mdstates the three rules above as the contractfor anyone touching this next, and records what the spike measured so the
numbers do not have to be rediscovered.
The viewer itself belongs in its own repository and is not here.
🤖 Generated with Claude Code