@aseity/idocs-preview 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # @aseity/idocs-preview
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - First public release of the read-only browser preview for DOCX and iDocs source
8
+ documents.
9
+
10
+ - `@aseity/idocs-preview/core`: a headless session that converts and lays out documents
11
+ with Word-calibrated metrics in a Web Worker and returns each page's display list,
12
+ diagnostics and conversion losses.
13
+ - `@aseity/idocs-preview/view`: a Canvas page view with virtualized pages, text
14
+ selection and copying, a find bar, zoom with fit-width support, Ctrl+wheel and
15
+ pinch zoom around the pointer, and page navigation with optional smooth
16
+ scrolling.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iDocs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
1
+ # iDocs preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only browser preview for Word documents (`.docx`) and iDocs source documents
4
+ (`.idoc`). A Web Worker converts and lays out the document with Word-calibrated font
5
+ metrics in WASM, and the view paints each page on a canvas. Pages break where the
6
+ layout predicts Word breaks them; glyphs are drawn with the browser's local fonts,
7
+ so their shapes can differ from Word.
8
+
9
+ The package ships ES modules, TypeScript declarations, the worker and the WASM
10
+ binary. It has no framework dependency and runs only in browsers.
11
+
12
+ ```sh
13
+ npm install @aseity/idocs-preview
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```ts
19
+ const { createPreview } = await import("@aseity/idocs-preview/view");
20
+
21
+ const preview = createPreview(container, {
22
+ onPageChange: (index, doc) => console.log(`page ${index + 1} of ${doc.pageCount}`),
23
+ onZoomChange: (zoom) => console.log(`zoomed to ${Math.round(zoom * 100)}%`),
24
+ });
25
+ const doc = await preview.open(bytes, { name: "report.docx" });
26
+ preview.setZoom(preview.view?.fitWidthZoom ?? 1);
27
+ preview.goToPage(2, { behavior: "smooth" });
28
+ preview.destroy();
29
+ ```
30
+
31
+ `container` must have a height; the view fills it and scrolls inside it. Load the
32
+ package lazily on the client (a dynamic `import()` or a client-only component), not
33
+ during server rendering.
34
+
35
+ ## Subpaths
36
+
37
+ - `@aseity/idocs-preview/core`: the headless session. `createSession()` starts the
38
+ worker; `session.open(bytes, options)` resolves to a `PreviewDocument` with the
39
+ pages' display lists, diagnostics and conversion losses.
40
+ - `@aseity/idocs-preview/view`: the reference view. `createPreview(container)`
41
+ owns a session and a view; `mountPreview(container, doc)` shows a document you
42
+ opened yourself. Both re-export the core types.
43
+
44
+ ## Opening documents
45
+
46
+ `open(bytes, { name?, kind?, readResource? })` takes a `Uint8Array` or
47
+ `ArrayBuffer`. The package copies the bytes before transferring them to the worker,
48
+ so the caller can keep using them, for example to download the original.
49
+
50
+ - `kind` defaults to `docx` for ZIP or OLE bytes or a Word file name, and to `idoc`
51
+ otherwise.
52
+ - `readResource(path)` resolves images referenced by an `.idoc` file, relative to
53
+ that file. DOCX images are read from the file itself.
54
+ - A failed open rejects with `PreviewError`, whose `code` is one of
55
+ `invalid-source`, `layout-failed`, `unsupported-format`, `wasm-unavailable`,
56
+ `worker-unavailable`, `internal-error`, or a DOCX conversion code
57
+ (`docx-not-zip`, `docx-encrypted`, `docx-legacy-doc`, `docx-invalid-package`,
58
+ `docx-strict-ooxml`).
59
+
60
+ DOCX conversion is lossy: features iDocs does not model, such as text boxes,
61
+ floating images, tracked changes and comments, are downgraded or omitted and listed
62
+ in `conversionLosses`.
63
+
64
+ ## View
65
+
66
+ - Zoom 1 is 100% (one point is 4/3 CSS pixels), clamped to 0.1–10. `setZoom` keeps
67
+ the spot at the top of the viewport in place. `view.fitWidthZoom` is the zoom at
68
+ which the widest page fills the viewport width; the view does not refit by
69
+ itself.
70
+ - Ctrl+wheel and trackpad pinch zoom around the pointer and report the new zoom
71
+ through `onZoomChange`. `setZoom` does not call it.
72
+ - `goToPage(index, { behavior })` scrolls a page (0-based) to the top. Pass
73
+ `"smooth"` to animate, and `"instant"` when the reader prefers reduced motion.
74
+ - Pages are virtualized: only the visible pages and one on either side keep a
75
+ canvas.
76
+ - Text can be selected and copied. Ctrl+F (Cmd+F on macOS) opens the built-in
77
+ find bar while the preview has focus. Escape that closes the find bar is marked
78
+ with `preventDefault()`, so hosts that close their panel on Escape should skip
79
+ events whose `defaultPrevented` is set.
80
+
81
+ ## Bundlers and hosting
82
+
83
+ The session creates its worker with `new Worker(new URL("./worker.js",
84
+ import.meta.url), { type: "module" })`, and the WASM glue loads
85
+ `new URL("idocs_wasm_bg.wasm", import.meta.url)`. Vite, webpack and Turbopack
86
+ (Next.js 16) recognize both forms and emit the worker chunk and the WASM asset
87
+ without configuration.
88
+
89
+ Without a bundler, host the package files at their relative positions and pass the
90
+ addresses: `createPreview(el, { session: { workerUrl, wasmUrl } })`, where
91
+ `workerUrl` points to `dist/core/worker.js` and `wasmUrl` to
92
+ `wasm/idocs_wasm_bg.wasm`.
93
+
94
+ The worker is a same-origin module worker. Serve the WASM file as
95
+ `application/wasm`. A Content Security Policy needs `script-src
96
+ 'wasm-unsafe-eval'` and `worker-src 'self'`.
97
+
98
+ ## Fonts
99
+
100
+ Layout uses metric profiles built into the WASM for Microsoft YaHei, SimSun,
101
+ DengXian, SimHei, FangSong, KaiTi, PMingLiU, Microsoft JhengHei, Arial, Times New
102
+ Roman, Consolas, Calibri and Courier New. Each glyph is drawn at the position its
103
+ profile gives, in the closest local font. Characters outside the profiles are
104
+ measured with the stand-in fonts the optional `session.readFont(name)` callback
105
+ returns, or with the browser's fonts when it is absent, so their line breaks can
106
+ differ slightly from the iDocs CLI.
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,118 @@
1
+ export type Story = 'body' | 'header' | 'footer' | 'footnotes';
2
+ export interface Rect {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export interface Glyph {
9
+ /** Left edge from the metric profile. */
10
+ x: number;
11
+ /** Width occupied in the line, including added spacing. */
12
+ width: number;
13
+ text: string;
14
+ /** UTF-16 offset of the source character within its run's text. */
15
+ offset: number;
16
+ /**
17
+ * No metric profile, stand-in font or local font measured this character:
18
+ * its width is an estimate, and the painter draws a placeholder box.
19
+ */
20
+ unmeasured?: true;
21
+ }
22
+ export interface GlyphRun {
23
+ kind: 'glyphs';
24
+ /** `leader` for tab leader characters, which are not part of the line text. */
25
+ runKind: 'text' | 'leader';
26
+ story: Story;
27
+ path: string;
28
+ role: string;
29
+ family: string;
30
+ declaredFamily: string;
31
+ size: number;
32
+ bold: boolean;
33
+ italic: boolean;
34
+ color: string;
35
+ baseline: number;
36
+ lineTop: number;
37
+ lineHeight: number;
38
+ glyphs: Glyph[];
39
+ }
40
+ export type RectRole = 'areaFill' | 'border' | 'shading' | 'characterBorder' | 'underline' | 'strike' | 'footnoteSeparator' | 'mathRule';
41
+ export interface RectItem extends Rect {
42
+ kind: 'rect';
43
+ role: RectRole;
44
+ color: string;
45
+ }
46
+ export interface LineItem {
47
+ kind: 'line';
48
+ role: 'diagonal';
49
+ x1: number;
50
+ y1: number;
51
+ x2: number;
52
+ y2: number;
53
+ width: number;
54
+ color: string;
55
+ }
56
+ export interface ImageItem extends Rect {
57
+ kind: 'image';
58
+ /** Index into PreviewDocument.images. */
59
+ image: number;
60
+ path: string;
61
+ }
62
+ export interface PathItem {
63
+ kind: 'path';
64
+ role: 'mathGlyph' | 'mathPath';
65
+ d: string;
66
+ transform: [number, number, number, number, number, number];
67
+ color: string;
68
+ clip?: Rect;
69
+ glyph?: {
70
+ file: string;
71
+ char: string;
72
+ size: number;
73
+ };
74
+ }
75
+ export interface ClipItem {
76
+ kind: 'clip';
77
+ rect: Rect;
78
+ items: DisplayItem[];
79
+ }
80
+ export interface TurnItem {
81
+ kind: 'turn';
82
+ x: number;
83
+ y: number;
84
+ items: DisplayItem[];
85
+ }
86
+ export type DisplayItem = GlyphRun | RectItem | LineItem | ImageItem | PathItem | ClipItem | TurnItem;
87
+ export interface LineGlyph {
88
+ x: number;
89
+ /** UTF-16 offset within the line's text. */
90
+ offset: number;
91
+ }
92
+ /** One laid-out line's glyph table, for hit testing. */
93
+ export interface TextLine {
94
+ story: Story;
95
+ path: string;
96
+ index: number;
97
+ x: number;
98
+ width: number;
99
+ top: number;
100
+ height: number;
101
+ baseline: number;
102
+ /** Extent of the text above and below `baseline` (the line box less its leading). */
103
+ ascent: number;
104
+ descent: number;
105
+ /** UTF-16 offset of the line start in its story's text. */
106
+ offset: number;
107
+ text: string;
108
+ glyphs: LineGlyph[];
109
+ turn?: [number, number];
110
+ }
111
+ export interface DisplayPage {
112
+ number: number;
113
+ label?: string;
114
+ width: number;
115
+ height: number;
116
+ items: DisplayItem[];
117
+ lines: TextLine[];
118
+ }
@@ -0,0 +1,4 @@
1
+ // Display list types (#442). The Rust definitions in
2
+ // rust/idocs-core/src/display/mod.rs are authoritative; coordinates are points,
3
+ // origin at the page's top-left corner, y down. Item order is drawing order.
4
+ export {};
@@ -0,0 +1,13 @@
1
+ import { type Measure } from './fonts.js';
2
+ import type { Endpoint } from './protocol.js';
3
+ export interface ServeOptions {
4
+ /** The wasm module bytes or address; defaults to the file next to the glue. */
5
+ wasm?: BufferSource | URL | string;
6
+ /**
7
+ * Measures characters that no metric profile or stand-in font has. Defaults
8
+ * to OffscreenCanvas `measureText` along the painter's font chain; `null`
9
+ * (or no OffscreenCanvas) leaves them estimated and drawn as boxes.
10
+ */
11
+ measure?: Measure | null;
12
+ }
13
+ export declare function serve(endpoint: Endpoint, options?: ServeOptions): void;
@@ -0,0 +1,81 @@
1
+ // The Worker side: owns the wasm instance and answers the protocol in
2
+ // protocol.ts. `serve` takes any message endpoint, so Node tests run the same
3
+ // code behind a MessageChannel. Layout is synchronous inside one wasm call;
4
+ // resources the host has not answered yet come back as `needs`, and the open
5
+ // is retried once they arrive.
6
+ import init, { openDocument } from '../../wasm/idocs_wasm.js';
7
+ import { canvasMeasure } from './fonts.js';
8
+ export function serve(endpoint, options = {}) {
9
+ let ready;
10
+ const start = (wasm = options.wasm) => (ready ??= init(wasm === undefined ? undefined : { module_or_path: wasm }));
11
+ // Stand-in fonts do not depend on the document, so answers outlive an open.
12
+ const fonts = new Map();
13
+ const pending = new Map();
14
+ const measure = options.measure === undefined ? canvasMeasure() : (options.measure ?? undefined);
15
+ const post = (message, transfer = []) => endpoint.postMessage(message, transfer);
16
+ const attempt = (id) => {
17
+ const job = pending.get(id);
18
+ if (!job)
19
+ return;
20
+ let outcome;
21
+ try {
22
+ outcome = openDocument(job.kind, job.bytes, job.name, job.assets, fonts, measure);
23
+ }
24
+ catch (error) {
25
+ pending.delete(id);
26
+ post({ type: 'failed', id, error: { code: 'internal-error', message: String(error), diagnostics: [] } });
27
+ return;
28
+ }
29
+ if (outcome.status === 'needs') {
30
+ post({ type: 'needs', id, needs: JSON.parse(outcome.json) });
31
+ return;
32
+ }
33
+ pending.delete(id);
34
+ if (outcome.status === 'failed') {
35
+ post({ type: 'failed', id, error: JSON.parse(outcome.json) });
36
+ return;
37
+ }
38
+ const parsed = JSON.parse(outcome.json);
39
+ const document = {
40
+ ...parsed,
41
+ images: parsed.images.map((image, i) => ({ ...image, bytes: outcome.images[i] })),
42
+ };
43
+ post({ type: 'opened', id, document }, outcome.images.map((bytes) => bytes.buffer));
44
+ };
45
+ endpoint.addEventListener('message', async (event) => {
46
+ const message = event.data;
47
+ if (message.type === 'init') {
48
+ // Start fetching early; a failure is reported to the first open.
49
+ start(message.wasmUrl).catch(() => { });
50
+ return;
51
+ }
52
+ try {
53
+ await start();
54
+ }
55
+ catch (error) {
56
+ const id = 'id' in message ? message.id : -1;
57
+ pending.delete(id);
58
+ post({ type: 'failed', id, error: { code: 'wasm-unavailable', message: String(error), diagnostics: [] } });
59
+ return;
60
+ }
61
+ if (message.type === 'open') {
62
+ pending.set(message.id, {
63
+ kind: message.kind,
64
+ name: message.name,
65
+ bytes: message.bytes,
66
+ assets: new Map(),
67
+ });
68
+ }
69
+ else if (message.type === 'resources') {
70
+ const job = pending.get(message.id);
71
+ if (!job)
72
+ return;
73
+ for (const { need, bytes } of message.answers) {
74
+ ;
75
+ (need.kind === 'font' ? fonts : job.assets).set(need.name, bytes);
76
+ }
77
+ }
78
+ attempt(message.id);
79
+ });
80
+ endpoint.start?.();
81
+ }
@@ -0,0 +1,28 @@
1
+ import type { GlyphRun } from './display.js';
2
+ export type FontChainInput = Pick<GlyphRun, 'family' | 'declaredFamily' | 'role'>;
3
+ /**
4
+ * The CSS font families to draw `run` with, most preferred first; the last is
5
+ * always a generic family. When layout measured the declared family itself,
6
+ * it leads, then the look-alikes of the Word family it names. When the
7
+ * declared family has no metric profile, layout measured a fallback face:
8
+ * that face and its look-alikes lead, so glyphs are drawn as wide as they
9
+ * were placed, and the declared family only covers characters they lack.
10
+ */
11
+ export declare function fontChain(run: FontChainInput): string[];
12
+ export type CanvasFontInput = FontChainInput & Pick<GlyphRun, 'size' | 'bold' | 'italic'>;
13
+ /**
14
+ * The `CanvasRenderingContext2D.font` for `run`, in a context whose unit is
15
+ * one point: the run's size in points, its weight and slant, then the chain.
16
+ */
17
+ export declare function canvasFont(run: CanvasFontInput): string;
18
+ /**
19
+ * `measure(family, declaredFamily, bold, italic, text)`: the advance of
20
+ * `text` in em with the host's own fonts. Core asks only for characters that
21
+ * neither the metric profile nor a stand-in font has.
22
+ */
23
+ export type Measure = (family: string, declaredFamily: string, bold: boolean, italic: boolean, text: string) => number;
24
+ /**
25
+ * A synchronous measurer on an OffscreenCanvas, with the same font chain the
26
+ * painter draws with; undefined where there is no OffscreenCanvas (Node).
27
+ */
28
+ export declare function canvasMeasure(): Measure | undefined;
@@ -0,0 +1,121 @@
1
+ const GENERIC = new Set(['serif', 'sans-serif', 'monospace']);
2
+ /**
3
+ * Word family → its look-alikes on Windows, macOS and Linux, ending in a
4
+ * generic family. Covers the default theme and every calibrated family
5
+ * (assets/font-metrics), plus the uncalibrated Word families the resolver
6
+ * knows (rust/idocs-core/src/fonts/resolver.rs `word_family`).
7
+ */
8
+ const SIMILAR = {
9
+ // Simplified Chinese
10
+ SimSun: ['SimSun', 'Songti SC', 'Noto Serif CJK SC', 'serif'],
11
+ SimHei: ['SimHei', 'Heiti SC', 'STHeiti', 'Noto Sans CJK SC', 'sans-serif'],
12
+ 'Microsoft YaHei': ['Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', 'sans-serif'],
13
+ DengXian: ['DengXian', 'PingFang SC', 'Noto Sans CJK SC', 'sans-serif'],
14
+ FangSong: ['FangSong', 'STFangsong', 'FandolFang', 'Noto Serif CJK SC', 'serif'],
15
+ KaiTi: ['KaiTi', 'Kaiti SC', 'STKaiti', 'AR PL UKai CN', 'FandolKai', 'Noto Serif CJK SC', 'serif'],
16
+ // Traditional Chinese
17
+ PMingLiU: ['PMingLiU', 'Songti TC', 'Noto Serif CJK TC', 'serif'],
18
+ MingLiU: ['MingLiU', 'Songti TC', 'Noto Serif CJK TC', 'serif'],
19
+ 'Microsoft JhengHei': ['Microsoft JhengHei', 'PingFang TC', 'Noto Sans CJK TC', 'sans-serif'],
20
+ // Latin
21
+ 'Times New Roman': ['Times New Roman', 'Times', 'Liberation Serif', 'Tinos', 'serif'],
22
+ Arial: ['Arial', 'Helvetica', 'Liberation Sans', 'Arimo', 'sans-serif'],
23
+ Calibri: ['Calibri', 'Carlito', 'sans-serif'],
24
+ Consolas: ['Consolas', 'Menlo', 'Inconsolata', 'Liberation Mono', 'monospace'],
25
+ 'Courier New': ['Courier New', 'Courier', 'Liberation Mono', 'Cousine', 'monospace'],
26
+ Cambria: ['Cambria', 'Caladea', 'serif'],
27
+ 'Cambria Math': ['Cambria Math', 'STIX Two Math', 'serif'],
28
+ Aptos: ['Aptos', 'sans-serif'],
29
+ 'Segoe UI': ['Segoe UI', 'Helvetica Neue', 'Noto Sans', 'sans-serif'],
30
+ };
31
+ /** Chinese names Word documents use for the families above. */
32
+ const ALIASES = {
33
+ 微软雅黑: 'Microsoft YaHei',
34
+ 等线: 'DengXian',
35
+ 宋体: 'SimSun',
36
+ 黑体: 'SimHei',
37
+ 仿宋: 'FangSong',
38
+ 楷体: 'KaiTi',
39
+ 新細明體: 'PMingLiU',
40
+ 微軟正黑體: 'Microsoft JhengHei',
41
+ 細明體: 'MingLiU',
42
+ };
43
+ const BY_LOWER = new Map(Object.keys(SIMILAR).map((name) => [name.toLowerCase(), name]));
44
+ /** The Word family a declared name stands for, matched as Word does (case-insensitive). */
45
+ function wordFamily(name) {
46
+ return BY_LOWER.get((ALIASES[name] ?? name).toLowerCase());
47
+ }
48
+ function roleGeneric(role) {
49
+ if (role === 'mono')
50
+ return 'monospace';
51
+ if (role === 'sans')
52
+ return 'sans-serif';
53
+ return 'serif';
54
+ }
55
+ /**
56
+ * The CSS font families to draw `run` with, most preferred first; the last is
57
+ * always a generic family. When layout measured the declared family itself,
58
+ * it leads, then the look-alikes of the Word family it names. When the
59
+ * declared family has no metric profile, layout measured a fallback face:
60
+ * that face and its look-alikes lead, so glyphs are drawn as wide as they
61
+ * were placed, and the declared family only covers characters they lack.
62
+ */
63
+ export function fontChain(run) {
64
+ const names = [];
65
+ let generic;
66
+ const add = (name) => {
67
+ if (GENERIC.has(name))
68
+ generic ??= name;
69
+ else if (!names.some((known) => known.toLowerCase() === name.toLowerCase()))
70
+ names.push(name);
71
+ };
72
+ const addSimilar = (family) => {
73
+ const word = wordFamily(family);
74
+ if (word)
75
+ for (const name of SIMILAR[word])
76
+ add(name);
77
+ };
78
+ const measured = (wordFamily(run.declaredFamily) ?? run.declaredFamily).toLowerCase() === run.family.toLowerCase();
79
+ if (!measured)
80
+ addSimilar(run.family);
81
+ add(run.declaredFamily);
82
+ addSimilar(run.declaredFamily);
83
+ if (measured)
84
+ addSimilar(run.family);
85
+ return [...names, generic ?? roleGeneric(run.role)];
86
+ }
87
+ const fonts = new Map();
88
+ /**
89
+ * The `CanvasRenderingContext2D.font` for `run`, in a context whose unit is
90
+ * one point: the run's size in points, its weight and slant, then the chain.
91
+ */
92
+ export function canvasFont(run) {
93
+ const key = `${run.italic}\0${run.bold}\0${run.size}\0${run.role}\0${run.declaredFamily}\0${run.family}`;
94
+ let font = fonts.get(key);
95
+ if (font === undefined) {
96
+ const chain = fontChain(run)
97
+ .map((name) => (GENERIC.has(name) ? name : `"${name.replaceAll('"', '')}"`))
98
+ .join(', ');
99
+ font = `${run.italic ? 'italic ' : ''}${run.bold ? 'bold ' : ''}${run.size}px ${chain}`;
100
+ fonts.set(key, font);
101
+ }
102
+ return font;
103
+ }
104
+ /** Measuring size in px: large enough that rounding inside measureText does not show. */
105
+ const MEASURE_SIZE = 100;
106
+ /**
107
+ * A synchronous measurer on an OffscreenCanvas, with the same font chain the
108
+ * painter draws with; undefined where there is no OffscreenCanvas (Node).
109
+ */
110
+ export function canvasMeasure() {
111
+ if (typeof OffscreenCanvas === 'undefined')
112
+ return undefined;
113
+ const ctx = new OffscreenCanvas(1, 1).getContext('2d');
114
+ if (!ctx)
115
+ return undefined;
116
+ return (family, declaredFamily, bold, italic, text) => {
117
+ // The role is not known here; a family outside the table falls to serif.
118
+ ctx.font = canvasFont({ family, declaredFamily, role: '', size: MEASURE_SIZE, bold, italic });
119
+ return ctx.measureText(text).width / MEASURE_SIZE;
120
+ };
121
+ }
@@ -0,0 +1,52 @@
1
+ import type { DocumentKind, Endpoint, PreviewDocument } from './protocol.js';
2
+ export type * from './display.js';
3
+ export type * from './protocol.js';
4
+ export interface SessionOptions {
5
+ /** Worker script address; defaults to the bundled worker beside this module. */
6
+ workerUrl?: string | URL;
7
+ /** `.wasm` address; defaults to the file next to the wasm glue. */
8
+ wasmUrl?: string | URL;
9
+ /** An already created worker (a MessagePort works too); `workerUrl` is then ignored. */
10
+ worker?: Endpoint;
11
+ /**
12
+ * Reads a stand-in font by basename (e.g. `LiberationSerif-Regular.ttf`).
13
+ * Layout uses stand-in metrics for characters the embedded profiles lack;
14
+ * without them those characters fall back, as the CLI does with no font
15
+ * directory. Fonts belong to the session: each file is read at most once.
16
+ */
17
+ readFont?: Read;
18
+ }
19
+ type Bytes = Uint8Array | ArrayBuffer;
20
+ export type Read = (name: string) => Bytes | null | undefined | Promise<Bytes | null | undefined>;
21
+ export interface OpenOptions {
22
+ /** File name used in diagnostics. */
23
+ name?: string;
24
+ /**
25
+ * Defaults to `docx` for zip or OLE compound-file bytes (an encrypted DOCX
26
+ * or a .doc, rejected with their own code) and for a Word file name
27
+ * (`.docx`, `.docm`, `.dotx`, `.dotm`, `.doc`); `idoc` otherwise.
28
+ */
29
+ kind?: DocumentKind;
30
+ /** Reads a path relative to the source document (images). Throw or return null when absent. */
31
+ readResource?: Read;
32
+ }
33
+ export declare class PreviewError extends Error {
34
+ code: string;
35
+ diagnostics: PreviewDocument['diagnostics'];
36
+ constructor(error: {
37
+ code: string;
38
+ message: string;
39
+ diagnostics: PreviewDocument['diagnostics'];
40
+ });
41
+ }
42
+ export interface Session {
43
+ open(bytes: Bytes, options?: OpenOptions): Promise<PreviewDocument>;
44
+ /**
45
+ * Terminates the worker; pending opens reject. A worker that fails on its
46
+ * own (script not loaded, message not deserialized) rejects pending and later
47
+ * opens with `worker-unavailable`; create a new session to retry.
48
+ */
49
+ dispose(): void;
50
+ }
51
+ export declare function createSession(options?: SessionOptions): Session;
52
+ export type { Measure } from './fonts.js';