@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.
@@ -0,0 +1,119 @@
1
+ export class PreviewError extends Error {
2
+ code;
3
+ diagnostics;
4
+ constructor(error) {
5
+ super(error.message);
6
+ this.name = 'PreviewError';
7
+ this.code = error.code;
8
+ this.diagnostics = error.diagnostics;
9
+ }
10
+ }
11
+ function isZip(bytes) {
12
+ return bytes[0] === 0x50 && bytes[1] === 0x4b;
13
+ }
14
+ const OLE_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
15
+ function isWordFile(bytes, name) {
16
+ return (isZip(bytes) ||
17
+ OLE_MAGIC.every((byte, i) => bytes[i] === byte) ||
18
+ /\.(docx|docm|dotx|dotm|doc)$/i.test(name ?? ''));
19
+ }
20
+ /** A private copy whose buffer can be transferred (a Node Buffer may share its pool). */
21
+ function copyBytes(bytes) {
22
+ return new Uint8Array(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
23
+ }
24
+ export function createSession(options = {}) {
25
+ const worker = options.worker ??
26
+ (options.workerUrl
27
+ ? new Worker(options.workerUrl, { type: 'module' })
28
+ : // Literally `new Worker(new URL(...), ...)`: the form bundlers
29
+ // (Turbopack, webpack, Vite) recognize to bundle the worker with its
30
+ // imports. Behind `??` they copy the file as a bare asset instead.
31
+ new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }));
32
+ const post = (message, transfer = []) => worker.postMessage(message, transfer);
33
+ const jobs = new Map();
34
+ let next = 1;
35
+ let disposed = false;
36
+ /** Set once the worker fails: every pending and later open rejects with it. */
37
+ let broken;
38
+ const answer = async (read, name) => {
39
+ if (!read)
40
+ return null;
41
+ try {
42
+ const bytes = await read(name);
43
+ return bytes ? copyBytes(bytes) : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ };
49
+ worker.addEventListener('message', async (event) => {
50
+ const message = event.data;
51
+ const job = jobs.get(message.id);
52
+ if (!job)
53
+ return;
54
+ if (message.type === 'needs') {
55
+ const answers = await Promise.all(message.needs.map(async (need) => ({
56
+ need,
57
+ bytes: await answer(need.kind === 'font' ? options.readFont : job.options.readResource, need.name),
58
+ })));
59
+ if (disposed)
60
+ return;
61
+ post({ type: 'resources', id: message.id, answers }, answers.flatMap((a) => (a.bytes ? [a.bytes.buffer] : [])));
62
+ return;
63
+ }
64
+ jobs.delete(message.id);
65
+ if (message.type === 'opened')
66
+ job.resolve(message.document);
67
+ else
68
+ job.reject(new PreviewError(message.error));
69
+ });
70
+ // A worker script that fails to load or run, or a message that cannot be
71
+ // deserialized, leaves no reply to wait for: give up on the worker.
72
+ const fail = (event) => {
73
+ if (disposed || broken)
74
+ return;
75
+ const detail = event.message;
76
+ broken = new PreviewError({
77
+ code: 'worker-unavailable',
78
+ message: `The preview worker failed (${event.type}${detail ? `: ${detail}` : ''})`,
79
+ diagnostics: [],
80
+ });
81
+ worker.terminate?.();
82
+ for (const job of jobs.values())
83
+ job.reject(broken);
84
+ jobs.clear();
85
+ };
86
+ worker.addEventListener('error', fail);
87
+ worker.addEventListener('messageerror', fail);
88
+ worker.start?.();
89
+ post({ type: 'init', wasmUrl: options.wasmUrl?.toString() });
90
+ return {
91
+ open(input, openOptions = {}) {
92
+ if (disposed)
93
+ return Promise.reject(new Error('The preview session is disposed'));
94
+ if (broken)
95
+ return Promise.reject(broken);
96
+ const bytes = copyBytes(input);
97
+ const id = next++;
98
+ return new Promise((resolve, reject) => {
99
+ jobs.set(id, { options: openOptions, resolve, reject });
100
+ post({
101
+ type: 'open',
102
+ id,
103
+ kind: openOptions.kind ?? (isWordFile(bytes, openOptions.name) ? 'docx' : 'idoc'),
104
+ name: openOptions.name ?? 'document.idoc',
105
+ bytes,
106
+ }, [bytes.buffer]);
107
+ });
108
+ },
109
+ dispose() {
110
+ if (disposed)
111
+ return;
112
+ disposed = true;
113
+ worker.terminate?.();
114
+ for (const job of jobs.values())
115
+ job.reject(new Error('The preview session is disposed'));
116
+ jobs.clear();
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,106 @@
1
+ import type { DisplayPage } 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';
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
+ }
52
+ /** A laid-out document: the whole layout converged, every page at once. */
53
+ export interface PreviewDocument {
54
+ pageCount: number;
55
+ pages: DisplayPage[];
56
+ images: PreviewImage[];
57
+ /** Load diagnostics, plus warnings about resources the host could not give. */
58
+ diagnostics: Diagnostic[];
59
+ layoutDiagnostics: Diagnostic[];
60
+ /** For `.idoc`: what export to DOCX would lose. For DOCX: what DOCX conversion lost. */
61
+ conversionLosses: ConversionLoss[];
62
+ }
63
+ /** A lookup core made that the host has not answered yet. */
64
+ export type ResourceNeed = {
65
+ kind: 'asset';
66
+ name: string;
67
+ } | {
68
+ kind: 'font';
69
+ name: string;
70
+ };
71
+ export type ToWorker = {
72
+ type: 'init';
73
+ wasmUrl?: string;
74
+ } | {
75
+ type: 'open';
76
+ id: number;
77
+ kind: DocumentKind;
78
+ name: string;
79
+ bytes: Uint8Array;
80
+ }
81
+ /** Answers to a `needs` message; `bytes: null` means the host has none. */
82
+ | {
83
+ type: 'resources';
84
+ id: number;
85
+ answers: {
86
+ need: ResourceNeed;
87
+ bytes: Uint8Array | null;
88
+ }[];
89
+ };
90
+ export type FromWorker = {
91
+ type: 'needs';
92
+ id: number;
93
+ needs: ResourceNeed[];
94
+ } | {
95
+ type: 'opened';
96
+ id: number;
97
+ document: PreviewDocument;
98
+ } | {
99
+ type: 'failed';
100
+ id: number;
101
+ error: {
102
+ code: string;
103
+ message: string;
104
+ diagnostics: Diagnostic[];
105
+ };
106
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -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;
@@ -0,0 +1,117 @@
1
+ function button(label, text) {
2
+ const node = document.createElement('button');
3
+ node.type = 'button';
4
+ node.textContent = text;
5
+ node.title = label;
6
+ node.setAttribute('aria-label', label);
7
+ Object.assign(node.style, { border: '0', background: 'transparent', padding: '2px 6px', cursor: 'pointer', font: 'inherit' });
8
+ return node;
9
+ }
10
+ export function createFindBar(host) {
11
+ const { frame, finder } = host;
12
+ const bar = document.createElement('div');
13
+ bar.className = 'idocs-preview-find';
14
+ bar.setAttribute('role', 'search');
15
+ Object.assign(bar.style, {
16
+ display: 'none',
17
+ position: 'absolute',
18
+ top: '8px',
19
+ right: '24px',
20
+ zIndex: '1',
21
+ alignItems: 'center',
22
+ gap: '4px',
23
+ padding: '4px 6px',
24
+ background: '#fff',
25
+ border: '1px solid #d1d5db',
26
+ borderRadius: '6px',
27
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
28
+ font: '13px system-ui, sans-serif',
29
+ });
30
+ const input = document.createElement('input');
31
+ input.type = 'text';
32
+ input.placeholder = '查找';
33
+ input.setAttribute('aria-label', '查找');
34
+ Object.assign(input.style, { width: '180px', border: '0', outline: 'none', font: 'inherit' });
35
+ const count = document.createElement('span');
36
+ count.className = 'idocs-preview-find-count';
37
+ count.setAttribute('aria-live', 'polite');
38
+ Object.assign(count.style, { minWidth: '4em', color: '#6b7280', textAlign: 'right' });
39
+ const previousButton = button('上一个', '↑');
40
+ const nextButton = button('下一个', '↓');
41
+ const closeButton = button('关闭', '×');
42
+ bar.append(input, count, previousButton, nextButton, closeButton);
43
+ frame.append(bar);
44
+ const show = () => {
45
+ const total = finder.matches.length;
46
+ count.textContent = input.value === '' ? '' : total === 0 ? '无结果' : `${finder.current + 1}/${total}`;
47
+ const bounds = finder.bounds();
48
+ if (bounds)
49
+ host.reveal(bounds);
50
+ host.repaint();
51
+ };
52
+ const search = () => {
53
+ finder.search(input.value, Math.max(host.currentPage(), 0));
54
+ show();
55
+ };
56
+ const api = {
57
+ get isOpen() {
58
+ return bar.style.display !== 'none';
59
+ },
60
+ open(query) {
61
+ bar.style.display = 'flex';
62
+ if (query !== undefined)
63
+ input.value = query;
64
+ // Reopened with the last query: its matches were dropped on close.
65
+ if (query !== undefined || finder.matches.length === 0)
66
+ search();
67
+ input.focus();
68
+ input.select();
69
+ },
70
+ close() {
71
+ if (!api.isOpen)
72
+ return;
73
+ bar.style.display = 'none';
74
+ host.closed();
75
+ finder.clear();
76
+ count.textContent = '';
77
+ host.repaint();
78
+ },
79
+ next() {
80
+ finder.next();
81
+ show();
82
+ },
83
+ previous() {
84
+ finder.previous();
85
+ show();
86
+ },
87
+ destroy() {
88
+ bar.remove();
89
+ },
90
+ };
91
+ // While an input method composes, search once the text is committed:
92
+ // Chromium sends the last input event before compositionend.
93
+ input.addEventListener('input', (event) => {
94
+ if (!event.isComposing)
95
+ search();
96
+ });
97
+ input.addEventListener('compositionend', search);
98
+ input.addEventListener('keydown', (event) => {
99
+ if (event.isComposing)
100
+ return;
101
+ if (event.key === 'Enter') {
102
+ event.preventDefault();
103
+ if (event.shiftKey)
104
+ api.previous();
105
+ else
106
+ api.next();
107
+ }
108
+ else if (event.key === 'Escape') {
109
+ event.preventDefault();
110
+ api.close();
111
+ }
112
+ });
113
+ previousButton.addEventListener('click', () => api.previous());
114
+ nextButton.addEventListener('click', () => api.next());
115
+ closeButton.addEventListener('click', () => api.close());
116
+ return api;
117
+ }
@@ -0,0 +1,46 @@
1
+ import { type HighlightBox, type StoryKey, type TextIndex, type TextSelection } from './selection.js';
2
+ import type { PageRect } from './viewport.js';
3
+ /** One match: UTF-16 offsets in its story's text, and the page it starts on. */
4
+ export interface FindMatch {
5
+ story: StoryKey;
6
+ start: number;
7
+ end: number;
8
+ page: number;
9
+ }
10
+ /** The selection covering `match`. */
11
+ export declare function matchSelection(match: FindMatch): TextSelection;
12
+ /**
13
+ * Every match of `query` in the document, ignoring case and not
14
+ * overlapping, in reading order: page by page (by where a match starts),
15
+ * and on a page header, body, footnotes, footer. A match never runs over a
16
+ * paragraph end or a formula or image placeholder.
17
+ */
18
+ export declare function findMatches(index: TextIndex, query: string): FindMatch[];
19
+ /**
20
+ * The find state: the matches of the last search and the current one.
21
+ * Stepping wraps around at either end.
22
+ */
23
+ export declare class Finder {
24
+ #private;
25
+ readonly index: TextIndex;
26
+ matches: FindMatch[];
27
+ /** Index of the current match in `matches`; -1 without matches. */
28
+ current: number;
29
+ constructor(index: TextIndex);
30
+ /**
31
+ * Search for `query`; the current match is the first on or after page
32
+ * `fromPage` (the first match when none is). Returns the number of matches.
33
+ */
34
+ search(query: string, fromPage?: number): number;
35
+ get match(): FindMatch | undefined;
36
+ next(): void;
37
+ previous(): void;
38
+ clear(): void;
39
+ /** Highlight boxes on page `page`: every match there, and the current one's. */
40
+ marks(page: number): {
41
+ matches: HighlightBox[];
42
+ current: HighlightBox[];
43
+ };
44
+ /** The current match's box on the page it starts on, to scroll to. */
45
+ bounds(): PageRect | undefined;
46
+ }
@@ -0,0 +1,121 @@
1
+ import { storyKind, turnedToPage, } from './selection.js';
2
+ /** The selection covering `match`. */
3
+ export function matchSelection(match) {
4
+ return { story: match.story, start: match.start, end: match.end, reversed: false };
5
+ }
6
+ /** Reading order of the stories on one page. */
7
+ const RANK = { header: 0, body: 1, footnotes: 2, footer: 3 };
8
+ function rank(story) {
9
+ return RANK[storyKind(story)];
10
+ }
11
+ /** Lower case, code point by code point, keeping the text's length so offsets carry over. */
12
+ function fold(text) {
13
+ let out = '';
14
+ for (const ch of text) {
15
+ const lower = ch.toLowerCase();
16
+ out += lower.length === ch.length ? lower : ch;
17
+ }
18
+ return out;
19
+ }
20
+ /**
21
+ * Every match of `query` in the document, ignoring case and not
22
+ * overlapping, in reading order: page by page (by where a match starts),
23
+ * and on a page header, body, footnotes, footer. A match never runs over a
24
+ * paragraph end or a formula or image placeholder.
25
+ */
26
+ export function findMatches(index, query) {
27
+ const needle = fold(query);
28
+ if (needle === '' || needle.includes('\n') || needle.includes(''))
29
+ return [];
30
+ const matches = [];
31
+ for (const story of index.stories()) {
32
+ const text = fold(index.text(story));
33
+ for (let at = text.indexOf(needle); at >= 0; at = text.indexOf(needle, at + needle.length)) {
34
+ matches.push({ story, start: at, end: at + needle.length, page: index.pageAt(story, at) });
35
+ }
36
+ }
37
+ return matches.sort((a, b) => a.page - b.page || rank(a.story) - rank(b.story) || a.start - b.start);
38
+ }
39
+ /**
40
+ * The find state: the matches of the last search and the current one.
41
+ * Stepping wraps around at either end.
42
+ */
43
+ export class Finder {
44
+ index;
45
+ matches = [];
46
+ /** Index of the current match in `matches`; -1 without matches. */
47
+ current = -1;
48
+ /** Matches by every page they show on (running onto the next page, in a repeated header row). */
49
+ #byPage = new Map();
50
+ constructor(index) {
51
+ this.index = index;
52
+ }
53
+ /**
54
+ * Search for `query`; the current match is the first on or after page
55
+ * `fromPage` (the first match when none is). Returns the number of matches.
56
+ */
57
+ search(query, fromPage = 0) {
58
+ this.matches = findMatches(this.index, query);
59
+ this.#byPage.clear();
60
+ for (const match of this.matches) {
61
+ for (const page of this.index.pagesShowing(match.story, match.start, match.end)) {
62
+ let list = this.#byPage.get(page);
63
+ if (!list)
64
+ this.#byPage.set(page, (list = []));
65
+ list.push(match);
66
+ }
67
+ }
68
+ const from = this.matches.findIndex((match) => match.page >= fromPage);
69
+ this.current = this.matches.length === 0 ? -1 : Math.max(from, 0);
70
+ return this.matches.length;
71
+ }
72
+ get match() {
73
+ return this.matches[this.current];
74
+ }
75
+ next() {
76
+ if (this.matches.length)
77
+ this.current = (this.current + 1) % this.matches.length;
78
+ }
79
+ previous() {
80
+ if (this.matches.length)
81
+ this.current = (this.current - 1 + this.matches.length) % this.matches.length;
82
+ }
83
+ clear() {
84
+ this.matches = [];
85
+ this.current = -1;
86
+ this.#byPage.clear();
87
+ }
88
+ /** Highlight boxes on page `page`: every match there, and the current one's. */
89
+ marks(page) {
90
+ const here = this.#byPage.get(page) ?? [];
91
+ const current = this.match;
92
+ return {
93
+ matches: here.flatMap((match) => this.index.highlights(matchSelection(match), page)),
94
+ current: current && here.includes(current) ? this.index.highlights(matchSelection(current), page) : [],
95
+ };
96
+ }
97
+ /** The current match's box on the page it starts on, to scroll to. */
98
+ bounds() {
99
+ const match = this.match;
100
+ if (!match)
101
+ return undefined;
102
+ const corners = this.index.highlights(matchSelection(match), match.page).flatMap((box) => {
103
+ const points = [
104
+ [box.left, box.top],
105
+ [box.right, box.bottom],
106
+ ];
107
+ return box.turn ? points.map(([x, y]) => turnedToPage(box.turn, x, y)) : points;
108
+ });
109
+ if (corners.length === 0)
110
+ return undefined;
111
+ const xs = corners.map(([x]) => x);
112
+ const ys = corners.map(([, y]) => y);
113
+ return {
114
+ page: match.page,
115
+ left: Math.min(...xs),
116
+ right: Math.max(...xs),
117
+ top: Math.min(...ys),
118
+ bottom: Math.max(...ys),
119
+ };
120
+ }
121
+ }
@@ -0,0 +1,24 @@
1
+ import type { DisplayPage, PreviewImage } from '../core/index.js';
2
+ export { canvasFont, type CanvasFontInput, fontChain, type FontChainInput } from '../core/fonts.js';
3
+ /**
4
+ * Decoded images by resource index (`ImageItem.image`): a source is drawn
5
+ * into its frame, `null` (missing or undecodable) as a placeholder box,
6
+ * `undefined` (not decoded yet) not at all.
7
+ */
8
+ export type PageImages = readonly (CanvasImageSource | null | undefined)[];
9
+ /**
10
+ * Decode every image resource of a document with `createImageBitmap`. A
11
+ * placeholder resource (`missing`) or bytes the browser cannot decode give
12
+ * `null`; the latter is reported through `onError`.
13
+ */
14
+ export declare function decodeImages(images: readonly PreviewImage[], onError?: (index: number, error: unknown) => void): Promise<(ImageBitmap | null)[]>;
15
+ /**
16
+ * Paint one page onto `ctx`, whose origin is the page's top-left corner and
17
+ * whose unit is `pixelsPerPoint` device pixels.
18
+ */
19
+ export declare function paintPage(ctx: CanvasRenderingContext2D, page: DisplayPage, pixelsPerPoint: number, images?: PageImages): void;
20
+ export * from './shell.js';
21
+ export * from './viewport.js';
22
+ export * from './selection.js';
23
+ export * from './find.js';
24
+ export type { FindBar } from './find-bar.js';