@aseity/idocs-preview 0.3.0 → 0.4.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.
@@ -1,15 +1,12 @@
1
- // The Worker side: owns the wasm instance and answers the protocol in
2
- // protocol.ts. `serve` takes any message endpoint, so Node tests run the same
3
- // code behind a MessageChannel. Layout is synchronous inside one wasm call;
4
- // resources the host has not answered yet come back as `needs`, and the open
5
- // is retried once they arrive.
6
- import init, { exportDocx, openDocument } from '../../wasm/idocs_wasm.js';
1
+ import init, { exportDocx, openDocument, WasmEditorSession } from '../../wasm/idocs_wasm.js';
7
2
  import { canvasMeasure } from './fonts.js';
3
+ const textDecoder = new TextDecoder();
8
4
  export function serve(endpoint, options = {}) {
9
5
  let ready;
10
6
  const start = (wasm = options.wasm) => (ready ??= init(wasm === undefined ? undefined : { module_or_path: wasm }));
11
7
  const measure = options.measure === undefined ? canvasMeasure() : (options.measure ?? undefined);
12
8
  const pending = new Map();
9
+ const editors = new Map();
13
10
  const post = (message, transfer = []) => endpoint.postMessage(message, transfer);
14
11
  const attempt = (id) => {
15
12
  const job = pending.get(id);
@@ -19,17 +16,7 @@ export function serve(endpoint, options = {}) {
19
16
  try {
20
17
  if (!measure)
21
18
  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
- }
19
+ outcome = job.run(measure);
33
20
  }
34
21
  catch (error) {
35
22
  pending.delete(id);
@@ -49,15 +36,79 @@ export function serve(endpoint, options = {}) {
49
36
  post({ type: 'exported', id, bytes: outcome.bytes }, [outcome.bytes.buffer]);
50
37
  return;
51
38
  }
52
- post({ type: 'opened', id, json: outcome.json, images: outcome.images, data: outcome.data }, [outcome.data.buffer, ...outcome.images.map((bytes) => bytes.buffer)]);
39
+ if (job.kind === 'editorJson') {
40
+ post({ type: 'editorJson', id, json: outcome.json });
41
+ return;
42
+ }
43
+ const transfers = [
44
+ outcome.data.buffer,
45
+ ...outcome.images.map((bytes) => bytes.buffer),
46
+ ];
47
+ if (job.kind === 'editorRendered') {
48
+ post({
49
+ type: 'editorRendered',
50
+ id,
51
+ json: outcome.json,
52
+ images: outcome.images,
53
+ data: outcome.data,
54
+ metaJson: outcome.metaJson ?? '{}',
55
+ }, transfers);
56
+ return;
57
+ }
58
+ post({ type: 'opened', id, json: outcome.json, images: outcome.images, data: outcome.data }, transfers);
59
+ };
60
+ const enqueueOpen = (message) => {
61
+ const assets = new Map();
62
+ const progressive = message.progressive ?? false;
63
+ pending.set(message.id, {
64
+ kind: 'open',
65
+ assets,
66
+ run: (activeMeasure) => {
67
+ if (progressive)
68
+ post({ type: 'pageReset', id: message.id });
69
+ const onPage = progressive
70
+ ? (packet) => {
71
+ post({ type: 'page', id: message.id, ...packet }, [packet.data.buffer, ...packet.images.map((b) => b.buffer)]);
72
+ }
73
+ : undefined;
74
+ return openDocument(message.kind, message.bytes, message.name, assets, activeMeasure, onPage);
75
+ },
76
+ });
77
+ };
78
+ const enqueueEditorCall = (id, editorId, kind, exec) => {
79
+ const existing = editors.get(editorId);
80
+ if (!existing) {
81
+ pending.set(id, {
82
+ kind,
83
+ assets: new Map(),
84
+ run: () => ({
85
+ status: 'failed',
86
+ json: JSON.stringify({ code: 'session-closed', message: 'The editor session is closed', diagnostics: [] }),
87
+ }),
88
+ });
89
+ return;
90
+ }
91
+ pending.set(id, {
92
+ kind,
93
+ assets: existing.assets,
94
+ run: (activeMeasure) => exec(existing, activeMeasure),
95
+ });
53
96
  };
54
97
  endpoint.addEventListener('message', async (event) => {
55
98
  const message = event.data;
56
99
  if (message.type === 'init') {
57
- // Start fetching early; a failure is reported to the first open.
58
100
  start(message.wasmUrl).catch(() => { });
59
101
  return;
60
102
  }
103
+ if (message.type === 'editorDispose') {
104
+ const existing = editors.get(message.editorId);
105
+ if (existing) {
106
+ existing.session.dispose();
107
+ existing.session.free?.();
108
+ editors.delete(message.editorId);
109
+ }
110
+ return;
111
+ }
61
112
  try {
62
113
  await start();
63
114
  }
@@ -67,26 +118,69 @@ export function serve(endpoint, options = {}) {
67
118
  post({ type: 'failed', id, error: { code: 'wasm-unavailable', message: String(error), diagnostics: [] } });
68
119
  return;
69
120
  }
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') {
74
- pending.set(message.id, {
75
- type: 'open',
76
- kind: message.kind,
77
- progressive: message.progressive ?? false,
78
- name: message.name,
79
- bytes: message.bytes,
80
- assets: new Map(),
81
- });
82
- }
83
- else if (message.type === 'resources') {
84
- const job = pending.get(message.id);
85
- if (!job)
86
- return;
87
- for (const { need, bytes } of message.answers) {
88
- job.assets.set(need.name, bytes);
121
+ switch (message.type) {
122
+ case 'exportDocx': {
123
+ const assets = new Map();
124
+ pending.set(message.id, {
125
+ kind: 'exportDocx',
126
+ assets,
127
+ run: (activeMeasure) => exportDocx(message.bytes, message.name, assets, activeMeasure),
128
+ });
129
+ break;
130
+ }
131
+ case 'open':
132
+ enqueueOpen(message);
133
+ break;
134
+ case 'resources': {
135
+ const job = pending.get(message.id);
136
+ if (!job)
137
+ return;
138
+ for (const { need, bytes } of message.answers) {
139
+ job.assets.set(need.name, bytes);
140
+ }
141
+ break;
142
+ }
143
+ case 'editorOpen': {
144
+ const session = new WasmEditorSession();
145
+ const assets = new Map();
146
+ editors.set(message.editorId, { session, assets });
147
+ const source = textDecoder.decode(message.bytes);
148
+ pending.set(message.id, {
149
+ kind: 'editorJson',
150
+ assets,
151
+ run: (activeMeasure) => session.open(source, message.name, assets, activeMeasure),
152
+ });
153
+ break;
154
+ }
155
+ case 'editorRange':
156
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session }) => session.range(message.revision, message.startJson, message.endJson));
157
+ break;
158
+ case 'editorApply':
159
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session, assets }, m) => session.apply(message.base, message.editJson, assets, m));
160
+ break;
161
+ case 'editorUndo':
162
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session, assets }, m) => session.undo(message.base, assets, m));
163
+ break;
164
+ case 'editorRedo':
165
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session, assets }, m) => session.redo(message.base, assets, m));
166
+ break;
167
+ case 'editorRefresh': {
168
+ const source = textDecoder.decode(message.bytes);
169
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session, assets }, m) => session.refresh(message.base, source, assets, m));
170
+ break;
89
171
  }
