@aseity/idocs-preview 0.0.0-stage → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -2
  4. package/dist/core/display.d.ts +180 -0
  5. package/dist/core/display.js +4 -0
  6. package/dist/core/engine.d.ts +9 -0
  7. package/dist/core/engine.js +81 -0
  8. package/dist/core/fonts.d.ts +27 -0
  9. package/dist/core/fonts.js +135 -0
  10. package/dist/core/index.d.ts +47 -0
  11. package/dist/core/index.js +156 -0
  12. package/dist/core/protocol.d.ts +137 -0
  13. package/dist/core/protocol.js +1 -0
  14. package/dist/core/wire.d.ts +14 -0
  15. package/dist/core/wire.js +100 -0
  16. package/dist/core/worker.d.ts +1 -0
  17. package/dist/core/worker.js +5 -0
  18. package/dist/view/find-bar.d.ts +26 -0
  19. package/dist/view/find-bar.js +117 -0
  20. package/dist/view/find.d.ts +46 -0
  21. package/dist/view/find.js +121 -0
  22. package/dist/view/index.d.ts +27 -0
  23. package/dist/view/index.js +274 -0
  24. package/dist/view/selection-layer.d.ts +27 -0
  25. package/dist/view/selection-layer.js +269 -0
  26. package/dist/view/selection.d.ts +131 -0
  27. package/dist/view/selection.js +350 -0
  28. package/dist/view/shell.d.ts +77 -0
  29. package/dist/view/shell.js +452 -0
  30. package/dist/view/viewport.d.ts +100 -0
  31. package/dist/view/viewport.js +145 -0
  32. package/package.json +49 -4
  33. package/wasm/hyphenation-licenses/LICENSE-APACHE +201 -0
  34. package/wasm/hyphenation-licenses/LICENSE-MIT +21 -0
  35. package/wasm/hyphenation-licenses/README.md +26 -0
  36. package/wasm/hyphenation-licenses/en-gb.tex +8615 -0
  37. package/wasm/hyphenation-licenses/en-us.tex +5018 -0
  38. package/wasm/hyphenation-licenses/ushyphex.tex +1454 -0
  39. package/wasm/idocs_wasm.d.ts +45 -0
  40. package/wasm/idocs_wasm.js +405 -0
  41. package/wasm/idocs_wasm_bg.wasm +0 -0
  42. package/wasm/idocs_wasm_bg.wasm.d.ts +10 -0
  43. package/wasm/package.json +1 -0
