@aseity/idocs-preview 0.1.0 → 0.3.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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # @aseity/idocs-preview
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `Session.exportDocx` to export iDocs source bytes and referenced images as a Word document in the browser. Invalid source, missing images and unapproved conversion losses reject instead of producing a partial file.
8
+
9
+ ## 0.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Use browser-local fonts for text measurement and Canvas drawing without downloading font files. Remove the font-file mode and the `readFont` / `readFamily` callbacks from the web API, and exclude the native shaping engine from the WASM bundle to reduce its download size. Mathematical symbols retain their embedded fonts. Different installed fonts can change line breaks and pagination.
14
+ - Floating objects (`floats` on a paragraph) now show in the preview: pictures, shapes, text boxes
15
+ and groups, over or behind the text, turned and flipped about their centres. The display list
16
+ gains two item kinds, `shape` (SVG path data, filled and/or stroked, with dashes) and `transform`
17
+ (an SVG matrix around rotated or flipped objects), and the `textbox` story for a text box's glyph
18
+ runs, whose lines are not in the hit-testing table.
19
+
20
+ ### Patch Changes
21
+
22
+ - Lines between bordered paragraphs (Word's "between" paragraph border, `w:pBdr/w:between`)
23
+ now show in the preview: a line between each two adjacent paragraphs that share it, with
24
+ its spacing above and below, and none above the first or below the last.
25
+ - Numbered and bulleted lists in table cells now show in the preview as lists: their labels
26
+ count on with the lists outside the table in document order (row by row, cell by cell),
27
+ and their indents are measured from the cell's text area, as Word does. Converted Word
28
+ documents keep the numbering of list paragraphs in table cells instead of dropping it.
29
+ - Emphasis marks (Word's `w:em`: dot, comma, circle, under dot) now show in the preview, one
30
+ per character except spaces. Path items in the display list may carry the role
31
+ `emphasisMark`.
32
+ - Text set to a fixed width in Word (Fit Text, `w:fitText`) now keeps that width in the
33
+ preview: wider spacing when the text is shorter, glyphs compressed horizontally when it
34
+ is longer. Glyph runs in the display list carry `scaleX` when compressed.
35
+ - Tables in table cells now show in the preview as tables: each sits in its cell's text area
36
+ the way a table sits in the page's text area, the outer row grows to hold it, and a row
37
+ that runs onto the next page splits the table in it between its rows, as Word does.
38
+ Converted Word documents keep a table in a cell as a table instead of flattening it into
39
+ the cell's paragraphs.
40
+ - Character scale and baseline shift (Word's `w:w` and `w:position`) now show in the preview:
41
+ `scaleX` on a glyph run also carries the character scale, and `baseline` the shift.
42
+
3
43
  ## 0.1.0
4
44
 
5
45
  ### Minor Changes
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # iDocs preview
2
2
 
3
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.
4
+ (`.idoc`). A Web Worker measures browser-local fonts with Canvas and lays out
5
+ the document in WASM. Canvas draws text using the same font settings. No text
6
+ font downloads or Local Font Access permissions are required. Different installed
7
+ fonts can change line breaks and page counts.
8
8
 
9
9
  The package ships ES modules, TypeScript declarations, the worker and the WASM
10
10
  binary. It has no framework dependency and runs only in browsers.
@@ -13,6 +13,19 @@ binary. It has no framework dependency and runs only in browsers.
13
13
  npm install @aseity/idocs-preview
14
14
  ```
15
15
 
16
+ ## Local development
17
+
18
+ ```sh
19
+ npm ci
20
+ npm run dev
21
+ ```
22
+
23
+ The build prepends `$CARGO_HOME/bin` (default `~/.cargo/bin`) to its own PATH
24
+ so rustup selects `rust/rust-toolchain.toml`, even when Homebrew Rust comes
25
+ first in your shell. The toolchain file includes the WASM target. No global
26
+ PATH changes or `.env` file are needed. Install rustup and wasm-bindgen CLI
27
+ 0.2.128 before building.
28
+
16
29
  ## Quick start
17
30
 
18
31
  ```ts
@@ -37,6 +50,7 @@ during server rendering.
37
50
  - `@aseity/idocs-preview/core`: the headless session. `createSession()` starts the
38
51
  worker; `session.open(bytes, options)` resolves to a `PreviewDocument` with the
39
52
  pages' display lists, diagnostics and conversion losses.
53
+ `session.exportDocx(bytes, options)` exports iDocs source to DOCX bytes.
40
54
  - `@aseity/idocs-preview/view`: the reference view. `createPreview(container)`
41
55
  owns a session and a view; `mountPreview(container, doc)` shows a document you
42
56
  opened yourself. Both re-export the core types.
@@ -57,9 +71,64 @@ so the caller can keep using them, for example to download the original.
57
71
  (`docx-not-zip`, `docx-encrypted`, `docx-legacy-doc`, `docx-invalid-package`,
58
72
  `docx-strict-ooxml`).
59
73
 
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`.
74
+ DOCX conversion is lossy: supported floating images, shapes, text boxes and groups
75
+ are preserved as floating objects. Tracked changes, comments and object properties
76
+ outside the model are downgraded or omitted and listed in `conversionLosses`.
77
+
78
+ ### Preview transport
79
+
80
+ Every open transfers glyph coordinates and text indices in binary buffers.
81
+ Coordinates retain double precision. The painter and hit testing read those
82
+ buffers directly. Page, line, and drawing metadata still use JSON.
83
+
84
+ Opened previews expose read-only glyph arrays. Indexed glyph objects are
85
+ snapshots; changing one does not change the drawing buffer. Array traversal,
86
+ selection, copying, and JSON serialization remain available.
87
+
88
+ By default, the view displays the document after every page is ready.
89
+ `createPreview(container, { progressive: true })` shows a provisional first-page
90
+ canvas while later display pages are generated. Full pagination completes before
91
+ any page is sent. Selection and find become available when `open` resolves.
92
+ A failure removes the provisional page.
93
+
94
+ Headless sessions can receive provisional pages through `open(bytes, { onPage })`.
95
+ Each packet contains a page, the total page count, and cumulative image and glyph
96
+ outline resources. Resource arrays grow as later pages arrive. Resource retries
97
+ can restart delivery from page one. The final promise resolves to the complete
98
+ document. A callback exception rejects its open.
99
+
100
+ The development page enables progressive delivery with `?progressive=1`.
101
+ There is no `compact` option or legacy JSON-only transport.
102
+
103
+ ## Exporting Word documents
104
+
105
+ `session.exportDocx(bytes, { name?, readResource? })` exports an iDocs source
106
+ into DOCX bytes. It accepts the same byte types and image callback as `open`.
107
+ The source bytes are copied before transfer. Every export owns its resources,
108
+ so concurrent opens and exports remain independent.
109
+
110
+ ```ts
111
+ const session = createSession();
112
+ try {
113
+ const bytes = await session.exportDocx(source, {
114
+ name: "report.idoc",
115
+ readResource: (path) => readImage(path),
116
+ });
117
+ const file = new Blob([bytes], {
118
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
119
+ });
120
+ saveFile(file, "report.docx");
121
+ } finally {
122
+ session.dispose();
123
+ }
124
+ ```
125
+
126
+ Export uses browser font measurements for layout and preserves the document's
127
+ font declarations in DOCX. Word may paginate differently on the recipient's
128
+ machine. Invalid source, unavailable or invalid images, layout errors and
129
+ unapproved conversion losses reject with `PreviewError`. Export never replaces
130
+ missing images with preview placeholders. Export failures also include the
131
+ `export-failed` code. Disposing the session rejects pending and future exports.
63
132
 
64
133
  ## View
65
134
 
@@ -91,19 +160,78 @@ addresses: `createPreview(el, { session: { workerUrl, wasmUrl } })`, where
91
160
  `workerUrl` points to `dist/core/worker.js` and `wasmUrl` to
92
161
  `wasm/idocs_wasm_bg.wasm`.
93
162
 
163
+ Use the same package version for the session and its worker. The successful worker
164
+ response carries metadata JSON, a binary glyph buffer, and separate image buffers. Progressive opens send resource deltas and page packets
165
+ before the final response. Custom workers must implement the corresponding
166
+ message variants in `src/core/protocol.ts`.
167
+
94
168
  The worker is a same-origin module worker. Serve the WASM file as
95
169
  `application/wasm`. A Content Security Policy needs `script-src
96
170
  'wasm-unsafe-eval'` and `worker-src 'self'`.
97
171
 
98
172
  ## Fonts
99
173
 
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.
174
+ The web preview uses installed browser fonts. The Worker uses
175
+ OffscreenCanvas `measureText()` for widths and font extents; the view uses the same
176
+ CSS family chain, weight, style and kerning policy with `fillText()`. Missing families
177
+ fall back through the browser's installed fonts. Source text and UTF-16 offsets
178
+ remain available for selection, copying and search. Math continues to use embedded
179
+ KaTeX resources and does not download fonts.
180
+
181
+ Native snapshots use actual font files. Their line breaks and page counts may
182
+ therefore differ from a browser with different installed fonts.
183
+
184
+ The WASM build excludes the native font-file feature and HarfRust. There is no
185
+ font-file mode, `readFont` or `readFamily` API. Font file registrations in a document
186
+ do not become network requests. Native builds retain the complete font-file
187
+ resolver, shaping and outline engine. The browser retains the embedded KaTeX
188
+ resources and the parser needed for mathematical outlines.
189
+
190
+ ## Profile core document opening
191
+
192
+ Build separate timing and allocation modules from the repository root. Run Cargo
193
+ inside `rust/` so rustup selects the pinned toolchain. Use wasm-bindgen CLI
194
+ 0.2.128 for both modules.
195
+
196
+ ```sh
197
+ (cd rust && cargo build --profile wasm -j 4 -p idocs-wasm --target wasm32-unknown-unknown --features perf-profile)
198
+ wasm-bindgen rust/target/wasm32-unknown-unknown/wasm/idocs_wasm.wasm --target web --out-dir .audit/core-profile/timing --out-name idocs_wasm
199
+ (cd rust && cargo build --profile wasm -j 4 -p idocs-wasm --target wasm32-unknown-unknown --features perf-alloc)
200
+ wasm-bindgen rust/target/wasm32-unknown-unknown/wasm/idocs_wasm.wasm --target web --out-dir .audit/core-profile/alloc --out-name idocs_wasm
201
+ ```
202
+
203
+ Start Vite in another terminal with `cd web && npx vite --host 127.0.0.1 --port
204
+ 5174 --strictPort`. Install Playwright and its Chromium browser in your tooling
205
+ environment. If this script cannot resolve `playwright`, set `PLAYWRIGHT_MODULE`
206
+ to its entry module path.
207
+
208
+ ```sh
209
+ node web/scripts/profile-core.mjs path/to/sample.docx path/to/source.idoc
210
+ ```
211
+
212
+ The script uses a blank page without Vite HMR and a fresh, isolated Worker for
213
+ each run. Each Worker uses the real browser Canvas measurements and enables
214
+ `configureProfile`. Runs are sequential and alternate mode order across at least
215
+ five rounds. Stop other builds and benchmarks before measuring.
216
+
217
+ Input paths and `OUT` resolve relative to the repository root. Configure
218
+ `BASE_URL`, `ROUNDS`, `TIMEOUT_MS`, and `OUT` through environment variables.
219
+ `PROFILE_DIRS` is a JSON object mapping mode names to module directories, such as
220
+ `{"before":".audit/before","after":".audit/after"}`. Vite must allow access to
221
+ these files and inputs through `/@fs`. The default output is
222
+ `.audit/core-profile/results.json`. Each row includes the complete profile,
223
+ elapsed milliseconds, a combined SHA-256 of metadata, glyph buffers, and images,
224
+ page and conversion loss counts,
225
+ diagnostic counts, output bytes, and WASM memory capacity.
226
+
227
+ The elapsed timer covers `openDocument`, including Rust output serialization.
228
+ It excludes module initialization, input fetch, profile retrieval, JavaScript
229
+ JSON parsing, and hashing. Allocation instrumentation adds overhead. Compare
230
+ timings with `perf-profile` builds and allocation counts with `perf-alloc` builds.
231
+ Live allocation bytes measure requested bytes. WASM memory capacity measures
232
+ linear memory reserved by the instance. Neither measures physical memory.
233
+ The allocation counters require a single-threaded, isolated Worker. Normal
234
+ builds leave both profiling features off.
107
235
 
108
236
  ## License
109
237
 
@@ -1,11 +1,28 @@
1
- export type Story = 'body' | 'header' | 'footer' | 'footnotes';
1
+ /** `textbox`: a floating text box's text (#552), whose lines are not in the hit-testing table. */
2
+ export type Story = 'body' | 'header' | 'footer' | 'footnotes' | 'textbox' | 'lineNumber';
2
3
  export interface Rect {
3
4
  x: number;
4
5
  y: number;
5
6
  width: number;
6
7
  height: number;
7
8
  }
9
+ export interface GlyphOutline {
10
+ faceId: string;
11
+ family: string;
12
+ glyphId: number;
13
+ d: string;
14
+ }
15
+ export interface GlyphShape {
16
+ outline: number;
17
+ advance: number;
18
+ xOffset: number;
19
+ yOffset: number;
20
+ cluster: number;
21
+ }
8
22
  export interface Glyph {
23
+ shapes?: GlyphShape[];
24
+ /** Display-only discretionary mark; does not consume source text. */
25
+ generated?: 'automatic' | 'manual';
9
26
  /** Left edge from the metric profile. */
10
27
  x: number;
11
28
  /** Width occupied in the line, including added spacing. */
@@ -31,13 +48,17 @@ export interface GlyphRun {
31
48
  size: number;
32
49
  bold: boolean;
33
50
  italic: boolean;
51
+ /** Horizontal scale of each glyph about its left edge: the character scale times a compressing fit-width group's; absent when 1. */
52
+ scaleX?: number;
34
53
  color: string;
35
54
  baseline: number;
36
55
  lineTop: number;
37
56
  lineHeight: number;
38
- glyphs: Glyph[];
57
+ /** Read-only in opened previews; indexed glyph objects are snapshots. */
58
+ glyphs: readonly Glyph[];
59
+ syntheticSlant?: number;
39
60
  }
40
- export type RectRole = 'areaFill' | 'border' | 'shading' | 'characterBorder' | 'underline' | 'strike' | 'footnoteSeparator' | 'mathRule';
61
+ export type RectRole = 'areaFill' | 'border' | 'shading' | 'characterBorder' | 'underline' | 'strike' | 'footnoteSeparator' | 'mathRule' | 'pageBorder';
41
62
  export interface RectItem extends Rect {
42
63
  kind: 'rect';
43
64
  role: RectRole;
@@ -61,7 +82,8 @@ export interface ImageItem extends Rect {
61
82
  }
62
83
  export interface PathItem {
63
84
  kind: 'path';
64
- role: 'mathGlyph' | 'mathPath';
85
+ /** emphasisMark: an emphasis mark, `d` in em about its centre. */
86
+ role: 'mathGlyph' | 'mathPath' | 'emphasisMark';
65
87
  d: string;
66
88
  transform: [number, number, number, number, number, number];
67
89
  color: string;
@@ -70,6 +92,20 @@ export interface PathItem {
70
92
  file: string;
71
93
  char: string;
72
94
  size: number;
95
+ text?: {
96
+ browser?: {
97
+ family: string;
98
+ declaredFamily: string;
99
+ bold: boolean;
100
+ italic: boolean;
101
+ };
102
+ standIn: {
103
+ strokeEm?: number;
104
+ };
105
+ width: number;
106
+ height: number;
107
+ depth: number;
108
+ };
73
109
  };
74
110
  }
75
111
  export interface ClipItem {
@@ -83,7 +119,32 @@ export interface TurnItem {
83
119
  y: number;
84
120
  items: DisplayItem[];
85
121
  }
86
- export type DisplayItem = GlyphRun | RectItem | LineItem | ImageItem | PathItem | ClipItem | TurnItem;
122
+ /** A floating object's geometry (#552): SVG path data in page coordinates, filled, stroked or both. */
123
+ export interface ShapeItem {
124
+ kind: 'shape';
125
+ path: string;
126
+ d: string;
127
+ fill?: string | {
128
+ width: number;
129
+ height: number;
130
+ foreground: string;
131
+ background: string;
132
+ rects: [number, number, number, number][];
133
+ };
134
+ /** Centred on the path; `dash` lengths in points, absent for a solid line. */
135
+ stroke?: {
136
+ width: number;
137
+ color: string;
138
+ dash?: number[];
139
+ };
140
+ }
141
+ /** Items mapped by `matrix` [a, b, c, d, e, f] (SVG `matrix`): a floating object's rotation and flips (#552). */
142
+ export interface TransformItem {
143
+ kind: 'transform';
144
+ matrix: [number, number, number, number, number, number];
145
+ items: DisplayItem[];
146
+ }
147
+ export type DisplayItem = GlyphRun | RectItem | LineItem | ImageItem | PathItem | ShapeItem | ClipItem | TurnItem | TransformItem;
87
148
  export interface LineGlyph {
88
149
  x: number;
89
150
  /** UTF-16 offset within the line's text. */
@@ -105,7 +166,8 @@ export interface TextLine {
105
166
  /** UTF-16 offset of the line start in its story's text. */
106
167
  offset: number;
107
168
  text: string;
108
- glyphs: LineGlyph[];
169
+ /** Read-only in opened previews; indexed glyph objects are snapshots. */
170
+ glyphs: readonly LineGlyph[];
109
171
  turn?: [number, number];
110
172
  }
111
173
  export interface DisplayPage {
@@ -3,11 +3,7 @@ import type { Endpoint } from './protocol.js';
3
3
  export interface ServeOptions {
4
4
  /** The wasm module bytes or address; defaults to the file next to the glue. */
5
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
- */
6
+ /** Browser font metrics; defaults to OffscreenCanvas. null disables measurement and causes open to fail. */
11
7
  measure?: Measure | null;
12
8
  }
13
9
  export declare function serve(endpoint: Endpoint, options?: ServeOptions): void;
@@ -3,15 +3,13 @@
3
3
  // code behind a MessageChannel. Layout is synchronous inside one wasm call;
4
4
  // resources the host has not answered yet come back as `needs`, and the open
5
5
  // is retried once they arrive.
6
- import init, { openDocument } from '../../wasm/idocs_wasm.js';
6
+ import init, { exportDocx, openDocument } from '../../wasm/idocs_wasm.js';
7
7
  import { canvasMeasure } from './fonts.js';
8
8
  export function serve(endpoint, options = {}) {
9
9
  let ready;
10
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
11
  const measure = options.measure === undefined ? canvasMeasure() : (options.measure ?? undefined);
12
+ const pending = new Map();
15
13
  const post = (message, transfer = []) => endpoint.postMessage(message, transfer);
16
14
  const attempt = (id) => {
17
15
  const job = pending.get(id);
@@ -19,7 +17,19 @@ export function serve(endpoint, options = {}) {
19
17
  return;
20
18
  let outcome;
21
19
  try {
22
- outcome = openDocument(job.kind, job.bytes, job.name, job.assets, fonts, measure);
20
+ if (!measure)
21
+ throw new Error('Browser font measurement requires OffscreenCanvas 2D');
22
+ if (job.type === 'exportDocx') {
23
+ outcome = exportDocx(job.bytes, job.name, job.assets, measure);
24
+ }
25
+ else {
26
+ if (job.progressive)
27
+ post({ type: 'pageReset', id });
28
+ const onPage = job.progressive ? (packet) => {
29
+ post({ type: 'page', id, ...packet }, [packet.data.buffer, ...packet.images.map(bytes => bytes.buffer)]);
30
+ } : undefined;
31
+ outcome = openDocument(job.kind, job.bytes, job.name, job.assets, measure, onPage);
32
+ }
23
33
  }
24
34
  catch (error) {
25
35
  pending.delete(id);
@@ -35,12 +45,11 @@ export function serve(endpoint, options = {}) {
35
45
  post({ type: 'failed', id, error: JSON.parse(outcome.json) });
36
46
  return;
37
47
  }
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));
48
+ if (outcome.status === 'exported') {
49
+ post({ type: 'exported', id, bytes: outcome.bytes }, [outcome.bytes.buffer]);
50
+ return;
51
+ }
52
+ post({ type: 'opened', id, json: outcome.json, images: outcome.images, data: outcome.data }, [outcome.data.buffer, ...outcome.images.map((bytes) => bytes.buffer)]);
44
53
  };
45
54
  endpoint.addEventListener('message', async (event) => {
46
55
  const message = event.data;
@@ -58,9 +67,14 @@ export function serve(endpoint, options = {}) {
58
67
  post({ type: 'failed', id, error: { code: 'wasm-unavailable', message: String(error), diagnostics: [] } });
59
68
  return;
60
69
  }
61
- if (message.type === 'open') {
70
+ if (message.type === 'exportDocx') {
71
+ pending.set(message.id, { type: 'exportDocx', name: message.name, bytes: message.bytes, assets: new Map() });
72
+ }
73
+ else if (message.type === 'open') {
62
74
  pending.set(message.id, {
75
+ type: 'open',
63
76
  kind: message.kind,
77
+ progressive: message.progressive ?? false,
64
78
  name: message.name,
65
79
  bytes: message.bytes,
66
80
  assets: new Map(),
@@ -71,8 +85,7 @@ export function serve(endpoint, options = {}) {
71
85
  if (!job)
72
86
  return;
73
87
  for (const { need, bytes } of message.answers) {
74
- ;
75
- (need.kind === 'font' ? fonts : job.assets).set(need.name, bytes);
88
+ job.assets.set(need.name, bytes);
76
89
  }
77
90
  }
78
91
  attempt(message.id);
@@ -1,10 +1,11 @@
1
1
  import type { GlyphRun } from './display.js';
2
+ export declare const CANVAS_TEXT_LANGUAGE = "und";
2
3
  export type FontChainInput = Pick<GlyphRun, 'family' | 'declaredFamily' | 'role'>;
3
4
  /**
4
5
  * The CSS font families to draw `run` with, most preferred first; the last is
5
6
  * always a generic family. When layout measured the declared family itself,
6
7
  * 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
+ * selected family differs from the declared family, layout measured a fallback face:
8
9
  * that face and its look-alikes lead, so glyphs are drawn as wide as they
9
10
  * were placed, and the declared family only covers characters they lack.
10
11
  */
@@ -15,14 +16,12 @@ export type CanvasFontInput = FontChainInput & Pick<GlyphRun, 'size' | 'bold' |
15
16
  * one point: the run's size in points, its weight and slant, then the chain.
16
17
  */
17
18
  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
- */
19
+ /** Measurements use the same CSS chain and kerning policy as the Canvas painter. */
20
+ export interface FontMeasurement {
21
+ width: number;
22
+ ascent: number;
23
+ descent: number;
24
+ }
25
+ export type Measure = (family: string, declaredFamily: string, bold: boolean, italic: boolean, text: string, kern?: boolean) => number | FontMeasurement;
26
+ /** Browser-local fonts only: this neither fetches files nor requests Local Font Access. */
28
27
  export declare function canvasMeasure(): Measure | undefined;
@@ -1,11 +1,13 @@
1
+ export const CANVAS_TEXT_LANGUAGE = 'und';
1
2
  const GENERIC = new Set(['serif', 'sans-serif', 'monospace']);
2
3
  /**
3
4
  * 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`).
5
+ * generic family. Covers the default theme and common Word families.
7
6
  */
8
7
  const SIMILAR = {
8
+ MiSans: ['MiSans', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', 'sans-serif'],
9
+ 'Noto Sans CJK SC': ['Noto Sans CJK SC', 'Microsoft YaHei', 'PingFang SC', 'sans-serif'],
10
+ 'Noto Serif CJK SC': ['Noto Serif CJK SC', 'SimSun', 'Songti SC', 'serif'],
9
11
  // Simplified Chinese
10
12
  SimSun: ['SimSun', 'Songti SC', 'Noto Serif CJK SC', 'serif'],
11
13
  SimHei: ['SimHei', 'Heiti SC', 'STHeiti', 'Noto Sans CJK SC', 'sans-serif'],
@@ -17,6 +19,8 @@ const SIMILAR = {
17
19
  PMingLiU: ['PMingLiU', 'Songti TC', 'Noto Serif CJK TC', 'serif'],
18
20
  MingLiU: ['MingLiU', 'Songti TC', 'Noto Serif CJK TC', 'serif'],
19
21
  'Microsoft JhengHei': ['Microsoft JhengHei', 'PingFang TC', 'Noto Sans CJK TC', 'sans-serif'],
22
+ // Japanese
23
+ 'MS Gothic': ['MS Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans CJK SC', 'sans-serif'],
20
24
  // Latin
21
25
  'Times New Roman': ['Times New Roman', 'Times', 'Liberation Serif', 'Tinos', 'serif'],
22
26
  Arial: ['Arial', 'Helvetica', 'Liberation Sans', 'Arimo', 'sans-serif'],
@@ -39,6 +43,7 @@ const ALIASES = {
39
43
  新細明體: 'PMingLiU',
40
44
  微軟正黑體: 'Microsoft JhengHei',
41
45
  細明體: 'MingLiU',
46
+ 'MS ゴシック': 'MS Gothic',
42
47
  };
43
48
  const BY_LOWER = new Map(Object.keys(SIMILAR).map((name) => [name.toLowerCase(), name]));
44
49
  /** The Word family a declared name stands for, matched as Word does (case-insensitive). */
@@ -56,7 +61,7 @@ function roleGeneric(role) {
56
61
  * The CSS font families to draw `run` with, most preferred first; the last is
57
62
  * always a generic family. When layout measured the declared family itself,
58
63
  * 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:
64
+ * selected family differs from the declared family, layout measured a fallback face:
60
65
  * that face and its look-alikes lead, so glyphs are drawn as wide as they
61
66
  * were placed, and the declared family only covers characters they lack.
62
67
  */
@@ -101,21 +106,30 @@ export function canvasFont(run) {
101
106
  }
102
107
  return font;
103
108
  }
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
- */
109
+ // Canvas rounds font box extents to pixels; retain sub-point precision
110
+ // when raised text sits near a line-grid boundary.
111
+ const MEASURE_SIZE = 1000;
112
+ /** Browser-local fonts only: this neither fetches files nor requests Local Font Access. */
110
113
  export function canvasMeasure() {
111
114
  if (typeof OffscreenCanvas === 'undefined')
112
115
  return undefined;
113
116
  const ctx = new OffscreenCanvas(1, 1).getContext('2d');
114
117
  if (!ctx)
115
118
  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;
119
+ Object.assign(ctx, { lang: CANVAS_TEXT_LANGUAGE });
120
+ const cache = new Map();
121
+ return (family, declaredFamily, bold, italic, text, kern = false) => {
122
+ const role = family === 'sans-serif' ? 'sans' : family === 'monospace' ? 'mono' : 'serif';
123
+ const font = canvasFont({ family, declaredFamily, role, size: MEASURE_SIZE, bold, italic });
124
+ const key = `${font}\0${kern}\0${text}`;
125
+ const known = cache.get(key);
126
+ if (known)
127
+ return known;
128
+ ctx.font = font;
129
+ ctx.fontKerning = kern ? 'normal' : 'none';
130
+ const m = ctx.measureText(text);
131
+ const result = { width: m.width / MEASURE_SIZE, ascent: (m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent) / MEASURE_SIZE, descent: (m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent) / MEASURE_SIZE };
132
+ cache.set(key, result);
133
+ return result;
120
134
  };
121
135
  }
@@ -1,4 +1,4 @@
1
- import type { DocumentKind, Endpoint, PreviewDocument } from './protocol.js';
1
+ import type { DocumentKind, Endpoint, PreviewDocument, PreviewPage } from './protocol.js';
2
2
  export type * from './display.js';
3
3
  export type * from './protocol.js';
4
4
  export interface SessionOptions {
@@ -8,17 +8,12 @@ export interface SessionOptions {
8
8
  wasmUrl?: string | URL;
9
9
  /** An already created worker (a MessagePort works too); `workerUrl` is then ignored. */
10
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
11
  }
19
12
  type Bytes = Uint8Array | ArrayBuffer;
20
13
  export type Read = (name: string) => Bytes | null | undefined | Promise<Bytes | null | undefined>;
21
14
  export interface OpenOptions {
15
+ /** Receive provisional pages while the worker builds the remaining display list. */
16
+ onPage?: (page: PreviewPage) => void;
22
17
  /** File name used in diagnostics. */
23
18
  name?: string;
24
19
  /**
@@ -39,7 +34,9 @@ export declare class PreviewError extends Error {
39
34
  diagnostics: PreviewDocument['diagnostics'];
40
35
  });
41
36
  }
37
+ export type ExportOptions = Pick<OpenOptions, 'name' | 'readResource'>;
42
38
  export interface Session {
39
+ exportDocx(bytes: Bytes, options?: ExportOptions): Promise<Uint8Array>;
43
40
  open(bytes: Bytes, options?: OpenOptions): Promise<PreviewDocument>;
44
41
  /**
45
42
  * Terminates the worker; pending opens reject. A worker that fails on its