172
+ case 'editorMarkSaved':
173
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session }) => session.markSaved(message.revision));
174
+ break;
175
+ case 'editorSnapshot':
176
+ enqueueEditorCall(message.id, message.editorId, 'editorJson', ({ session }) => session.snapshot());
177
+ break;
178
+ case 'editorRender':
179
+ enqueueEditorCall(message.id, message.editorId, 'editorRendered', ({ session, assets }, m) => session.render(assets, m));
180
+ break;
181
+ case 'editorExportDocx':
182
+ enqueueEditorCall(message.id, message.editorId, 'editorExport', ({ session, assets }, m) => session.exportDocx(assets, m));
183
+ break;
90
184
  }
91
185
  attempt(message.id);
92
186
  });
@@ -1,16 +1,11 @@
1
- import type { DocumentKind, Endpoint, PreviewDocument, PreviewPage } from './protocol.js';
1
+ import { type OpenEditorOptions, type EditorSession, lineCaretX, locateLineSource } from './editor.js';
2
+ import { type DocumentKind, type PreviewDocument, type PreviewPage, type Read, type SessionOptions } from './protocol.js';
2
3
  export type * from './display.js';
3
4
  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
- }
5
+ export { PreviewError, type Read, type SessionOptions, } from './protocol.js';
6
+ export type { Commit, Edit, EditRange, EditorFrame, EditorSession, EditorState, ImageAlign, InsertBlock, LayoutState, LineHit, Mark, OpenEditorOptions, RenderStats, Selection, SourceSnapshot, TableAction, } from './editor.js';
7
+ export { lineCaretX, locateLineSource };
12
8
  type Bytes = Uint8Array | ArrayBuffer;
