mosage 0.2.0 → 0.8.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/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document as a Word file Word can reflow.
|
|
3
|
+
*
|
|
4
|
+
* A PDF reproduces the sheets; a .docx must not, or the first edit breaks it.
|
|
5
|
+
* So every flow section is laid out again as one continuous column — no page
|
|
6
|
+
* breaks, no repeated footers, no top margins stripped at the head of a sheet —
|
|
7
|
+
* with each block still answering as the sheet it printed on, and that copy is
|
|
8
|
+
* scanned and read.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createElement, type ReactNode } from 'react';
|
|
12
|
+
import { FlowPage } from '../components/flow-page';
|
|
13
|
+
import { defaultDesign } from './design';
|
|
14
|
+
import { type ExtractSource, extractDocument } from './docx/extract';
|
|
15
|
+
import { halfPoints, twipsFromMm } from './docx/units';
|
|
16
|
+
import { DOCX_MIME, writeDocx } from './docx/write';
|
|
17
|
+
import { collectCss, downloadBundle, type FileBundle, mountOffscreen, pacer } from './export-dom';
|
|
18
|
+
import { designDeclarations, htmlToSvg, inlineAssets, rasterise } from './rasterize';
|
|
19
|
+
import { type DocModule, resolvePageGeometry } from './sdk';
|
|
20
|
+
import type { ExpandedPage, FlowSlice } from './use-doc-pages';
|
|
21
|
+
|
|
22
|
+
export type DocxExportProgress = {
|
|
23
|
+
phase: 'rendering' | 'reading' | 'writing' | 'done';
|
|
24
|
+
percent: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Page numbers no real footer prints. A running footer is drawn once more with
|
|
29
|
+
* these, and each one becomes a PAGE or NUMPAGES field.
|
|
30
|
+
*/
|
|
31
|
+
const SENTINELS = { page: 38271, count: 69154 };
|
|
32
|
+
|
|
33
|
+
type FlowGroup = FlowSlice & {
|
|
34
|
+
kind: 'flow';
|
|
35
|
+
index: number;
|
|
36
|
+
/** How many sheets the section printed on. */
|
|
37
|
+
pages: number;
|
|
38
|
+
/** Block index → the sheet it printed on. */
|
|
39
|
+
sheets: Map<number, number>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Group = { kind: 'fixed'; index: number; page: ExpandedPage } | FlowGroup;
|
|
43
|
+
|
|
44
|
+
/** Consecutive sheets of one flow section become one run of blocks again. */
|
|
45
|
+
function groupPages(pages: ExpandedPage[]): Group[] {
|
|
46
|
+
const groups: Group[] = [];
|
|
47
|
+
pages.forEach((page, index) => {
|
|
48
|
+
const slice = page.flow;
|
|
49
|
+
if (!slice) {
|
|
50
|
+
groups.push({ kind: 'fixed', index, page });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const last = groups[groups.length - 1];
|
|
54
|
+
let group: FlowGroup;
|
|
55
|
+
if (last?.kind === 'flow' && last.entry === slice.entry) {
|
|
56
|
+
group = last;
|
|
57
|
+
} else {
|
|
58
|
+
group = {
|
|
59
|
+
...slice,
|
|
60
|
+
kind: 'flow',
|
|
61
|
+
index,
|
|
62
|
+
pages: 0,
|
|
63
|
+
blockIndices: [],
|
|
64
|
+
notes: [],
|
|
65
|
+
sheets: new Map(),
|
|
66
|
+
};
|
|
67
|
+
groups.push(group);
|
|
68
|
+
}
|
|
69
|
+
group.pages += 1;
|
|
70
|
+
group.blockIndices.push(...slice.blockIndices);
|
|
71
|
+
group.notes.push(...slice.notes);
|
|
72
|
+
for (const block of slice.blockIndices) group.sheets.set(block, index);
|
|
73
|
+
});
|
|
74
|
+
return groups;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mountCopy(doc: DocModule, pages: ExpandedPage[], onProgress: (percent: number) => void) {
|
|
78
|
+
const total = pages.length;
|
|
79
|
+
return mountOffscreen(doc, async (mount, pace) => {
|
|
80
|
+
const sheet = (node: ReactNode, index: number, frame: boolean) =>
|
|
81
|
+
mount(node, { index, total }, { frame: frame ? index : undefined, sheet: true, paint: true });
|
|
82
|
+
// A running header or footer reads the page number, so it is drawn again
|
|
83
|
+
// with sentinels in place of the real ones.
|
|
84
|
+
const sentinel = (node: ReactNode) =>
|
|
85
|
+
mount(
|
|
86
|
+
node,
|
|
87
|
+
{ index: SENTINELS.page - 1, total: SENTINELS.count },
|
|
88
|
+
{ sheet: true, paint: true },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const sources: ExtractSource[] = [];
|
|
92
|
+
const groups = groupPages(pages);
|
|
93
|
+
for (const [done, group] of groups.entries()) {
|
|
94
|
+
if (group.kind === 'fixed') {
|
|
95
|
+
const { content } = group.page;
|
|
96
|
+
sources.push({
|
|
97
|
+
kind: 'fixed',
|
|
98
|
+
page: group.index + 1,
|
|
99
|
+
host: sheet(content, group.index, true),
|
|
100
|
+
sentinel: sentinel(content),
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
const { section } = group;
|
|
104
|
+
const body = createElement(FlowPage, {
|
|
105
|
+
section: { ...section, footer: undefined },
|
|
106
|
+
design: doc.design,
|
|
107
|
+
blocks: group.blocks,
|
|
108
|
+
blockIndices: group.blockIndices,
|
|
109
|
+
notes: group.notes,
|
|
110
|
+
sheets: group.sheets,
|
|
111
|
+
});
|
|
112
|
+
const footer = createElement(FlowPage, { section, design: doc.design, blockIndices: [] });
|
|
113
|
+
sources.push({
|
|
114
|
+
kind: 'flow',
|
|
115
|
+
page: group.index + 1,
|
|
116
|
+
// Not a frame itself: its blocks are, each as the sheet it printed on.
|
|
117
|
+
host: mount(body, { index: group.index, total }, { paint: true }),
|
|
118
|
+
// Drawn for the first sheet and the next: a footer hidden or different
|
|
119
|
+
// on the opening page is Word's first-page footer, not the running one.
|
|
120
|
+
footer: section.footer && {
|
|
121
|
+
sentinel: sentinel(footer),
|
|
122
|
+
first: sheet(footer, group.index, false),
|
|
123
|
+
next: group.pages > 1 ? sheet(footer, group.index + 1, false) : undefined,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
onProgress(Math.round(((done + 1) / groups.length) * 40));
|
|
128
|
+
await pace();
|
|
129
|
+
}
|
|
130
|
+
return sources;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Properties a drawing inherits from where it sits, and loses when drawn out of place. */
|
|
135
|
+
const INHERITED = [
|
|
136
|
+
'color',
|
|
137
|
+
'direction',
|
|
138
|
+
'font-family',
|
|
139
|
+
'font-feature-settings',
|
|
140
|
+
'font-size',
|
|
141
|
+
'font-stretch',
|
|
142
|
+
'font-style',
|
|
143
|
+
'font-variant',
|
|
144
|
+
'font-weight',
|
|
145
|
+
'hyphens',
|
|
146
|
+
'letter-spacing',
|
|
147
|
+
'line-height',
|
|
148
|
+
'overflow-wrap',
|
|
149
|
+
'tab-size',
|
|
150
|
+
'text-align',
|
|
151
|
+
'text-indent',
|
|
152
|
+
'text-transform',
|
|
153
|
+
'white-space',
|
|
154
|
+
'word-break',
|
|
155
|
+
'word-spacing',
|
|
156
|
+
'writing-mode',
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/** One element, drawn as a picture of itself with the page's stylesheet and fonts. */
|
|
160
|
+
function rasterizer(doc: DocModule): (el: Element) => Promise<Uint8Array | null> {
|
|
161
|
+
let css: Promise<string> | null = null;
|
|
162
|
+
const design = designDeclarations(doc);
|
|
163
|
+
// The same drawing in the same place — a logo in every section's footer — is drawn once.
|
|
164
|
+
const drawn = new Map<string, Promise<Uint8Array | null>>();
|
|
165
|
+
const draw = async (html: string, size: { width: number; height: number }, style: string) => {
|
|
166
|
+
try {
|
|
167
|
+
css ??= inlineAssets(collectCss(), []).then((result) => result.css);
|
|
168
|
+
const sheet = await css;
|
|
169
|
+
const { html: inlined } = await inlineAssets('', [html]);
|
|
170
|
+
const svg = htmlToSvg(inlined[0] ?? '', sheet, size, style);
|
|
171
|
+
return new Uint8Array(await (await rasterise(svg, size)).arrayBuffer());
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
return (el) => {
|
|
177
|
+
const { width, height } = el.getBoundingClientRect();
|
|
178
|
+
const cs = getComputedStyle(el);
|
|
179
|
+
const clone = el.cloneNode(true) as HTMLElement;
|
|
180
|
+
Object.assign(clone.style, {
|
|
181
|
+
margin: '0',
|
|
182
|
+
width: `${width}px`,
|
|
183
|
+
height: `${height}px`,
|
|
184
|
+
boxSizing: 'border-box',
|
|
185
|
+
});
|
|
186
|
+
// The picture's box is where it was drawn; offsets and transforms would move
|
|
187
|
+
// it again inside that box, and out of the picture.
|
|
188
|
+
if (cs.position !== 'static')
|
|
189
|
+
Object.assign(clone.style, { position: 'relative', inset: 'auto' });
|
|
190
|
+
if (
|
|
191
|
+
cs.transform !== 'none' ||
|
|
192
|
+
cs.translate !== 'none' ||
|
|
193
|
+
cs.rotate !== 'none' ||
|
|
194
|
+
cs.scale !== 'none'
|
|
195
|
+
) {
|
|
196
|
+
Object.assign(clone.style, {
|
|
197
|
+
transform: 'none',
|
|
198
|
+
translate: 'none',
|
|
199
|
+
rotate: 'none',
|
|
200
|
+
scale: 'none',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// A clone's canvases are blank: their pixels go along as pictures.
|
|
204
|
+
const canvases = el.querySelectorAll('canvas');
|
|
205
|
+
try {
|
|
206
|
+
clone.querySelectorAll('canvas').forEach((copy, i) => {
|
|
207
|
+
const canvas = canvases[i];
|
|
208
|
+
const box = canvas.getBoundingClientRect();
|
|
209
|
+
const picture = document.createElement('img');
|
|
210
|
+
picture.src = canvas.toDataURL();
|
|
211
|
+
picture.setAttribute('style', copy.getAttribute('style') ?? '');
|
|
212
|
+
picture.className = copy.className;
|
|
213
|
+
Object.assign(picture.style, { width: `${box.width}px`, height: `${box.height}px` });
|
|
214
|
+
copy.replaceWith(picture);
|
|
215
|
+
});
|
|
216
|
+
} catch {
|
|
217
|
+
// A canvas drawn from another origin cannot be read back.
|
|
218
|
+
return Promise.resolve(null);
|
|
219
|
+
}
|
|
220
|
+
const parent = el.parentElement ? getComputedStyle(el.parentElement) : null;
|
|
221
|
+
const inherited = parent
|
|
222
|
+
? [...INHERITED, ...Array.from(parent).filter((name) => name.startsWith('--'))]
|
|
223
|
+
.map((name) => `${name}:${parent.getPropertyValue(name)}`)
|
|
224
|
+
.join(';')
|
|
225
|
+
: '';
|
|
226
|
+
const style = `${design};${inherited}`;
|
|
227
|
+
const html = clone.outerHTML;
|
|
228
|
+
const key = `${width}x${height}\n${style}\n${html}`;
|
|
229
|
+
let picture = drawn.get(key);
|
|
230
|
+
if (!picture) {
|
|
231
|
+
picture = draw(html, { width, height }, style);
|
|
232
|
+
drawn.set(key, picture);
|
|
233
|
+
}
|
|
234
|
+
return picture;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function buildDocxBundle(
|
|
239
|
+
doc: DocModule,
|
|
240
|
+
docId: string,
|
|
241
|
+
pages: ExpandedPage[],
|
|
242
|
+
onProgress?: (progress: DocxExportProgress) => void,
|
|
243
|
+
): Promise<FileBundle | null> {
|
|
244
|
+
if (pages.length === 0) return null;
|
|
245
|
+
const report = (phase: DocxExportProgress['phase'], percent: number) =>
|
|
246
|
+
onProgress?.({ phase, percent });
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const copy = await mountCopy(doc, pages, (percent) => report('rendering', percent));
|
|
250
|
+
const [across, down] = resolvePageGeometry(doc.meta).mm;
|
|
251
|
+
const pace = pacer();
|
|
252
|
+
const extracted = await extractDocument({
|
|
253
|
+
sources: copy.value,
|
|
254
|
+
sheet: { width: twipsFromMm(across), height: twipsFromMm(down) },
|
|
255
|
+
margin: doc.design?.margin ?? defaultDesign.margin,
|
|
256
|
+
sentinels: SENTINELS,
|
|
257
|
+
pageCount: pages.length,
|
|
258
|
+
rasterize: rasterizer(doc),
|
|
259
|
+
onSection: (done, total) => {
|
|
260
|
+
report('reading', 45 + Math.round((done / total) * 45));
|
|
261
|
+
return pace();
|
|
262
|
+
},
|
|
263
|
+
}).finally(copy.dispose);
|
|
264
|
+
|
|
265
|
+
report('writing', 90);
|
|
266
|
+
const scale = doc.design?.typeScale;
|
|
267
|
+
const bytes = writeDocx({
|
|
268
|
+
title: doc.meta?.title ?? docId,
|
|
269
|
+
subject: doc.meta?.subtitle,
|
|
270
|
+
author: doc.meta?.author,
|
|
271
|
+
created: doc.meta?.createdAt,
|
|
272
|
+
headingSizes: scale && [scale.h1, scale.h2, scale.h3].map(halfPoints),
|
|
273
|
+
...extracted,
|
|
274
|
+
});
|
|
275
|
+
return { filename: `${docId}.docx`, mimeType: DOCX_MIME, bytes };
|
|
276
|
+
} finally {
|
|
277
|
+
report('done', 100);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function exportDocAsDocx(
|
|
282
|
+
doc: DocModule,
|
|
283
|
+
docId: string,
|
|
284
|
+
pages: ExpandedPage[],
|
|
285
|
+
onProgress?: (progress: DocxExportProgress) => void,
|
|
286
|
+
): Promise<void> {
|
|
287
|
+
const bundle = await buildDocxBundle(doc, docId, pages, onProgress);
|
|
288
|
+
if (bundle) downloadBundle(bundle);
|
|
289
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared by every exporter so there is one way to draw the document offscreen.
|
|
3
|
+
* Two copies would drift on font waiting, scanning, or design variables, and the
|
|
4
|
+
* difference would only ever show up in an exported file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Component, createElement, type ReactNode } from 'react';
|
|
8
|
+
import { flushSync } from 'react-dom';
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
10
|
+
import { designToCssVars } from './design';
|
|
11
|
+
import { PAGE_ATTR, PAGE_INDEX_ATTR } from './outline';
|
|
12
|
+
import { DocPageProvider } from './page-context';
|
|
13
|
+
import { nextFrame, waitForDataWaitfor, waitForFonts, waitForImages } from './print-ready';
|
|
14
|
+
import { captureScan, restoreScan, scanDocument } from './scan';
|
|
15
|
+
import { type DocModule, resolvePageGeometry } from './sdk';
|
|
16
|
+
import type { ExpandedPage } from './use-doc-pages';
|
|
17
|
+
|
|
18
|
+
export const ASSET_EXT_RE =
|
|
19
|
+
/\.(?:png|jpe?g|gif|svg|webp|avif|woff2?|ttf|otf)(?:\?[^#]*)?(?:#.*)?$/i;
|
|
20
|
+
|
|
21
|
+
export type FileBundle = { filename: string; mimeType: string; bytes: Uint8Array };
|
|
22
|
+
|
|
23
|
+
export type HostOptions = {
|
|
24
|
+
/** 0-based page index; makes the host a frame the outline and numbering scans read. */
|
|
25
|
+
frame?: number;
|
|
26
|
+
/** Sheet height; without it the host grows with its content. */
|
|
27
|
+
sheet?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
/** The design's paper and ink on the host itself, as a printed sheet has them. */
|
|
30
|
+
paint?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Mounts `node` in a host of its own, rendered as page `index` of `total`. */
|
|
34
|
+
export type PageMount = (
|
|
35
|
+
node: ReactNode,
|
|
36
|
+
page: { index: number; total: number },
|
|
37
|
+
host?: HostOptions,
|
|
38
|
+
) => HTMLElement;
|
|
39
|
+
|
|
40
|
+
export type OffscreenCopy<T> = { root: HTMLElement; value: T; dispose: () => void };
|
|
41
|
+
|
|
42
|
+
function offscreenContainer(): HTMLElement {
|
|
43
|
+
const container = document.createElement('div');
|
|
44
|
+
container.setAttribute('aria-hidden', 'true');
|
|
45
|
+
Object.assign(container.style, {
|
|
46
|
+
position: 'fixed',
|
|
47
|
+
left: '-99999px',
|
|
48
|
+
top: '0',
|
|
49
|
+
pointerEvents: 'none',
|
|
50
|
+
});
|
|
51
|
+
document.body.appendChild(container);
|
|
52
|
+
return container;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Around every page of a copy. Rendered synchronously, a page that throws would
|
|
57
|
+
* take the whole export with it; one that only fails for the pages chosen —
|
|
58
|
+
* `chapters[n - 3]` on page 1 of a one-page export, or on the Word export's
|
|
59
|
+
* sentinel page 38271 — prints blank instead.
|
|
60
|
+
*/
|
|
61
|
+
class PageBoundary extends Component<{ children?: ReactNode }, { failed: boolean }> {
|
|
62
|
+
state = { failed: false };
|
|
63
|
+
|
|
64
|
+
static getDerivedStateFromError() {
|
|
65
|
+
return { failed: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render() {
|
|
69
|
+
return this.state.failed ? null : this.props.children;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Renders and commits before returning. A concurrent render can still be
|
|
75
|
+
* pending when the next line reads the DOM — right after a document first
|
|
76
|
+
* loads, the viewer's own rendering is ahead of it in the queue — and a scan of
|
|
77
|
+
* an uncommitted copy sees blank sheets: an empty table of contents, figures
|
|
78
|
+
* without numbers. Never call it from inside a render or an effect.
|
|
79
|
+
*/
|
|
80
|
+
function renderNow(host: HTMLElement, node: ReactNode): Root {
|
|
81
|
+
const root = createRoot(host);
|
|
82
|
+
flushSync(() => root.render(node));
|
|
83
|
+
return root;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Everything a page waits on before it can be read: fonts, images, `data-waitfor`. */
|
|
87
|
+
async function settle(root: HTMLElement): Promise<void> {
|
|
88
|
+
await nextFrame();
|
|
89
|
+
await waitForFonts();
|
|
90
|
+
await waitForImages(root);
|
|
91
|
+
await waitForDataWaitfor(root);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hands the thread back between synchronous renders. Committed back to back, a
|
|
96
|
+
* long document holds the main thread and the progress percentage never paints.
|
|
97
|
+
* It yields through a message rather than a timer: a timer in a background tab
|
|
98
|
+
* waits a whole second, once per yield.
|
|
99
|
+
*/
|
|
100
|
+
export function pacer(budgetMs = 50): () => Promise<void> {
|
|
101
|
+
let last = performance.now();
|
|
102
|
+
return async () => {
|
|
103
|
+
if (performance.now() - last < budgetMs) return;
|
|
104
|
+
await new Promise<void>((resolve) => {
|
|
105
|
+
const channel = new MessageChannel();
|
|
106
|
+
channel.port1.onmessage = () => {
|
|
107
|
+
channel.port1.close();
|
|
108
|
+
resolve();
|
|
109
|
+
};
|
|
110
|
+
channel.port2.postMessage(null);
|
|
111
|
+
});
|
|
112
|
+
last = performance.now();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A private copy of the document, drawn offscreen by `draw`, scanned, and
|
|
118
|
+
* settled — the lifecycle every exporter shares. The scan is the copy's own, so
|
|
119
|
+
* the pages its contents and references quote are the pages it shows; the
|
|
120
|
+
* viewer's is put back by `dispose()`, which also runs if anything throws first.
|
|
121
|
+
*/
|
|
122
|
+
export async function mountOffscreen<T>(
|
|
123
|
+
doc: DocModule,
|
|
124
|
+
draw: (mount: PageMount, pace: () => Promise<void>) => Promise<T>,
|
|
125
|
+
opts: { root?: HTMLElement; onDispose?: () => void } = {},
|
|
126
|
+
): Promise<OffscreenCopy<T>> {
|
|
127
|
+
const root = opts.root ?? offscreenContainer();
|
|
128
|
+
const geometry = resolvePageGeometry(doc.meta);
|
|
129
|
+
const vars = doc.design ? Object.entries(designToCssVars(doc.design)) : [];
|
|
130
|
+
const previousScan = captureScan();
|
|
131
|
+
const roots: Root[] = [];
|
|
132
|
+
const dispose = () => {
|
|
133
|
+
for (const r of roots) r.unmount();
|
|
134
|
+
root.remove();
|
|
135
|
+
restoreScan(previousScan);
|
|
136
|
+
opts.onDispose?.();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Styled as the viewer styles a sheet, so positioned content lands where it
|
|
140
|
+
// does on screen.
|
|
141
|
+
const mount: PageMount = (node, page, host = {}) => {
|
|
142
|
+
const el = document.createElement('div');
|
|
143
|
+
if (host.className) el.className = host.className;
|
|
144
|
+
if (host.frame !== undefined) {
|
|
145
|
+
el.setAttribute(PAGE_ATTR, '');
|
|
146
|
+
el.setAttribute(PAGE_INDEX_ATTR, String(host.frame));
|
|
147
|
+
}
|
|
148
|
+
Object.assign(el.style, {
|
|
149
|
+
width: `${geometry.width}px`,
|
|
150
|
+
height: host.sheet ? `${geometry.height}px` : '',
|
|
151
|
+
position: 'relative',
|
|
152
|
+
overflow: 'hidden',
|
|
153
|
+
textAlign: 'start',
|
|
154
|
+
});
|
|
155
|
+
for (const [name, value] of vars) el.style.setProperty(name, value);
|
|
156
|
+
if (host.paint) {
|
|
157
|
+
// Paper and ink of its own, as the viewer's sheet has: the copy hangs off
|
|
158
|
+
// <body>, and a document with no design would otherwise print in the
|
|
159
|
+
// chrome's colours, which follow the viewer's dark mode.
|
|
160
|
+
el.style.background = doc.design ? 'var(--od-bg)' : '#ffffff';
|
|
161
|
+
el.style.color = doc.design ? 'var(--od-text)' : '#000000';
|
|
162
|
+
}
|
|
163
|
+
root.appendChild(el);
|
|
164
|
+
const content = createElement(PageBoundary, null, node);
|
|
165
|
+
roots.push(renderNow(el, createElement(DocPageProvider, page, content)));
|
|
166
|
+
return el;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const value = await draw(mount, pacer());
|
|
171
|
+
await settle(root);
|
|
172
|
+
// What the scan resolves — contents, numbers, references — is committed
|
|
173
|
+
// before anything reads the copy, and gets its fonts like the rest. Images
|
|
174
|
+
// and data-waitfor were settled above; waiting on them again only doubles
|
|
175
|
+
// the timeout of one that never arrives.
|
|
176
|
+
flushSync(() => scanDocument(root, doc.meta));
|
|
177
|
+
await nextFrame();
|
|
178
|
+
await waitForFonts();
|
|
179
|
+
return { root, value, dispose };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
dispose();
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Every sheet as it would print, serialized. */
|
|
187
|
+
export async function renderPagesToHtml(pages: ExpandedPage[], doc: DocModule): Promise<string[]> {
|
|
188
|
+
const total = pages.length;
|
|
189
|
+
const copy = await mountOffscreen(doc, async (mount, pace) => {
|
|
190
|
+
const hosts: HTMLElement[] = [];
|
|
191
|
+
for (const [index, page] of pages.entries()) {
|
|
192
|
+
hosts.push(mount(page.content, { index, total }, { frame: index, sheet: true }));
|
|
193
|
+
await pace();
|
|
194
|
+
}
|
|
195
|
+
return hosts;
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
return copy.value.map((host) => host.innerHTML);
|
|
199
|
+
} finally {
|
|
200
|
+
copy.dispose();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function collectCss(): string {
|
|
205
|
+
const chunks: string[] = [];
|
|
206
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
207
|
+
let rules: CSSRuleList | null = null;
|
|
208
|
+
try {
|
|
209
|
+
rules = sheet.cssRules;
|
|
210
|
+
} catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!rules) continue;
|
|
214
|
+
for (const rule of Array.from(rules)) chunks.push(rule.cssText);
|
|
215
|
+
}
|
|
216
|
+
return chunks.join('\n');
|
|
217
|
+
}
|
|
218
|
+
export function collectExternalStylesheetLinks(): string {
|
|
219
|
+
const links: string[] = [];
|
|
220
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
221
|
+
try {
|
|
222
|
+
void sheet.cssRules;
|
|
223
|
+
} catch {
|
|
224
|
+
if (sheet.href) links.push(`<link rel="stylesheet" href="${escapeAttr(sheet.href)}">`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return links.join('\n');
|
|
228
|
+
}
|
|
229
|
+
export function findHtmlAssetUrls(html: string): string[] {
|
|
230
|
+
const out: string[] = [];
|
|
231
|
+
for (const m of html.matchAll(/\s(?:src|href)="([^"]+)"/g)) {
|
|
232
|
+
if (looksLikeAsset(m[1])) out.push(m[1]);
|
|
233
|
+
}
|
|
234
|
+
for (const m of html.matchAll(/\ssrcset="([^"]+)"/g)) {
|
|
235
|
+
for (const part of m[1].split(',')) {
|
|
236
|
+
const url = part.trim().split(/\s+/)[0];
|
|
237
|
+
if (url && looksLikeAsset(url)) out.push(url);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
export function findCssAssetUrls(css: string): string[] {
|
|
243
|
+
const out: string[] = [];
|
|
244
|
+
for (const m of css.matchAll(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/g)) {
|
|
245
|
+
const url = m[2].trim();
|
|
246
|
+
if (looksLikeAsset(url)) out.push(url);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
export function looksLikeAsset(url: string): boolean {
|
|
251
|
+
if (!url) return false;
|
|
252
|
+
if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith('#')) return false;
|
|
253
|
+
if (url.startsWith('mailto:') || url.startsWith('javascript:')) return false;
|
|
254
|
+
const abs = toAbsolute(url);
|
|
255
|
+
if (!abs) return false;
|
|
256
|
+
try {
|
|
257
|
+
if (new URL(abs).origin !== window.location.origin) return false;
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return ASSET_EXT_RE.test(url);
|
|
262
|
+
}
|
|
263
|
+
export function toAbsolute(url: string): string | null {
|
|
264
|
+
try {
|
|
265
|
+
return new URL(url, window.location.href).toString();
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export function uniqueAssetName(absoluteUrl: string, used: Set<string>): string {
|
|
271
|
+
let base = 'asset';
|
|
272
|
+
try {
|
|
273
|
+
base = new URL(absoluteUrl).pathname.split('/').pop() || 'asset';
|
|
274
|
+
} catch {}
|
|
275
|
+
if (!used.has(base)) {
|
|
276
|
+
used.add(base);
|
|
277
|
+
return base;
|
|
278
|
+
}
|
|
279
|
+
const hash = shortHash(absoluteUrl);
|
|
280
|
+
const dot = base.lastIndexOf('.');
|
|
281
|
+
const name = dot > 0 ? `${base.slice(0, dot)}-${hash}${base.slice(dot)}` : `${base}-${hash}`;
|
|
282
|
+
used.add(name);
|
|
283
|
+
return name;
|
|
284
|
+
}
|
|
285
|
+
export function shortHash(input: string): string {
|
|
286
|
+
let h = 2166136261;
|
|
287
|
+
for (let i = 0; i < input.length; i++) {
|
|
288
|
+
h ^= input.charCodeAt(i);
|
|
289
|
+
h = Math.imul(h, 16777619);
|
|
290
|
+
}
|
|
291
|
+
return (h >>> 0).toString(36).slice(0, 6);
|
|
292
|
+
}
|
|
293
|
+
export function downloadBundle(bundle: FileBundle): void {
|
|
294
|
+
downloadBlob(new Blob([bundle.bytes as BlobPart], { type: bundle.mimeType }), bundle.filename);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function downloadBlob(blob: Blob, filename: string): void {
|
|
298
|
+
const url = URL.createObjectURL(blob);
|
|
299
|
+
const a = document.createElement('a');
|
|
300
|
+
a.href = url;
|
|
301
|
+
a.download = filename;
|
|
302
|
+
a.rel = 'noopener';
|
|
303
|
+
document.body.appendChild(a);
|
|
304
|
+
a.click();
|
|
305
|
+
a.remove();
|
|
306
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
307
|
+
}
|
|
308
|
+
export function escapeHtml(s: string): string {
|
|
309
|
+
return s
|
|
310
|
+
.replace(/&/g, '&')
|
|
311
|
+
.replace(/</g, '<')
|
|
312
|
+
.replace(/>/g, '>')
|
|
313
|
+
.replace(/"/g, '"')
|
|
314
|
+
.replace(/'/g, ''');
|
|
315
|
+
}
|
|
316
|
+
export function escapeAttr(s: string): string {
|
|
317
|
+
return s.replace(/&/g, '&').replace(/"/g, '"');
|
|
318
|
+
}
|