@aseity/idocs-preview 0.1.0 → 0.3.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,3 +1,4 @@
1
+ import { decodeCompact } from './wire.js';
1
2
  export class PreviewError extends Error {
2
3
  code;
3
4
  diagnostics;
@@ -51,10 +52,33 @@ export function createSession(options = {}) {
51
52
  const job = jobs.get(message.id);
52
53
  if (!job)
53
54
  return;
55
+ if (message.type === 'pageReset' && job.kind === 'open') {
56
+ job.pages = [];
57
+ job.images = [];
58
+ job.previewImages = [];
59
+ job.outlines = [];
60
+ return;
61
+ }
62
+ if (message.type === 'page' && job.kind === 'open') {
63
+ try {
64
+ job.images.push(...message.images);
65
+ const partial = decodeCompact(message.json, message.data, message.images);
66
+ job.previewImages.push(...partial.images);
67
+ job.outlines.push(...partial.glyphOutlines ?? []);
68
+ const page = partial.pages[0];
69
+ job.pages.push(page);
70
+ job.options.onPage?.({ page, images: job.previewImages, glyphOutlines: job.outlines, pageCount: partial.pageCount });
71
+ }
72
+ catch (error) {
73
+ jobs.delete(message.id);
74
+ job.reject(new PreviewError({ code: 'internal-error', message: String(error), diagnostics: [] }));
75
+ }
76
+ return;
77
+ }
54
78
  if (message.type === 'needs') {
55
79
  const answers = await Promise.all(message.needs.map(async (need) => ({
56
80
  need,
57
- bytes: await answer(need.kind === 'font' ? options.readFont : job.options.readResource, need.name),
81
+ bytes: await answer(job.options.readResource, need.name),
58
82
  })));
59
83
  if (disposed)
60
84
  return;
@@ -62,10 +86,27 @@ export function createSession(options = {}) {
62
86
  return;
63
87
  }
64
88
  jobs.delete(message.id);
65
- if (message.type === 'opened')
66
- job.resolve(message.document);
67
- else
89
+ if (message.type === 'exported' && job.kind === 'export') {
90
+ job.resolve(message.bytes);
91
+ }
92
+ else if (message.type === 'opened' && job.kind === 'open') {
93
+ try {
94
+ const doc = decodeCompact(message.json, message.data, job.options.onPage ? job.images : message.images);
95
+ if (job.options.onPage) {
96
+ if (job.pages.length !== doc.pageCount)
97
+ throw new Error('Preview page count mismatch');
98
+ doc.pages = job.pages;
99
+ }
100
+ job.resolve(doc);
101
+ }
102
+ catch (error) {
103
+ job.reject(new PreviewError({ code: 'internal-error', message: String(error), diagnostics: [] }));
104
+ }
105
+ }
106
+ else if (message.type === 'failed')
68
107
  job.reject(new PreviewError(message.error));
108
+ else
109
+ job.reject(new PreviewError({ code: 'internal-error', message: 'Unexpected worker response', diagnostics: [] }));
69
110
  });
70
111
  // A worker script that fails to load or run, or a message that cannot be
71
112
  // deserialized, leaves no reply to wait for: give up on the worker.
@@ -88,6 +129,18 @@ export function createSession(options = {}) {
88
129
  worker.start?.();
89
130
  post({ type: 'init', wasmUrl: options.wasmUrl?.toString() });
90
131
  return {
132
+ exportDocx(input, exportOptions = {}) {
133
+ if (disposed)
134
+ return Promise.reject(new Error('The preview session is disposed'));
135
+ if (broken)
136
+ return Promise.reject(broken);
137
+ const bytes = copyBytes(input);
138
+ const id = next++;
139
+ return new Promise((resolve, reject) => {
140
+ jobs.set(id, { kind: 'export', options: exportOptions, resolve, reject });
141
+ post({ type: 'exportDocx', id, name: exportOptions.name ?? 'document.idoc', bytes }, [bytes.buffer]);
142
+ });
143
+ },
91
144
  open(input, openOptions = {}) {
92
145
  if (disposed)
93
146
  return Promise.reject(new Error('The preview session is disposed'));
@@ -96,9 +149,10 @@ export function createSession(options = {}) {
96
149
  const bytes = copyBytes(input);
97
150
  const id = next++;
98
151
  return new Promise((resolve, reject) => {
99
- jobs.set(id, { options: openOptions, resolve, reject });
152
+ jobs.set(id, { kind: 'open', options: openOptions, resolve, reject, pages: [], images: [], previewImages: [], outlines: [] });
100
153
  post({
101
154
  type: 'open',
155
+ progressive: openOptions.onPage !== undefined,
102
156
  id,
103
157
  kind: openOptions.kind ?? (isWordFile(bytes, openOptions.name) ? 'docx' : 'idoc'),
104
158
  name: openOptions.name ?? 'document.idoc',
@@ -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,19 +80,22 @@ 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';
73
86
  wasmUrl?: string;
87
+ } | {
88
+ type: 'exportDocx';
89
+ id: number;
90
+ name: string;
91
+ bytes: Uint8Array;
74
92
  } | {
75
93
  type: 'open';
76
94
  id: number;
77
95
  kind: DocumentKind;
78
96
  name: string;
79
97
  bytes: Uint8Array;
98
+ progressive?: boolean;
80
99
  }
81
100
  /** Answers to a `needs` message; `bytes: null` means the host has none. */
82
101
  | {
@@ -87,14 +106,35 @@ export type ToWorker = {
87
106
  bytes: Uint8Array | null;
88
107
  }[];
89
108
  };
109
+ export interface PreviewPage {
110
+ page: DisplayPage;
111
+ images: PreviewImage[];
112
+ glyphOutlines: GlyphOutline[];
113
+ pageCount: number;
114
+ }
90
115
  export type FromWorker = {
116
+ type: 'pageReset';
117
+ id: number;
118
+ } | {
119
+ type: 'page';
120
+ id: number;
121
+ json: string;
122
+ data: Uint8Array;
123
+ images: Uint8Array[];
124
+ } | {
91
125
  type: 'needs';
92
126
  id: number;
93
127
  needs: ResourceNeed[];
128
+ } | {
129
+ type: 'exported';
130
+ id: number;
131
+ bytes: Uint8Array;
94
132
  } | {
95
133
  type: 'opened';
96
134
  id: number;
97
- document: PreviewDocument;
135
+ json: string;
136
+ images: Uint8Array[];
137
+ data: Uint8Array;
98
138
  } | {
99
139
  type: 'failed';
100
140
  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 {