13
- export type Read = (name: string) => Bytes | null | undefined | Promise<Bytes | null | undefined>;
14
9
  export interface OpenOptions {
15
10
  /** Receive provisional pages while the worker builds the remaining display list. */
16
11
  onPage?: (page: PreviewPage) => void;
@@ -25,15 +20,6 @@ export interface OpenOptions {
25
20
  /** Reads a path relative to the source document (images). Throw or return null when absent. */
26
21
  readResource?: Read;
27
22
  }
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
23
  export type ExportOptions = Pick<OpenOptions, 'name' | 'readResource'>;
38
24
  export interface Session {
39
25
  exportDocx(bytes: Bytes, options?: ExportOptions): Promise<Uint8Array>;
@@ -45,5 +31,6 @@ export interface Session {
45
31
  */
46
32
  dispose(): void;
47
33
  }
34
+ export declare function openEditor(options: OpenEditorOptions): Promise<EditorSession>;
48
35
  export declare function createSession(options?: SessionOptions): Session;
49
36
  export type { Measure } from './fonts.js';
@@ -1,14 +1,8 @@
1
+ import { openEditorWith, lineCaretX, locateLineSource, } from './editor.js';
2
+ import { copyBytes, PreviewError, } from './protocol.js';
1
3
  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
- }
4
+ export { PreviewError, } from './protocol.js';
5
+ export { lineCaretX, locateLineSource };
12
6
  function isZip(bytes) {
13
7
  return bytes[0] === 0x50 && bytes[1] === 0x4b;
14
8
  }
@@ -18,18 +12,20 @@ function isWordFile(bytes, name) {
18
12
  OLE_MAGIC.every((byte, i) => bytes[i] === byte) ||
19
13
  /\.(docx|docm|dotx|dotm|doc)$/i.test(name ?? ''));
20
14
  }
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 ??
15
+ function createWorker(options = {}) {
16
+ return (options.worker ??
27
17
  (options.workerUrl
28
18
  ? new Worker(options.workerUrl, { type: 'module' })
29
19
  : // Literally `new Worker(new URL(...), ...)`: the form bundlers
30
20
  // (Turbopack, webpack, Vite) recognize to bundle the worker with its
31
21
  // imports. Behind `??` they copy the file as a bare asset instead.
32
- new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }));
22
+ new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })));
23
+ }
24
+ export function openEditor(options) {
25
+ return openEditorWith(createWorker(options.runtime), options);
26
+ }
27
+ export function createSession(options = {}) {
28
+ const worker = createWorker(options);
33
29
  const post = (message, transfer = []) => worker.postMessage(message, transfer);
34
30
  const jobs = new Map();
35
31
  let next = 1;
@@ -76,6 +76,27 @@ export interface PreviewDocument {
76
76
  /** For `.idoc`: what export to DOCX would lose. For DOCX: what DOCX conversion lost. */
77
77
  conversionLosses: ConversionLoss[];
78
78
  }
79
+ export type PreviewDiagnostic = Diagnostic;
80
+ export interface SessionOptions {
81
+ /** Worker script address; defaults to the bundled worker beside this module. */
82
+ workerUrl?: string | URL;
83
+ /** `.wasm` address; defaults to the file next to the wasm glue. */
84
+ wasmUrl?: string | URL;
85
+ /** An already created worker (a MessagePort works too); `workerUrl` is then ignored. */
86
+ worker?: Endpoint;
87
+ }
88
+ export type Bytes = Uint8Array | ArrayBuffer;
89
+ export type Read = (name: string) => Bytes | null | undefined | Promise<Bytes | null | undefined>;
90
+ export declare class PreviewError extends Error {
91
+ code: string;
92
+ diagnostics: Diagnostic[];
93
+ constructor(error: {
94
+ code: string;
95
+ message: string;
96
+ diagnostics: Diagnostic[];
97
+ });
98
+ }
99
+ export declare function copyBytes(bytes: Bytes): Uint8Array;
79
100
  /** A lookup core made that the host has not answered yet. */
80
101
  export type ResourceNeed = {
81
102
  kind: 'asset';
@@ -105,6 +126,61 @@ export type ToWorker = {
105
126
  need: ResourceNeed;
106
127
  bytes: Uint8Array | null;
107
128
  }[];
129
+ } | {
130
+ type: 'editorOpen';
131
+ id: number;
132
+ editorId: number;
133
+ name: string;
134
+ bytes: Uint8Array;
135
+ } | {
136
+ type: 'editorRange';
137
+ id: number;
138
+ editorId: number;
139
+ revision: string;
140
+ startJson: string;
141
+ endJson: string;
142
+ } | {
143
+ type: 'editorApply';
144
+ id: number;
145
+ editorId: number;
146
+ base: string;
147
+ editJson: string;
148
+ } | {
149
+ type: 'editorUndo';
150
+ id: number;
151
+ editorId: number;
152
+ base: string;
153
+ } | {
154
+ type: 'editorRedo';
155
+ id: number;
156
+ editorId: number;
157
+ base: string;
158
+ } | {
159
+ type: 'editorRefresh';
160
+ id: number;
161
+ editorId: number;
162
+ base: string;
163
+ bytes: Uint8Array;
164
+ } | {
165
+ type: 'editorMarkSaved';
166
+ id: number;
167
+ editorId: number;
168
+ revision: string;
169
+ } | {
170
+ type: 'editorSnapshot';
171
+ id: number;
172
+ editorId: number;
173
+ } | {
174
+ type: 'editorRender';
175
+ id: number;
176
+ editorId: number;
177
+ } | {
178
+ type: 'editorExportDocx';
179
+ id: number;
180
+ editorId: number;
181
+ } | {
182
+ type: 'editorDispose';
183
+ editorId: number;
108
184
  };
109
185
  export interface PreviewPage {
110
186
  page: DisplayPage;
@@ -135,6 +211,17 @@ export type FromWorker = {
135
211
  json: string;
136
212
  images: Uint8Array[];
137
213
  data: Uint8Array;
214
+ } | {
215
+ type: 'editorJson';
216
+ id: number;
217
+ json: string;
218
+ } | {
219
+ type: 'editorRendered';
220
+ id: number;
221
+ json: string;
222
+ images: Uint8Array[];
223
+ data: Uint8Array;
224
+ metaJson: string;
138
225
  } | {
139
226
  type: 'failed';
140
227
  id: number;
@@ -1 +1,13 @@
1
- export {};
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
+ export function copyBytes(bytes) {
12
+ return new Uint8Array(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
13
+ }
@@ -0,0 +1,80 @@
1
+ import { type Anchor, type DisplayPage, type EditRange, type FlowId, type GeneratedRole, type ReadOnlyReason } from '../core/index.js';
2
+ import { type HighlightBox, type PagePoint } from './selection.js';
3
+ export interface CaretBox {
4
+ readonly page: number;
5
+ readonly x: number;
6
+ readonly top: number;
7
+ readonly bottom: number;
8
+ readonly baseline: number;
9
+ readonly height: number;
10
+ readonly turn?: [number, number];
11
+ }
12
+ export interface ReadOnlyTarget {
13
+ readonly page: number;
14
+ readonly path: string;
15
+ readonly reason: ReadOnlyReason;
16
+ readonly role?: GeneratedRole;
17
+ readonly box: HighlightBox;
18
+ readonly before?: Anchor;
19
+ readonly after?: Anchor;
20
+ }
21
+ export type EditPointHit = {
22
+ kind: 'editable';
23
+ anchor: Anchor;
24
+ caret: CaretBox;
25
+ page: number;
26
+ path: string;
27
+ } | {
28
+ kind: 'readOnly';
29
+ target: ReadOnlyTarget;
30
+ } | {
31
+ kind: 'empty';
32
+ };
33
+ export declare class EditDocumentIndex {
34
+ #private;
35
+ constructor(pages: readonly DisplayPage[]);
36
+ flowPath(flow: FlowId): string | undefined;
37
+ firstFlow(): FlowId | undefined;
38
+ flowBounds(flow: FlowId): {
39
+ start: Anchor;
40
+ end: Anchor;
41
+ } | undefined;
42
+ locatePoint(point: PagePoint): EditPointHit;
43
+ locatePointInFlow(flow: FlowId, point: PagePoint): Anchor | undefined;
44
+ wordRangeInFlow(flow: FlowId, point: PagePoint): {
45
+ start: Anchor;
46
+ end: Anchor;
47
+ } | undefined;
48
+ selectionPageBounds(range: EditRange): {
49
+ page: number;
50
+ bounds: {
51
+ left: number;
52
+ right: number;
53
+ top: number;
54
+ bottom: number;
55
+ };
56
+ } | undefined;
57
+ caretForAnchor(anchor: Anchor): CaretBox | undefined;
58
+ highlightsForRange(range: EditRange, page: number): HighlightBox[];
59
+ selectedText(range: EditRange): string;
60
+ neighborStop(anchor: Anchor, direction: 'backward' | 'forward'): Anchor | undefined;
61
+ moveHorizontal(anchor: Anchor, direction: 'backward' | 'forward', granularity?: 'character' | 'word'): Anchor;
62
+ moveVertical(anchor: Anchor, direction: 'up' | 'down', preferredX?: number): {
63
+ anchor: Anchor;
64
+ preferredX: number;
65
+ };
66
+ moveLineBoundary(anchor: Anchor, edge: 'start' | 'end'): Anchor;
67
+ wordRangeAt(point: PagePoint): {
68
+ start: Anchor;
69
+ end: Anchor;
70
+ } | undefined;
71
+ flowRangeAt(point: PagePoint): {
72
+ start: Anchor;
73
+ end: Anchor;
74
+ } | undefined;
75
+ }
76
+ export declare function orderedAnchors(a: Anchor, b: Anchor): {
77
+ start: Anchor;
78
+ end: Anchor;
79
+ reversed: boolean;
80
+ };