@auto-hwp/engine

中文文档
0.0.22发布于 2026/11/05Apache-2.0TypeScriptES Module零依赖构建来源已验证近 30 天下载 2357

auto-hwp engine as WebAssembly: open .hwp/.hwpx in the browser, render pages to SVG, edit via Intent JSON, export HTML/PDF/HWPX. No LLM, no filesystem, no bundled fonts.

原始使用文档
文档目录11 节
  1. Where this sits in the SDK (layer map)
  2. Install & quick start
  3. Where the wasm comes from (0.0.3+)
  4. API
  5. ⚠️ wasm panic recovery (required reading)
  6. Run the engine in a Web Worker (issue 055, FG-14)
  7. ⚠️ Security — SVG is untrusted (R7)
  8. Fonts are injected, never bundled (R8)
  9. Registering a font re-layouts the document (issue 022)
  10. Bundle size
  11. Table row heights on export ( noAdjust )

@auto-hwp/engine

The auto-hwp document engine compiled to WebAssembly — open .hwp/.hwpx in the browser, render every page to SVG, edit via Intent JSON, and export HTML / PDF / HWPX, entirely client-side. No LLM, no filesystem, no bundled fonts.

  • Edit lane = hwp-mcp (Intent JSON schema v0): applyIntent / undo / redo, toHwpx.
  • Render / geometry / export lane = hwp-session: renderPageSvg, hitTest, tableAt, exportHtml, exportPdf.
  • The AI stays on the host server (R6): run your model server-side and apply the resulting Intent JSON here. This package never sees an API key.

Where this sits in the SDK (layer map)

This wasm engine is L1 — the headless engine (parse/layout/render/geometry/Intent/undo/export). It knows only the document; it has no UI and no selection state. See docs/SDK-LAYERS.md.

L4 host app (apps/hwp-lab, Tauri, your app) — supplies the LLM proxy (its key) + assembles a UI
L3 @auto-hwp/react        — optional React binding (useHwpEditor + components); all replaceable
L2 @auto-hwp/editor-core  — headless editor (DocSession/SelectionModel/EditController) over an EngineAdapter
L2' @auto-hwp/ai-protocol — vendor-neutral LLM protocol (prompt/context/validate); no fetch, no keys
L1 @auto-hwp/engine (this) / hwp-mcp — headless engine; state is the document only

A WasmAdapter (in @auto-hwp/react) wraps this package to satisfy L2's EngineAdapter contract, so the same editor-core drives the web (wasm) and a desktop app (Tauri) alike.

Install & quick start

import { initEngine, HwpDoc } from '@auto-hwp/engine';

await initEngine();                      // instantiate the wasm module once (see "Where the wasm comes from")
const bytes = new Uint8Array(await file.arrayBuffer());
const doc = HwpDoc.open(bytes, file.name); // .hwp or .hwpx (auto-detected)

const pages = doc.pageCount();
for (let p = 0; p < pages; p++) {
  container.insertAdjacentHTML('beforeend', doc.renderPageSvgSanitized(p)); // sanitized SVG — see R7
}

// edit: fill a table cell (Intent schema v0)
doc.applyIntent({ intent: 'SetTableCell', section: 0, index: 1, row: 0, col: 0, text: '셀 값' });
doc.undo();

const html = doc.exportHtml();
const hwpx = doc.toHwpx();                // Uint8Array — hand to a download

doc.free();                              // free the wasm allocation on swap (R13)

Where the wasm comes from (0.0.3+)

initEngine() with no argument fetches the binary from jsDelivr, pinned to this package's own version — so npm i is the whole setup:

https://cdn.jsdelivr.net/npm/@auto-hwp/engine@<this version>/pkg/hwp_wasm_bg.wasm

Never @latest: the wasm-bindgen glue and the binary are one artifact compiled together, and a mismatched pair fails to link (or links and then rejects newer Intents). ENGINE_VERSION, defaultWasmUrl() and defaultWorkerUrl() are exported from @auto-hwp/engine (and @auto-hwp/engine/cdn) if you want to inspect or re-pin them.

Self-hosting is the override, not a requirement — pass a URL (or bytes / a compiled WebAssembly.Module):

await initEngine('/hwp/hwp_wasm_bg.wasm');
// or let your bundler emit it (Vite):
import wasmUrl from '@auto-hwp/engine/pkg/hwp_wasm_bg.wasm?url';

Download progress (the binary is ~7.7 MB, ~3.0 MB over the wire):

await initEngine(undefined, { onProgress: ({ ratio, loaded, estimated }) => … });

