@aseity/idocs-preview 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -2
  4. package/dist/core/display.d.ts +180 -0
  5. package/dist/core/display.js +4 -0
  6. package/dist/core/engine.d.ts +9 -0
  7. package/dist/core/engine.js +81 -0
  8. package/dist/core/fonts.d.ts +27 -0
  9. package/dist/core/fonts.js +135 -0
  10. package/dist/core/index.d.ts +47 -0
  11. package/dist/core/index.js +156 -0
  12. package/dist/core/protocol.d.ts +137 -0
  13. package/dist/core/protocol.js +1 -0
  14. package/dist/core/wire.d.ts +14 -0
  15. package/dist/core/wire.js +100 -0
  16. package/dist/core/worker.d.ts +1 -0
  17. package/dist/core/worker.js +5 -0
  18. package/dist/view/find-bar.d.ts +26 -0
  19. package/dist/view/find-bar.js +117 -0
  20. package/dist/view/find.d.ts +46 -0
  21. package/dist/view/find.js +121 -0
  22. package/dist/view/index.d.ts +27 -0
  23. package/dist/view/index.js +274 -0
  24. package/dist/view/selection-layer.d.ts +27 -0
  25. package/dist/view/selection-layer.js +269 -0
  26. package/dist/view/selection.d.ts +131 -0
  27. package/dist/view/selection.js +350 -0
  28. package/dist/view/shell.d.ts +77 -0
  29. package/dist/view/shell.js +452 -0
  30. package/dist/view/viewport.d.ts +100 -0
  31. package/dist/view/viewport.js +145 -0
  32. package/package.json +49 -4
  33. package/wasm/hyphenation-licenses/LICENSE-APACHE +201 -0
  34. package/wasm/hyphenation-licenses/LICENSE-MIT +21 -0
  35. package/wasm/hyphenation-licenses/README.md +26 -0
  36. package/wasm/hyphenation-licenses/en-gb.tex +8615 -0
  37. package/wasm/hyphenation-licenses/en-us.tex +5018 -0
  38. package/wasm/hyphenation-licenses/ushyphex.tex +1454 -0
  39. package/wasm/idocs_wasm.d.ts +45 -0
  40. package/wasm/idocs_wasm.js +405 -0
  41. package/wasm/idocs_wasm_bg.wasm +0 -0
  42. package/wasm/idocs_wasm_bg.wasm.d.ts +10 -0
  43. package/wasm/package.json +1 -0
