Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/config/conan/profiles/emscripten-wasm
Original file line number Diff line number Diff line change
@@ -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'}
275 changes: 275 additions & 0 deletions .github/workflows/wasm.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)). |
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 ()
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading
Loading