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,156 @@
|
|
|
1
|
+
import { designToCssVars } from './design';
|
|
2
|
+
import {
|
|
3
|
+
collectCss,
|
|
4
|
+
collectExternalStylesheetLinks,
|
|
5
|
+
downloadBundle,
|
|
6
|
+
escapeAttr,
|
|
7
|
+
escapeHtml,
|
|
8
|
+
type FileBundle,
|
|
9
|
+
findCssAssetUrls,
|
|
10
|
+
findHtmlAssetUrls,
|
|
11
|
+
renderPagesToHtml,
|
|
12
|
+
toAbsolute,
|
|
13
|
+
uniqueAssetName,
|
|
14
|
+
} from './export-dom';
|
|
15
|
+
import { PAGE_ATTR, PAGE_INDEX_ATTR } from './outline';
|
|
16
|
+
import { type DocModule, type PageGeometry, resolvePageGeometry } from './sdk';
|
|
17
|
+
import type { ExpandedPage } from './use-doc-pages';
|
|
18
|
+
|
|
19
|
+
type AssetEntry = { name: string; bytes: Uint8Array };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serializes the document into a self-contained page — a plain `.html` when
|
|
23
|
+
* nothing is referenced, a zip alongside its assets when something is. The
|
|
24
|
+
* browser downloads it; the headless exporter writes it to disk.
|
|
25
|
+
*/
|
|
26
|
+
export async function buildDocHtmlBundle(
|
|
27
|
+
doc: DocModule,
|
|
28
|
+
docId: string,
|
|
29
|
+
pages: ExpandedPage[],
|
|
30
|
+
): Promise<FileBundle | null> {
|
|
31
|
+
if (pages.length === 0) return null;
|
|
32
|
+
|
|
33
|
+
const title = doc.meta?.title ?? docId;
|
|
34
|
+
const geometry = resolvePageGeometry(doc.meta);
|
|
35
|
+
const pagesHtml = await renderPagesToHtml(pages, doc);
|
|
36
|
+
const bundledCss = collectCss();
|
|
37
|
+
const externalLinks = collectExternalStylesheetLinks();
|
|
38
|
+
|
|
39
|
+
const assets = new Map<string, AssetEntry>();
|
|
40
|
+
const usedNames = new Set<string>();
|
|
41
|
+
const urls = new Set<string>([
|
|
42
|
+
...findHtmlAssetUrls(pagesHtml.join('\n')),
|
|
43
|
+
...findCssAssetUrls(bundledCss),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
for (const url of urls) {
|
|
47
|
+
const absolute = toAbsolute(url);
|
|
48
|
+
if (!absolute) continue;
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(absolute);
|
|
51
|
+
if (!res.ok) continue;
|
|
52
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
53
|
+
assets.set(url, { name: uniqueAssetName(absolute, usedNames), bytes });
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const html = buildHtml({
|
|
58
|
+
title,
|
|
59
|
+
geometry,
|
|
60
|
+
design: doc.design,
|
|
61
|
+
pagesHtml: pagesHtml.map((page) => rewriteUrls(page, assets, 'html')),
|
|
62
|
+
bundledCss: rewriteUrls(bundledCss, assets, 'css'),
|
|
63
|
+
externalLinks,
|
|
64
|
+
});
|
|
65
|
+
const htmlBytes = new TextEncoder().encode(html);
|
|
66
|
+
|
|
67
|
+
if (assets.size === 0) {
|
|
68
|
+
return { filename: `${docId}.html`, mimeType: 'text/html', bytes: htmlBytes };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { zipSync } = await import('fflate');
|
|
72
|
+
const zipTree: Record<string, Uint8Array | Record<string, Uint8Array>> = {
|
|
73
|
+
[`${docId}.html`]: htmlBytes,
|
|
74
|
+
assets: {},
|
|
75
|
+
};
|
|
76
|
+
for (const { name, bytes } of assets.values()) {
|
|
77
|
+
(zipTree.assets as Record<string, Uint8Array>)[name] = bytes;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
filename: `${docId}.zip`,
|
|
81
|
+
mimeType: 'application/zip',
|
|
82
|
+
bytes: zipSync(zipTree as Parameters<typeof zipSync>[0]),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function exportDocAsHtml(
|
|
87
|
+
doc: DocModule,
|
|
88
|
+
docId: string,
|
|
89
|
+
pages: ExpandedPage[],
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const bundle = await buildDocHtmlBundle(doc, docId, pages);
|
|
92
|
+
if (bundle) downloadBundle(bundle);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rewriteUrls(
|
|
96
|
+
source: string,
|
|
97
|
+
assets: Map<string, AssetEntry>,
|
|
98
|
+
kind: 'html' | 'css',
|
|
99
|
+
): string {
|
|
100
|
+
let out = source;
|
|
101
|
+
for (const [orig, { name }] of assets) {
|
|
102
|
+
out = out.split(orig).join(kind === 'css' ? `./assets/${name}` : `assets/${name}`);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildHtml(opts: {
|
|
108
|
+
title: string;
|
|
109
|
+
geometry: PageGeometry;
|
|
110
|
+
design: DocModule['design'];
|
|
111
|
+
pagesHtml: string[];
|
|
112
|
+
bundledCss: string;
|
|
113
|
+
externalLinks: string;
|
|
114
|
+
}): string {
|
|
115
|
+
const { width, height, css } = opts.geometry;
|
|
116
|
+
const designStyle = opts.design
|
|
117
|
+
? Object.entries(designToCssVars(opts.design))
|
|
118
|
+
.map(([k, v]) => `${k}: ${v};`)
|
|
119
|
+
.join(' ')
|
|
120
|
+
: '';
|
|
121
|
+
|
|
122
|
+
const pagesMarkup = opts.pagesHtml
|
|
123
|
+
.map(
|
|
124
|
+
(page, i) =>
|
|
125
|
+
`<div class="od-page" ${PAGE_ATTR} ${PAGE_INDEX_ATTR}="${i}"${designStyle ? ` style="${escapeAttr(designStyle)}"` : ''}>${page}</div>`,
|
|
126
|
+
)
|
|
127
|
+
.join('\n');
|
|
128
|
+
|
|
129
|
+
return `<!doctype html>
|
|
130
|
+
<html lang="en">
|
|
131
|
+
<head>
|
|
132
|
+
<meta charset="utf-8">
|
|
133
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
134
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
135
|
+
${opts.externalLinks}
|
|
136
|
+
<style>
|
|
137
|
+
html, body { margin: 0; background: #6b7280; font-family: system-ui, sans-serif; }
|
|
138
|
+
.od-doc { display: flex; flex-direction: column; align-items: center; gap: 24px; padding: 32px 16px 64px; }
|
|
139
|
+
.od-page { width: ${width}px; height: ${height}px; flex: none; position: relative; overflow: hidden; background: #fff; color: #111; box-shadow: 0 1px 3px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.22); }
|
|
140
|
+
@page { size: ${css}; margin: 0; }
|
|
141
|
+
@media print {
|
|
142
|
+
html, body { background: #fff; }
|
|
143
|
+
.od-doc { display: block; padding: 0; gap: 0; }
|
|
144
|
+
.od-page { box-shadow: none; page-break-after: always; break-after: page; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
145
|
+
.od-page:last-child { page-break-after: auto; break-after: auto; }
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
148
|
+
<style>${opts.bundledCss}</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<div class="od-doc">
|
|
152
|
+
${pagesMarkup}
|
|
153
|
+
</div>
|
|
154
|
+
</body>
|
|
155
|
+
</html>`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { collectCss, downloadBlob, downloadBundle, renderPagesToHtml } from './export-dom';
|
|
2
|
+
import { designDeclarations, htmlToSvg, inlineAssets, rasterise } from './rasterize';
|
|
3
|
+
import { type DocModule, resolvePageGeometry } from './sdk';
|
|
4
|
+
import type { ExpandedPage } from './use-doc-pages';
|
|
5
|
+
|
|
6
|
+
export type ImageFormat = 'png' | 'svg';
|
|
7
|
+
|
|
8
|
+
export type ImageExportProgress = {
|
|
9
|
+
phase: 'rendering' | 'embedding' | 'drawing' | 'done';
|
|
10
|
+
current: number;
|
|
11
|
+
total: number;
|
|
12
|
+
percent: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function exportDocAsImages(
|
|
16
|
+
doc: DocModule,
|
|
17
|
+
docId: string,
|
|
18
|
+
pages: ExpandedPage[],
|
|
19
|
+
format: ImageFormat,
|
|
20
|
+
onProgress?: (progress: ImageExportProgress) => void,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
if (pages.length === 0) return;
|
|
23
|
+
|
|
24
|
+
const total = pages.length;
|
|
25
|
+
const geometry = resolvePageGeometry(doc.meta);
|
|
26
|
+
const report = (phase: ImageExportProgress['phase'], current: number, percent: number) =>
|
|
27
|
+
onProgress?.({ phase, current, total, percent });
|
|
28
|
+
|
|
29
|
+
report('rendering', 0, 2);
|
|
30
|
+
const pagesHtml = await renderPagesToHtml(pages, doc);
|
|
31
|
+
|
|
32
|
+
report('embedding', 0, 20);
|
|
33
|
+
const { css, html } = await inlineAssets(collectCss(), pagesHtml);
|
|
34
|
+
const declarations = designDeclarations(doc);
|
|
35
|
+
|
|
36
|
+
const files: { name: string; blob: Blob }[] = [];
|
|
37
|
+
for (let i = 0; i < html.length; i++) {
|
|
38
|
+
const svg = htmlToSvg(html[i] ?? '', css, geometry, declarations);
|
|
39
|
+
const name = `${docId}-${String(i + 1).padStart(2, '0')}`;
|
|
40
|
+
if (format === 'svg') {
|
|
41
|
+
files.push({ name: `${name}.svg`, blob: new Blob([svg], { type: 'image/svg+xml' }) });
|
|
42
|
+
} else {
|
|
43
|
+
report('drawing', i + 1, 20 + Math.round(((i + 1) / total) * 75));
|
|
44
|
+
files.push({ name: `${name}.png`, blob: await rasterise(svg, geometry) });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
report('done', total, 100);
|
|
49
|
+
await deliver(files, docId, format);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function deliver(
|
|
53
|
+
files: { name: string; blob: Blob }[],
|
|
54
|
+
docId: string,
|
|
55
|
+
format: ImageFormat,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const only = files[0];
|
|
58
|
+
if (files.length === 1 && only) {
|
|
59
|
+
downloadBlob(only.blob, only.name);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { zipSync } = await import('fflate');
|
|
63
|
+
const tree: Record<string, Uint8Array> = {};
|
|
64
|
+
for (const file of files) tree[file.name] = new Uint8Array(await file.blob.arrayBuffer());
|
|
65
|
+
downloadBundle({
|
|
66
|
+
filename: `${docId}-${format}.zip`,
|
|
67
|
+
mimeType: 'application/zip',
|
|
68
|
+
bytes: zipSync(tree as Parameters<typeof zipSync>[0]),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { mountOffscreen } from './export-dom';
|
|
2
|
+
import { type DocModule, resolvePageGeometry } from './sdk';
|
|
3
|
+
import type { ExpandedPage } from './use-doc-pages';
|
|
4
|
+
|
|
5
|
+
export const PRINT_ROOT_ID = 'od-print-root';
|
|
6
|
+
export const PRINT_PAGE_CLASS = 'od-print-page';
|
|
7
|
+
const PRINT_STYLE_ID = 'od-print-style';
|
|
8
|
+
|
|
9
|
+
function printStyles(geometry: { width: number; height: number; css: string }): string {
|
|
10
|
+
return `
|
|
11
|
+
@page { size: ${geometry.css}; margin: 0; }
|
|
12
|
+
|
|
13
|
+
@media screen {
|
|
14
|
+
#${PRINT_ROOT_ID} {
|
|
15
|
+
position: fixed !important;
|
|
16
|
+
left: -99999px !important;
|
|
17
|
+
top: 0 !important;
|
|
18
|
+
pointer-events: none !important;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media print {
|
|
23
|
+
html, body {
|
|
24
|
+
margin: 0 !important;
|
|
25
|
+
padding: 0 !important;
|
|
26
|
+
background: #fff !important;
|
|
27
|
+
}
|
|
28
|
+
body > *:not(#${PRINT_ROOT_ID}) { display: none !important; }
|
|
29
|
+
#${PRINT_ROOT_ID} {
|
|
30
|
+
position: static !important;
|
|
31
|
+
left: 0 !important;
|
|
32
|
+
top: 0 !important;
|
|
33
|
+
display: block !important;
|
|
34
|
+
pointer-events: auto !important;
|
|
35
|
+
background: #fff !important;
|
|
36
|
+
}
|
|
37
|
+
#${PRINT_ROOT_ID} .${PRINT_PAGE_CLASS} {
|
|
38
|
+
width: ${geometry.width}px !important;
|
|
39
|
+
height: ${geometry.height}px !important;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
position: relative;
|
|
42
|
+
background: #fff;
|
|
43
|
+
color: #000;
|
|
44
|
+
page-break-after: always;
|
|
45
|
+
break-after: page;
|
|
46
|
+
-webkit-print-color-adjust: exact;
|
|
47
|
+
print-color-adjust: exact;
|
|
48
|
+
}
|
|
49
|
+
#${PRINT_ROOT_ID} .${PRINT_PAGE_CLASS}:last-child {
|
|
50
|
+
page-break-after: auto;
|
|
51
|
+
break-after: auto;
|
|
52
|
+
}
|
|
53
|
+
/* Chromium serializes box-shadow as a PDF transparency group, which makes
|
|
54
|
+
macOS Preview re-composite on every page turn. Paper shadows are chrome,
|
|
55
|
+
not content — drop them for the printed copy. */
|
|
56
|
+
#${PRINT_ROOT_ID} * { box-shadow: none !important; }
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PdfExportProgress = {
|
|
62
|
+
phase: 'rendering' | 'printing' | 'done';
|
|
63
|
+
current: number;
|
|
64
|
+
total: number;
|
|
65
|
+
/** 0–99 while rendering, 99 during printing, 100 when done. */
|
|
66
|
+
percent: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type PrintCopy = {
|
|
70
|
+
/** The offscreen container holding one `.od-print-page` per sheet, at true size. */
|
|
71
|
+
root: HTMLElement;
|
|
72
|
+
dispose: () => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders every page into an offscreen copy laid out at the real sheet size,
|
|
77
|
+
* with the print stylesheet installed. Callers decide what to do with it —
|
|
78
|
+
* hand it to the print engine, screenshot it, or measure it — and must call
|
|
79
|
+
* `dispose()` when done.
|
|
80
|
+
*/
|
|
81
|
+
export async function mountPrintCopy(
|
|
82
|
+
doc: DocModule,
|
|
83
|
+
docId: string,
|
|
84
|
+
pages: ExpandedPage[],
|
|
85
|
+
onProgress?: (progress: PdfExportProgress) => void,
|
|
86
|
+
): Promise<PrintCopy> {
|
|
87
|
+
const total = pages.length;
|
|
88
|
+
const geometry = resolvePageGeometry(doc.meta);
|
|
89
|
+
onProgress?.({ phase: 'rendering', current: 0, total, percent: 0 });
|
|
90
|
+
|
|
91
|
+
const style = document.createElement('style');
|
|
92
|
+
style.id = PRINT_STYLE_ID;
|
|
93
|
+
style.textContent = printStyles(geometry);
|
|
94
|
+
document.head.appendChild(style);
|
|
95
|
+
|
|
96
|
+
const root = document.createElement('div');
|
|
97
|
+
root.id = PRINT_ROOT_ID;
|
|
98
|
+
root.setAttribute('aria-hidden', 'true');
|
|
99
|
+
document.body.appendChild(root);
|
|
100
|
+
|
|
101
|
+
const previousTitle = document.title;
|
|
102
|
+
document.title = doc.meta?.title ?? docId;
|
|
103
|
+
|
|
104
|
+
return mountOffscreen(
|
|
105
|
+
doc,
|
|
106
|
+
async (mount, pace) => {
|
|
107
|
+
for (const [index, page] of pages.entries()) {
|
|
108
|
+
mount(
|
|
109
|
+
page.content,
|
|
110
|
+
{ index, total },
|
|
111
|
+
{ frame: index, sheet: true, className: PRINT_PAGE_CLASS, paint: true },
|
|
112
|
+
);
|
|
113
|
+
onProgress?.({
|
|
114
|
+
phase: 'rendering',
|
|
115
|
+
current: index + 1,
|
|
116
|
+
total,
|
|
117
|
+
percent: Math.min(90, ((index + 1) / total) * 90),
|
|
118
|
+
});
|
|
119
|
+
await pace();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
root,
|
|
124
|
+
onDispose: () => {
|
|
125
|
+
document.title = previousTitle;
|
|
126
|
+
style.remove();
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function exportDocAsPdf(
|
|
133
|
+
doc: DocModule,
|
|
134
|
+
docId: string,
|
|
135
|
+
pages: ExpandedPage[],
|
|
136
|
+
onProgress?: (progress: PdfExportProgress) => void,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
if (pages.length === 0) return;
|
|
139
|
+
|
|
140
|
+
const total = pages.length;
|
|
141
|
+
const copy = await mountPrintCopy(doc, docId, pages, onProgress);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
onProgress?.({ phase: 'printing', current: total, total, percent: 99 });
|
|
145
|
+
const printDone = waitForAfterPrint();
|
|
146
|
+
window.print();
|
|
147
|
+
await printDone;
|
|
148
|
+
} finally {
|
|
149
|
+
onProgress?.({ phase: 'done', current: total, total, percent: 100 });
|
|
150
|
+
copy.dispose();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function waitForAfterPrint(timeoutMs = 60_000): Promise<void> {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
window.removeEventListener('afterprint', onAfter);
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
resolve();
|
|
160
|
+
};
|
|
161
|
+
const onAfter = () => cleanup();
|
|
162
|
+
const timer = setTimeout(cleanup, timeoutMs);
|
|
163
|
+
window.addEventListener('afterprint', onAfter, { once: true });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { blockHints } from './flow-measure';
|
|
3
|
+
|
|
4
|
+
function element(tagName: string, attrs: Record<string, string> = {}): Element {
|
|
5
|
+
return { tagName, getAttribute: (name: string) => attrs[name] ?? null } as unknown as Element;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('blockHints', () => {
|
|
9
|
+
it('keeps a heading with what follows unless it says otherwise', () => {
|
|
10
|
+
expect(blockHints(element('H2')).keepWithNext).toBe(true);
|
|
11
|
+
expect(blockHints(element('H5')).keepWithNext).toBe(false);
|
|
12
|
+
expect(blockHints(element('H2', { 'data-od-keep-with-next': 'false' })).keepWithNext).toBe(
|
|
13
|
+
false,
|
|
14
|
+
);
|
|
15
|
+
expect(blockHints(element('P', { 'data-od-keep-with-next': '' })).keepWithNext).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('reads keep-with-previous and break-before only when they are declared', () => {
|
|
19
|
+
expect(blockHints(element('P'))).toEqual({
|
|
20
|
+
keepWithNext: false,
|
|
21
|
+
keepWithPrevious: false,
|
|
22
|
+
breakBefore: false,
|
|
23
|
+
});
|
|
24
|
+
expect(
|
|
25
|
+
blockHints(
|
|
26
|
+
element('H1', { 'data-od-break-before': '', 'data-od-keep-with-previous': 'true' }),
|
|
27
|
+
),
|
|
28
|
+
).toEqual({ keepWithNext: true, keepWithPrevious: true, breakBefore: true });
|
|
29
|
+
expect(blockHints(null).keepWithNext).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { FLOW_BLOCK_ATTR, FlowBlock } from '../components/flow-page';
|
|
4
|
+
import { FOOTNOTE_AREA_MARGIN_TOP, FOOTNOTE_ROW_ATTR, Footnotes } from '../components/footnote';
|
|
5
|
+
import { type DesignSystem, designToCssVars } from './design';
|
|
6
|
+
import type { BlockMetrics } from './flow';
|
|
7
|
+
import type { ExtractedNote } from './footnotes';
|
|
8
|
+
import { nextFrame, waitForFonts } from './print-ready';
|
|
9
|
+
import type { PageGeometry } from './sdk';
|
|
10
|
+
|
|
11
|
+
const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4']);
|
|
12
|
+
|
|
13
|
+
/** A flow section reduced to what measurement needs: blocks, and their notes. */
|
|
14
|
+
export type MeasurableSection = {
|
|
15
|
+
blocks: ReactNode[];
|
|
16
|
+
/** Index-aligned with `blocks`. */
|
|
17
|
+
notesByBlock: ExtractedNote[][];
|
|
18
|
+
padding?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BlockHints = Required<
|
|
22
|
+
Pick<BlockMetrics, 'keepWithNext' | 'keepWithPrevious' | 'breakBefore'>
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
/** The page-break hints a block declares — read once, for the packer and the Word export alike. */
|
|
26
|
+
export function blockHints(el: Element | null): BlockHints {
|
|
27
|
+
const declared = (name: string) => {
|
|
28
|
+
const value = el?.getAttribute(name);
|
|
29
|
+
return value === null || value === undefined ? undefined : value !== 'false';
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
// A heading alone at the bottom of a page is the most visible layout error
|
|
33
|
+
// in a report, so headings glue to whatever follows them by default.
|
|
34
|
+
keepWithNext: declared('data-od-keep-with-next') ?? HEADING_TAGS.has(el?.tagName ?? ''),
|
|
35
|
+
keepWithPrevious: declared('data-od-keep-with-previous') ?? false,
|
|
36
|
+
breakBefore: declared('data-od-break-before') ?? false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function metricsFor(el: HTMLElement, height: number, footnoteHeight: number): BlockMetrics {
|
|
41
|
+
return { height, footnoteHeight, ...blockHints(el.firstElementChild) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FlowMeasurement = {
|
|
45
|
+
metrics: BlockMetrics[];
|
|
46
|
+
/** Usable height of one page for this section. */
|
|
47
|
+
available: number;
|
|
48
|
+
/** What a page's footnote area costs before its first note. */
|
|
49
|
+
footnoteOverhead: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How much vertical space each node claims, measured from where the next one
|
|
54
|
+
* starts — which is what already accounts for collapsed margins.
|
|
55
|
+
*
|
|
56
|
+
* Offsets are taken relative to the container's own box, not `offsetTop`: every
|
|
57
|
+
* container shares one positioned host, so `offsetTop` is host-relative while
|
|
58
|
+
* the container's height is not, and mixing the two silently measures the last
|
|
59
|
+
* node of every container after the first as zero.
|
|
60
|
+
*/
|
|
61
|
+
function stackedHeights(container: HTMLElement, selector: string): number[] {
|
|
62
|
+
const nodes = Array.from(container.querySelectorAll<HTMLElement>(selector));
|
|
63
|
+
const origin = container.getBoundingClientRect();
|
|
64
|
+
const tops = nodes.map((node) => node.getBoundingClientRect().top - origin.top);
|
|
65
|
+
return nodes.map((_, index) =>
|
|
66
|
+
Math.max(0, (index + 1 < tops.length ? tops[index + 1] : origin.height) - tops[index]),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Renders each section's blocks offscreen at the real page width and reads back
|
|
72
|
+
* their stacked heights. Measuring the live DOM is the only way to know how a
|
|
73
|
+
* paragraph wraps or how tall a table grew.
|
|
74
|
+
*
|
|
75
|
+
* Footnotes are measured in the same pass, in the component that prints them,
|
|
76
|
+
* because the space they claim at the foot of a page is space the packer must
|
|
77
|
+
* not hand to body content.
|
|
78
|
+
*/
|
|
79
|
+
export async function measureFlowSections(
|
|
80
|
+
sections: MeasurableSection[],
|
|
81
|
+
opts: { geometry: PageGeometry; design?: DesignSystem },
|
|
82
|
+
): Promise<FlowMeasurement[]> {
|
|
83
|
+
if (sections.length === 0) return [];
|
|
84
|
+
|
|
85
|
+
const host = document.createElement('div');
|
|
86
|
+
host.setAttribute('aria-hidden', 'true');
|
|
87
|
+
Object.assign(host.style, {
|
|
88
|
+
position: 'fixed',
|
|
89
|
+
left: '-99999px',
|
|
90
|
+
top: '0',
|
|
91
|
+
pointerEvents: 'none',
|
|
92
|
+
visibility: 'hidden',
|
|
93
|
+
});
|
|
94
|
+
document.body.appendChild(host);
|
|
95
|
+
|
|
96
|
+
const designVars = opts.design ? designToCssVars(opts.design) : null;
|
|
97
|
+
const roots: Array<ReturnType<typeof createRoot>> = [];
|
|
98
|
+
const blockContainers: HTMLElement[] = [];
|
|
99
|
+
const noteContainers: Array<HTMLElement | null> = [];
|
|
100
|
+
|
|
101
|
+
const paddingOf = (section: MeasurableSection) => section.padding ?? opts.design?.margin ?? 76;
|
|
102
|
+
|
|
103
|
+
const makeContainer = (width: number): HTMLElement => {
|
|
104
|
+
const container = document.createElement('div');
|
|
105
|
+
container.style.width = `${width}px`;
|
|
106
|
+
container.style.fontFamily = 'var(--od-font-body)';
|
|
107
|
+
container.style.fontSize = 'var(--od-size-body)';
|
|
108
|
+
container.style.lineHeight = 'var(--od-leading)';
|
|
109
|
+
if (designVars) {
|
|
110
|
+
for (const [k, v] of Object.entries(designVars)) container.style.setProperty(k, v);
|
|
111
|
+
}
|
|
112
|
+
host.appendChild(container);
|
|
113
|
+
return container;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for (const section of sections) {
|
|
118
|
+
const width = opts.geometry.width - paddingOf(section) * 2;
|
|
119
|
+
|
|
120
|
+
const blocks = makeContainer(width);
|
|
121
|
+
blockContainers.push(blocks);
|
|
122
|
+
const blockRoot = createRoot(blocks);
|
|
123
|
+
blockRoot.render(
|
|
124
|
+
section.blocks.map((block, index) =>
|
|
125
|
+
createElement(FlowBlock, { key: index }, block),
|
|
126
|
+
) as unknown as Parameters<typeof blockRoot.render>[0],
|
|
127
|
+
);
|
|
128
|
+
roots.push(blockRoot);
|
|
129
|
+
|
|
130
|
+
const allNotes = section.notesByBlock.flat();
|
|
131
|
+
if (allNotes.length === 0) {
|
|
132
|
+
noteContainers.push(null);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const notes = makeContainer(width);
|
|
136
|
+
noteContainers.push(notes);
|
|
137
|
+
const noteRoot = createRoot(notes);
|
|
138
|
+
noteRoot.render(createElement(Footnotes, { notes: allNotes }));
|
|
139
|
+
roots.push(noteRoot);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await nextFrame();
|
|
143
|
+
await waitForFonts();
|
|
144
|
+
await nextFrame();
|
|
145
|
+
|
|
146
|
+
return sections.map((section, sectionIndex) => {
|
|
147
|
+
const padding = paddingOf(section);
|
|
148
|
+
const blocks = blockContainers[sectionIndex];
|
|
149
|
+
const blockHeights = stackedHeights(blocks, `:scope > [${FLOW_BLOCK_ATTR}]`);
|
|
150
|
+
|
|
151
|
+
const noteContainer = noteContainers[sectionIndex];
|
|
152
|
+
let rowHeights: number[] = [];
|
|
153
|
+
let footnoteOverhead = 0;
|
|
154
|
+
if (noteContainer) {
|
|
155
|
+
rowHeights = stackedHeights(noteContainer, `[${FOOTNOTE_ROW_ATTR}]`);
|
|
156
|
+
const area = noteContainer.getBoundingClientRect().height;
|
|
157
|
+
const rows = rowHeights.reduce((sum, height) => sum + height, 0);
|
|
158
|
+
footnoteOverhead = Math.max(0, area - rows) + FOOTNOTE_AREA_MARGIN_TOP;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let noteCursor = 0;
|
|
162
|
+
const nodes = Array.from(
|
|
163
|
+
blocks.querySelectorAll<HTMLElement>(`:scope > [${FLOW_BLOCK_ATTR}]`),
|
|
164
|
+
);
|
|
165
|
+
const metrics = nodes.map((node, index) => {
|
|
166
|
+
const count = section.notesByBlock[index]?.length ?? 0;
|
|
167
|
+
let notesHeight = 0;
|
|
168
|
+
for (let i = 0; i < count; i++) notesHeight += rowHeights[noteCursor + i] ?? 0;
|
|
169
|
+
noteCursor += count;
|
|
170
|
+
return metricsFor(node, blockHeights[index] ?? 0, notesHeight);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
metrics,
|
|
175
|
+
available: opts.geometry.height - padding * 2,
|
|
176
|
+
footnoteOverhead,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
for (const root of roots) root.unmount();
|
|
181
|
+
host.remove();
|
|
182
|
+
}
|
|
183
|
+
}
|