@@ -0,0 +1,135 @@
1
+ export const CANVAS_TEXT_LANGUAGE = 'und';
2
+ const GENERIC = new Set(['serif', 'sans-serif', 'monospace']);
3
+ /**
4
+ * Word family → its look-alikes on Windows, macOS and Linux, ending in a
5
+ * generic family. Covers the default theme and common Word families.
6
+ */
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'],
11
+ // Simplified Chinese
12
+ SimSun: ['SimSun', 'Songti SC', 'Noto Serif CJK SC', 'serif'],
13
+ SimHei: ['SimHei', 'Heiti SC', 'STHeiti', 'Noto Sans CJK SC', 'sans-serif'],
14
+ 'Microsoft YaHei': ['Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', 'sans-serif'],
15
+ DengXian: ['DengXian', 'PingFang SC', 'Noto Sans CJK SC', 'sans-serif'],
16
+ FangSong: ['FangSong', 'STFangsong', 'FandolFang', 'Noto Serif CJK SC', 'serif'],
17
+ KaiTi: ['KaiTi', 'Kaiti SC', 'STKaiti', 'AR PL UKai CN', 'FandolKai', 'Noto Serif CJK SC', 'serif'],
18
+ // Traditional Chinese
19
+ PMingLiU: ['PMingLiU', 'Songti TC', 'Noto Serif CJK TC', 'serif'],
20
+ MingLiU: ['MingLiU', 'Songti TC', 'Noto Serif CJK TC', 'serif'],
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'],
24
+ // Latin
25
+ 'Times New Roman': ['Times New Roman', 'Times', 'Liberation Serif', 'Tinos', 'serif'],
26
+ Arial: ['Arial', 'Helvetica', 'Liberation Sans', 'Arimo', 'sans-serif'],
27
+ Calibri: ['Calibri', 'Carlito', 'sans-serif'],
28
+ Consolas: ['Consolas', 'Menlo', 'Inconsolata', 'Liberation Mono', 'monospace'],
29
+ 'Courier New': ['Courier New', 'Courier', 'Liberation Mono', 'Cousine', 'monospace'],
30
+ Cambria: ['Cambria', 'Caladea', 'serif'],
31
+ 'Cambria Math': ['Cambria Math', 'STIX Two Math', 'serif'],
32
+ Aptos: ['Aptos', 'sans-serif'],
33
+ 'Segoe UI': ['Segoe UI', 'Helvetica Neue', 'Noto Sans', 'sans-serif'],
34
+ };
35
+ /** Chinese names Word documents use for the families above. */
36
+ const ALIASES = {
37
+ 微软雅黑: 'Microsoft YaHei',
38
+ 等线: 'DengXian',
39
+ 宋体: 'SimSun',
40
+ 黑体: 'SimHei',
41
+ 仿宋: 'FangSong',
42
+ 楷体: 'KaiTi',
43
+ 新細明體: 'PMingLiU',
44
+ 微軟正黑體: 'Microsoft JhengHei',
45
+ 細明體: 'MingLiU',
46
+ 'MS ゴシック': 'MS Gothic',
47
+ };
48
+ const BY_LOWER = new Map(Object.keys(SIMILAR).map((name) => [name.toLowerCase(), name]));
49
+ /** The Word family a declared name stands for, matched as Word does (case-insensitive). */
50
+ function wordFamily(name) {
51
+ return BY_LOWER.get((ALIASES[name] ?? name).toLowerCase());
52
+ }
53
+ function roleGeneric(role) {
54
+ if (role === 'mono')
55
+ return 'monospace';
56
+ if (role === 'sans')
57
+ return 'sans-serif';
58
+ return 'serif';
59
+ }
60
+ /**
61
+ * The CSS font families to draw `run` with, most preferred first; the last is
62
+ * always a generic family. When layout measured the declared family itself,
63
+ * it leads, then the look-alikes of the Word family it names. When the
64
+ * selected family differs from the declared family, layout measured a fallback face:
65
+ * that face and its look-alikes lead, so glyphs are drawn as wide as they
66
+ * were placed, and the declared family only covers characters they lack.
67
+ */
68
+ export function fontChain(run) {
69
+ const names = [];
70
+ let generic;
71
+ const add = (name) => {
72
+ if (GENERIC.has(name))
73
+ generic ??= name;
74
+ else if (!names.some((known) => known.toLowerCase() === name.toLowerCase()))
75
+ names.push(name);
76
+ };
77
+ const addSimilar = (family) => {
78
+ const word = wordFamily(family);
79
+ if (word)
80
+ for (const name of SIMILAR[word])
81
+ add(name);
82
+ };
83
+ const measured = (wordFamily(run.declaredFamily) ?? run.declaredFamily).toLowerCase() === run.family.toLowerCase();
84
+ if (!measured)
85
+ addSimilar(run.family);
86
+ add(run.declaredFamily);
87
+ addSimilar(run.declaredFamily);
88
+ if (measured)
89
+ addSimilar(run.family);
90
+ return [...names, generic ?? roleGeneric(run.role)];
91
+ }
92
+ const fonts = new Map();
93
+ /**
94
+ * The `CanvasRenderingContext2D.font` for `run`, in a context whose unit is
95
+ * one point: the run's size in points, its weight and slant, then the chain.
96
+ */
97
+ export function canvasFont(run) {
98
+ const key = `${run.italic}\0${run.bold}\0${run.size}\0${run.role}\0${run.declaredFamily}\0${run.family}`;
99
+ let font = fonts.get(key);
100
+ if (font === undefined) {
101
+ const chain = fontChain(run)
102
+ .map((name) => (GENERIC.has(name) ? name : `"${name.replaceAll('"', '')}"`))
103
+ .join(', ');
104
+ font = `${run.italic ? 'italic ' : ''}${run.bold ? 'bold ' : ''}${run.size}px ${chain}`;
105
+ fonts.set(key, font);
106
+ }
107
+ return font;
108
+ }
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. */
113
+ export function canvasMeasure() {
114
+ if (typeof OffscreenCanvas === 'undefined')
115
+ return undefined;
116
+ const ctx = new OffscreenCanvas(1, 1).getContext('2d');
117
+ if (!ctx)
118
+ return undefined;
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;
134
+ };
135
+ }
@@ -0,0 +1,47 @@
1
+ import type { DocumentKind, Endpoint, PreviewDocument, PreviewPage } 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
+ type Bytes = Uint8Array | ArrayBuffer;
13
+ export type Read = (name: string) => Bytes | null | undefined | Promise<Bytes | null | undefined>;
14
+ export interface OpenOptions {
15
+ /** Receive provisional pages while the worker builds the remaining display list. */
16
+ onPage?: (page: PreviewPage) => void;
17
+ /** File name used in diagnostics. */
18
+ name?: string;
19
+ /**
20
+ * Defaults to `docx` for zip or OLE compound-file bytes (an encrypted DOCX
21
+ * or a .doc, rejected with their own code) and for a Word file name
22
+ * (`.docx`, `.docm`, `.dotx`, `.dotm`, `.doc`); `idoc` otherwise.
23
+ */
24
+ kind?: DocumentKind;
25
+ /** Reads a path relative to the source document (images). Throw or return null when absent. */
26
+ readResource?: Read;
27
+ }
28
+ export declare class PreviewError extends Error {
29
+ code: string;
30
+ diagnostics: PreviewDocument['diagnostics'];
31
+ constructor(error: {
32
+ code: string;
33
+ message: string;
34
+ diagnostics: PreviewDocument['diagnostics'];
35
+ });
36
+ }
37
+ export interface Session {
38
+ open(bytes: Bytes, options?: OpenOptions): Promise<PreviewDocument>;
39
+ /**
40
+ * Terminates the worker; pending opens reject. A worker that fails on its
41
+ * own (script not loaded, message not deserialized) rejects pending and later
42
+ * opens with `worker-unavailable`; create a new session to retry.
43
+ */
44
+ dispose(): void;
45
+ }
46
+ export declare function createSession(options?: SessionOptions): Session;
47
+ export type { Measure } from './fonts.js';
@@ -0,0 +1,156 @@
1
+ import { decodeCompact } from './wire.js';
2
+ export class PreviewError extends Error {
3
+ code;
4
+ diagnostics;
5
+ constructor(error) {
6
+ super(error.message);
7
+ this.name = 'PreviewError';
8
+ this.code = error.code;
9
+ this.diagnostics = error.diagnostics;
10
+ }
11
+ }
12
+ function isZip(bytes) {
13
+ return bytes[0] === 0x50 && bytes[1] === 0x4b;
14
+ }
15
+ const OLE_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
16
+ function isWordFile(bytes, name) {
17
+ return (isZip(bytes) ||
18
+ OLE_MAGIC.every((byte, i) => bytes[i] === byte) ||
19
+ /\.(docx|docm|dotx|dotm|doc)$/i.test(name ?? ''));
20
+ }
21
+ /** A private copy whose buffer can be transferred (a Node Buffer may share its pool). */
22
+ function copyBytes(bytes) {
23
+ return new Uint8Array(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
24
+ }
25
+ export function createSession(options = {}) {
26
+ const worker = options.worker ??
27
+ (options.workerUrl
28
+ ? new Worker(options.workerUrl, { type: 'module' })
29
+ : // Literally `new Worker(new URL(...), ...)`: the form bundlers
30
+ // (Turbopack, webpack, Vite) recognize to bundle the worker with its
31
+ // imports. Behind `??` they copy the file as a bare asset instead.
32
+ new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }));
33
+ const post = (message, transfer = []) => worker.postMessage(message, transfer);
34
+ const jobs = new Map();
35
+ let next = 1;
36
+ let disposed = false;
37
+ /** Set once the worker fails: every pending and later open rejects with it. */
38
+ let broken;
39
+ const answer = async (read, name) => {
40
+ if (!read)
41
+ return null;
42
+ try {
43
+ const bytes = await read(name);
44
+ return bytes ? copyBytes(bytes) : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ };
50
+ worker.addEventListener('message', async (event) => {
51
+ const message = event.data;
52
+ const job = jobs.get(message.id);
53
+ if (!job)
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
+ }
78
+ if (message.type === 'needs') {
79
+ const answers = await Promise.all(message.needs.map(async (need) => ({
80
+ need,
81
+ bytes: await answer(job.options.readResource, need.name),
82
+ })));
83
+ if (disposed)
84
+ return;
85
+ post({ type: 'resources', id: message.id, answers }, answers.flatMap((a) => (a.bytes ? [a.bytes.buffer] : [])));
86
+ return;
87
+ }
88
+ jobs.delete(message.id);
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
+ }
103
+ else
104
+ job.reject(new PreviewError(message.error));
105
+ });
106
+ // A worker script that fails to load or run, or a message that cannot be
107
+ // deserialized, leaves no reply to wait for: give up on the worker.
108
+ const fail = (event) => {
109
+ if (disposed || broken)
110
+ return;
111
+ const detail = event.message;
112
+ broken = new PreviewError({
113
+ code: 'worker-unavailable',
114
+ message: `The preview worker failed (${event.type}${detail ? `: ${detail}` : ''})`,
115
+ diagnostics: [],
116
+ });
117
+ worker.terminate?.();
118
+ for (const job of jobs.values())
119
+ job.reject(broken);
120
+ jobs.clear();
121
+ };
122
+ worker.addEventListener('error', fail);
123
+ worker.addEventListener('messageerror', fail);
124
+ worker.start?.();
125
+ post({ type: 'init', wasmUrl: options.wasmUrl?.toString() });
126
+ return {
127
+ open(input, openOptions = {}) {
128
+ if (disposed)
129
+ return Promise.reject(new Error('The preview session is disposed'));
130
+ if (broken)
131
+ return Promise.reject(broken);
132
+ const bytes = copyBytes(input);
133
+ const id = next++;
134
+ return new Promise((resolve, reject) => {
135
+ jobs.set(id, { options: openOptions, resolve, reject, pages: [], images: [], previewImages: [], outlines: [] });
136
+ post({
137
+ type: 'open',
138
+ progressive: openOptions.onPage !== undefined,
139
+ id,
140
+ kind: openOptions.kind ?? (isWordFile(bytes, openOptions.name) ? 'docx' : 'idoc'),
141
+ name: openOptions.name ?? 'document.idoc',
142
+ bytes,
143
+ }, [bytes.buffer]);
144
+ });
145
+ },
146
+ dispose() {
147
+ if (disposed)
148
+ return;
149
+ disposed = true;
150
+ worker.terminate?.();
151
+ for (const job of jobs.values())
152
+ job.reject(new Error('The preview session is disposed'));
153
+ jobs.clear();
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,137 @@
1
+ import type { DisplayPage, GlyphOutline } from './display.js';
2
+ export type DocumentKind = 'idoc' | 'docx';
3
+ /**
4
+ * Either end of the channel: a Worker (or a MessagePort) on the session side,
5
+ * the worker's global scope (or the other port) on the engine side.
6
+ */
7
+ export interface Endpoint {
8
+ postMessage(message: unknown, transfer?: Transferable[]): void;
9
+ /** On the session side `error` and `messageerror` make the session unusable (`worker-unavailable`). */
10
+ addEventListener(type: 'message' | 'error' | 'messageerror', listener: (event: MessageEvent) => void): void;
11
+ start?(): void;
12
+ terminate?(): void;
13
+ }
14
+ /** A core diagnostic (load, layout or host-resource), as the CLI reports it. */
15
+ export interface Diagnostic {
16
+ severity: 'error' | 'warning' | string;
17
+ code: string;
18
+ message: string;
19
+ file?: string;
20
+ line?: number;
21
+ col?: number;
22
+ path?: string;
23
+ nodeId?: string;
24
+ predicted?: boolean;
25
+ [key: string]: unknown;
26
+ }
27
+ export interface ConversionLoss {
28
+ code: string;
29
+ message: string;
30
+ /** Export losses (`.idoc`) only: whether the loss is approved. Convert losses (DOCX) have none. */
31
+ approved?: boolean;
32
+ /** Where in the source the loss is; for DOCX, in the converted source `<stem>.idoc`. */
33
+ source?: {
34
+ file?: string;
35
+ line?: number;
36
+ col?: number;
37
+ path?: string;
38
+ [key: string]: unknown;
39
+ };
40
+ [key: string]: unknown;
41
+ }
42
+ export interface PreviewImage {
43
+ format: 'png' | 'jpeg' | 'svg+xml';
44
+ bytes: Uint8Array;
45
+ /**
46
+ * A placeholder for an image `readResource` could not give or core could
47
+ * not read; the matching `image-missing` / `image-format` diagnostic is a
48
+ * warning. Draw a placeholder box rather than `bytes`.
49
+ */
50
+ missing?: boolean;
51
+ /** Draw washed out, as Word's picture watermark: each channel `min(255, 205 + 0.3 × value)`. */
52
+ washout?: boolean;
53
+ }
54
+ /** A laid-out document: the whole layout converged, every page at once. */
55
+ export interface FontUse {
56
+ file: string;
57
+ sha256: string;
58
+ faceIndex: number;
59
+ declaredFamily: string;
60
+ family: string;
61
+ style: string;
62
+ bold: boolean;
63
+ italic: boolean;
64
+ syntheticSlant: number;
65
+ substituted: boolean;
66
+ }
67
+ export interface PreviewDocument {
68
+ fonts?: FontUse[];
69
+ glyphOutlines?: GlyphOutline[];
70
+ pageCount: number;
71
+ pages: DisplayPage[];
72
+ images: PreviewImage[];
73
+ /** Load diagnostics, plus warnings about resources the host could not give. */
74
+ diagnostics: Diagnostic[];
75
+ layoutDiagnostics: Diagnostic[];
76
+ /** For `.idoc`: what export to DOCX would lose. For DOCX: what DOCX conversion lost. */
77
+ conversionLosses: ConversionLoss[];
78
+ }
79
+ /** A lookup core made that the host has not answered yet. */
80
+ export type ResourceNeed = {
81
+ kind: 'asset';
82
+ name: string;
83
+ };
84
+ export type ToWorker = {
85
+ type: 'init';
86
+ wasmUrl?: string;
87
+ } | {
88
+ type: 'open';
89
+ id: number;
90
+ kind: DocumentKind;
91
+ name: string;
92
+ bytes: Uint8Array;
93
+ progressive?: boolean;
94
+ }
95
+ /** Answers to a `needs` message; `bytes: null` means the host has none. */
96
+ | {
97
+ type: 'resources';
98
+ id: number;
99
+ answers: {
100
+ need: ResourceNeed;
101
+ bytes: Uint8Array | null;
102
+ }[];
103
+ };
104
+ export interface PreviewPage {
105
+ page: DisplayPage;
106
+ images: PreviewImage[];
107
+ glyphOutlines: GlyphOutline[];
108
+ pageCount: number;
109
+ }
110
+ export type FromWorker = {
111
+ type: 'pageReset';
112
+ id: number;
113
+ } | {
114
+ type: 'page';
115
+ id: number;
116
+ json: string;
117
+ data: Uint8Array;
118
+ images: Uint8Array[];
119
+ } | {
120
+ type: 'needs';
121
+ id: number;
122
+ needs: ResourceNeed[];
123
+ } | {
124
+ type: 'opened';
125
+ id: number;
126
+ json: string;
127
+ images: Uint8Array[];
128
+ data: Uint8Array;
129
+ } | {
130
+ type: 'failed';
131
+ id: number;
132
+ error: {
133
+ code: string;
134
+ message: string;
135
+ diagnostics: Diagnostic[];
136
+ };
137
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { Glyph, GlyphShape, LineGlyph } from './display.js';
2
+ import type { PreviewDocument } from './protocol.js';
3
+ interface Packed {
4
+ f64: Float64Array;
5
+ u32: Uint32Array;
6
+ count: number;
7
+ texts: string[];
8
+ shapes: GlyphShape[][];
9
+ }
10
+ export declare function packedGlyphs(glyphs: readonly Glyph[]): Packed | undefined;
11
+ export declare function glyphX(glyphs: readonly LineGlyph[], index: number): number;
12
+ export declare function glyphOffset(glyphs: readonly LineGlyph[], index: number): number;
13
+ export declare function decodeCompact(json: string, data: Uint8Array, images: Uint8Array[]): PreviewDocument;
14
+ export {};
@@ -0,0 +1,100 @@
1
+ const glyphRecords = new WeakMap();
2
+ const lineRecords = new WeakMap();
3
+ export function packedGlyphs(glyphs) {
4
+ return glyphRecords.get(glyphs);
5
+ }
6
+ export function glyphX(glyphs, index) {
7
+ const packed = lineRecords.get(glyphs);
8
+ return packed ? packed.f64[index * 2] : glyphs[index].x;
9
+ }
10
+ export function glyphOffset(glyphs, index) {
11
+ const packed = lineRecords.get(glyphs);
12
+ return packed ? packed.u32[index * 4 + 2] : glyphs[index].offset;
13
+ }
14
+ function lazyArray(count, read) {
15
+ const target = new Array(count);
16
+ const indexOf = (key) => typeof key === 'string' && /^(0|[1-9]\d*)$/.test(key) ? Number(key) : -1;
17
+ return new Proxy(target, {
18
+ get(target, key, receiver) {
19
+ const index = indexOf(key);
20
+ return index >= 0 && index < count ? read(index) : Reflect.get(target, key, receiver);
21
+ },
22
+ has(target, key) {
23
+ const index = indexOf(key);
24
+ return index >= 0 && index < count || Reflect.has(target, key);
25
+ },
26
+ ownKeys() { return [...Array.from({ length: count }, (_, i) => String(i)), 'length']; },
27
+ getOwnPropertyDescriptor(target, key) {
28
+ const index = indexOf(key);
29
+ return index >= 0 && index < count
30
+ ? { value: read(index), writable: false, enumerable: true, configurable: true }
31
+ : Reflect.getOwnPropertyDescriptor(target, key);
32
+ },
33
+ set() { throw new TypeError('Compact preview glyph arrays are read-only'); },
34
+ deleteProperty() { throw new TypeError('Compact preview glyph arrays are read-only'); },
35
+ defineProperty() { throw new TypeError('Compact preview glyph arrays are read-only'); },
36
+ });
37
+ }
38
+ function range(value, kind, data) {
39
+ if (typeof value !== 'object' || value === null || !('offset' in value) || !('count' in value) || !('kind' in value)
40
+ || value.kind !== kind || typeof value.offset !== 'number' || typeof value.count !== 'number'
41
+ || !Number.isSafeInteger(value.offset) || !Number.isSafeInteger(value.count)
42
+ || value.offset < 0 || value.count < 0 || value.offset % 8 !== 0
43
+ || value.offset + value.count * (kind === 'glyph' ? 32 : 16) > data.byteLength) {
44
+ throw new Error('Invalid compact glyph range');
45
+ }
46
+ return { offset: value.offset, count: value.count, kind };
47
+ }
48
+ export function decodeCompact(json, data, images) {
49
+ const parsed = JSON.parse(json);
50
+ if (parsed.wireVersion !== 1 || !Array.isArray(parsed.texts) || !parsed.texts.every(text => typeof text === 'string')
51
+ || !Array.isArray(parsed.shapes) || parsed.images.length !== images.length || data.byteOffset % 8 !== 0
52
+ || new Uint8Array(new Uint32Array([1]).buffer)[0] !== 1)
53
+ throw new Error('Invalid compact preview');
54
+ const { texts, shapes, wireVersion: _, ...document } = parsed;
55
+ function records(value, kind) {
56
+ const { offset, count } = range(value, kind, data);
57
+ const bytes = count * (kind === 'glyph' ? 32 : 16);
58
+ const packed = {
59
+ f64: new Float64Array(data.buffer, data.byteOffset + offset, bytes / 8),
60
+ u32: new Uint32Array(data.buffer, data.byteOffset + offset, bytes / 4), count, texts, shapes,
61
+ };
62
+ if (kind === 'glyph')
63
+ for (let i = 0; i < count; i++) {
64
+ if (packed.u32[i * 8 + 5] >= texts.length || packed.u32[i * 8 + 6] > 7 || (packed.u32[i * 8 + 6] & 6) === 6 || packed.u32[i * 8 + 7] > shapes.length)
65
+ throw new Error('Invalid compact glyph reference');
66
+ }
67
+ return packed;
68
+ }
69
+ function visitItems(items) {
70
+ for (const item of items) {
71
+ if (item.kind === 'glyphs') {
72
+ const p = records(item.glyphs, 'glyph');
73
+ const glyphs = lazyArray(p.count, i => {
74
+ const flags = p.u32[i * 8 + 6];
75
+ const shape = p.u32[i * 8 + 7];
76
+ return {
77
+ x: p.f64[i * 4], width: p.f64[i * 4 + 1], offset: p.u32[i * 8 + 4], text: texts[p.u32[i * 8 + 5]],
78
+ shapes: shape === 0 ? [] : shapes[shape - 1],
79
+ ...(flags & 1 ? { unmeasured: true } : {}),
80
+ ...(flags & 2 ? { generated: 'automatic' } : flags & 4 ? { generated: 'manual' } : {}),
81
+ };
82
+ });
83
+ glyphRecords.set(glyphs, p);
84
+ item.glyphs = glyphs;
85
+ }
86
+ else if (item.kind === 'clip' || item.kind === 'turn' || item.kind === 'transform')
87
+ visitItems(item.items);
88
+ }
89
+ }
90
+ for (const page of document.pages) {
91
+ visitItems(page.items);
92
+ for (const line of page.lines) {
93
+ const p = records(line.glyphs, 'line');
94
+ const glyphs = lazyArray(p.count, i => ({ x: p.f64[i * 2], offset: p.u32[i * 4 + 2] }));
95
+ lineRecords.set(glyphs, p);
96
+ line.glyphs = glyphs;
97
+ }
98
+ }
99
+ return { ...document, images: parsed.images.map((image, i) => ({ ...image, bytes: images[i] })) };
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ // Web Worker entry. The session creates it with `new URL('./worker.ts',
2
+ // import.meta.url)` so the host's bundler emits it; hosts without a bundler
3
+ // pass `workerUrl` to createSession instead.
4
+ import { serve } from './engine.js';
5
+ serve(self);
@@ -0,0 +1,26 @@
1
+ import type { Finder } from './find.js';
2
+ import type { PageRect } from './viewport.js';
3
+ export interface FindHost {
4
+ /** The positioned box the bar floats in, over the top-right of the pages. */
5
+ frame: HTMLElement;
6
+ finder: Finder;
7
+ /** Index of the current page, where a new search starts. */
8
+ currentPage(): number;
9
+ /** Scroll so the current match shows. */
10
+ reveal(bounds: PageRect): void;
11
+ /** Repaint every overlay that exists (the matches changed). */
12
+ repaint(): void;
13
+ /** The find bar closed: select the match (if any) and give the preview the keyboard back. */
14
+ closed(): void;
15
+ }
16
+ export interface FindBar {
17
+ readonly isOpen: boolean;
18
+ /** Show the bar and focus its field; with `query`, search for it. */
19
+ open(query?: string): void;
20
+ /** Hide the bar and drop the matches. */
21
+ close(): void;
22
+ next(): void;
23
+ previous(): void;
24
+ destroy(): void;
25
+ }
26
+ export declare function createFindBar(host: FindHost): FindBar;