From 347a9bb0e7101e2561a055f89fb75aef417380de Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 8 Aug 2026 13:13:43 +0200 Subject: [PATCH 1/7] build(wasm): add an emscripten conan profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for a WebAssembly build of the library, so it can back a client-side viewer in the browser. Nothing consumes the profile yet; this is the toolchain half, verified by resolving and building the full dependency graph for `os=Emscripten, arch=wasm`. Notably cryptopp builds unpatched. Its recipe has no Emscripten branch and it probes CPU features by compiling assembly, so `CRYPTOPP_DISABLE_ASM` was the expected escalation; it turned out not to be needed. Two things in the profile look like oversights and are not: - `compiler.threads` is absent rather than `null`. A profile value is a string, so `compiler.threads=null` reads as the literal "null" and conan rejects it against `settings.yml`. Omitting the line is how "unset" is spelled — and unset is what we want, because `-pthread` implies SharedArrayBuffer, which implies COOP/COEP response headers on whoever hosts the viewer, which rules out plain GitHub Pages. - The exception flags are `[conf]` rather than CMake flags. The EH mode is an ABI, so the dependencies have to be built with the same one as the core. Flat rather than a `.jinja` plus per-slice includers like `android`/`apple`: those split because they build one body for four ABIs and five platform-arch pairs, and there is exactly one wasm slice. Split it when wasm64 arrives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjFJ66sma1ye9ZnhM3tNeH --- .github/config/conan/profiles/emscripten-wasm | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/config/conan/profiles/emscripten-wasm 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'} From 3b44e45d80c7b09ea97383205384b58fe2aea4fc Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 8 Aug 2026 13:39:48 +0200 Subject: [PATCH 2/7] feat(wasm): embind bindings, packaged for npm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a document to HTML in the browser: no upload, no server, and the bytes never leave the machine. The library was already shaped for this — every renderer writes to a stream, the css and js are compiled in, and one view comes out as a complete self-contained document — so this is the binding and the package, not new rendering. Three things here are unlike python, jni and apple, and all three follow from the binding being driven from a Web Worker, where everything that crosses is structured-cloned: Nothing throws across the boundary. Every entry point returns `{ok, value | error}`. The worker protocol has to turn a failure into data regardless, so a JS exception would only be converted back again — and an unconverted C++ exception reaches JS as an opaque pointer. `js/index.js` turns the envelope back into a thrown `OdrError`, so only the wire carries envelopes. Nothing escapes as an embind handle, because a bound wrapper cannot be cloned. A document is a `uint32_t` into a registry and a view an index within it. That also dissolves the keep-alive problem the other three each hand-built: a `HtmlView` holds a bare pointer into its service and an `Element` into the document adapter, and here neither is ever handed out — `Session` owns the file, service and views together. Config crosses as a plain object rather than a bound mutable type, for the same cloning reason. Enums are derived where the library has a runtime table for them — `FileType`, `FileCategory`, `DocumentType` — so they cannot drift, and pinned by a snapshot test where it does not. Appending stays silent and reordering goes loud, which is what the headers ask for and what every binding so far has left to hope. The package is plain JavaScript with a hand-written `.d.ts`: a TypeScript source tree would drag `tsc` and a build step into a C++ repository to produce one file of declarations. Tested under node from ctest, 26 cases. Inputs are built in memory — including a hand-rolled zip writer for a minimal odt — following `python/AGENTS.md`; the two fixtures on disk are a document with real layout and an encrypted one. The lifetime cases are the point of the suite: use-after-close, double close, and that a handle survives `structuredClone` while the wrapper silently does not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjFJ66sma1ye9ZnhM3tNeH --- .gitignore | 3 +- AGENTS.md | 3 +- CMakeLists.txt | 5 + README.md | 6 +- conanfile.py | 5 +- wasm/AGENTS.md | 103 +++++++++++++++++++ wasm/CMakeLists.txt | 100 +++++++++++++++++++ wasm/README.md | 102 +++++++++++++++++++ wasm/example/index.html | 118 ++++++++++++++++++++++ wasm/js/index.d.ts | 139 ++++++++++++++++++++++++++ wasm/js/index.js | 130 ++++++++++++++++++++++++ wasm/js/package.json | 44 +++++++++ wasm/src/odr_wasm.cpp | 137 +++++++++++++++++++++++++ wasm/src/odr_wasm.hpp | 66 +++++++++++++ wasm/src/wasm_core.cpp | 136 +++++++++++++++++++++++++ wasm/src/wasm_file.cpp | 131 ++++++++++++++++++++++++ wasm/src/wasm_html.cpp | 154 +++++++++++++++++++++++++++++ wasm/src/wasm_logger.cpp | 67 +++++++++++++ wasm/testfixtures/encrypted.docx | Bin 0 -> 7168 bytes wasm/testfixtures/mixed-layout.odt | Bin 0 -> 8981 bytes wasm/tests/enums.test.mjs | 76 ++++++++++++++ wasm/tests/helper.mjs | 128 ++++++++++++++++++++++++ wasm/tests/lifetime.test.mjs | 92 +++++++++++++++++ wasm/tests/render.test.mjs | 90 +++++++++++++++++ wasm/tests/smoke.test.mjs | 103 +++++++++++++++++++ 25 files changed, 1933 insertions(+), 5 deletions(-) create mode 100644 wasm/AGENTS.md create mode 100644 wasm/CMakeLists.txt create mode 100644 wasm/README.md create mode 100644 wasm/example/index.html create mode 100644 wasm/js/index.d.ts create mode 100644 wasm/js/index.js create mode 100644 wasm/js/package.json create mode 100644 wasm/src/odr_wasm.cpp create mode 100644 wasm/src/odr_wasm.hpp create mode 100644 wasm/src/wasm_core.cpp create mode 100644 wasm/src/wasm_file.cpp create mode 100644 wasm/src/wasm_html.cpp create mode 100644 wasm/src/wasm_logger.cpp create mode 100644 wasm/testfixtures/encrypted.docx create mode 100644 wasm/testfixtures/mixed-layout.odt create mode 100644 wasm/tests/enums.test.mjs create mode 100644 wasm/tests/helper.mjs create mode 100644 wasm/tests/lifetime.test.mjs create mode 100644 wasm/tests/render.test.mjs create mode 100644 wasm/tests/smoke.test.mjs 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/wasm/AGENTS.md b/wasm/AGENTS.md new file mode 100644 index 000000000..76fbc57cb --- /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 built, not packaged. | + +## 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 3.0 M of wasm, 816 K brotli'd, plus 77 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..aba8fcc2d --- /dev/null +++ b/wasm/CMakeLists.txt @@ -0,0 +1,100 @@ +# 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() + find_program(ODR_WASM_NODE NAMES node nodejs) + if (ODR_WASM_NODE) + add_test(NAME odr_wasm_node + COMMAND "${ODR_WASM_NODE}" --test "${CMAKE_CURRENT_SOURCE_DIR}/tests") + set_tests_properties(odr_wasm_node PROPERTIES + ENVIRONMENT "ODR_WASM_DIST=${CMAKE_CURRENT_BINARY_DIR}/dist") + else () + message(WARNING "node not found; 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..26b5d153d --- /dev/null +++ b/wasm/README.md @@ -0,0 +1,102 @@ +# 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 + +``` + +## 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 3.0 M to about 820 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 0000000000000000000000000000000000000000..6e639820395bed8f6a054bc1065a0f9b8d920fca GIT binary patch literal 7168 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 literal 0 HcmV?d00001 diff --git a/wasm/testfixtures/mixed-layout.odt b/wasm/testfixtures/mixed-layout.odt new file mode 100644 index 0000000000000000000000000000000000000000..2407fe668ef1a7c471dfefaa31cea7681b1b49fb GIT binary patch literal 8981 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 { + 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, /