@aseity/idocs-preview 0.1.0 → 0.2.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,39 @@
1
1
  # @aseity/idocs-preview
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - Floating objects (`floats` on a paragraph) now show in the preview: pictures, shapes, text boxes
9
+ and groups, over or behind the text, turned and flipped about their centres. The display list
10
+ gains two item kinds, `shape` (SVG path data, filled and/or stroked, with dashes) and `transform`
11
+ (an SVG matrix around rotated or flipped objects), and the `textbox` story for a text box's glyph
12
+ runs, whose lines are not in the hit-testing table.
13
+
14
+ ### Patch Changes
15
+
16
+ - Lines between bordered paragraphs (Word's "between" paragraph border, `w:pBdr/w:between`)
17
+ now show in the preview: a line between each two adjacent paragraphs that share it, with
18
+ its spacing above and below, and none above the first or below the last.
19
+ - Numbered and bulleted lists in table cells now show in the preview as lists: their labels
20
+ count on with the lists outside the table in document order (row by row, cell by cell),
21
+ and their indents are measured from the cell's text area, as Word does. Converted Word
22
+ documents keep the numbering of list paragraphs in table cells instead of dropping it.
23
+ - Emphasis marks (Word's `w:em`: dot, comma, circle, under dot) now show in the preview, one
24
+ per character except spaces. Path items in the display list may carry the role
25
+ `emphasisMark`.
26
+ - Text set to a fixed width in Word (Fit Text, `w:fitText`) now keeps that width in the
27
+ preview: wider spacing when the text is shorter, glyphs compressed horizontally when it
28
+ is longer. Glyph runs in the display list carry `scaleX` when compressed.
29
+ - Tables in table cells now show in the preview as tables: each sits in its cell's text area
30
+ the way a table sits in the page's text area, the outer row grows to hold it, and a row
31
+ that runs onto the next page splits the table in it between its rows, as Word does.
32
+ Converted Word documents keep a table in a cell as a table instead of flattening it into
33
+ the cell's paragraphs.
34
+ - Character scale and baseline shift (Word's `w:w` and `w:position`) now show in the preview:
35
+ `scaleX` on a glyph run also carries the character scale, and `baseline` the shift.
36
+
3
37
  ## 0.1.0
4
38
 
5
39
  ### 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
@@ -57,9 +70,34 @@ so the caller can keep using them, for example to download the original.
57
70
  (`docx-not-zip`, `docx-encrypted`, `docx-legacy-doc`, `docx-invalid-package`,
58
71
  `docx-strict-ooxml`).
59
72
 
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`.
73
+ DOCX conversion is lossy: supported floating images, shapes, text boxes and groups
74
+ are preserved as floating objects. Tracked changes, comments and object properties
75
+ outside the model are downgraded or omitted and listed in `conversionLosses`.
76
+
77
+ ### Preview transport
78
+
79
+ Every open transfers glyph coordinates and text indices in binary buffers.
80
+ Coordinates retain double precision. The painter and hit testing read those
81
+ buffers directly. Page, line, and drawing metadata still use JSON.
82
+
83
+ Opened previews expose read-only glyph arrays. Indexed glyph objects are
84
+ snapshots; changing one does not change the drawing buffer. Array traversal,
85
+ selection, copying, and JSON serialization remain available.
86
+
87
+ By default, the view displays the document after every page is ready.
88
+ `createPreview(container, { progressive: true })` shows a provisional first-page
89
+ canvas while later display pages are generated. Full pagination completes before
90
+ any page is sent. Selection and find become available when `open` resolves.
91
+ A failure removes the provisional page.
92
+
93
+ Headless sessions can receive provisional pages through `open(bytes, { onPage })`.
94
+ Each packet contains a page, the total page count, and cumulative image and glyph
95
+ outline resources. Resource arrays grow as later pages arrive. Resource retries
96
+ can restart delivery from page one. The final promise resolves to the complete
97
+ document. A callback exception rejects its open.
98
+
99
+ The development page enables progressive delivery with `?progressive=1`.
100
+ There is no `compact` option or legacy JSON-only transport.
63
101
 
64
102
  ## View
65
103
 
@@ -91,19 +129,78 @@ addresses: `createPreview(el, { session: { workerUrl, wasmUrl } })`, where
91
129
  `workerUrl` points to `dist/core/worker.js` and `wasmUrl` to
92
130
  `wasm/idocs_wasm_bg.wasm`.
93
131
 
132
+ Use the same package version for the session and its worker. The successful worker
133
+ response carries metadata JSON, a binary glyph buffer, and separate image buffers. Progressive opens send resource deltas and page packets
134
+ before the final response. Custom workers must implement the corresponding
135
+ message variants in `src/core/protocol.ts`.
136
+
94
137
  The worker is a same-origin module worker. Serve the WASM file as
95
138
  `application/wasm`. A Content Security Policy needs `script-src
96
139
  'wasm-unsafe-eval'` and `worker-src 'self'`.
97
140
 
98
141
  ## Fonts
99
142
 
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.
143
+ The web preview uses installed browser fonts. The Worker uses
144
+ OffscreenCanvas `measureText()` for widths and font extents; the view uses the same
145
+ CSS family chain, weight, style and kerning policy with `fillText()`. Missing families
146
+ fall back through the browser's installed fonts. Source text and UTF-16 offsets
147
+ remain available for selection, copying and search. Math continues to use embedded
148
+ KaTeX resources and does not download fonts.
149
+
150
+ Native snapshots use actual font files. Their line breaks and page counts may
151
+ therefore differ from a browser with different installed fonts.
152
+
153
+ The WASM build excludes the native font-file feature and HarfRust. There is no
154
+ font-file mode, `readFont` or `readFamily` API. Font file registrations in a document
155
+ do not become network requests. Native builds retain the complete font-file
156
+ resolver, shaping and outline engine. The browser retains the embedded KaTeX
157
+ resources and the parser needed for mathematical outlines.
158
+
159
+ ## Profile core document opening
160
+
161
+ Build separate timing and allocation modules from the repository root. Run Cargo
162
+ inside `rust/` so rustup selects the pinned toolchain. Use wasm-bindgen CLI
163
+ 0.2.128 for both modules.
164
+
165
+ ```sh
166
+ (cd rust && cargo build --profile wasm -j 4 -p idocs-wasm --target wasm32-unknown-unknown --features perf-profile)
167
+ wasm-bindgen rust/target/wasm32-unknown-unknown/wasm/idocs_wasm.wasm --target web --out-dir .audit/core-profile/timing --out-name idocs_wasm
168
+ (cd rust && cargo build --profile wasm -j 4 -p idocs-wasm --target wasm32-unknown-unknown --features perf-alloc)
169
+ wasm-bindgen rust/target/wasm32-unknown-unknown/wasm/idocs_wasm.wasm --target web --out-dir .audit/core-profile/alloc --out-name idocs_wasm
170
+ ```
171
+
172
+ Start Vite in another terminal with `cd web && npx vite --host 127.0.0.1 --port
173
+ 5174 --strictPort`. Install Playwright and its Chromium browser in your tooling
174
+ environment. If this script cannot resolve `playwright`, set `PLAYWRIGHT_MODULE`
175
+ to its entry module path.
176
+
177
+ ```sh
178
+ node web/scripts/profile-core.mjs path/to/sample.docx path/to/source.idoc
179
+ ```
180
+
181
+ The script uses a blank page without Vite HMR and a fresh, isolated Worker for
182
+ each run. Each Worker uses the real browser Canvas measurements and enables
183
+ `configureProfile`. Runs are sequential and alternate mode order across at least
184
+ five rounds. Stop other builds and benchmarks before measuring.
185
+
186
+ Input paths and `OUT` resolve relative to the repository root. Configure
187
+ `BASE_URL`, `ROUNDS`, `TIMEOUT_MS`, and `OUT` through environment variables.
188
+ `PROFILE_DIRS` is a JSON object mapping mode names to module directories, such as
189
+ `{"before":".audit/before","after":".audit/after"}`. Vite must allow access to
190
+ these files and inputs through `/@fs`. The default output is
191
+ `.audit/core-profile/results.json`. Each row includes the complete profile,
192
+ elapsed milliseconds, a combined SHA-256 of metadata, glyph buffers, and images,
193
+ page and conversion loss counts,
194
+ diagnostic counts, output bytes, and WASM memory capacity.
195
+
196
+ The elapsed timer covers `openDocument`, including Rust output serialization.
197
+ It excludes module initialization, input fetch, profile retrieval, JavaScript
198
+ JSON parsing, and hashing. Allocation instrumentation adds overhead. Compare
199
+ timings with `perf-profile` builds and allocation counts with `perf-alloc` builds.
200
+ Live allocation bytes measure requested bytes. WASM memory capacity measures
201
+ linear memory reserved by the instance. Neither measures physical memory.
202
+ The allocation counters require a single-threaded, isolated Worker. Normal
203
+ builds leave both profiling features off.
107
204
 
108
205
  ## License
109
206
 
@@ -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;
@@ -8,10 +8,8 @@ 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,14 @@ 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.progressive)
23
+ post({ type: 'pageReset', id });
24
+ const onPage = job.progressive ? (packet) => {
25
+ post({ type: 'page', id, ...packet }, [packet.data.buffer, ...packet.images.map(bytes => bytes.buffer)]);
26
+ } : undefined;
27
+ outcome = openDocument(job.kind, job.bytes, job.name, job.assets, measure, onPage);
23
28
  }
24
29
  catch (error) {
25
30
  pending.delete(id);
@@ -35,12 +40,7 @@ export function serve(endpoint, options = {}) {
35
40
  post({ type: 'failed', id, error: JSON.parse(outcome.json) });
36
41
  return;
37
42
  }
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));
43
+ post({ type: 'opened', id, json: outcome.json, images: outcome.images, data: outcome.data }, [outcome.data.buffer, ...outcome.images.map((bytes) => bytes.buffer)]);
44
44
  };
45
45
  endpoint.addEventListener('message', async (event) => {
46
46
  const message = event.data;
@@ -61,6 +61,7 @@ export function serve(endpoint, options = {}) {
61
61
  if (message.type === 'open') {
62
62
  pending.set(message.id, {
63
63
  kind: message.kind,
64
+ progressive: message.progressive ?? false,
64
65
  name: message.name,
65
66
  bytes: message.bytes,
66
67
  assets: new Map(),
@@ -71,8 +72,7 @@ export function serve(endpoint, options = {}) {
71
72
  if (!job)
72
73
  return;
73
74
  for (const { need, bytes } of message.answers) {
74
- ;
75
- (need.kind === 'font' ? fonts : job.assets).set(need.name, bytes);
75
+ job.assets.set(need.name, bytes);
76
76
  }
77
77
  }
78
78
  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
  /**
@@ -1,3 +1,4 @@
1
+ import { decodeCompact } from './wire.js';
1
2
  export class PreviewError extends Error {
2
3
  code;
3
4
  diagnostics;
@@ -51,10 +52,33 @@ export function createSession(options = {}) {
51
52
  const job = jobs.get(message.id);
52
53
  if (!job)
53
54
  return;
55
+ if (message.type === 'pageReset') {
56
+ job.pages = [];
57
+ job.images = [];
58
+ job.previewImages = [];
59
+ job.outlines = [];
60
+ return;
61
+ }
62
+ if (message.type === 'page') {
63
+ try {
64
+ job.images.push(...message.images);
65
+ const partial = decodeCompact(message.json, message.data, message.images);
66
+ job.previewImages.push(...partial.images);
67
+ job.outlines.push(...partial.glyphOutlines ?? []);
68
+ const page = partial.pages[0];
69
+ job.pages.push(page);
70
+ job.options.onPage?.({ page, images: job.previewImages, glyphOutlines: job.outlines, pageCount: partial.pageCount });
71
+ }
72
+ catch (error) {
73
+ jobs.delete(message.id);
74
+ job.reject(new PreviewError({ code: 'internal-error', message: String(error), diagnostics: [] }));
75
+ }
76
+ return;
77
+ }
54
78
  if (message.type === 'needs') {
55
79
  const answers = await Promise.all(message.needs.map(async (need) => ({
56
80
  need,
57
- bytes: await answer(need.kind === 'font' ? options.readFont : job.options.readResource, need.name),
81
+ bytes: await answer(job.options.readResource, need.name),
58
82
  })));
59
83
  if (disposed)
60
84
  return;
@@ -62,8 +86,20 @@ export function createSession(options = {}) {
62
86
  return;
63
87
  }
64
88
  jobs.delete(message.id);
65
- if (message.type === 'opened')
66
- job.resolve(message.document);
89
+ if (message.type === 'opened') {
90
+ try {
91
+ const doc = decodeCompact(message.json, message.data, job.options.onPage ? job.images : message.images);
92
+ if (job.options.onPage) {
93
+ if (job.pages.length !== doc.pageCount)
94
+ throw new Error('Preview page count mismatch');
95
+ doc.pages = job.pages;
96
+ }
97
+ job.resolve(doc);
98
+ }
99
+ catch (error) {
100
+ job.reject(new PreviewError({ code: 'internal-error', message: String(error), diagnostics: [] }));
101
+ }
102
+ }
67
103
  else
68
104
  job.reject(new PreviewError(message.error));
69
105
  });
@@ -96,9 +132,10 @@ export function createSession(options = {}) {
96
132
  const bytes = copyBytes(input);
97
133
  const id = next++;
98
134
  return new Promise((resolve, reject) => {
99
- jobs.set(id, { options: openOptions, resolve, reject });
135
+ jobs.set(id, { options: openOptions, resolve, reject, pages: [], images: [], previewImages: [], outlines: [] });
100
136
  post({
101
137
  type: 'open',
138
+ progressive: openOptions.onPage !== undefined,
102
139
  id,
103
140
  kind: openOptions.kind ?? (isWordFile(bytes, openOptions.name) ? 'docx' : 'idoc'),
104
141
  name: openOptions.name ?? 'document.idoc',