@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.
- package/CHANGELOG.md +40 -0
- package/README.md +142 -14
- package/dist/core/display.d.ts +68 -6
- package/dist/core/engine.d.ts +1 -5
- package/dist/core/engine.js +27 -14
- package/dist/core/fonts.d.ts +10 -11
- package/dist/core/fonts.js +28 -14
- package/dist/core/index.d.ts +5 -8
- package/dist/core/index.js +59 -5
- package/dist/core/protocol.d.ts +46 -6
- package/dist/core/wire.d.ts +14 -0
- package/dist/core/wire.js +100 -0
- package/dist/view/find.js +1 -1
- package/dist/view/index.d.ts +7 -4
- package/dist/view/index.js +156 -25
- package/dist/view/selection.js +8 -7
- package/dist/view/shell.d.ts +2 -0
- package/dist/view/shell.js +53 -3
- package/package.json +3 -3
- package/wasm/hyphenation-licenses/LICENSE-APACHE +201 -0
- package/wasm/hyphenation-licenses/LICENSE-MIT +21 -0
- package/wasm/hyphenation-licenses/README.md +26 -0
- package/wasm/hyphenation-licenses/en-gb.tex +8615 -0
- package/wasm/hyphenation-licenses/en-us.tex +5018 -0
- package/wasm/hyphenation-licenses/ushyphex.tex +1454 -0
- package/wasm/idocs_wasm.d.ts +7 -4
- package/wasm/idocs_wasm.js +31 -7
- package/wasm/idocs_wasm_bg.wasm +0 -0
- package/wasm/idocs_wasm_bg.wasm.d.ts +1 -0
package/dist/core/index.js
CHANGED
|
@@ -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(
|
|
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 === '
|
|
66
|
-
job.resolve(message.
|
|
67
|
-
|
|
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',
|
package/dist/core/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/view/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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';
|
package/dist/view/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
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
|
|
103
|
-
// list (KaTeX outlines in core), so no math font is
|
|
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
|
-
|
|
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.
|
package/dist/view/selection.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
16
|
-
return line.glyphs
|
|
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
|
|
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
|
|
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
|
|
35
|
+
return glyphX(line.glyphs, k);
|
|
35
36
|
}
|
|
36
37
|
return line.x + line.width;
|
|
37
38
|
}
|
package/dist/view/shell.d.ts
CHANGED
|
@@ -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 {
|