Skip to content

feat(wasm): render documents in the browser, with no server - #661

Merged
andiwand merged 7 commits into
mainfrom
feat/wasm-groundwork
Aug 8, 2026
Merged

feat(wasm): render documents in the browser, with no server#661
andiwand merged 7 commits into
mainfrom
feat/wasm-groundwork

Conversation

@andiwand

@andiwand andiwand commented Aug 8, 2026

Copy link
Copy Markdown
Member

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 fetch in it. So this is the
toolchain, the binding and the package, not new rendering.

Draft because two things need a human decision — see the end.

What it looks like

import { Odr } from '@opendocument/odr-core';

const odr = await Odr.load();
const doc = odr.open(new Uint8Array(await file.arrayBuffer()));
try {
  const { html } = doc.render(0);
  iframe.src = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
} finally {
  doc.close();
}

A blob: iframe keeps the same origin, so the page can still reach
iframe.contentWindow.odr and drive search() / generateDiff() — the same
host 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_ASM turned out not to be
needed, 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 -r covers endianness, float formatting, hash
ordering 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 -O3 before any
tuning. 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.
-Oz and -flto are still untried.

The stack

Everything that is useful without wasm was split out and has now landed, so this
PR sits directly on main and what is left in it is the toolchain, the binding
and the package:

#662 ✅ merged feat(api) open a file from memory, and take a File wherever a path is taken
#663 ✅ merged feat(html) translate without a cache path
#664 ✅ merged refactor(csv) scan for csv in-tree, dropping vincentlaucsb-csv-parser
this build(wasm) emscripten conan profile · feat(wasm) embind bindings, packaged for npm · ci(wasm) build, test and publish

Why 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 ArrayBuffer
and no path, and internal::MemoryFile existed but binding the internals is
what 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::thread and immediately joined it, with no way to switch it
off — and that matters here because -pthread implies SharedArrayBuffer, which
implies COOP/COEP headers on whoever hosts the viewer, which rules out plain
GitHub Pages.

feat(wasm) — the binding

Three things are unlike python/jni/apple, all following from the binding being
driven from a Web Worker where everything crossing is structured-cloned:

  • Nothing throws across the boundary. Entry points return
    {ok, value | error}. The worker protocol has to serialise failures anyway,
    and an unconverted C++ exception reaches JS as an opaque pointer.
    js/index.js turns envelopes back into thrown OdrErrors, so only the wire
    carries them.
  • Nothing escapes as an embind handle, because a bound wrapper cannot be
    cloned. A document is a uint32_t into a registry. This also dissolves the
    keep-alive problem the other three each hand-built: HtmlView holds a bare
    pointer into its service and Element into the document adapter, and neither
    is ever handed out — Session owns file, service and views together.
  • Config crosses as a plain object, same reason.

Enums are derived where the library has a runtime table (FileType,
FileCategory, DocumentType) so they cannot drift, and pinned by a snapshot
test 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 source
tree would drag tsc and a build step into a C++ repository to produce one file
of declarations.

Testing

  • Native suite green: 762 passed, 9 skipped, same skip list as before.
  • 26 node tests via ctest. 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 that suite: use-after-close, double close,
    and that a handle survives structuredClone while the wrapper silently does
    not.
  • The packed tarball was installed into a clean project and rendered a document.

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 {} — silently
useless, 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.lock has no emsdk entry, so the wasm profile needs
--lockfile-partial. Adding it properly means a lockfile regen, which drifts
unrelated pins — worth doing deliberately rather than in passing.

The npm scope is settled: @opendocument is ours, reserved by the npm account
of the same name, so wasm/js/package.json needs no change. Publishing is
gated 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.md states the three rules above as the contract
for 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

@andiwand
andiwand force-pushed the feat/wasm-groundwork branch 6 times, most recently from aa61d0a to 2845bda Compare August 8, 2026 17:41
@andiwand
andiwand changed the base branch from main to refactor/csv-in-tree August 8, 2026 17:42
@andiwand
andiwand force-pushed the refactor/csv-in-tree branch from 07f6ef1 to cd490a2 Compare August 8, 2026 18:03
@andiwand
andiwand force-pushed the feat/wasm-groundwork branch 2 times, most recently from e6347c1 to 6298795 Compare August 8, 2026 18:09
@andiwand
andiwand force-pushed the refactor/csv-in-tree branch 2 times, most recently from 0e40b6c to c9a9b42 Compare August 8, 2026 18:41
Base automatically changed from refactor/csv-in-tree to main August 8, 2026 18:51
@andiwand
andiwand force-pushed the feat/wasm-groundwork branch 2 times, most recently from 3ef6254 to b55a659 Compare August 8, 2026 19:04
andiwand and others added 4 commits August 8, 2026 21:16
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
andiwand force-pushed the feat/wasm-groundwork branch from b55a659 to 64b791c Compare August 8, 2026 19:17
@andiwand
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread .github/workflows/wasm.yml Outdated
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
andiwand force-pushed the feat/wasm-groundwork branch from 6552b89 to 3f5c30c Compare August 8, 2026 19:44
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.
@andiwand
andiwand merged commit 4662292 into main Aug 8, 2026
36 checks passed
@andiwand
andiwand deleted the feat/wasm-groundwork branch August 8, 2026 20:13
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.

1 participant