to-ooxml
v0.0.6
Published
Isolated browser conversion of legacy binary Office files to OOXML bytes.
Readme
to-ooxml
An isolated, browser-first TypeScript library for converting legacy binary Microsoft Office bytes to OOXML bytes. It renders nothing, uploads nothing, and runs native parsers in disposable Web Workers.
This repository contains working conversion paths for all three legacy families.
BIFF8 .xls/.xlt uses pinned DocSharp.Binary.Xls 0.21.0 on the shared .NET
browser-WASM runtime; BIFF5/7 and safely undetectable workbooks fall back to the
pinned rxls 0.1.3 Rust engine. Word 97–2003 .doc/.dot uses the matching
patched DocSharp.Binary.Doc engine. PowerPoint 97–2003
.ppt/.pot/.pps uses DocSharp.Binary.Ppt on the same isolated runtime.
Usage
Use both the filename and MIME type when they are available. The CFB directory inside the bytes remains authoritative, and contradictory hints are rejected.
import { LegacyOfficeConverter } from "to-ooxml";
const bytes = await file.arrayBuffer();
const converter = LegacyOfficeConverter.forInput(bytes, {
fileName: file.name,
mimeType: file.type,
});
if (!converter) {
throw new Error("Not a supported legacy Office file");
}
const result = await converter.convert(bytes, {
signal: abortController.signal,
timeoutMs: 60_000,
});
// Feed the OOXML payload to @silurus/ooxml.
consumeOoxml(result.bytes, result.mimeType);
console.table(result.diagnostics);MIME-only selection is also available for dispatch code:
const converter = LegacyOfficeConverter.forMimeType(
"application/vnd.ms-excel",
);
const xlsx = await converter?.convertBytes(bytes);The metadata-rich convert() result is preferred. It returns the actual output
extension and MIME type, engine identity, source detection, engine report, and
fidelity diagnostics. Macro-enabled and template outputs are returned with their
actual extension and MIME type rather than being mislabeled as plain OOXML.
Vite and SvelteKit
Add the asset plugin to the Vite configuration. In a SvelteKit project it goes
in the same plugins array as sveltekit().
import { defineConfig } from "vite";
import { toOoxmlAssets } from "to-ooxml/vite";
export default defineConfig({
plugins: [toOoxmlAssets()],
});to-ooxml refers to its two engine entry files with
new URL("./runtime/...", import.meta.url). Vite treats those as static assets:
it emits dotnet.js and rxls_wasm_bg.wasm into build.assetsDir under
content-hashed names and rewrites both URLs for the final base, including an
absolute kit.paths.assets CDN origin. The plugin copies the remaining .NET
runtime files (assemblies, native module, ICU data) beside the emitted
dotnet.js, which is where the loader resolves them. The .NET SDK inlines the
boot configuration into dotnet.js, so there is no separate manifest request,
and every other runtime file carries an SDK content fingerprint. The emitted
tree is therefore fully content-addressed and safe under SvelteKit's immutable
asset caching across package upgrades.
In development the plugin excludes to-ooxml from dependency pre-bundling so
the package-relative URLs keep pointing into node_modules/to-ooxml/dist/runtime,
which the Vite dev server serves directly. Nothing is copied. An SSR build
receives no runtime files.
Both worker programs are bundled into the browser entry and started from a
page-owned blob: URL. No worker script is fetched from the application or a
CDN.
CORS and CSP
When assets live on another origin, that origin needs only a plain
Access-Control-Allow-Origin for the application (or *). The loader and its
runtime modules are imported as anonymous ES modules, and assemblies, ICU data,
and WASM are fetched without credentials, so no Access-Control-Allow-Credentials
header and no cookies are involved. Bucket-backed CDNs such as Google Cloud
Storage, which cannot send that header, work unchanged.
The asset origin must be allowed in script-src (module imports of dotnet.js
and its native and runtime modules) and in connect-src (assembly, ICU, and
WASM fetches). 'wasm-unsafe-eval' permits WebAssembly compilation. worker-src
needs only 'self' blob:. A strict policy can use:
Content-Security-Policy: default-src 'none'; script-src 'self' https://assets.example.com 'wasm-unsafe-eval'; connect-src 'self' https://assets.example.com; worker-src 'self' blob:Add the directives the rest of the application needs. The package's browser
integration test runs exactly this policy against a CDN origin that reflects
Access-Control-Allow-Origin and sends no credentials header.
Hosting the runtime yourself
Deployments that do not use the plugin can copy node_modules/to-ooxml/dist/runtime
to a static directory and pass its URL. The layout below runtimeBaseUrl must
match dist/runtime: docsharp/dotnet.js with its sibling files and
rxls/rxls_wasm_bg.wasm. Relative values resolve against the page URL.
await converter.convert(bytes, { runtimeBaseUrl: "/to-ooxml/" });Files served this way are not content-hashed by a bundler; version the
directory or set cache headers accordingly when upgrading. This option replaces
the earlier resolveWorkerUrl hook.
Current support
| Input | Output | Status | Engine |
| --- | --- | --- | --- |
| .xls (BIFF8) | .xlsx or .xlsm | Implemented; output follows binary macro metadata | DocSharp .NET/WASM |
| .xlt (BIFF8) | .xltx or .xltm | Implemented; output follows binary template/macro metadata | DocSharp .NET/WASM |
| .xls (BIFF5/7) | .xlsx | Implemented compatibility path | rxls Rust/WASM |
| .xlt (BIFF5/7) | .xlsx | Implemented with explicit template-loss diagnostic | rxls Rust/WASM |
| .doc | .docx or .docm | Implemented; output follows binary macro metadata | DocSharp .NET/WASM |
| .dot | .dotx or .dotm | Implemented; output follows binary template/macro metadata | DocSharp .NET/WASM |
| .ppt | .pptx or .pptm | Implemented; output follows binary macro metadata | DocSharp .NET/WASM |
| .pot | .potx or .potm | Implemented; template subtype follows filename, macros follow bytes | DocSharp .NET/WASM |
| .pps | .ppsx or .ppsm | Implemented; show subtype follows filename, macros follow bytes | DocSharp .NET/WASM |
“Lossless” is an acceptance target, not a blanket promise. DocSharp's direct
BIFF8-to-SpreadsheetML mapper has a much broader preservation surface than the
compact fallback, including workbook styles, drawings, charts, external links,
and VBA where its model supports them. Older BIFF workbooks use rxls, which
retains only the workbook model it understands; macros, embedded objects, and
other unsupported records may not survive. DocSharp's Word path preserves
sections, headers/footers, numbering, notes, comments, fields, drawings,
embedded OLE, and VBA where modeled. Its PowerPoint path preserves slide
structure, masters, images, embedded OLE, and VBA where modeled. All three still
have unsupported binary edge cases and need a much larger fidelity corpus.
Every conversion therefore includes an engine-boundary warning.
Isolation and limits
- MIME and extensions are hints; CFB stream names identify the actual family.
- Each conversion gets a new Worker. Success, failure, cancellation, and timeout all terminate it, reclaiming its WebAssembly memory.
- Caller-owned buffers are copied before transfer, so conversion never detaches the source buffer.
- XLS, Word, and PowerPoint input are capped at 32 MiB; both runtimes cap output at 128 MiB.
- Workers start from a page-owned
blob:URL and load only the engine assets the bundler emitted (or the caller-providedruntimeBaseUrl). No document bytes are sent over the network. - Encrypted files fail with a typed
encryptederror; password handling is not implemented. - Detected BIFF2–4 files fail with typed
unsupported-format; the implemented spreadsheet range begins at BIFF5/7. - VBA is packaged as data and never executed. Macro-bearing BIFF8, Word, and PowerPoint input returns a macro-enabled OOXML subtype instead of silently pretending to be macro-free.
- PPT bytes do not reliably encode whether the source was a presentation,
template, or show.
.pot/.ppsidentity therefore comes from the supplied filename and is disclosed assubtype-selected-from-filename.
The minified public entry, including both inlined worker programs, is 29.6 KB raw (8.9 KB gzip). The older-BIFF fallback worker and its 1.19 MB raw WASM asset cost about 463 KB Brotli including glue. BIFF8 uses the larger .NET host shared with Word and PowerPoint: the complete published framework is 10.9 MB raw, while an EFIGS session is about 2.63 MB Brotli. The rooted XLS assembly itself is 212 KB raw/70 KB Brotli. The current shared host fetches all rooted DocSharp format assemblies on its first conversion; neither runtime is downloaded merely by importing the dispatcher.
Development
git submodule update --init --recursive
pnpm install
pnpm checkpnpm build compiles the pinned Rust crate with wasm-pack, publishes the
pinned DocSharp host with the .NET 10 browser-WASM SDK, bundles both worker
programs into strings, and then uses tsdown to emit the browser ESM entry, its
declarations, the Vite plugin, and the dist/runtime engine assets. The
DocSharp assemblies are deliberately rooted during trimming because upstream's
record and shape registries still use reflection.
DocSharp is a submodule pinned to the to-ooxml-0.21.0.1 tag in the
kvist-no/DocSharp fork. Clone with
--recurse-submodules, or run the initialization command above before building.
The reproducible toolchain is Rust 1.97.1 with the wasm32-unknown-unknown
target, wasm-pack 0.15.0, and .NET SDK 10.0.107. After building,
tests/browser/smoke.html can be served from the repository root with a
same-origin ?fixture=/path/to/file.xls&fileName=file.xls to exercise the
actual Worker, runtime assets, byte interop, and OOXML ZIP structure in a
browser.
The full engine audit and staged architecture are in docs/architecture/legacy-office-engine-research.md.