ratio is null when no denominator is known, and estimated: true means the transfer was content-encoded — Content-Length then counts compressed bytes while the body yields decompressed ones, so the published size is used as the denominator and the ratio never reaches 1 early. Pass expectedBytes when self-hosting a custom build.

API

MethodReturnsNotes
initEngine(input?, options?)PromiseInstantiate once (idempotent). No input = CDN default; options.onProgress reports download ticks.
HwpDoc.open(bytes, name?)HwpDoc.hwp (needs the default rhwp build) or .hwpx.
pageCount()numberOwn-render pagination.
renderPageSvg(n)stringUNTRUSTED — see Security.
renderPageSvgSanitized(n)stringsanitizeSvg applied.
hitTest(page, x, y)BlockHit | nullown-render px coords.
tableAt(page, x, y)TableBox | nullown-render px coords (marking).
applyIntent(intent)Outcomeobject or JSON string; Intent schema v0.
undo() / redo()booleangraceful no-op when empty.
registerFont(family, bytes)voiddrives metrics and PDF; re-layouts (re-query pageCount).
exportPdf()Uint8Arraythrows {code:"font_missing"} if none.
exportHtml()stringself-contained HTML.
toHwpx(options?)Uint8Arrayround-trip-safe HWPX. options.hwpRowHeights: "exact" (default) | "auto" — see below.
free()voididempotent; frees the wasm handle.

Coordinates for hitTest/tableAt are own-render px (= HWPUNIT / 75). Edit Intents address the model in structure indices (section/block/row/col), never pixels.

⚠️ wasm panic recovery (required reading)

A Rust panic on wasm32 is a trap: it poisons the entire instance. catch_unwind-style guards do not work on wasm, so a malicious/corrupt document that panics the parser kills the wasm instance — but not your page. This wrapper contains it:

  • Every call is try/catched. A WebAssembly.RuntimeError throws an Error with code === "wasm_trap" and marks all live HwpDoc handles dead.
  • Recovery protocol: on a wasm_trap, call resetEngine() to re-instantiate the module, then re-open() your document. Unsaved document state is lost — converge on a "reopen this file" UX.
try {
  doc.applyIntent(intent);
} catch (e) {
  if (e.code === 'wasm_trap') {
    await resetEngine();       // fresh instance
    doc = HwpDoc.open(bytes, name); // re-open; previous handles are dead
  } else {
    throw e;                   // ordinary {code, message} engine error
  }
}

A FinalizationRegistry frees handles the host forgets to free(), but explicit free() on document swap is the contract (undo snapshots + original bytes are held per document — R13).

Run the engine in a Web Worker (issue 055, FG-14)

Parsing / re-layout / toHwpx on a multi-MB document blocks the thread they run on. This package ships a module-worker entry (worker.js) plus a main-thread RPC client (@auto-hwp/engine/worker-client) so the whole engine can live off the main thread — no SharedArrayBuffer, no COOP/COEP headers, no bundler magic.

import { EngineWorkerClient } from '@auto-hwp/engine/worker-client';

const client = new EngineWorkerClient();     // 0.0.3+: this version's worker.js from the CDN
await client.init();                          // instantiate wasm IN the worker (CDN default)
const { pages } = await client.open(bytes, file.name);               // parse off-thread
const svg = await client.call('renderPageSvg', [0]);                 // every HwpDoc method, awaited

A cross-origin worker script cannot be handed to new Worker(), so the CDN default is spawned through a same-origin blob shim that imports it (worker-src blob: under a strict CSP). Self-hosting is the override: deploy worker.js, index.js, cdn.js and pkg/hwp_wasm.js as static assets keeping their relative paths (the worker imports ./index.js, which imports ./cdn.js and ./pkg/hwp_wasm.js — omitting cdn.js kills the worker with a module-load 404) and pass the URLs:

const client = new EngineWorkerClient({ url: '/hwp/worker.js' });
await client.init('/hwp/hwp_wasm_bg.wasm');

Error codes across the boundary: wasm_trap (instance poisoned inside the worker → client.reset() then re-open), worker_dead (the worker itself died → the next init()/reset() respawns), worker_terminated (the host called terminate() — an intentional cancel, not a crash). Ordinary engine errors (no_document, font_missing, …) pass through unchanged. @auto-hwp/react's WasmAdapter wires all of this (including 052 snapshot-first recovery) behind new WasmAdapter(wasmUrl, { worker: { url: workerUrl } }).

⚠️ Security — SVG is untrusted (R7)

