@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.
- package/CHANGELOG.md +34 -0
- package/README.md +111 -14
- package/dist/core/display.d.ts +68 -6
- package/dist/core/engine.d.ts +1 -5
- package/dist/core/engine.js +12 -12
- package/dist/core/fonts.d.ts +10 -11
- package/dist/core/fonts.js +28 -14
- package/dist/core/index.d.ts +3 -8
- package/dist/core/index.js +41 -4
- package/dist/core/protocol.d.ts +37 -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 +4 -4
- package/wasm/idocs_wasm.js +15 -7
- package/wasm/idocs_wasm_bg.wasm +0 -0
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,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
|
-
|
|
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
|
}
|
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 {
|
package/dist/view/shell.js
CHANGED
|
@@ -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,
|
|
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.
|
|
4
|
-
"description": "Read-only browser preview for DOCX and iDocs source documents:
|
|
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": "
|
|
40
|
+
"release:check": "node scripts/release-pack.mjs"
|
|
41
41
|
},
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=24"
|