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, /