@@ -0,0 +1,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, textbox: 4, lineNumber: 5 };
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,27 @@
1
+ import type { GlyphOutline, 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 raster resources as bitmaps and SVGs as vector image elements. A
11
+ * placeholder resource (`missing`) or bytes the browser cannot decode give
12
+ * `null`; the latter is reported through `onError`.
13
+ */
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)[]>;
18
+ /**
19
+ * Paint one page onto `ctx`, whose origin is the page's top-left corner and
20
+ * whose unit is `pixelsPerPoint` device pixels.
21
+ */
22
+ export declare function paintPage(ctx: CanvasRenderingContext2D, page: DisplayPage, pixelsPerPoint: number, images?: PageImages, outlines?: readonly GlyphOutline[]): void;
23
+ export * from './shell.js';
24
+ export * from './viewport.js';
25
+ export * from './selection.js';
26
+ export * from './find.js';
27
+ export type { FindBar } from './find-bar.js';
@@ -0,0 +1,274 @@
1
+ import { packedGlyphs } from '../core/wire.js';
2
+ import { canvasFont, CANVAS_TEXT_LANGUAGE } from '../core/fonts.js';
3
+ export { canvasFont, fontChain } from '../core/fonts.js';
4
+ /** Parsed paths, kept while the page's display list lives (redraws on zoom). */
5
+ const paths = new WeakMap();
6
+ function path2d(item) {
7
+ let path = paths.get(item);
8
+ if (!path) {
9
+ path = new Path2D(item.d);
10
+ paths.set(item, path);
11
+ }
12
+ return path;
13
+ }
14
+ export function decodeImages(images, onError = (index, error) => console.warn(`image ${index} cannot be decoded`, error)) {
15
+ return Promise.all(images.map(async (image, index) => {
16
+ if (image.missing)
17
+ return null;
18
+ try {
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;
23
+ }
24
+ catch (error) {
25
+ onError(index, error);
26
+ return null;
27
+ }
28
+ }));
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
+ }
74
+ function paintImage(ctx, item, images) {
75
+ const source = images[item.image];
76
+ if (source) {
77
+ ctx.drawImage(source, item.x, item.y, item.width, item.height);
78
+ return;
79
+ }
80
+ if (source === undefined)
81
+ return;
82
+ // Placeholder: a grey box with a cross, inside the frame.
83
+ ctx.save();
84
+ ctx.fillStyle = '#f3f4f6';
85
+ ctx.fillRect(item.x, item.y, item.width, item.height);
86
+ ctx.strokeStyle = '#9ca3af';
87
+ ctx.lineWidth = 0.75;
88
+ const inset = ctx.lineWidth / 2;
89
+ ctx.strokeRect(item.x + inset, item.y + inset, item.width - 2 * inset, item.height - 2 * inset);
90
+ ctx.beginPath();
91
+ ctx.moveTo(item.x, item.y);
92
+ ctx.lineTo(item.x + item.width, item.y + item.height);
93
+ ctx.moveTo(item.x + item.width, item.y);
94
+ ctx.lineTo(item.x, item.y + item.height);
95
+ ctx.stroke();
96
+ ctx.restore();
97
+ }
98
+ function paintItems(ctx, items, images, outlines) {
99
+ for (const item of items) {
100
+ switch (item.kind) {
101
+ case 'glyphs':
102
+ ctx.fillStyle = item.color;
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);
137
+ }
138
+ else {
139
+ ctx.strokeStyle = item.color;
140
+ ctx.lineWidth = 0.5;
141
+ ctx.strokeRect(x, item.lineTop, width, item.lineHeight);
142
+ }
143
+ }
144
+ break;
145
+ case 'rect':
146
+ ctx.fillStyle = item.color;
147
+ ctx.fillRect(item.x, item.y, item.width, item.height);
148
+ break;
149
+ case 'line':
150
+ ctx.strokeStyle = item.color;
151
+ ctx.lineWidth = item.width;
152
+ ctx.beginPath();
153
+ ctx.moveTo(item.x1, item.y1);
154
+ ctx.lineTo(item.x2, item.y2);
155
+ ctx.stroke();
156
+ break;
157
+ case 'clip':
158
+ ctx.save();
159
+ ctx.beginPath();
160
+ ctx.rect(item.rect.x, item.rect.y, item.rect.width, item.rect.height);
161
+ ctx.clip();
162
+ paintItems(ctx, item.items, images, outlines);
163
+ ctx.restore();
164
+ break;
165
+ case 'turn':
166
+ ctx.save();
167
+ ctx.transform(0, 1, -1, 0, item.x, item.y);
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);
175
+ ctx.restore();
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
+ }
214
+ case 'path': {
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.
218
+ ctx.save();
219
+ if (item.clip) {
220
+ ctx.beginPath();
221
+ ctx.rect(item.clip.x, item.clip.y, item.clip.width, item.clip.height);
222
+ ctx.clip();
223
+ }
224
+ ctx.transform(...item.transform);
225
+ ctx.fillStyle = item.color;
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
+ }
243
+ ctx.restore();
244
+ break;
245
+ }
246
+ case 'image':
247
+ paintImage(ctx, item, images);
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ /**
253
+ * Paint one page onto `ctx`, whose origin is the page's top-left corner and
254
+ * whose unit is `pixelsPerPoint` device pixels.
255
+ */
256
+ export function paintPage(ctx, page, pixelsPerPoint, images = [], outlines = []) {
257
+ ctx.save();
258
+ Object.assign(ctx, { lang: CANVAS_TEXT_LANGUAGE });
259
+ ctx.setTransform(pixelsPerPoint, 0, 0, pixelsPerPoint, 0, 0);
260
+ ctx.fillStyle = '#fff';
261
+ ctx.fillRect(0, 0, page.width, page.height);
262
+ ctx.textBaseline = 'alphabetic';
263
+ ctx.textAlign = 'left';
264
+ ctx.direction = 'ltr';
265
+ paintItems(ctx, page.items, images, outlines);
266
+ ctx.restore();
267
+ }
268
+ // The shell (scrolling, zoom, virtualization, host interface): shell.ts.
269
+ export * from './shell.js';
270
+ export * from './viewport.js';
271
+ // Selection and copy (#449): the model is pure (selection.ts); the shell wires it up.
272
+ export * from './selection.js';
273
+ // The find bar (#451): the model is pure too (find.ts).
274
+ export * from './find.js';
@@ -0,0 +1,27 @@
1
+ import type { PreviewDocument } from '../core/index.js';
2
+ import { type HighlightBox, Selector, type TextSelection } from './selection.js';
3
+ import { type PageLayout } from './viewport.js';
4
+ export interface SelectionHost {
5
+ scroller: HTMLElement;
6
+ /** The positioned column that holds the page boxes. */
7
+ content: HTMLElement;
8
+ doc: PreviewDocument;
9
+ layout(): PageLayout;
10
+ zoom(): number;
11
+ /** Repaint every overlay that exists (the selection changed). */
12
+ repaint(): void;
13
+ /** Find matches to paint under the selection on page `page` (#451). */
14
+ marks(page: number): {
15
+ matches: HighlightBox[];
16
+ current: HighlightBox[];
17
+ };
18
+ }
19
+ export interface SelectionLayer {
20
+ readonly selector: Selector;
21
+ /** Focus the preview for the keyboard, selecting `selection` if given. */
22
+ select(selection: TextSelection | undefined): void;
23
+ /** Paint page `index`'s highlight onto its overlay, already sized like the page canvas. */
24
+ paint(index: number, overlay: HTMLCanvasElement, pixelsPerPoint: number): void;
25
+ destroy(): void;
26
+ }
27
+ export declare function createSelectionLayer(host: SelectionHost): SelectionLayer;