diff --git a/.github/config/conan/profiles/emscripten-wasm b/.github/config/conan/profiles/emscripten-wasm
new file mode 100644
index 000000000..7854be44c
--- /dev/null
+++ b/.github/config/conan/profiles/emscripten-wasm
@@ -0,0 +1,36 @@
+{# The WebAssembly slice; see `wasm/AGENTS.md` for the constraints behind it.
+
+ `compiler.threads` is absent on purpose — the build must stay single-threaded
+ — and cannot be written as `compiler.threads=null`: a profile value is a
+ string, so that reads as the literal "null" and fails against `settings.yml`.
+ Omitting the line is how "unset" is spelled.
+
+ `-fwasm-exceptions` is `[conf]` rather than a CMake flag because the EH mode
+ is an ABI: every dependency has to be built with the same one or the link
+ fails. #}
+{% set emsdk_version = "3.1.73" %}
+
+[settings]
+os=Emscripten
+arch=wasm
+build_type=Release
+compiler=emcc
+compiler.version={{emsdk_version}}
+compiler.libcxx=libc++
+compiler.cppstd=20
+
+[options]
+# No sockets and no threads in a browser; the CLI has no meaning here either.
+&:shared=False
+&:with_http_server=False
+&:with_cli=False
+
+[tool_requires]
+emsdk/{{emsdk_version}}
+
+[conf]
+tools.build:cflags=['-fwasm-exceptions']
+tools.build:cxxflags=['-fwasm-exceptions']
+tools.build:exelinkflags=['-fwasm-exceptions']
+tools.build:sharedlinkflags=['-fwasm-exceptions']
+tools.cmake.cmaketoolchain:extra_variables={'CMAKE_CXX_COMPILER_LAUNCHER': 'ccache', 'CMAKE_C_COMPILER_LAUNCHER': 'ccache'}
diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml
new file mode 100644
index 000000000..2e9e7dd68
--- /dev/null
+++ b/.github/workflows/wasm.yml
@@ -0,0 +1,275 @@
+name: wasm
+
+on:
+ push:
+ # release branches are `release.yml`'s alone
+ branches-ignore:
+ - 'releases'
+ - 'release/**'
+ release:
+ types:
+ - published
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+# See the cache-key comment in `build_test.yml` for why the keys look like this.
+env:
+ CCACHE_DIR: ${{ github.workspace }}/.ccache
+ CCACHE_MAXSIZE: 1G
+ CCACHE_KEY_SUFFIX: r1
+ CONAN_HOME: ${{ github.workspace }}/.conan2
+ CONAN_KEY_SUFFIX: r1
+
+jobs:
+ # Its own dependency set — no http server, no cli, a different exception ABI —
+ # so it cannot share a cache with `build-test`.
+ build:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write # attaching the bundle to the release
+ env:
+ CACHE_FLAVOR: wasm
+ HOST_PROFILE: emscripten-wasm
+ BUILD_PROFILE: ubuntu-24.04-clang-18
+ steps:
+ - name: checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ # the profiles use ccache as compiler launcher, so it must exist even for
+ # `--build missing`
+ - name: install ccache
+ run: |
+ sudo apt install ccache
+ ccache -V
+
+ - name: setup node
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
+ with:
+ node-version: 22
+
+ - name: setup python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+ with:
+ python-version: 3.14
+ - name: install conan
+ run: pip install conan
+
+ - name: conan cache key
+ shell: bash
+ run: echo "CONAN_CACHE_KEY=${{ hashFiles('conanfile.py', '.github/config/conan/**') }}" >> "$GITHUB_ENV"
+
+ - name: cache conan
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
+ with:
+ path: ${{ env.CONAN_HOME }}
+ key: conan-${{ env.CACHE_FLAVOR }}-${{ env.HOST_PROFILE }}-${{ env.CONAN_KEY_SUFFIX }}-${{ env.CONAN_CACHE_KEY }}
+ restore-keys: |
+ conan-${{ env.CACHE_FLAVOR }}-${{ env.HOST_PROFILE }}-${{ env.CONAN_KEY_SUFFIX }}-
+
+ - name: conan config
+ run: conan config install .github/config/conan
+
+ - name: restore ccache
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
+ with:
+ path: ${{ env.CCACHE_DIR }}
+ key: ccache-${{ env.CACHE_FLAVOR }}-${{ env.HOST_PROFILE }}-${{ env.CCACHE_KEY_SUFFIX }}-${{ github.run_id }}
+ restore-keys: |
+ ccache-${{ env.CACHE_FLAVOR }}-${{ env.HOST_PROFILE }}-${{ env.CCACHE_KEY_SUFFIX }}-
+
+ # `--lockfile-partial`: the lockfile knows nothing of `emsdk`, a build
+ # requirement of this slice alone
+ - name: conan install
+ run: >
+ conan install .
+ --output-folder build
+ -o '&:with_wasm=True'
+ --profile:host '${{ env.HOST_PROFILE }}'
+ --profile:build '${{ env.BUILD_PROFILE }}'
+ --lockfile-partial
+ --build missing
+
+ - name: cmake configure
+ run: >
+ cmake -B build -S .
+ -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake
+ -DCMAKE_BUILD_TYPE=Release
+ -DBUILD_SHARED_LIBS=OFF
+ -DODR_WASM=ON
+ -DODR_CLI=OFF
+ -DODR_WITH_HTTP_SERVER=OFF
+ -DODR_TEST=ON
+
+ - name: cmake build
+ run: cmake --build build --target odr_wasm
+
+ - name: save ccache
+ if: github.ref == 'refs/heads/main'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
+ with:
+ path: ${{ env.CCACHE_DIR }}
+ key: ccache-${{ env.CACHE_FLAVOR }}-${{ env.HOST_PROFILE }}-${{ env.CCACHE_KEY_SUFFIX }}-${{ github.run_id }}
+
+ - name: test
+ # `--no-tests=error`: without a usable node the suite is skipped, and a
+ # silently green job would be worse than a red one
+ run: ctest --test-dir build/wasm --output-on-failure --no-tests=error
+
+ # The regression that matters for a new target: a difference here is
+ # endianness, float formatting, hash ordering or locale drift. The wasm
+ # side goes through the package because that is what ships, and the CLI
+ # would need `-sNODERAWFS` to see the host's files. `translate` writes
+ # views with `write_html`, which is what `render()` returns, so matching
+ # `editable`/`formatHtml` makes the two byte-comparable.
+ - name: build the native translate to compare against
+ run: |
+ set -euo pipefail
+ conan install . \
+ --output-folder build-native \
+ --profile:host '${{ env.BUILD_PROFILE }}' \
+ --profile:build '${{ env.BUILD_PROFILE }}' \
+ --build missing
+ cmake -B build-native -S . \
+ -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DODR_CLI=ON -DODR_WITH_HTTP_SERVER=OFF -DODR_TEST=OFF
+ cmake --build build-native --target translate
+
+ - name: render matches the native build
+ run: |
+ set -euo pipefail
+ input="$PWD/wasm/testfixtures/mixed-layout.odt"
+ ./build-native/cli/translate "$input" "$PWD/out-native"
+ node --input-type=module -e '
+ import { Odr } from "./build/wasm/dist/index.js";
+ import { readFileSync, writeFileSync } from "node:fs";
+ const odr = await Odr.load();
+ const doc = odr.open(new Uint8Array(readFileSync(process.argv[1])), {
+ editable: true,
+ formatHtml: true,
+ });
+ writeFileSync(process.argv[2], doc.render(0).html);
+ doc.close();
+ ' "$input" "$PWD/out-wasm.html"
+ diff "$PWD/out-native/document.html" "$PWD/out-wasm.html"
+
+ - name: size report
+ run: |
+ set -euo pipefail
+ sudo apt install -y brotli
+ wasm=build/wasm/dist/odr-core.wasm
+ raw=$(stat -c%s "$wasm")
+ br=$(brotli -q 11 -c "$wasm" | wc -c)
+ {
+ echo "| | bytes |"
+ echo "|---|---|"
+ echo "| \`odr-core.wasm\` | $raw |"
+ echo "| brotli -q 11 | $br |"
+ } >> "$GITHUB_STEP_SUMMARY"
+ # A ratchet, not a target: the library sat at ~820 K brotli'd when
+ # this was written, so 1.5 M means something was linked in that
+ # should not have been.
+ if [ "$br" -gt 1572864 ]; then
+ echo "::error::brotli'd wasm is ${br} bytes, over the 1.5 M ratchet"
+ exit 1
+ fi
+
+ - name: assemble package
+ run: |
+ set -euo pipefail
+ version="${GITHUB_REF_NAME#v}"
+ if [ "${{ github.event_name }}" = "release" ]; then
+ (cd build/wasm/dist && npm version --no-git-tag-version "$version")
+ fi
+ cp wasm/README.md build/wasm/dist/
+ (cd build/wasm/dist && npm pack)
+
+ - name: upload package
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: release-asset-odr-core-npm
+ path: build/wasm/dist/*.tgz
+ if-no-files-found: error
+
+ # The npm tarball is an npm layout; this is the same files flat, to unzip
+ # onto a static host. `example.html` is `wasm/example/index.html` with its
+ # dev import repointed, so the demo has one source.
+ - name: assemble the browser bundle
+ run: |
+ set -euo pipefail
+ sed 's|\.\./\.\./build-wasm/wasm/dist/index\.js|./index.js|' \
+ wasm/example/index.html > build/wasm/dist/example.html
+ grep -q "'./index.js'" build/wasm/dist/example.html
+ bundle="odr-core-browser.zip"
+ if [ "${{ github.event_name }}" = "release" ]; then
+ bundle="odr-core-browser-${GITHUB_REF_NAME}.zip"
+ fi
+ (cd build/wasm/dist && zip -qr "${GITHUB_WORKSPACE}/${bundle}" . -x '*.tgz')
+ echo "BUNDLE=${bundle}" >> "$GITHUB_ENV"
+ unzip -l "$bundle"
+
+ - name: upload the browser bundle
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: odr-core-browser
+ path: ${{ env.BUNDLE }}
+ if-no-files-found: error
+
+ # Uploaded here rather than collected by `release.yml` as a
+ # `release-asset-*`: that only sweeps artifacts from the release run, and
+ # this workflow is not part of it — it reacts to `release: published`.
+ - name: attach the bundle to the release
+ if: github.event_name == 'release'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: gh release upload "$GITHUB_REF_NAME" "$BUNDLE" --clobber
+
+ # npm trusted publishing (OIDC), as `python.yml` does with PyPI: the package
+ # on npm must name this repository and workflow.
+ npm:
+ needs: build
+ runs-on: ubuntu-24.04
+ if: github.event_name == 'release' && github.event.action == 'published'
+ environment: npm
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - name: download package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: release-asset-odr-core-npm
+ path: dist
+
+ # OIDC needs npm >= 11.5.1, and node 24's bundled npm varies by minor, so
+ # the CLI is upgraded outright. Too old and the publish fails `ENEEDAUTH`.
+ - name: setup node
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+ - name: upgrade npm for OIDC
+ run: |
+ npm install -g npm@latest
+ npm --version
+
+ # `apple/AGENTS.md`'s rule: a merely well-formed package publishes happily
+ # and then fails at the consumer.
+ - name: smoke the packed tarball
+ run: |
+ set -euo pipefail
+ mkdir -p verify && cd verify
+ npm init -y >/dev/null
+ npm install ../dist/*.tgz
+ node --input-type=module -e '
+ import { Odr } from "@opendocument/odr-core";
+ const odr = await Odr.load();
+ if (!odr.identify()) throw new Error("no identity");
+ if (odr.fileTypes().length === 0) throw new Error("no file types");
+ console.log("ok:", odr.identify());
+ '
+
+ - name: publish
+ run: npm publish dist/*.tgz --provenance --access public
diff --git a/.gitignore b/.gitignore
index 9fcb5c4ad..5401bf5f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,9 +80,10 @@ tools/pdf/afm/
*.ppt
*.xls
# the bindings ship their own copy of one public test document, since neither a
-# SwiftPM checkout nor an android build tree has test/data/
+# SwiftPM checkout, an android build tree nor an npm consumer has test/data/
!apple/tests/Fixtures/*
!jni/testfixtures/resources/**/*
+!wasm/testfixtures/*
## Python
# Byte-compiled / optimized / DLL files
diff --git a/AGENTS.md b/AGENTS.md
index 832afffe3..3dfaae755 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,7 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme
| `jni/` | JNI bindings (Java package `app.opendocument.core`); see [`jni/AGENTS.md`](jni/AGENTS.md). |
| `android/` | The bindings packaged as an AAR (`odr-core-android`) + the instrumented tests; see [`android/AGENTS.md`](android/AGENTS.md). |
| `apple/` | Objective-C bindings + the Swift package, shipped as `OdrCoreObjC.xcframework`; see [`apple/AGENTS.md`](apple/AGENTS.md). |
+| `wasm/` | WebAssembly bindings (embind), packaged as the npm package `@opendocument/odr-core`; see [`wasm/AGENTS.md`](wasm/AGENTS.md). |
| `tools/pdf/` | Dev tooling (not built): PDF encoding-data generators, see `tools/pdf/README.md`. |
| `test/src/` | GoogleTest suites; data fetched into `test/data` (see `cmake/test_data.cmake`). |
| `offline/documentation/MS-*/` | Vendored Microsoft spec text (see [Specs](#specs)). |
@@ -90,7 +91,7 @@ cmake --build cmake-build-relwithdebinfo --target translate # CLI: file → HTM
- **Run the test binary from the build dir** so output stays out of the repo tree.
- **For debugging, prefer the `translate` CLI** on a single file over the suite.
- CMake options (`CMakeLists.txt`): `ODR_TEST`, `ODR_CLI`, `ODR_PYTHON`,
- `ODR_JNI`, `ODR_APPLE`, `ODR_CLANG_TIDY`. A new `.cpp` must be added to
+ `ODR_JNI`, `ODR_APPLE`, `ODR_WASM`, `ODR_CLANG_TIDY`. A new `.cpp` must be added to
`ODR_SOURCE_FILES`.
- **Test data is fetched, not vendored**, and opt in: `-DODR_TEST_FETCH_DATA=ON`
makes `cmake/test_data.cmake` clone the repositories pinned in
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 12cb3e3f3..3e4299d96 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -25,6 +25,7 @@ option(ODR_BUNDLE_ASSETS "Removed, does nothing (deprecated)" OFF)
option(ODR_PYTHON "Build Python bindings" OFF)
option(ODR_JNI "Build JNI bindings" OFF)
option(ODR_APPLE "Build Objective-C bindings as a framework" OFF)
+option(ODR_WASM "Build WebAssembly bindings" OFF)
include(GNUInstallDirs)
@@ -320,6 +321,10 @@ if (ODR_APPLE)
add_subdirectory("apple")
endif ()
+if (ODR_WASM)
+ add_subdirectory("wasm")
+endif ()
+
if (ODR_TEST)
add_subdirectory("test")
endif ()
diff --git a/README.md b/README.md
index 9fc65f236..4d33ce0bf 100644
--- a/README.md
+++ b/README.md
@@ -58,8 +58,10 @@ supported for any format.
Currently, used as backend for [OpenDocument.droid](https://github.com/opendocument-app/OpenDocument.droid) and [OpenDocument.ios](https://github.com/opendocument-app/OpenDocument.ios).
Bindings: [Python](python/README.md) (`pyodr`), [Java/JNI](jni/README.md) and
-[Android](android/README.md) (`app.opendocument:odr-core-android`), and
-[Apple](apple/README.md) (`OdrCore`, a Swift package).
+[Android](android/README.md) (`app.opendocument:odr-core-android`),
+[Apple](apple/README.md) (`OdrCore`, a Swift package), and
+[WebAssembly](wasm/README.md) (`@opendocument/odr-core`, for rendering in the
+browser with no server).
Replaces legacy projects [OpenDocument.java](https://github.com/andiwand/OpenDocument.java), [JOpenDocument](https://github.com/andiwand/JOpenDocument) and [svm](https://github.com/andiwand/svm).
diff --git a/conanfile.py b/conanfile.py
index a5ed2f5f6..8996baafc 100644
--- a/conanfile.py
+++ b/conanfile.py
@@ -24,6 +24,7 @@ class OpenDocumentCoreConan(ConanFile):
"with_python": [True, False],
"with_jni": [True, False],
"with_apple": [True, False],
+ "with_wasm": [True, False],
"bundle_assets": [True, False],
}
default_options = {
@@ -35,10 +36,11 @@ class OpenDocumentCoreConan(ConanFile):
"with_python": False,
"with_jni": False,
"with_apple": False,
+ "with_wasm": False,
"bundle_assets": False,
}
- exports_sources = ["apple/*", "cli/*", "cmake/*", "jni/*", "python/*", "resources/dist/*", "src/*", "CMakeLists.txt"]
+ exports_sources = ["apple/*", "cli/*", "cmake/*", "jni/*", "python/*", "resources/dist/*", "wasm/*", "src/*", "CMakeLists.txt"]
def config_options(self):
if self.settings.os == "Windows":
@@ -80,6 +82,7 @@ def generate(self):
tc.variables["ODR_PYTHON"] = self.options.get_safe("with_python", False)
tc.variables["ODR_JNI"] = self.options.get_safe("with_jni", False)
tc.variables["ODR_APPLE"] = self.options.get_safe("with_apple", False)
+ tc.variables["ODR_WASM"] = self.options.get_safe("with_wasm", False)
tc.variables["ODR_BUNDLE_ASSETS"] = self.options.get_safe("bundle_assets", False)
tc.generate()
diff --git a/scripts/release_status.py b/scripts/release_status.py
index 0c49bb49e..7d02b4a54 100755
--- a/scripts/release_status.py
+++ b/scripts/release_status.py
@@ -27,6 +27,7 @@
"android": "`app.opendocument:odr-core-android` — Maven Central + GitHub Packages",
"python": "`pyodr` wheels — PyPI",
"apple": "`OdrCoreObjC.xcframework` — the release asset and its manifest",
+ "wasm": "`@opendocument/odr-core` — npm",
}
BEGIN = ""
diff --git a/wasm/AGENTS.md b/wasm/AGENTS.md
new file mode 100644
index 000000000..d090835e6
--- /dev/null
+++ b/wasm/AGENTS.md
@@ -0,0 +1,103 @@
+# AGENTS.md — the WebAssembly bindings
+
+Embind bindings for the public C++ API (`src/odr/*.hpp`), packaged as the npm
+package `@opendocument/odr-core`. The browser counterpart of
+[`../python`](../python/AGENTS.md); read that first, the layout convention is
+the same.
+
+The point is a viewer that renders client-side with no upload and no backend.
+The library was already in the right shape: every renderer writes to a
+`std::ostream`, the CSS and JS are string literals compiled in
+(`internal/html/frontend.cpp`), and with `HtmlConfig::embed_images` one view is
+a complete HTML document whose emitted JS is pure DOM — no `fetch`, no `XHR`.
+
+## Layout
+
+| Path | What |
+|------|------|
+| `CMakeLists.txt` | The `odr_wasm` target, behind `ODR_WASM`. Fails fast without Emscripten, and on `BUILD_SHARED_LIBS`. |
+| `src/` | The bindings, one unit per public-API area; `odr_wasm.{hpp,cpp}` holds the session registry, the result envelope and the exception mapping. |
+| `js/` | The hand-written half of the package. Copied next to the generated glue at build time, so the build directory is importable and `npm pack` has one source. |
+| `tests/` | `node --test` suite, run via ctest (`odr_wasm_node`). |
+| `testfixtures/` | The two documents the suite cannot build in memory. |
+| `example/` | A no-bundler page for eyeballing output. Not packaged, but `wasm.yml` repoints its import and ships it in the release zip, so keep that import a plain relative path. |
+
+## The three rules, and why they are not the other bindings' rules
+
+Everything unusual here follows from the binding being driven from a **Web
+Worker**, where every value that crosses is structured-cloned.
+
+- **Nothing throws across the boundary.** Every entry point runs inside
+ `guarded` and returns `{ok, value | error}`. The worker protocol has to turn
+ a failure into data regardless, 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. The `error.type` names come
+ from the same list as `jni/src/odr_jni.cpp`'s `throw_java` and
+ `apple/src/ODRInternal.mm`; keep the three in step.
+- **Nothing escapes as an embind handle.** A `class_`-bound wrapper cannot be
+ structured-cloned, so a document is a `std::uint32_t` into a registry and a
+ view an index within its session. This also dissolves the keep-alive problem
+ the other bindings hand-built: `HtmlView` holds a bare pointer into its
+ service and `Element` into the document adapter, and here neither is handed
+ out — `Session` owns file, service and views together. Handle `0` is never
+ issued, so a zeroed handle is always invalid.
+- **Config crosses as a plain object.** `to_html_config` reads known keys and
+ leaves the rest defaulted. Never bind a mutable config: it could not cross
+ `postMessage`.
+
+## Rules
+
+- **Bind the public API only** — never include `odr/internal/...`, with the one
+ deliberate exception of `odr_meta_util.hpp`, reused so the meta blob matches
+ `cli/src/meta.cpp` byte for byte.
+- **An embind `std::string` parameter is binary-safe; a `std::string` return is
+ not.** A parameter takes a `Uint8Array` verbatim, a return goes through
+ `UTF8ToString` under the default `-sEMBIND_STD_STRING_IS_UTF8`. Fine for HTML,
+ wrong for a PNG or a font, so binary results go through `to_uint8_array`.
+- **`to_uint8_array` copies, deliberately.** A `typed_memory_view` aliases the
+ wasm heap and `ALLOW_MEMORY_GROWTH` detaches it on the next allocation.
+- **Enums cross by ordinal.** `enum_tables()` derives `FileType`,
+ `FileCategory` and `DocumentType` from the library's own tables; the rest are
+ listed by hand in `wasm_core.cpp` and pinned by `tests/enums.test.mjs`.
+ Appending stays silent, reordering goes loud — the rule
+ `src/odr/html.hpp:34` and `src/odr/file.hpp` state.
+- **A C++→JS callback must be worker-local and synchronous.** Both of them —
+ the logger sink, and the resource locator when it lands — are called during a
+ render, and one needing the main thread would deadlock behind a `postMessage`
+ round trip.
+- **The package is plain JavaScript with a hand-written `.d.ts`.** A TypeScript
+ source tree would drag `tsc` into a C++ repository for one file of
+ declarations. Keep `js/index.d.ts` in step with `js/index.js` by hand.
+- **Test inputs are built in memory** (`tests/helper.mjs` has a hand-rolled zip
+ writer), following `python/AGENTS.md`. `testfixtures/` holds only what cannot
+ be: a document with real layout, and an encrypted one. Never `test/data/` —
+ that is gigabytes behind two private repositories and opt-in.
+
+## Build
+
+Emscripten only; see [`../README.md`](README.md) for the conan invocation and
+`.github/config/conan/profiles/emscripten-wasm` for the profile.
+
+- **No `-pthread`.** It implies SharedArrayBuffer, which implies COOP/COEP
+ headers on whoever hosts the viewer, which rules out plain GitHub Pages.
+ Outside `http_server.cpp` — excluded here — the library spawns no thread.
+- **`-fwasm-exceptions` lives in the profile's `[conf] tools.build:*flags`, not
+ in CMake flags.** The EH mode is an ABI: every dependency has to be built
+ with the same one or the link fails.
+- **`compiler.threads` is omitted, not set to `null`.** A profile value is a
+ string, so `null` reads as the literal `"null"` and fails against
+ `settings.yml`. Omitting the line is how "unset" is spelled — this looks like
+ an oversight and is not.
+- **`-sSTACK_SIZE=8388608` in `CMakeLists.txt` is load-bearing.** Emscripten
+ defaults to 64 KB and the element-registry builders, the renderer's tree walk
+ and the PDF object parser all recurse. The failure at 64 KB does not look
+ like a stack overflow.
+
+Every dependency cross-compiles, cryptopp included and unpatched — no
+`CRYPTOPP_DISABLE_ASM`. 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 covers endianness, float
+formatting and hash ordering in one check. At `-O3` and before any size tuning
+the whole library is 2.9 M of wasm, 831 K brotli'd, plus 92 K of JS glue — so
+splitting PDF into a lazily loaded second bundle is not worth introducing.
+`-Oz` and `-flto` are untried.
diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt
new file mode 100644
index 000000000..676f3a8a2
--- /dev/null
+++ b/wasm/CMakeLists.txt
@@ -0,0 +1,131 @@
+# WebAssembly bindings for OpenDocument.core (npm package `@opendocument/odr-core`).
+#
+# Included from the top-level CMakeLists.txt when `ODR_WASM` is ON. Can also be
+# configured standalone against an installed `odrcore`.
+
+if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
+ cmake_minimum_required(VERSION 3.18)
+ project(odr_wasm LANGUAGES CXX)
+ set(CMAKE_CXX_STANDARD 20)
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
+ set(CMAKE_CXX_EXTENSIONS OFF)
+
+ find_package(odrcore REQUIRED)
+ set(ODR_WASM_ODR_TARGET odrcore::odrcore)
+else ()
+ set(ODR_WASM_ODR_TARGET odr)
+endif ()
+
+if (NOT EMSCRIPTEN)
+ message(FATAL_ERROR
+ "ODR_WASM needs the Emscripten toolchain. Configure with the "
+ "`emscripten-wasm` conan profile "
+ "(.github/config/conan/profiles/emscripten-wasm).")
+endif ()
+
+# The `.wasm` is the only artifact; there is no such thing as linking odrcore
+# beside it. Same reasoning as `apple/CMakeLists.txt`.
+if (BUILD_SHARED_LIBS)
+ message(FATAL_ERROR "ODR_WASM needs BUILD_SHARED_LIBS=OFF")
+endif ()
+
+add_executable(odr_wasm
+ "src/odr_wasm.cpp"
+ "src/wasm_core.cpp"
+ "src/wasm_file.cpp"
+ "src/wasm_html.cpp"
+ "src/wasm_logger.cpp"
+)
+# `odr_meta_util.hpp` is reused for the meta blob and returns a json object, so
+# the binding needs the header the library keeps private.
+find_package(nlohmann_json REQUIRED)
+
+target_link_libraries(odr_wasm PRIVATE
+ ${ODR_WASM_ODR_TARGET}
+ nlohmann_json::nlohmann_json
+ embind
+)
+target_include_directories(odr_wasm PRIVATE "src")
+
+# `-sEXPORT_ES6` requires the `.mjs` suffix. The `.wasm` stays a separate file
+# rather than `-sSINGLE_FILE`: base64 inside the glue costs a third more bytes
+# and gives up both streaming compilation and edge caching, which matter more
+# than the extra request.
+set_target_properties(odr_wasm PROPERTIES
+ OUTPUT_NAME "odr-core"
+ SUFFIX ".mjs"
+ RUNTIME_OUTPUT_DIRECTORY "$<1:${CMAKE_CURRENT_BINARY_DIR}/dist>"
+)
+target_link_options(odr_wasm PRIVATE
+ --bind
+ --no-entry
+ -sMODULARIZE=1
+ -sEXPORT_ES6=1
+ -sEXPORT_NAME=createOdrModule
+ -sENVIRONMENT=web,worker,node
+ -sALLOW_MEMORY_GROWTH=1
+ -sINITIAL_MEMORY=33554432
+ -sMAXIMUM_MEMORY=4294967296
+ # Emscripten defaults to 64 KB. The element-registry builders, the
+ # renderer's tree walk and the PDF object parser all recurse, and the
+ # failure at 64 KB does not look like a stack overflow.
+ -sSTACK_SIZE=8388608
+ -sFILESYSTEM=1
+)
+
+# The hand-written half of the package sits beside the generated glue, so the
+# build directory is directly importable and `npm pack` has one source.
+add_custom_target(odr_wasm_package
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory
+ "${CMAKE_CURRENT_SOURCE_DIR}/js" "${CMAKE_CURRENT_BINARY_DIR}/dist"
+)
+add_dependencies(odr_wasm odr_wasm_package)
+
+if (ODR_TEST)
+ enable_testing()
+
+ # Look outside the conan search path first. `emsdk` pulls in `nodejs/16.3.0`
+ # as a tool requirement, and CMakeToolchain puts its `bin` on
+ # `CMAKE_PROGRAM_PATH` — so a plain `find_program` finds a node that predates
+ # `--test` and the suite fails as "bad option" rather than as a test.
+ find_program(ODR_WASM_NODE NAMES node nodejs
+ NO_CMAKE_PATH NO_CMAKE_SYSTEM_PATH)
+ if (NOT ODR_WASM_NODE)
+ find_program(ODR_WASM_NODE NAMES node nodejs)
+ endif ()
+
+ set(ODR_WASM_NODE_MINIMUM 18) # `node --test`
+
+ if (ODR_WASM_NODE)
+ execute_process(COMMAND "${ODR_WASM_NODE}" --version
+ OUTPUT_VARIABLE ODR_WASM_NODE_VERSION
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ ERROR_QUIET)
+ string(REGEX REPLACE "^v" "" ODR_WASM_NODE_VERSION "${ODR_WASM_NODE_VERSION}")
+ endif ()
+
+ if (ODR_WASM_NODE AND ODR_WASM_NODE_VERSION
+ AND NOT ODR_WASM_NODE_VERSION VERSION_LESS ODR_WASM_NODE_MINIMUM)
+ # No path argument: node discovers `*.test.mjs` under the working
+ # directory itself. Passing the directory instead works on node 20 but
+ # not on 22, which reads a bare path as a module to execute and fails
+ # with `Cannot find module`.
+ add_test(NAME odr_wasm_node COMMAND "${ODR_WASM_NODE}" --test)
+ set_tests_properties(odr_wasm_node PROPERTIES
+ WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests"
+ ENVIRONMENT "ODR_WASM_DIST=${CMAKE_CURRENT_BINARY_DIR}/dist")
+ else ()
+ # Left as a warning rather than an error so a contributor without node
+ # can still build. CI passes `--no-tests=error` to ctest, so skipping
+ # there is loud.
+ message(WARNING
+ "no node >= ${ODR_WASM_NODE_MINIMUM} "
+ "(found '${ODR_WASM_NODE}' ${ODR_WASM_NODE_VERSION}); "
+ "skipping the odr_wasm test registration")
+ endif ()
+endif ()
+
+install(TARGETS odr_wasm RUNTIME DESTINATION dist COMPONENT wasm)
+install(FILES
+ "${CMAKE_CURRENT_BINARY_DIR}/dist/odr-core.wasm"
+ DESTINATION dist COMPONENT wasm)
diff --git a/wasm/README.md b/wasm/README.md
new file mode 100644
index 000000000..1b04fc021
--- /dev/null
+++ b/wasm/README.md
@@ -0,0 +1,107 @@
+# WebAssembly bindings
+
+`@opendocument/odr-core` — render documents to HTML **in the browser**, with no
+server and no upload. The bytes never leave the machine.
+
+Not to be confused with [OpenDocument.js](https://github.com/opendocument-app/OpenDocument.js),
+which is the renderer's own frontend TypeScript. That is compiled *into* the
+library (`src/odr/internal/html/frontend.cpp`) and is not published; this package
+is the library itself.
+
+## Install
+
+```sh
+npm install @opendocument/odr-core
+```
+
+Or skip the build step entirely — the package works straight off a CDN, which
+is the least-machinery way to put a viewer on a static host:
+
+```html
+
+```
+
+For a self-hosted copy with no npm and no CDN, every release carries
+`odr-core-browser-.zip`: the same files flat, plus an `example.html`
+that runs against them. Unzip it where your pages are served from and import
+`./index.js`.
+
+## Use
+
+```js
+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();
+}
+```
+
+`html` is a complete document — styles, scripts, images and fonts all inline —
+so it needs nothing fetched alongside it. A `blob:` iframe keeps the same
+origin, so the page can still reach `iframe.contentWindow.odr` to drive
+`search()`, `searchNext()` and `generateDiff()`, exactly as the Android and iOS
+apps do from their WebViews.
+
+Multi-page formats render one view at a time:
+
+```js
+for (const view of doc.listViews()) {
+ render(doc.render(view.index).html);
+}
+```
+
+Encrypted documents:
+
+```js
+if (doc.isPasswordEncrypted()) {
+ try {
+ doc.decrypt(password);
+ } catch (e) {
+ if (e.name === 'WrongPassword') { /* ask again */ }
+ }
+}
+```
+
+**Close what you open.** JS has no destructors, so a `Document` holds a handle
+into the wasm heap until you say otherwise. `using doc = odr.open(...)` works
+where `Symbol.dispose` is supported.
+
+## Hosting
+
+- Serve `.wasm` as `application/wasm`, or the browser cannot stream-compile it.
+- **Enable brotli.** It takes the module from 2.9 M to about 830 K — worth more
+ than every code-size flag put together. Hosts that only gzip land at ~1.2 M.
+- No COOP/COEP headers needed. The build is deliberately single-threaded so
+ that a plain static host, GitHub Pages included, is enough.
+- Rendering is synchronous and a large PDF takes seconds, so run the module in
+ a Web Worker. Pass `doc.handle` across `postMessage`, never the `Document`.
+
+## Building
+
+Needs the Emscripten toolchain, via the conan profile in the repository:
+
+```sh
+conan install . --output-folder=build-wasm --build=missing --lockfile-partial \
+ --profile:host=emscripten-wasm --profile:build= \
+ -o '&:with_wasm=True'
+cmake -B build-wasm -DCMAKE_TOOLCHAIN_FILE=build-wasm/conan_toolchain.cmake \
+ -DODR_WASM=ON -DODR_CLI=OFF -DODR_WITH_HTTP_SERVER=OFF -DBUILD_SHARED_LIBS=OFF
+cmake --build build-wasm --target odr_wasm
+```
+
+The package lands in `build-wasm/wasm/dist` and is directly importable.
+`wasm/example/index.html` opens it with no bundler; serve the repository over
+HTTP and visit it.
+
+Tests run under node, from ctest with `-DODR_TEST=ON`:
+
+```sh
+ctest --test-dir build-wasm/wasm
+```
diff --git a/wasm/example/index.html b/wasm/example/index.html
new file mode 100644
index 000000000..ef6ff1211
--- /dev/null
+++ b/wasm/example/index.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+ odr — WebAssembly example
+
+
+
+
+
+ loading…
+
+
+
drop a document anywhere, or pick one above
+
+
+
+
+
diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts
new file mode 100644
index 000000000..261f1889e
--- /dev/null
+++ b/wasm/js/index.d.ts
@@ -0,0 +1,139 @@
+/** Hand-written, because the package ships plain JavaScript — see `wasm/AGENTS.md`. */
+
+/** Ordinals, mirroring the C++ enums. Read them from `Odr.enums`, never inline
+ * a number: the headers guarantee only that values are appended. */
+export interface EnumTables {
+ FileType: Record;
+ FileCategory: Record;
+ DocumentType: Record;
+ HtmlResourceType: Record;
+ HtmlTableGridlines: Record;
+ HtmlViewportMode: Record;
+ PdfTextMode: Record;
+ EncryptionState: Record;
+ LogLevel: Record;
+}
+
+export interface Capabilities {
+ detectByContent: boolean;
+ open: boolean;
+ decrypt: boolean;
+ translateHtml: boolean;
+ edit: boolean;
+ save: boolean;
+ encrypt: boolean;
+}
+
+export interface FileTypeInfo {
+ fileType: number;
+ name: string;
+ category: number;
+ documentType: number;
+ extensions: string[];
+ mimeTypes: string[];
+ capabilities: Capabilities;
+}
+
+export interface Detection {
+ /** Most specific last: a zip names the container first, its contents after. */
+ fileTypes: number[];
+ mimeType: string;
+}
+
+export interface View {
+ name: string;
+ index: number;
+ path: string;
+}
+
+/** A resource the markup links to rather than inlining. Fetch with
+ * {@link Document.read}. Empty unless the document carries media, or
+ * `embedImages` was turned off. */
+export interface ExternalResource {
+ path: string;
+ mimeType: string;
+ type: number;
+}
+
+export interface Rendered {
+ html: string;
+ externalResources: ExternalResource[];
+}
+
+export interface Content {
+ bytes: Uint8Array;
+ mimeType: string;
+}
+
+/** Anything omitted keeps the library's default. */
+export interface HtmlConfig {
+ embedImages?: boolean;
+ editable?: boolean;
+ textDocumentMargin?: boolean;
+ formatHtml?: boolean;
+ embedOutline?: boolean;
+ noDrm?: boolean;
+ backgroundImageFormat?: string;
+ backgroundImageDpi?: number;
+ pageRangeBegin?: number;
+ pageRangeEnd?: number;
+ spreadsheetGridlines?: number;
+ viewportMode?: number;
+ pdfTextMode?: number;
+}
+
+export interface OpenOptions extends HtmlConfig {
+ /** Force an interpretation instead of detecting one. */
+ fileType?: number;
+}
+
+/** `name` is the C++ exception type: `WrongPassword`, `UnsupportedFileType`, … */
+export declare class OdrError extends Error {
+ /** Set when `name` is `UnsupportedFileType`. */
+ fileType?: number;
+}
+
+export declare class Document {
+ /** Pass this across `postMessage`, never the `Document` — the wrapper's state
+ * is in private fields and clones away to an empty object. */
+ readonly handle: number;
+ readonly fileType: number;
+
+ meta(): Record;
+ capabilities(): Capabilities;
+ isPasswordEncrypted(): boolean;
+ /** @throws OdrError `WrongPassword` */
+ decrypt(password: string): this;
+
+ listViews(): View[];
+ /** With the default `embedImages`, `html` is self-contained and can go
+ * straight into a `blob:` iframe. */
+ render(index?: number): Rendered;
+ read(path: string): Content;
+
+ /** Idempotent; returns whether it released anything. */
+ close(): boolean;
+ [Symbol.dispose](): void;
+}
+
+export declare class Odr {
+ readonly enums: EnumTables;
+
+ static load(moduleOptions?: Record): Promise;
+
+ version(): string;
+ /** Version, commit and dirty flag. Reads "unknown version" on an unreleased
+ * build, which is correct — `main` carries none. */
+ identify(): string;
+ /** Every known type, enough to populate an `` or a PWA
+ * manifest's file handlers without opening anything. */
+ fileTypes(): FileTypeInfo[];
+ detect(bytes: Uint8Array): Detection;
+ open(bytes: Uint8Array, options?: OpenOptions): Document;
+ /** Applies to documents opened after the call. Null silences it again. */
+ setLogger(sink: ((level: number, message: string) => void) | null, level?: number): void;
+ /** Releases every open document; prefer closing them individually. */
+ closeAll(): void;
+}
+
+export default Odr;
diff --git a/wasm/js/index.js b/wasm/js/index.js
new file mode 100644
index 000000000..97e24026a
--- /dev/null
+++ b/wasm/js/index.js
@@ -0,0 +1,130 @@
+// The ergonomic layer over the embind surface: unwraps `{ok, value | error}`
+// envelopes into exceptions and wraps handles in a `Document`. See
+// `wasm/AGENTS.md` for why the binding itself does neither.
+
+import createOdrModule from './odr-core.mjs';
+
+export class OdrError extends Error {
+ constructor(type, message, detail) {
+ super(message);
+ this.name = type;
+ Object.assign(this, detail);
+ }
+}
+
+function unwrap(envelope) {
+ if (envelope.ok) {
+ return envelope.value;
+ }
+ const { type, message, ...detail } = envelope.error;
+ throw new OdrError(type, message, detail);
+}
+
+// Holds a handle into the wasm heap, so it must be closed: JS has no
+// destructors and the module cannot know when you are done.
+export class Document {
+ #core;
+ #handle;
+
+ constructor(core, handle) {
+ this.#core = core;
+ this.#handle = handle;
+ }
+
+ // Pass this across `postMessage` rather than the object, which does not
+ // survive structured cloning.
+ get handle() {
+ return this.#handle;
+ }
+
+ get fileType() {
+ return unwrap(this.#core.fileType(this.#handle));
+ }
+
+ meta() {
+ return JSON.parse(unwrap(this.#core.meta(this.#handle)));
+ }
+
+ capabilities() {
+ return unwrap(this.#core.capabilities(this.#handle));
+ }
+
+ isPasswordEncrypted() {
+ return unwrap(this.#core.isPasswordEncrypted(this.#handle));
+ }
+
+ // Anything already rendered is discarded, having come from the encrypted file.
+ decrypt(password) {
+ unwrap(this.#core.decrypt(this.#handle, password));
+ return this;
+ }
+
+ listViews() {
+ return unwrap(this.#core.listViews(this.#handle));
+ }
+
+ render(index = 0) {
+ return unwrap(this.#core.renderView(this.#handle, index));
+ }
+
+ read(path) {
+ return unwrap(this.#core.readPath(this.#handle, path));
+ }
+
+ close() {
+ return unwrap(this.#core.close(this.#handle));
+ }
+
+ [Symbol.dispose]() {
+ this.close();
+ }
+}
+
+export class Odr {
+ #core;
+
+ constructor(core) {
+ this.#core = core;
+ this.enums = core.enumTables();
+ }
+
+ static async load(moduleOptions) {
+ return new Odr(await createOdrModule(moduleOptions));
+ }
+
+ version() {
+ return this.#core.version();
+ }
+
+ identify() {
+ return this.#core.identify();
+ }
+
+ fileTypes() {
+ return this.#core.fileTypes();
+ }
+
+ detect(bytes) {
+ return unwrap(this.#core.detect(bytes));
+ }
+
+ // `fileType` forces an interpretation instead of detecting one.
+ open(bytes, { fileType, ...config } = {}) {
+ const handle =
+ fileType === undefined
+ ? unwrap(this.#core.open(bytes, config))
+ : unwrap(this.#core.openAs(bytes, fileType, config));
+ return new Document(this.#core, handle);
+ }
+
+ setLogger(sink, level = 2) {
+ unwrap(this.#core.setLogger(sink, level));
+ }
+
+ // The escape hatch for a worker being torn down; prefer closing individually.
+ closeAll() {
+ unwrap(this.#core.closeAll());
+ }
+}
+
+export default Odr;
diff --git a/wasm/js/package.json b/wasm/js/package.json
new file mode 100644
index 000000000..312a65bba
--- /dev/null
+++ b/wasm/js/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@opendocument/odr-core",
+ "version": "0.0.0",
+ "description": "Render documents (ODF, OOXML, legacy MS binary, PDF, ...) to HTML in the browser, with no server",
+ "keywords": [
+ "odf",
+ "odt",
+ "ooxml",
+ "docx",
+ "pdf",
+ "wasm",
+ "webassembly",
+ "viewer"
+ ],
+ "homepage": "https://github.com/opendocument-app/OpenDocument.core/tree/main/wasm",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/opendocument-app/OpenDocument.core.git",
+ "directory": "wasm"
+ },
+ "license": "MPL-2.0",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./index.js",
+ "types": "./index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./index.d.ts",
+ "default": "./index.js"
+ },
+ "./odr-core.wasm": "./odr-core.wasm",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "index.js",
+ "index.d.ts",
+ "odr-core.mjs",
+ "odr-core.wasm",
+ "README.md"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/wasm/src/odr_wasm.cpp b/wasm/src/odr_wasm.cpp
new file mode 100644
index 000000000..1c9f09411
--- /dev/null
+++ b/wasm/src/odr_wasm.cpp
@@ -0,0 +1,137 @@
+#include
+
+#include
+
+#include
+
+#include
+#include
+#include
+
+namespace odr::wasm {
+
+namespace {
+
+std::unordered_map &sessions() {
+ static std::unordered_map instance;
+ return instance;
+}
+
+Handle &next_handle() {
+ // 0 is never handed out, so a zeroed handle in JS is always invalid
+ static Handle instance = 1;
+ return instance;
+}
+
+emscripten::val error_for(const std::exception &e, const std::string &type) {
+ return error(type, e.what());
+}
+
+} // namespace
+
+Session &session(const Handle handle) {
+ const auto it = sessions().find(handle);
+ if (it == sessions().end()) {
+ throw std::out_of_range("no such document handle: " +
+ std::to_string(handle));
+ }
+ return it->second;
+}
+
+Handle add_session(Session session) {
+ const Handle handle = next_handle()++;
+ sessions().emplace(handle, std::move(session));
+ return handle;
+}
+
+bool remove_session(const Handle handle) noexcept {
+ return sessions().erase(handle) != 0;
+}
+
+void clear_sessions() noexcept { sessions().clear(); }
+
+emscripten::val ok(emscripten::val value) {
+ emscripten::val result = emscripten::val::object();
+ result.set("ok", true);
+ result.set("value", std::move(value));
+ return result;
+}
+
+emscripten::val ok() { return ok(emscripten::val::undefined()); }
+
+emscripten::val error(const std::string &type, const std::string &message) {
+ emscripten::val detail = emscripten::val::object();
+ detail.set("type", type);
+ detail.set("message", message);
+
+ emscripten::val result = emscripten::val::object();
+ result.set("ok", false);
+ result.set("error", std::move(detail));
+ return result;
+}
+
+emscripten::val current_exception_error() {
+ try {
+ throw;
+ } catch (const UnsupportedOperation &e) {
+ return error_for(e, "UnsupportedOperation");
+ } catch (const FileNotFound &e) {
+ return error_for(e, "FileNotFound");
+ } catch (const UnknownFileType &e) {
+ return error_for(e, "UnknownFileType");
+ } catch (const UnsupportedFileType &e) {
+ // the only error carrying a payload the caller acts on: a viewer names the
+ // format it cannot show
+ emscripten::val result = error_for(e, "UnsupportedFileType");
+ result["error"].set("fileType", static_cast(e.file_type));
+ return result;
+ } catch (const FileReadError &e) {
+ return error_for(e, "FileReadError");
+ } catch (const FileWriteError &e) {
+ return error_for(e, "FileWriteError");
+ } catch (const NoDocumentFile &e) {
+ return error_for(e, "NoDocumentFile");
+ } catch (const UnknownDocumentType &e) {
+ return error_for(e, "UnknownDocumentType");
+ } catch (const UnsupportedCryptoAlgorithm &e) {
+ return error_for(e, "UnsupportedCryptoAlgorithm");
+ } catch (const WrongPasswordError &e) {
+ return error_for(e, "WrongPassword");
+ } catch (const DecryptionFailed &e) {
+ return error_for(e, "DecryptionFailed");
+ } catch (const NotEncryptedError &e) {
+ return error_for(e, "NotEncrypted");
+ } catch (const FileEncryptedError &e) {
+ return error_for(e, "FileEncrypted");
+ } catch (const DocumentCopyProtectedException &e) {
+ return error_for(e, "DocumentCopyProtected");
+ } catch (const std::exception &e) {
+ return error_for(e, "OdrError");
+ } catch (...) {
+ return error("OdrError", "unknown native error");
+ }
+}
+
+emscripten::val to_capabilities(const FileTypeCapabilities &capabilities) {
+ emscripten::val result = emscripten::val::object();
+ result.set("detectByContent", capabilities.detect_by_content);
+ result.set("open", capabilities.open);
+ result.set("decrypt", capabilities.decrypt);
+ result.set("translateHtml", capabilities.translate_html);
+ result.set("edit", capabilities.edit);
+ result.set("save", capabilities.save);
+ result.set("encrypt", capabilities.encrypt);
+ return result;
+}
+
+emscripten::val to_uint8_array(const std::string &bytes) {
+ const emscripten::val view(emscripten::typed_memory_view(
+ bytes.size(), reinterpret_cast(bytes.data())));
+
+ emscripten::val result =
+ emscripten::val::global("Uint8Array").new_(bytes.size());
+ result.call("set", view);
+ return result;
+}
+
+} // namespace odr::wasm
diff --git a/wasm/src/odr_wasm.hpp b/wasm/src/odr_wasm.hpp
new file mode 100644
index 000000000..3e8a942dd
--- /dev/null
+++ b/wasm/src/odr_wasm.hpp
@@ -0,0 +1,66 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+
+/// Shared plumbing for the WebAssembly bindings. Nothing throws across the
+/// boundary and nothing escapes as an embind handle; `wasm/AGENTS.md` says why.
+namespace odr::wasm {
+
+using Handle = std::uint32_t;
+
+/// One open document. Owns everything reachable from it, because the pieces do
+/// not own each other: `HtmlView` holds a bare pointer into its service, so the
+/// service has to outlive the views.
+struct Session final {
+ DecodedFile file;
+ Logger logger;
+ HtmlConfig config;
+ std::optional service;
+ HtmlViews views;
+};
+
+Logger &default_logger();
+
+/// @throws std::out_of_range if @p handle is unknown.
+Session &session(Handle handle);
+Handle add_session(Session session);
+bool remove_session(Handle handle) noexcept;
+void clear_sessions() noexcept;
+
+emscripten::val ok(emscripten::val value);
+emscripten::val ok();
+/// `{ok: false, error: {type, message, ...}}`, with `type` naming the C++
+/// exception. Kept in step with `jni/src/odr_jni.cpp`'s `throw_java` and
+/// `apple/src/ODRInternal.mm`.
+emscripten::val error(const std::string &type, const std::string &message);
+
+/// The envelope for the exception being handled. Call from a `catch` block.
+emscripten::val current_exception_error();
+
+template emscripten::val guarded(F &&f) {
+ try {
+ return std::forward(f)();
+ } catch (...) {
+ return current_exception_error();
+ }
+}
+
+/// A `Uint8Array` copy of @p bytes. A copy because `typed_memory_view` aliases
+/// the wasm heap, which `ALLOW_MEMORY_GROWTH` detaches on the next allocation.
+emscripten::val to_uint8_array(const std::string &bytes);
+
+emscripten::val to_capabilities(const FileTypeCapabilities &capabilities);
+
+/// Reads a `HtmlConfig` off a plain JS object, leaving unset keys defaulted.
+HtmlConfig to_html_config(const emscripten::val &value);
+
+} // namespace odr::wasm
diff --git a/wasm/src/wasm_core.cpp b/wasm/src/wasm_core.cpp
new file mode 100644
index 000000000..47173fd4b
--- /dev/null
+++ b/wasm/src/wasm_core.cpp
@@ -0,0 +1,136 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace odr::wasm {
+
+namespace {
+
+emscripten::val version() { return emscripten::val(odr::version()); }
+emscripten::val identify() { return emscripten::val(odr::identify()); }
+
+emscripten::val string_array(const std::span values) {
+ emscripten::val result = emscripten::val::array();
+ for (const std::string_view value : values) {
+ result.call("push", std::string(value));
+ }
+ return result;
+}
+
+/// Every file type, with what a viewer needs before it holds a file: an
+/// `` list, and what the PWA manifest declares it opens.
+emscripten::val file_types() {
+ emscripten::val result = emscripten::val::array();
+ for (const FileType type : odr::all_file_types()) {
+ emscripten::val entry = emscripten::val::object();
+ entry.set("fileType", static_cast(type));
+ entry.set("name", odr::file_type_to_string(type));
+ entry.set("category",
+ static_cast(odr::file_category_by_file_type(type)));
+ entry.set("documentType",
+ static_cast(odr::document_type_by_file_type(type)));
+ entry.set("extensions",
+ string_array(odr::file_extensions_by_file_type(type)));
+ entry.set("mimeTypes", string_array(odr::mimetypes_by_file_type(type)));
+ entry.set("capabilities",
+ to_capabilities(odr::capabilities_by_file_type(type)));
+
+ result.call("push", entry);
+ }
+ return result;
+}
+
+/// Enum name to ordinal, so the JS side never restates an ordinal by hand.
+/// `FileType`, `FileCategory` and `DocumentType` are derived from the library's
+/// tables and cannot drift; the rest have no runtime table and are listed here,
+/// pinned by `tests/enums.test.mjs`.
+emscripten::val enum_tables() {
+ const auto table = [](const auto &...entries) {
+ emscripten::val result = emscripten::val::object();
+ (result.set(entries.first, entries.second), ...);
+ return result;
+ };
+ const auto entry = [](const char *name, auto value) {
+ return std::pair{name, static_cast(value)};
+ };
+
+ emscripten::val file_type = emscripten::val::object();
+ for (const FileType type : odr::all_file_types()) {
+ file_type.set(odr::file_type_to_string(type), static_cast(type));
+ }
+
+ emscripten::val file_category = emscripten::val::object();
+ for (const FileCategory category :
+ {FileCategory::unknown, FileCategory::text, FileCategory::image,
+ FileCategory::archive, FileCategory::document, FileCategory::audio,
+ FileCategory::video, FileCategory::font}) {
+ file_category.set(odr::file_category_to_string(category),
+ static_cast(category));
+ }
+
+ emscripten::val document_type = emscripten::val::object();
+ for (const DocumentType type :
+ {DocumentType::unknown, DocumentType::text, DocumentType::presentation,
+ DocumentType::spreadsheet, DocumentType::drawing}) {
+ document_type.set(odr::document_type_to_string(type),
+ static_cast(type));
+ }
+
+ emscripten::val result = emscripten::val::object();
+ result.set("FileType", file_type);
+ result.set("FileCategory", file_category);
+ result.set("DocumentType", document_type);
+ result.set("HtmlResourceType",
+ table(entry("html_fragment", HtmlResourceType::html_fragment),
+ entry("css", HtmlResourceType::css),
+ entry("js", HtmlResourceType::js),
+ entry("image", HtmlResourceType::image),
+ entry("font", HtmlResourceType::font),
+ entry("media", HtmlResourceType::media)));
+ result.set("HtmlTableGridlines",
+ table(entry("none", HtmlTableGridlines::none),
+ entry("soft", HtmlTableGridlines::soft),
+ entry("hard", HtmlTableGridlines::hard)));
+ result.set("HtmlViewportMode",
+ table(entry("automatic", HtmlViewportMode::automatic),
+ entry("fit_width", HtmlViewportMode::fit_width),
+ entry("actual_size", HtmlViewportMode::actual_size),
+ entry("none", HtmlViewportMode::none)));
+ result.set("PdfTextMode",
+ table(entry("dual_layer", PdfTextMode::dual_layer),
+ entry("single_layer", PdfTextMode::single_layer)));
+ result.set("EncryptionState",
+ table(entry("unknown", EncryptionState::unknown),
+ entry("not_encrypted", EncryptionState::not_encrypted),
+ entry("encrypted", EncryptionState::encrypted),
+ entry("decrypted", EncryptionState::decrypted)));
+ result.set("LogLevel", table(entry("verbose", LogLevel::verbose),
+ entry("debug", LogLevel::debug),
+ entry("info", LogLevel::info),
+ entry("warning", LogLevel::warning),
+ entry("error", LogLevel::error),
+ entry("fatal", LogLevel::fatal)));
+ return result;
+}
+
+} // namespace
+
+} // namespace odr::wasm
+
+EMSCRIPTEN_BINDINGS(odr_core) {
+ emscripten::function("version", &odr::wasm::version);
+ emscripten::function("identify", &odr::wasm::identify);
+ emscripten::function("fileTypes", &odr::wasm::file_types);
+ emscripten::function("enumTables", &odr::wasm::enum_tables);
+}
diff --git a/wasm/src/wasm_file.cpp b/wasm/src/wasm_file.cpp
new file mode 100644
index 000000000..2fa4fc133
--- /dev/null
+++ b/wasm/src/wasm_file.cpp
@@ -0,0 +1,131 @@
+#include
+
+#include
+#include
+
+#include
+
+#include
+
+#include
+#include
+
+namespace odr::wasm {
+
+namespace {
+
+/// An embind `std::string` *parameter* takes a `Uint8Array` and copies the
+/// bytes verbatim, so this is binary-safe — unlike a `std::string` *return*,
+/// which goes through `UTF8ToString`.
+File from_bytes(const std::string &bytes) { return File::from_memory(bytes); }
+
+emscripten::val opened(DecodedFile file, const emscripten::val &config) {
+ Session s{.file = std::move(file),
+ .logger = default_logger(),
+ .config = to_html_config(config),
+ .service = {},
+ .views = {}};
+ return ok(emscripten::val(add_session(std::move(s))));
+}
+
+emscripten::val detect(const std::string &bytes) {
+ return guarded([&] {
+ const File file = from_bytes(bytes);
+ const Logger &logger = default_logger();
+
+ emscripten::val types = emscripten::val::array();
+ for (const FileType type : DecodedFile::list_file_types(file, logger)) {
+ types.call("push", static_cast(type));
+ }
+
+ emscripten::val result = emscripten::val::object();
+ result.set("fileTypes", types);
+ result.set("mimeType", std::string(DecodedFile::mimetype(file, logger)));
+ return ok(result);
+ });
+}
+
+emscripten::val open(const std::string &bytes, const emscripten::val &config) {
+ return guarded([&] {
+ return opened(DecodedFile(from_bytes(bytes), default_logger()), config);
+ });
+}
+
+emscripten::val open_as(const std::string &bytes, const int as,
+ const emscripten::val &config) {
+ return guarded([&] {
+ return opened(DecodedFile(from_bytes(bytes), static_cast(as),
+ default_logger()),
+ config);
+ });
+}
+
+/// The meta blob as `cli/src/meta.cpp` produces it, reusing the same serialiser
+/// rather than growing a second one that drifts.
+emscripten::val meta(const Handle handle) {
+ return guarded([&] {
+ const Session &s = session(handle);
+ const auto json = internal::util::meta::meta_to_json(s.file.file_meta());
+ return ok(emscripten::val(json.dump()));
+ });
+}
+
+emscripten::val capabilities(const Handle handle) {
+ return guarded(
+ [&] { return ok(to_capabilities(session(handle).file.capabilities())); });
+}
+
+emscripten::val is_password_encrypted(const Handle handle) {
+ return guarded([&] {
+ return ok(emscripten::val(session(handle).file.password_encrypted()));
+ });
+}
+
+/// Decrypts in place: a new handle would leave the caller holding two, one of
+/// them useless.
+emscripten::val decrypt(const Handle handle, const std::string &password) {
+ return guarded([&] {
+ Session &s = session(handle);
+ s.file = s.file.decrypt(password);
+ // whatever was translated came from the encrypted file
+ s.service.reset();
+ s.views.clear();
+ return ok();
+ });
+}
+
+emscripten::val file_type(const Handle handle) {
+ return guarded([&] {
+ return ok(
+ emscripten::val(static_cast(session(handle).file.file_type())));
+ });
+}
+
+emscripten::val close(const Handle handle) {
+ return guarded([&] { return ok(emscripten::val(remove_session(handle))); });
+}
+
+emscripten::val close_all() {
+ return guarded([] {
+ clear_sessions();
+ return ok();
+ });
+}
+
+} // namespace
+
+} // namespace odr::wasm
+
+EMSCRIPTEN_BINDINGS(odr_file) {
+ emscripten::function("detect", &odr::wasm::detect);
+ emscripten::function("open", &odr::wasm::open);
+ emscripten::function("openAs", &odr::wasm::open_as);
+ emscripten::function("meta", &odr::wasm::meta);
+ emscripten::function("capabilities", &odr::wasm::capabilities);
+ emscripten::function("isPasswordEncrypted",
+ &odr::wasm::is_password_encrypted);
+ emscripten::function("decrypt", &odr::wasm::decrypt);
+ emscripten::function("fileType", &odr::wasm::file_type);
+ emscripten::function("close", &odr::wasm::close);
+ emscripten::function("closeAll", &odr::wasm::close_all);
+}
diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp
new file mode 100644
index 000000000..2914338db
--- /dev/null
+++ b/wasm/src/wasm_html.cpp
@@ -0,0 +1,154 @@
+#include
+
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+
+namespace odr::wasm {
+
+namespace {
+
+/// An absent or null key leaves @p target alone, so a caller sends only what it
+/// means to change.
+template
+void read(const emscripten::val &value, const char *key, T &target) {
+ const emscripten::val field = value[key];
+ if (field.isUndefined() || field.isNull()) {
+ return;
+ }
+ target = field.as();
+}
+
+/// @ref read for an enum, which JS carries as its ordinal.
+template
+void read_enum(const emscripten::val &value, const char *key, T &target) {
+ const emscripten::val field = value[key];
+ if (field.isUndefined() || field.isNull()) {
+ return;
+ }
+ target = static_cast(field.as());
+}
+
+/// Translates on first use, so a caller that only wants metadata does not pay
+/// for a render at open.
+Session &warm(const Handle handle) {
+ Session &s = session(handle);
+ if (!s.service.has_value()) {
+ s.service = html::translate(s.file, s.config, s.logger);
+ s.views = s.service->list_views();
+ }
+ return s;
+}
+
+emscripten::val list_views(const Handle handle) {
+ return guarded([&] {
+ const Session &s = warm(handle);
+
+ emscripten::val result = emscripten::val::array();
+ for (const HtmlView &view : s.views) {
+ emscripten::val entry = emscripten::val::object();
+ entry.set("name", view.name());
+ entry.set("index", static_cast(view.index()));
+ entry.set("path", view.path());
+ result.call("push", entry);
+ }
+ return ok(result);
+ });
+}
+
+/// The rendered view as one HTML string, self-contained under the default
+/// `embedImages` — which is what lets a viewer drop it into a `blob:` iframe.
+emscripten::val render_view(const Handle handle, const std::size_t index) {
+ return guarded([&] {
+ const Session &s = warm(handle);
+ if (index >= s.views.size()) {
+ return error("OdrError", "no such view index: " + std::to_string(index));
+ }
+
+ std::ostringstream out;
+ const HtmlResources resources = s.views[index].write_html(out);
+
+ // A located resource is one the markup links to rather than inlines. The
+ // viewer has to serve those itself, so it is told rather than discovering
+ // a broken `src`.
+ emscripten::val external = emscripten::val::array();
+ for (const auto &[resource, location] : resources) {
+ if (!location.has_value()) {
+ continue;
+ }
+ emscripten::val entry = emscripten::val::object();
+ entry.set("path", *location);
+ entry.set("mimeType", resource.mime_type());
+ entry.set("type", static_cast(resource.type()));
+ external.call("push", entry);
+ }
+
+ emscripten::val result = emscripten::val::object();
+ result.set("html", out.str());
+ result.set("externalResources", external);
+ return ok(result);
+ });
+}
+
+/// The bytes behind a path the service knows — a view, or a resource
+/// `renderView` reported. Same contract as `HttpServer::serve_file`.
+emscripten::val read_path(const Handle handle, const std::string &path) {
+ return guarded([&] {
+ const Session &s = warm(handle);
+ if (!s.service->exists(path)) {
+ return error("FileNotFound", "no such path in the document: " + path);
+ }
+
+ std::ostringstream out;
+ s.service->write(path, out);
+
+ emscripten::val result = emscripten::val::object();
+ result.set("bytes", to_uint8_array(out.str()));
+ result.set("mimeType", s.service->mimetype(path));
+ return ok(result);
+ });
+}
+
+} // namespace
+
+HtmlConfig to_html_config(const emscripten::val &value) {
+ HtmlConfig config;
+ if (value.isUndefined() || value.isNull()) {
+ return config;
+ }
+
+ read(value, "embedImages", config.embed_images);
+ read(value, "editable", config.editable);
+ read(value, "textDocumentMargin", config.text_document_margin);
+ read(value, "formatHtml", config.format_html);
+ read(value, "embedOutline", config.embed_outline);
+ read(value, "noDrm", config.no_drm);
+
+ read(value, "backgroundImageFormat", config.background_image_format);
+ read(value, "backgroundImageDpi", config.background_image_dpi);
+
+ read(value, "pageRangeBegin", config.page_range_begin);
+ if (const emscripten::val end = value["pageRangeEnd"];
+ !end.isUndefined() && !end.isNull()) {
+ config.page_range_end = end.as();
+ }
+
+ read_enum(value, "spreadsheetGridlines", config.spreadsheet_gridlines);
+ read_enum(value, "viewportMode", config.viewport_mode);
+ read_enum(value, "pdfTextMode", config.pdf_text_mode);
+
+ return config;
+}
+
+} // namespace odr::wasm
+
+EMSCRIPTEN_BINDINGS(odr_html) {
+ emscripten::function("listViews", &odr::wasm::list_views);
+ emscripten::function("renderView", &odr::wasm::render_view);
+ emscripten::function("readPath", &odr::wasm::read_path);
+}
diff --git a/wasm/src/wasm_logger.cpp b/wasm/src/wasm_logger.cpp
new file mode 100644
index 000000000..29de4b78b
--- /dev/null
+++ b/wasm/src/wasm_logger.cpp
@@ -0,0 +1,67 @@
+#include
+
+#include
+
+#include
+
+#include
+#include
+#include
+
+namespace odr::wasm {
+
+namespace {
+
+/// Forwards log records to a JS callback. The sink must be worker-local and
+/// synchronous: one that needed the main thread would deadlock a render behind
+/// a `postMessage` round trip.
+class JsLogger final : public ILogger {
+public:
+ JsLogger(emscripten::val sink, const LogLevel level)
+ : m_sink{std::move(sink)}, m_level{level} {}
+
+ [[nodiscard]] bool will_log(const LogLevel level) const override {
+ return level >= m_level;
+ }
+
+ void log(Time /*time*/, const LogLevel level, const std::string &message,
+ const std::source_location & /*location*/) override {
+ if (!will_log(level)) {
+ return;
+ }
+ m_sink(static_cast(level), message);
+ }
+
+ void flush() override {}
+
+private:
+ emscripten::val m_sink;
+ LogLevel m_level;
+};
+
+/// Routes logging into @p sink for every document opened after this call; null
+/// restores silence.
+emscripten::val set_logger(const emscripten::val &sink, const int level) {
+ return guarded([&] {
+ if (sink.isUndefined() || sink.isNull()) {
+ default_logger() = Logger::null();
+ return ok();
+ }
+ default_logger() =
+ Logger(std::make_shared(sink, static_cast(level)));
+ return ok();
+ });
+}
+
+} // namespace
+
+Logger &default_logger() {
+ static Logger instance = Logger::null();
+ return instance;
+}
+
+} // namespace odr::wasm
+
+EMSCRIPTEN_BINDINGS(odr_logger) {
+ emscripten::function("setLogger", &odr::wasm::set_logger);
+}
diff --git a/wasm/testfixtures/encrypted.docx b/wasm/testfixtures/encrypted.docx
new file mode 100644
index 000000000..6e6398203
Binary files /dev/null and b/wasm/testfixtures/encrypted.docx differ
diff --git a/wasm/testfixtures/mixed-layout.odt b/wasm/testfixtures/mixed-layout.odt
new file mode 100644
index 000000000..2407fe668
Binary files /dev/null and b/wasm/testfixtures/mixed-layout.odt differ
diff --git a/wasm/tests/enums.test.mjs b/wasm/tests/enums.test.mjs
new file mode 100644
index 000000000..42c3a7bd4
--- /dev/null
+++ b/wasm/tests/enums.test.mjs
@@ -0,0 +1,76 @@
+// Enums cross by ordinal, and the headers say to append and never reorder
+// (`src/odr/html.hpp`, `src/odr/file.hpp`). `FileType`, `FileCategory` and
+// `DocumentType` are derived from the library's tables and cannot drift; the
+// rest have none, so they are pinned here. Appending stays silent by design;
+// reordering fails here rather than silently in a consumer months later.
+
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+
+import { Odr } from './helper.mjs';
+
+const pinned = {
+ HtmlResourceType: {
+ html_fragment: 0,
+ css: 1,
+ js: 2,
+ image: 3,
+ font: 4,
+ media: 5,
+ },
+ HtmlTableGridlines: { none: 0, soft: 1, hard: 2 },
+ HtmlViewportMode: {
+ automatic: 0,
+ fit_width: 1,
+ actual_size: 2,
+ none: 3,
+ },
+ PdfTextMode: { dual_layer: 0, single_layer: 1 },
+ EncryptionState: {
+ unknown: 0,
+ not_encrypted: 1,
+ encrypted: 2,
+ decrypted: 3,
+ },
+ LogLevel: {
+ verbose: 0,
+ debug: 1,
+ info: 2,
+ warning: 3,
+ error: 4,
+ fatal: 5,
+ },
+};
+
+describe('enums', () => {
+ let enums;
+ before(async () => {
+ enums = (await Odr()).enums;
+ });
+
+ for (const [name, expected] of Object.entries(pinned)) {
+ it(`${name} keeps its ordinals`, () => {
+ for (const [key, ordinal] of Object.entries(expected)) {
+ assert.equal(
+ enums[name][key],
+ ordinal,
+ `${name}.${key} moved from ${ordinal} to ${enums[name][key]}`,
+ );
+ }
+ });
+ }
+
+ it('derives FileType from the library, unknown first', () => {
+ assert.equal(enums.FileType.unknown, 0);
+ assert.equal(typeof enums.FileType.odt, 'number');
+ assert.equal(typeof enums.FileType.docx, 'number');
+ assert.equal(typeof enums.FileType.pdf, 'number');
+ });
+
+ it('derives FileCategory and DocumentType too', () => {
+ assert.equal(enums.FileCategory.unknown, 0);
+ assert.equal(enums.DocumentType.unknown, 0);
+ assert.equal(typeof enums.DocumentType.text, 'number');
+ assert.equal(typeof enums.FileCategory.document, 'number');
+ });
+});
diff --git a/wasm/tests/helper.mjs b/wasm/tests/helper.mjs
new file mode 100644
index 000000000..1b560fd64
--- /dev/null
+++ b/wasm/tests/helper.mjs
@@ -0,0 +1,128 @@
+// Test plumbing. Inputs are built in memory wherever the assertion allows it,
+// following `python/AGENTS.md`; `testfixtures/` holds only the two that cannot
+// be — a document with real layout, and an encrypted one.
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { deflateRawSync } from 'node:zlib';
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+// `ODR_WASM_DIST` is set by ctest; the fallback is where a by-hand cmake build
+// puts it.
+const dist = process.env.ODR_WASM_DIST ?? join(here, '..', '..', 'dist');
+
+// A static `export ... from` needs a literal specifier, and the package's
+// location is only known at run time, so the module is loaded once up front.
+const pkg = await import(`${dist}/index.js`);
+
+export const { OdrError, Document } = pkg;
+
+export async function Odr() {
+ return pkg.Odr.load();
+}
+
+export function fixture(name) {
+ return new Uint8Array(readFileSync(join(here, '..', 'testfixtures', name)));
+}
+
+const crcTable = (() => {
+ const table = new Int32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let k = 0; k < 8; k++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[i] = c;
+ }
+ return table;
+})();
+
+function crc32(buffer) {
+ let c = -1;
+ for (const byte of buffer) {
+ c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
+ }
+ return (c ^ -1) >>> 0;
+}
+
+// Built by hand, so the tests carry no packaging dependency. `store: true`
+// writes an entry uncompressed, which ODF requires of `mimetype`.
+function zip(entries) {
+ const locals = [];
+ const centrals = [];
+ let offset = 0;
+
+ for (const { name, data, store = false } of entries) {
+ const raw = Buffer.from(data);
+ const body = store ? raw : deflateRawSync(raw);
+ const nameBytes = Buffer.from(name, 'utf8');
+ const method = store ? 0 : 8;
+
+ const local = Buffer.alloc(30 + nameBytes.length);
+ local.writeUInt32LE(0x04034b50, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(method, 8);
+ local.writeUInt32LE(crc32(raw), 14);
+ local.writeUInt32LE(body.length, 18);
+ local.writeUInt32LE(raw.length, 22);
+ local.writeUInt16LE(nameBytes.length, 26);
+ nameBytes.copy(local, 30);
+ locals.push(local, body);
+
+ const central = Buffer.alloc(46 + nameBytes.length);
+ central.writeUInt32LE(0x02014b50, 0);
+ central.writeUInt16LE(20, 4);
+ central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(method, 10);
+ central.writeUInt32LE(crc32(raw), 16);
+ central.writeUInt32LE(body.length, 20);
+ central.writeUInt32LE(raw.length, 24);
+ central.writeUInt16LE(nameBytes.length, 28);
+ central.writeUInt32LE(offset, 42);
+ nameBytes.copy(central, 46);
+ centrals.push(central);
+
+ offset += local.length + body.length;
+ }
+
+ const directory = Buffer.concat(centrals);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(entries.length, 8);
+ end.writeUInt16LE(entries.length, 10);
+ end.writeUInt32LE(directory.length, 12);
+ end.writeUInt32LE(offset, 16);
+
+ return new Uint8Array(Buffer.concat([...locals, directory, end]));
+}
+
+// The smallest odt that renders: one paragraph carrying `text`.
+export function minimalOdt(text = 'hello') {
+ const mimetype = 'application/vnd.oasis.opendocument.text';
+ return zip([
+ { name: 'mimetype', data: mimetype, store: true },
+ {
+ name: 'META-INF/manifest.xml',
+ data:
+ '' +
+ '' +
+ `` +
+ '' +
+ '',
+ },
+ {
+ name: 'content.xml',
+ data:
+ '' +
+ '' +
+ '' +
+ `${text}` +
+ '',
+ },
+ ]);
+}
diff --git a/wasm/tests/lifetime.test.mjs b/wasm/tests/lifetime.test.mjs
new file mode 100644
index 000000000..48c8c1ed1
--- /dev/null
+++ b/wasm/tests/lifetime.test.mjs
@@ -0,0 +1,92 @@
+// The failure mode this binding is shaped to avoid: JS has no destructors and
+// embind has no keep-alive, so `HtmlView`'s bare pointer into its service would
+// dangle if a view were ever handed out. Nothing escapes but an integer, and
+// every case here has to end in an error rather than a crash.
+
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+
+import { Odr, OdrError, minimalOdt } from './helper.mjs';
+
+describe('lifetimes', () => {
+ let odr;
+ before(async () => {
+ odr = await Odr();
+ });
+ after(() => odr.closeAll());
+
+ it('refuses a handle that has been closed', () => {
+ const doc = odr.open(minimalOdt());
+ doc.render(0);
+ doc.close();
+
+ for (const call of [
+ () => doc.render(0),
+ () => doc.listViews(),
+ () => doc.meta(),
+ () => doc.read('document.html'),
+ () => doc.capabilities(),
+ ]) {
+ assert.throws(call, (e) => {
+ assert.ok(e instanceof OdrError);
+ assert.match(e.message, /no such document handle/);
+ return true;
+ });
+ }
+ });
+
+ it('closes twice without complaint, and says so', () => {
+ const doc = odr.open(minimalOdt());
+ assert.equal(doc.close(), true);
+ assert.equal(doc.close(), false);
+ });
+
+ it('never hands out handle 0, so a zeroed handle is always invalid', () => {
+ const doc = odr.open(minimalOdt());
+ try {
+ assert.ok(doc.handle > 0);
+ } finally {
+ doc.close();
+ }
+ });
+
+ it('survives the worker boundary, because a handle is a number', () => {
+ const doc = odr.open(minimalOdt('across the wire'));
+ try {
+ // `structuredClone` is what `postMessage` does to a value.
+ assert.equal(structuredClone(doc.handle), doc.handle);
+
+ // The wrapper does not make the trip, and — the trap — it does not fail
+ // loudly either: its state is in private fields, which clone away to an
+ // empty object. Post the handle, never the `Document`.
+ assert.deepEqual(structuredClone(doc), {});
+ } finally {
+ doc.close();
+ }
+ });
+
+ it('keeps documents independent', () => {
+ const a = odr.open(minimalOdt('first'));
+ const b = odr.open(minimalOdt('second'));
+ try {
+ assert.notEqual(a.handle, b.handle);
+ a.close();
+ // closing one must not disturb the other
+ assert.match(b.render(0).html, /second/);
+ } finally {
+ b.close();
+ }
+ });
+
+ it('releases everything on closeAll', () => {
+ const doc = odr.open(minimalOdt());
+ odr.closeAll();
+ assert.throws(() => doc.render(0), OdrError);
+ });
+
+ it('closes through Symbol.dispose, so `using` works', () => {
+ const doc = odr.open(minimalOdt());
+ doc[Symbol.dispose]();
+ assert.throws(() => doc.render(0), OdrError);
+ });
+});
diff --git a/wasm/tests/render.test.mjs b/wasm/tests/render.test.mjs
new file mode 100644
index 000000000..51b66ddf3
--- /dev/null
+++ b/wasm/tests/render.test.mjs
@@ -0,0 +1,90 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+
+import { Odr, OdrError, fixture, minimalOdt } from './helper.mjs';
+
+describe('render', () => {
+ let odr;
+ before(async () => {
+ odr = await Odr();
+ });
+ after(() => odr.closeAll());
+
+ it('renders a view to self-contained html', () => {
+ const doc = odr.open(fixture('mixed-layout.odt'));
+ try {
+ const views = doc.listViews();
+ assert.equal(views.length, 1);
+ assert.deepEqual(views[0], {
+ name: 'document',
+ index: 0,
+ path: 'document.html',
+ });
+
+ const { html, externalResources } = doc.render(0);
+ assert.match(html, /^/);
+ assert.match(html, /