@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.
@@ -1,4 +1,4 @@
1
- import type { DisplayPage } from './display.js';
1
+ import type { DisplayPage, GlyphOutline } from './display.js';
2
2
  export type DocumentKind = 'idoc' | 'docx';
3
3
  /**
4
4
  * Either end of the channel: a Worker (or a MessagePort) on the session side,
@@ -40,7 +40,7 @@ export interface ConversionLoss {
40
40
  [key: string]: unknown;
41
41
  }
42
42
  export interface PreviewImage {
43
- format: 'png' | 'jpeg';
43
+ format: 'png' | 'jpeg' | 'svg+xml';
44
44
  bytes: Uint8Array;
45
45
  /**
46
46
  * A placeholder for an image `readResource` could not give or core could
@@ -48,9 +48,25 @@ export interface PreviewImage {
48
48
  * warning. Draw a placeholder box rather than `bytes`.
49
49
  */
50
50
  missing?: boolean;
51
+ /** Draw washed out, as Word's picture watermark: each channel `min(255, 205 + 0.3 × value)`. */
52
+ washout?: boolean;
51
53
  }
52
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
+ }
53
67
  export interface PreviewDocument {
68
+ fonts?: FontUse[];
69
+ glyphOutlines?: GlyphOutline[];
54
70
  pageCount: number;
55
71
  pages: DisplayPage[];
56
72
  images: PreviewImage[];
@@ -64,9 +80,6 @@ export interface PreviewDocument {
64
80
  export type ResourceNeed = {
65
81
  kind: 'asset';
66
82
  name: string;
67
- } | {
68
- kind: 'font';
69
- name: string;
70
83
  };
71
84
  export type ToWorker = {
72
85
  type: 'init';
@@ -77,6 +90,7 @@ export type ToWorker = {
77
90
  kind: DocumentKind;
78
91
  name: string;
79
92
  bytes: Uint8Array;
93
+ progressive?: boolean;
80
94
  }
81
95
  /** Answers to a `needs` message; `bytes: null` means the host has none. */
82
96
  | {
@@ -87,14 +101,31 @@ export type ToWorker = {
87
101
  bytes: Uint8Array | null;
88
102
  }[];
89
103
  };
104
+ export interface PreviewPage {
105
+ page: DisplayPage;
106
+ images: PreviewImage[];
107
+ glyphOutlines: GlyphOutline[];
108
+ pageCount: number;
109
+ }
90
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
+ } | {
91
120
  type: 'needs';
92
121
  id: number;
93
122
  needs: ResourceNeed[];
94
123
  } | {
95
124
  type: 'opened';
96
125
  id: number;
97
- document: PreviewDocument;
126
+ json: string;
127
+ images: Uint8Array[];
128
+ data: Uint8Array;
98
129
  } | {
99
130
  type: 'failed';
100
131
  id: number;
@@ -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
+ }
package/dist/view/find.js CHANGED
@@ -4,7 +4,7 @@ export function matchSelection(match) {
4
4
  return { story: match.story, start: match.start, end: match.end, reversed: false };
5
5
  }
6
6
  /** Reading order of the stories on one page. */
7
- const RANK = { header: 0, body: 1, footnotes: 2, footer: 3 };
7
+ const RANK = { header: 0, body: 1, footnotes: 2, footer: 3, textbox: 4, lineNumber: 5 };
8
8
  function rank(story) {
9
9
  return RANK[storyKind(story)];
10
10
  }
@@ -1,4 +1,4 @@
1
- import type { DisplayPage, PreviewImage } from '../core/index.js';
1
+ import type { GlyphOutline, DisplayPage, PreviewImage } from '../core/index.js';
2
2
  export { canvasFont, type CanvasFontInput, fontChain, type FontChainInput } from '../core/fonts.js';
3
3
  /**
4
4
  * Decoded images by resource index (`ImageItem.image`): a source is drawn
@@ -7,16 +7,19 @@ export { canvasFont, type CanvasFontInput, fontChain, type FontChainInput } from
7
7
  */
8
8
  export type PageImages = readonly (CanvasImageSource | null | undefined)[];
9
9
  /**
10
- * Decode every image resource of a document with `createImageBitmap`. A
10
+ * Decode raster resources as bitmaps and SVGs as vector image elements. A
11
11
  * placeholder resource (`missing`) or bytes the browser cannot decode give
12
12
  * `null`; the latter is reported through `onError`.
13
13
  */
14
- export declare function decodeImages(images: readonly PreviewImage[], onError?: (index: number, error: unknown) => void): Promise<(ImageBitmap | null)[]>;
14
+ export type DecodedImage = ImageBitmap | (HTMLImageElement & {
15
+ close(): void;
16
+ });
17
+ export declare function decodeImages(images: readonly PreviewImage[], onError?: (index: number, error: unknown) => void): Promise<(DecodedImage | null)[]>;
15
18
  /**
16
19
  * Paint one page onto `ctx`, whose origin is the page's top-left corner and
17
20
  * whose unit is `pixelsPerPoint` device pixels.
18
21
  */
19
- export declare function paintPage(ctx: CanvasRenderingContext2D, page: DisplayPage, pixelsPerPoint: number, images?: PageImages): void;
22
+ export declare function paintPage(ctx: CanvasRenderingContext2D, page: DisplayPage, pixelsPerPoint: number, images?: PageImages, outlines?: readonly GlyphOutline[]): void;
20
23
  export * from './shell.js';
21
24
  export * from './viewport.js';
22
25
  export * from './selection.js';
@@ -1,4 +1,5 @@
1
- import { canvasFont } from '../core/fonts.js';
1
+ import { packedGlyphs } from '../core/wire.js';
2
+ import { canvasFont, CANVAS_TEXT_LANGUAGE } from '../core/fonts.js';
2
3
  export { canvasFont, fontChain } from '../core/fonts.js';
3
4
  /** Parsed paths, kept while the page's display list lives (redraws on zoom). */
4
5
  const paths = new WeakMap();
@@ -10,17 +11,15 @@ function path2d(item) {
10
11
  }
11
12
  return path;
12
13
  }
13
- /**
14
- * Decode every image resource of a document with `createImageBitmap`. A
15
- * placeholder resource (`missing`) or bytes the browser cannot decode give
16
- * `null`; the latter is reported through `onError`.
17
- */
18
14
  export function decodeImages(images, onError = (index, error) => console.warn(`image ${index} cannot be decoded`, error)) {
19
15
  return Promise.all(images.map(async (image, index) => {
20
16
  if (image.missing)
21
17
  return null;
22
18
  try {
23
- return await createImageBitmap(new Blob([image.bytes], { type: `image/${image.format}` }));
19
+ if (image.format === 'svg+xml')
20
+ return await decodeSvg(image.bytes);
21
+ const bitmap = await createImageBitmap(new Blob([image.bytes], { type: `image/${image.format}` }));
22
+ return image.washout ? await washedOut(bitmap) : bitmap;
24
23
  }
25
24
  catch (error) {
26
25
  onError(index, error);
@@ -28,6 +27,50 @@ export function decodeImages(images, onError = (index, error) => console.warn(`i
28
27
  }
29
28
  }));
30
29
  }
30
+ /** Keep vectors available for every Canvas zoom; ImageBitmap cannot decode SVG. */
31
+ async function decodeSvg(bytes) {
32
+ const xml = new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'image/svg+xml');
33
+ const root = xml.documentElement;
34
+ // Browsers give viewBox-only image documents a 300×150 viewport. Match the
35
+ // source's intrinsic ratio instead, including when just one dimension is set.
36
+ const box = root.viewBox.baseVal;
37
+ const width = root.getAttribute('width');
38
+ const height = root.getAttribute('height');
39
+ if (box.width > 0 && box.height > 0) {
40
+ if (!width)
41
+ root.setAttribute('width', String(height ? root.height.baseVal.value * box.width / box.height : box.width));
42
+ if (!height)
43
+ root.setAttribute('height', String(width ? root.width.baseVal.value * box.height / box.width : box.height));
44
+ }
45
+ const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(xml)], { type: 'image/svg+xml' }));
46
+ const image = Object.assign(new Image(), { close: () => URL.revokeObjectURL(url) });
47
+ image.src = url;
48
+ try {
49
+ await image.decode();
50
+ return image;
51
+ }
52
+ catch (error) {
53
+ image.close();
54
+ throw error;
55
+ }
56
+ }
57
+ /** Word's washout (#473): each channel `min(255, 205 + 0.3 × value)`. */
58
+ async function washedOut(bitmap) {
59
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
60
+ const ctx = canvas.getContext('2d');
61
+ if (!ctx)
62
+ return bitmap;
63
+ ctx.drawImage(bitmap, 0, 0);
64
+ const pixels = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
65
+ const data = pixels.data;
66
+ for (let i = 0; i < data.length; i += 4) {
67
+ for (let k = 0; k < 3; k++)
68
+ data[i + k] = Math.min(255, Math.round(205 + 0.3 * data[i + k]));
69
+ }
70
+ ctx.putImageData(pixels, 0, 0);
71
+ bitmap.close();
72
+ return createImageBitmap(canvas);
73
+ }
31
74
  function paintImage(ctx, item, images) {
32
75
  const source = images[item.image];
33
76
  if (source) {
@@ -52,23 +95,50 @@ function paintImage(ctx, item, images) {
52
95
  ctx.stroke();
53
96
  ctx.restore();
54
97
  }
55
- function paintItems(ctx, items, images) {
98
+ function paintItems(ctx, items, images, outlines) {
56
99
  for (const item of items) {
57
100
  switch (item.kind) {
58
101
  case 'glyphs':
59
- ctx.font = canvasFont(item);
60
102
  ctx.fillStyle = item.color;
61
- // Every glyph at the x its metric profile gives, never scaled. A
62
- // character nothing could measure gets a box over its estimated width,
63
- // as the native snapshot draws a missing glyph.
64
- for (const glyph of item.glyphs) {
65
- if (glyph.unmeasured) {
66
- ctx.strokeStyle = item.color;
67
- ctx.lineWidth = 0.5;
68
- ctx.strokeRect(glyph.x, item.lineTop, glyph.width, item.lineHeight);
103
+ ctx.font = canvasFont(item);
104
+ ctx.fontKerning = 'none';
105
+ // File mode supplies outlines; browser mode shares the measurement CSS.
106
+ const packed = packedGlyphs(item.glyphs);
107
+ const count = packed?.count ?? item.glyphs.length;
108
+ for (let i = 0; i < count; i++) {
109
+ const glyph = packed ? undefined : item.glyphs[i];
110
+ const x = packed ? packed.f64[i * 4] : glyph.x;
111
+ const width = packed ? packed.f64[i * 4 + 1] : glyph.width;
112
+ const text = packed ? packed.texts[packed.u32[i * 8 + 5]] : glyph.text;
113
+ const unmeasured = packed ? (packed.u32[i * 8 + 6] & 1) !== 0 : glyph.unmeasured;
114
+ const shapes = packed ? packed.shapes[packed.u32[i * 8 + 7] - 1] : glyph.shapes;
115
+ if (shapes?.length) {
116
+ for (const shape of shapes) {
117
+ const outline = outlines[shape.outline];
118
+ if (!outline)
119
+ throw new Error(`Missing glyph outline ${shape.outline}`);
120
+ ctx.save();
121
+ const sx = item.scaleX ?? 1;
122
+ ctx.transform(item.size * sx, 0, item.size * (item.syntheticSlant ?? 0) * sx, -item.size, x + shape.xOffset * item.size * sx, item.baseline - shape.yOffset * item.size);
123
+ ctx.fill(path2d(outline));
124
+ ctx.restore();
125
+ }
126
+ }
127
+ else if (!unmeasured) {
128
+ if (item.scaleX !== undefined) {
129
+ ctx.save();
130
+ ctx.translate(x, item.baseline);
131
+ ctx.scale(item.scaleX, 1);
132
+ ctx.fillText(text, 0, 0);
133
+ ctx.restore();
134
+ }
135
+ else
136
+ ctx.fillText(text, x, item.baseline);
69
137
  }
70
138
  else {
71
- ctx.fillText(glyph.text, glyph.x, item.baseline);
139
+ ctx.strokeStyle = item.color;
140
+ ctx.lineWidth = 0.5;
141
+ ctx.strokeRect(x, item.lineTop, width, item.lineHeight);
72
142
  }
73
143
  }
74
144
  break;
@@ -89,18 +159,62 @@ function paintItems(ctx, items, images) {
89
159
  ctx.beginPath();
90
160
  ctx.rect(item.rect.x, item.rect.y, item.rect.width, item.rect.height);
91
161
  ctx.clip();
92
- paintItems(ctx, item.items, images);
162
+ paintItems(ctx, item.items, images, outlines);
93
163
  ctx.restore();
94
164
  break;
95
165
  case 'turn':
96
166
  ctx.save();
97
167
  ctx.transform(0, 1, -1, 0, item.x, item.y);
98
- paintItems(ctx, item.items, images);
168
+ paintItems(ctx, item.items, images, outlines);
169
+ ctx.restore();
170
+ break;
171
+ case 'transform':
172
+ ctx.save();
173
+ ctx.transform(...item.matrix);
174
+ paintItems(ctx, item.items, images, outlines);
99
175
  ctx.restore();
100
176
  break;
177
+ case 'shape': {
178
+ const path = path2d(item);
179
+ if (item.fill !== undefined) {
180
+ if (typeof item.fill === 'string') {
181
+ ctx.fillStyle = item.fill;
182
+ }
183
+ else {
184
+ const paint = item.fill;
185
+ const scale = 4;
186
+ const tile = new OffscreenCanvas(paint.width * scale, paint.height * scale);
187
+ const brush = tile.getContext('2d');
188
+ if (!brush)
189
+ throw new Error('Pattern canvas context unavailable');
190
+ brush.scale(scale, scale);
191
+ brush.fillStyle = paint.background;
192
+ brush.fillRect(0, 0, paint.width, paint.height);
193
+ brush.fillStyle = paint.foreground;
194
+ for (const rectangle of paint.rects)
195
+ brush.fillRect(...rectangle);
196
+ const pattern = ctx.createPattern(tile, 'repeat');
197
+ if (!pattern)
198
+ throw new Error('Pattern fill unavailable');
199
+ pattern.setTransform(new DOMMatrix().scale(1 / scale));
200
+ ctx.fillStyle = pattern;
201
+ }
202
+ ctx.fill(path);
203
+ }
204
+ if (item.stroke) {
205
+ ctx.save();
206
+ ctx.strokeStyle = item.stroke.color;
207
+ ctx.lineWidth = item.stroke.width;
208
+ ctx.setLineDash(item.stroke.dash ?? []);
209
+ ctx.stroke(path);
210
+ ctx.restore();
211
+ }
212
+ break;
213
+ }
101
214
  case 'path': {
102
- // Formula glyph outlines and stretchy delimiters come with the display
103
- // list (KaTeX outlines in core), so no math font is needed here.
215
+ // Formula glyph outlines, stretchy delimiters and emphasis marks come
216
+ // with the display list (KaTeX outlines in core), so no math font is
217
+ // needed here.
104
218
  ctx.save();
105
219
  if (item.clip) {
106
220
  ctx.beginPath();
@@ -109,7 +223,23 @@ function paintItems(ctx, items, images) {
109
223
  }
110
224
  ctx.transform(...item.transform);
111
225
  ctx.fillStyle = item.color;
112
- ctx.fill(path2d(item));
226
+ const browserFont = item.glyph?.text?.browser;
227
+ if (browserFont) {
228
+ // Formula coordinates use an em square with an upward y axis.
229
+ ctx.scale(1 / 100, -1 / 100);
230
+ ctx.font = canvasFont({ ...browserFont, role: 'serif', size: 100 });
231
+ ctx.fontKerning = 'none';
232
+ ctx.fillText(item.glyph.char, 0, 0);
233
+ }
234
+ else {
235
+ ctx.fill(path2d(item));
236
+ }
237
+ const stroke = item.glyph?.text?.standIn.strokeEm;
238
+ if (stroke && item.transform[3] !== 0) {
239
+ ctx.strokeStyle = item.color;
240
+ ctx.lineWidth = stroke * item.glyph.size / Math.abs(item.transform[3]);
241
+ ctx.stroke(path2d(item));
242
+ }
113
243
  ctx.restore();
114
244
  break;
115
245
  }
@@ -123,15 +253,16 @@ function paintItems(ctx, items, images) {
123
253
  * Paint one page onto `ctx`, whose origin is the page's top-left corner and
124
254
  * whose unit is `pixelsPerPoint` device pixels.
125
255
  */
126
- export function paintPage(ctx, page, pixelsPerPoint, images = []) {
256
+ export function paintPage(ctx, page, pixelsPerPoint, images = [], outlines = []) {
127
257
  ctx.save();
258
+ Object.assign(ctx, { lang: CANVAS_TEXT_LANGUAGE });
128
259
  ctx.setTransform(pixelsPerPoint, 0, 0, pixelsPerPoint, 0, 0);
129
260
  ctx.fillStyle = '#fff';
130
261
  ctx.fillRect(0, 0, page.width, page.height);
131
262
  ctx.textBaseline = 'alphabetic';
132
263
  ctx.textAlign = 'left';
133
264
  ctx.direction = 'ltr';
134
- paintItems(ctx, page.items, images);
265
+ paintItems(ctx, page.items, images, outlines);
135
266
  ctx.restore();
136
267
  }
137
268
  // The shell (scrolling, zoom, virtualization, host interface): shell.ts.
@@ -1,29 +1,30 @@
1
+ import { glyphX, glyphOffset } from '../core/wire.js';
1
2
  function textLength(line) {
2
3
  return line.text.length;
3
4
  }
4
5
  /** Right edge of glyph `k`: the next glyph's x, the last one's at the line end. */
5
6
  function glyphEnd(line, k) {
6
- return k + 1 < line.glyphs.length ? line.glyphs[k + 1].x : line.x + line.width;
7
+ return k + 1 < line.glyphs.length ? glyphX(line.glyphs, k + 1) : line.x + line.width;
7
8
  }
8
9
  /** Offset of glyph `k`'s end within the line text. */
9
10
  function glyphEndOffset(line, k) {
10
- return k + 1 < line.glyphs.length ? line.glyphs[k + 1].offset : textLength(line);
11
+ return k + 1 < line.glyphs.length ? glyphOffset(line.glyphs, k + 1) : textLength(line);
11
12
  }
12
13
  /** Offset (in the line text) of the glyph whose box holds `x`, or undefined outside the glyphs. */
13
14
  export function indexForX(line, x) {
14
15
  for (let k = 0; k < line.glyphs.length; k++) {
15
- if (x >= line.glyphs[k].x && x < glyphEnd(line, k))
16
- return line.glyphs[k].offset;
16
+ if (x >= glyphX(line.glyphs, k) && x < glyphEnd(line, k))
17
+ return glyphOffset(line.glyphs, k);
17
18
  }
18
19
  return undefined;
19
20
  }
20
21
  /** Offset (in the line text) of the glyph boundary nearest `x`: left half of a glyph before it, right half after. */
21
22
  export function closestIndexForX(line, x) {
22
23
  for (let k = 0; k < line.glyphs.length; k++) {
23
- const start = line.glyphs[k].x;
24
+ const start = glyphX(line.glyphs, k);
24
25
  const end = glyphEnd(line, k);
25
26
  if (x < (start + end) / 2)
26
- return line.glyphs[k].offset;
27
+ return glyphOffset(line.glyphs, k);
27
28
  }
28
29
  return textLength(line);
29
30
  }
@@ -31,7 +32,7 @@ export function closestIndexForX(line, x) {
31
32
  export function xForIndex(line, index) {
32
33
  for (let k = 0; k < line.glyphs.length; k++) {
33
34
  if (index < glyphEndOffset(line, k))
34
- return line.glyphs[k].x;
35
+ return glyphX(line.glyphs, k);
35
36
  }
36
37
  return line.x + line.width;
37
38
  }
@@ -45,6 +45,8 @@ export interface PreviewOptions extends ViewOptions {
45
45
  session?: SessionOptions;
46
46
  /** Shown while layout runs. */
47
47
  loadingText?: string;
48
+ /** Show the first page while later display pages are generated. Selection becomes available when open completes. */
49
+ progressive?: boolean;
48
50
  }
49
51
  export type PreviewState = 'empty' | 'loading' | 'ready' | 'error';
50
52
  export interface Preview {
@@ -153,7 +153,7 @@ export function mountPreview(container, doc, options = {}) {
153
153
  const ctx = canvas.getContext('2d');
154
154
  slot.pixelsPerPoint = PX_PER_PT * zoom * res.scale;
155
155
  if (ctx)
156
- paintPage(ctx, doc.pages[index], slot.pixelsPerPoint, images);
156
+ paintPage(ctx, doc.pages[index], slot.pixelsPerPoint, images, doc.glyphOutlines);
157
157
  const { overlay } = slot;
158
158
  overlay.width = res.width;
159
159
  overlay.height = res.height;
@@ -321,6 +321,16 @@ export function createPreview(container, options = {}) {
321
321
  let view;
322
322
  let latest = 0;
323
323
  let destroyed = false;
324
+ let firstPage;
325
+ let firstImages = [];
326
+ const clearFirstPage = () => {
327
+ if (firstPage)
328
+ freeCanvas(firstPage);
329
+ firstPage = undefined;
330
+ for (const image of firstImages)
331
+ image?.close();
332
+ firstImages = [];
333
+ };
324
334
  const root = element('div', 'idocs-preview');
325
335
  Object.assign(root.style, { height: '100%', position: 'relative' });
326
336
  const status = element('div', 'idocs-preview-status');
@@ -340,13 +350,50 @@ export function createPreview(container, options = {}) {
340
350
  if (destroyed)
341
351
  throw new Error('the preview was destroyed');
342
352
  const ticket = ++latest;
353
+ clearFirstPage();
343
354
  view?.destroy();
344
355
  view = undefined;
345
356
  doc = undefined;
346
357
  setState('loading', options.loadingText ?? '正在排版…');
347
358
  try {
348
- const opened = await session.open(bytes, openOptions);
359
+ const opened = await session.open(bytes, {
360
+ ...openOptions,
361
+ onPage: options.progressive ? (part) => {
362
+ openOptions?.onPage?.(part);
363
+ if (ticket !== latest || destroyed || part.page.number !== 1)
364
+ return;
365
+ clearFirstPage();
366
+ const canvas = element('canvas', 'idocs-preview-first-page');
367
+ firstPage = canvas;
368
+ const scale = Math.min(zoom * PX_PER_PT, Math.max(1, root.clientWidth - 32) / part.page.width);
369
+ const resolution = canvasResolution(part.page.width * scale, part.page.height * scale, globalThis.devicePixelRatio || 1, options.maxCanvasPixels);
370
+ canvas.width = resolution.width;
371
+ canvas.height = resolution.height;
372
+ Object.assign(canvas.style, {
373
+ width: `${resolution.width / resolution.scale}px`,
374
+ height: `${resolution.height / resolution.scale}px`,
375
+ display: 'block', margin: '0 auto',
376
+ });
377
+ const paint = (images) => {
378
+ const context = canvas.getContext('2d');
379
+ if (context)
380
+ paintPage(context, part.page, scale * resolution.scale, images, part.glyphOutlines);
381
+ };
382
+ paint([]);
383
+ root.append(canvas);
384
+ void decodeImages(part.images).then((images) => {
385
+ if (firstPage !== canvas || ticket !== latest || destroyed) {
386
+ for (const image of images)
387
+ image?.close();
388
+ return;
389
+ }
390
+ firstImages = images;
391
+ paint(images);
392
+ });
393
+ } : openOptions?.onPage,
394
+ });
349
395
  if (ticket === latest && !destroyed) {
396
+ clearFirstPage();
350
397
  setState('ready');
351
398
  doc = opened;
352
399
  view = mountPreview(root, opened, {
@@ -361,8 +408,10 @@ export function createPreview(container, options = {}) {
361
408
  return opened;
362
409
  }
363
410
  catch (error) {
364
- if (ticket === latest && !destroyed)
411
+ if (ticket === latest && !destroyed) {
412
+ clearFirstPage();
365
413
  setState('error', error.message);
414
+ }
366
415
  throw error;
367
416
  }
368
417
  },
@@ -392,6 +441,7 @@ export function createPreview(container, options = {}) {
392
441
  if (destroyed)
393
442
  return;
394
443
  destroyed = true;
444
+ clearFirstPage();
395
445
  view?.destroy();
396
446
  view = undefined;
397
447
  doc = undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aseity/idocs-preview",
3
- "version": "0.1.0",
4
- "description": "Read-only browser preview for DOCX and iDocs source documents: Word-calibrated layout in a Web Worker (WASM) and a Canvas page view.",
3
+ "version": "0.2.0",
4
+ "description": "Read-only browser preview for DOCX and iDocs source documents: Browser font measurements with layout in a Web Worker (WASM) and drawing in a Canvas page view.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -37,7 +37,7 @@
37
37
  "dev": "vite dev",
38
38
  "changeset": "changeset",
39
39
  "release:version": "changeset version && npm install --package-lock-only",
40
- "release:check": "npm run typecheck && npm test && node scripts/release-pack.mjs"
40
+ "release:check": "node scripts/release-pack.mjs"
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=24"