renderPageSvg returns a document-derived string. Never innerHTML it raw. Use renderPageSvgSanitized(n) or sanitizeSvg(svg) (strips <script>, on*, <foreignObject>, javascript: URLs). This is the minimum guard; full hardening + CSP is a host concern (issue 016).

Fonts are injected, never bundled (R8)

This package ships no fonts (redistributing Hancom/함초롬 faces is not permitted; see docs/LICENSE-POLICY.md). For PDF export you must inject a face first:

const font = new Uint8Array(await (await fetch('/fonts/NotoSansKR-Regular.ttf')).arrayBuffer());
doc.registerFont('Noto Sans KR', font);   // OFL — safe to serve yourself
const pdf = doc.exportPdf();              // real, subsetted Korean glyphs embedded

exportPdf() throws {code:"font_missing"} if no font was registered (never silently emits empty glyphs).

Font bytes must be a single-face TTF/OTF — a TTC (TrueType Collection) is not accepted: it throws {code:"ttc_unsupported"} (krilla's simple-text backend can't subset a collection, and the shaper takes face index 0 only). The injected bytes thread all the way through to krilla (issue 018): the first parseable registered face becomes the PDF body face, so the exported PDF embeds a real subset of your Korean glyphs.

Registering a font re-layouts the document (issue 022)

registerFont now feeds the injected face into the layout metrics as well as the PDF embed — the same bytes shape screen SVG, pagination and PDF, so all three agree. Until you register a font, render/layout use a deterministic per-script Approx fallback (no font file needed). Because real metrics differ from Approx, registering (or replacing) a font can change the page count and line breaks, so:

doc.registerFont('Nanum Gothic', font); // → re-layouts; internal SVG cache invalidated
const pages = doc.pageCount();           // re-query — may differ from before
for (let i = 0; i < pages; i++) render(doc.renderPageSvgSanitized(i)); // re-render every page

v1 maps every document font name to this one injected face (register the face you want the body drawn in); document-level per-family mapping is a follow-up.

Bundle size

pkg/hwp_wasm_bg.wasm is ~11 MiB raw / ~3.5 MiB gzipped (no fonts). The bulk is the HWP5 (.hwp) binary parser (rhwp) + the krilla PDF stack. An HWPX-only build (drop the crate's default hwp5 feature) is ~4.6 MiB raw / ~1.4 MiB gzipped. Run wasm-opt -Oz (binaryen) on the artifact to shrink further; it was not available in the build environment.

Table row heights on export (noAdjust)

HWPX marks a table <hp:tbl noAdjust="1"> when its rows must NOT grow with their content (Hancom clips the overflow). When the engine writes it:

Source of the tablenoAdjust written
HWPX inputthe file's own value, unchanged (untouched tables and cell edits keep the original bytes)
binary .hwp input, toHwpx() / hwpRowHeights: "exact" (0.0.6+)1 — a .hwp's saved row heights are Hancom's real layout, so the page count round-trips
binary .hwp input, hwpRowHeights: "auto"0 — rows grow with filled content in Hancom (pre-0.0.6 behaviour)
table inserted by an edit0

Filling a .hwp form with longer text? Export with doc.toHwpx({ hwpRowHeights: "auto" }) so Hancom grows the rows instead of clipping.

版本与发布记录

@auto-hwp/engine 共发布 22 个版本,首次发布于 2026/07/22;过去 12 个月发布了 22 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.0.22latest2026/11/052 天前
0.0.212026/10/2810 天前
0.0.202026/10/2711 天前
0.0.192026/10/2612 天前
0.0.182026/10/2415 天前
0.0.172026/10/1424 天前

关于 @auto-hwp/engine 的常见问题

@auto-hwp/engine 是什么?

auto-hwp engine as WebAssembly: open .hwp/.hwpx in the browser, render pages to SVG, edit via Intent JSON, export HTML/PDF/HWPX. No LLM, no filesystem, no bundled fonts.

如何安装 @auto-hwp/engine?

在项目目录运行 npm install @auto-hwp/engine,或使用 pnpm add / yarn add / bun add。

@auto-hwp/engine 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@auto-hwp/engine 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@auto-hwp/engine 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@auto-hwp/engine 可以商用吗?

@auto-hwp/engine 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@auto-hwp/engine 的下载量有多少?

根据 npm 官方下载统计,@auto-hwp/engine 在 2026/10/07 – 2026/11/05 这 30 天内被下载 2,357 次,日均约 79 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@auto-hwp/engine 还在维护吗?

最新版本 0.0.22 发布于 2026/11/05(2 天前);至今共发布 22 个版本,其中过去 12 个月发布了 22 个;首次发布于 2026/07/22。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。