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,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
eastAsiaLangFromFonts,
|
|
4
|
+
fontKindOf,
|
|
5
|
+
isCjkFace,
|
|
6
|
+
parseFontFamily,
|
|
7
|
+
pickFonts,
|
|
8
|
+
standInFor,
|
|
9
|
+
} from './fonts';
|
|
10
|
+
|
|
11
|
+
describe('parseFontFamily', () => {
|
|
12
|
+
it('splits on commas outside quotes and drops the quotes', () => {
|
|
13
|
+
expect(parseFontFamily('"Noto Serif TC", \'Source Han Serif\', serif')).toEqual([
|
|
14
|
+
'Noto Serif TC',
|
|
15
|
+
'Source Han Serif',
|
|
16
|
+
'serif',
|
|
17
|
+
]);
|
|
18
|
+
expect(parseFontFamily('"Font, With Comma", Arial')).toEqual(['Font, With Comma', 'Arial']);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('pickFonts', () => {
|
|
23
|
+
it('skips system aliases Word cannot look up', () => {
|
|
24
|
+
expect(pickFonts('-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif')).toEqual({
|
|
25
|
+
ascii: 'Inter',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('names a real face for a stack of generics only', () => {
|
|
30
|
+
expect(pickFonts('system-ui, sans-serif')).toEqual({ ascii: 'Arial' });
|
|
31
|
+
expect(pickFonts('ui-monospace, monospace')).toEqual({ ascii: 'Courier New' });
|
|
32
|
+
expect(pickFonts('serif')).toEqual({ ascii: 'Times New Roman' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/* 主題指定的中文字型必須寫進 w:eastAsia,否則 Word 會換字型、斷行跟著變。 */
|
|
36
|
+
it('keeps the theme’s CJK face for East Asian text', () => {
|
|
37
|
+
expect(pickFonts('"Noto Serif TC", serif')).toEqual({
|
|
38
|
+
ascii: 'Noto Serif TC',
|
|
39
|
+
eastAsia: 'Noto Serif TC',
|
|
40
|
+
});
|
|
41
|
+
expect(pickFonts('"Inter", "Noto Sans TC", sans-serif')).toEqual({
|
|
42
|
+
ascii: 'Inter',
|
|
43
|
+
eastAsia: 'Noto Sans TC',
|
|
44
|
+
});
|
|
45
|
+
expect(pickFonts('"微軟正黑體", sans-serif')).toEqual({
|
|
46
|
+
ascii: '微軟正黑體',
|
|
47
|
+
eastAsia: '微軟正黑體',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/* 小型大寫的拉丁字型(Playfair Display SC)和 HK Grotesk 不是中日韓字型。 */
|
|
52
|
+
it('does not take a Latin small-caps or "HK" family for a CJK face', () => {
|
|
53
|
+
for (const name of ['Playfair Display SC', 'Alegreya Sans SC', 'Amatic SC', 'HK Grotesk']) {
|
|
54
|
+
expect(isCjkFace(name)).toBe(false);
|
|
55
|
+
}
|
|
56
|
+
for (const name of ['Noto Serif SC', 'Noto Sans CJK TC', 'LXGW WenKai TC', 'Chiron Hei HK']) {
|
|
57
|
+
expect(isCjkFace(name)).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
expect(pickFonts('"Playfair Display SC", serif')).toEqual({ ascii: 'Playfair Display SC' });
|
|
60
|
+
expect(pickFonts('"HK Grotesk", "Noto Sans TC", sans-serif')).toEqual({
|
|
61
|
+
ascii: 'HK Grotesk',
|
|
62
|
+
eastAsia: 'Noto Sans TC',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('leaves the East Asian face to Word when the theme names none', () => {
|
|
67
|
+
expect(pickFonts('"Century Gothic", Arial, sans-serif')).toEqual({ ascii: 'Century Gothic' });
|
|
68
|
+
expect(isCjkFace('Century Gothic')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('ignores emoji faces at the end of a stack', () => {
|
|
72
|
+
expect(pickFonts('"Apple Color Emoji", "Segoe UI Emoji", sans-serif')).toEqual({
|
|
73
|
+
ascii: 'Arial',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('eastAsiaLangFromFonts', () => {
|
|
79
|
+
it('reads the language from the face name', () => {
|
|
80
|
+
expect(eastAsiaLangFromFonts(['Noto Sans TC'])).toBe('zh-TW');
|
|
81
|
+
expect(eastAsiaLangFromFonts(['PingFang SC'])).toBe('zh-CN');
|
|
82
|
+
expect(eastAsiaLangFromFonts(['Noto Sans HK'])).toBe('zh-HK');
|
|
83
|
+
expect(eastAsiaLangFromFonts(['Hiragino Sans'])).toBe('ja-JP');
|
|
84
|
+
expect(eastAsiaLangFromFonts(['Noto Sans KR'])).toBe('ko-KR');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('guesses nothing from a face that does not say', () => {
|
|
88
|
+
expect(eastAsiaLangFromFonts(['Source Han Sans'])).toBeUndefined();
|
|
89
|
+
expect(eastAsiaLangFromFonts([])).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('standInFor', () => {
|
|
94
|
+
/* 沒有字族資訊時,Word 會把缺少的字型一律換成 Times New Roman。 */
|
|
95
|
+
it('tells Word what kind of face to substitute for a missing one', () => {
|
|
96
|
+
expect(fontKindOf('"Inter", system-ui, sans-serif')).toBe('sans');
|
|
97
|
+
expect(fontKindOf('"Noto Serif TC", serif')).toBe('serif');
|
|
98
|
+
expect(fontKindOf('"JetBrains Mono", monospace')).toBe('mono');
|
|
99
|
+
expect(standInFor({ name: 'Inter', kind: 'sans', eastAsia: false }, undefined)).toEqual({
|
|
100
|
+
altName: 'Arial',
|
|
101
|
+
charset: '00',
|
|
102
|
+
family: 'swiss',
|
|
103
|
+
pitch: 'variable',
|
|
104
|
+
});
|
|
105
|
+
expect(
|
|
106
|
+
standInFor({ name: 'JetBrains Mono', kind: 'mono', eastAsia: false }, undefined),
|
|
107
|
+
).toMatchObject({
|
|
108
|
+
altName: 'Courier New',
|
|
109
|
+
family: 'modern',
|
|
110
|
+
pitch: 'fixed',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('stands a CJK face in with one for the same language', () => {
|
|
115
|
+
expect(standInFor({ name: 'Noto Serif TC', kind: 'serif', eastAsia: true }, 'zh-TW')).toEqual({
|
|
116
|
+
altName: 'PMingLiU',
|
|
117
|
+
charset: '88',
|
|
118
|
+
family: 'roman',
|
|
119
|
+
pitch: 'variable',
|
|
120
|
+
});
|
|
121
|
+
expect(
|
|
122
|
+
standInFor({ name: 'Noto Sans JP', kind: 'sans', eastAsia: true }, 'ja-JP'),
|
|
123
|
+
).toMatchObject({
|
|
124
|
+
altName: 'Yu Gothic',
|
|
125
|
+
charset: '80',
|
|
126
|
+
});
|
|
127
|
+
// No language, no guess at a face — only the family.
|
|
128
|
+
expect(
|
|
129
|
+
standInFor({ name: 'Source Han Sans', kind: 'sans', eastAsia: true }, undefined),
|
|
130
|
+
).toEqual({
|
|
131
|
+
charset: '00',
|
|
132
|
+
family: 'swiss',
|
|
133
|
+
pitch: 'variable',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { FontSet } from './model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Names in a CSS stack that are not faces Word can look up. The browser
|
|
5
|
+
* resolves them to whatever the system uses; Word would take them literally and
|
|
6
|
+
* fall back to Times New Roman.
|
|
7
|
+
*/
|
|
8
|
+
const NOT_A_FACE = new Set([
|
|
9
|
+
'serif',
|
|
10
|
+
'sans-serif',
|
|
11
|
+
'monospace',
|
|
12
|
+
'cursive',
|
|
13
|
+
'fantasy',
|
|
14
|
+
'system-ui',
|
|
15
|
+
'ui-serif',
|
|
16
|
+
'ui-sans-serif',
|
|
17
|
+
'ui-monospace',
|
|
18
|
+
'ui-rounded',
|
|
19
|
+
'emoji',
|
|
20
|
+
'math',
|
|
21
|
+
'fangsong',
|
|
22
|
+
'-apple-system',
|
|
23
|
+
'blinkmacsystemfont',
|
|
24
|
+
'inherit',
|
|
25
|
+
'initial',
|
|
26
|
+
'unset',
|
|
27
|
+
'revert',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const EMOJI_FACE = /emoji|symbol/i;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Faces that carry CJK glyphs. Matched by name because there is nothing else to
|
|
34
|
+
* go on — the stack is text — and a name in any non-Latin script is taken as one too.
|
|
35
|
+
* Deliberately not a bare "Gothic": Century Gothic is a Latin face.
|
|
36
|
+
*/
|
|
37
|
+
const CJK_FACE =
|
|
38
|
+
/\bCJK\b|Source Han|PingFang|Hiragino|Meiryo|JhengHei|YaHei|SimSun|SimHei|MingLiU|DFKai|BiauKai|LiGothic|LiSung|LiHei|Heiti|Songti|Kaiti|STSong|FangSong|Malgun|Batang|Gulim|Dotum|Nanum|Apple SD Gothic|Yu Gothic|Mincho|MS P?Gothic|[^ -\u024F]/i;
|
|
39
|
+
|
|
40
|
+
/** Splits a computed `font-family` into names, quotes removed, order kept. */
|
|
41
|
+
export function parseFontFamily(value: string): string[] {
|
|
42
|
+
const names: string[] = [];
|
|
43
|
+
let current = '';
|
|
44
|
+
let quote: string | null = null;
|
|
45
|
+
for (const ch of value) {
|
|
46
|
+
if (quote) {
|
|
47
|
+
if (ch === quote) quote = null;
|
|
48
|
+
else current += ch;
|
|
49
|
+
} else if (ch === '"' || ch === "'") {
|
|
50
|
+
quote = ch;
|
|
51
|
+
} else if (ch === ',') {
|
|
52
|
+
if (current.trim()) names.push(current.trim());
|
|
53
|
+
current = '';
|
|
54
|
+
} else {
|
|
55
|
+
current += ch;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (current.trim()) names.push(current.trim());
|
|
59
|
+
return names;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A region cut named at the end — "Noto Sans TC", "Chiron Hei HK". Case matters,
|
|
64
|
+
* and SC counts only on a family that has CJK cuts: "Playfair Display SC" and
|
|
65
|
+
* "Alegreya Sans SC" are small caps, and "HK Grotesk" is Latin.
|
|
66
|
+
*/
|
|
67
|
+
const CJK_REGION = /\s(?:TC|HK|JP|KR)$|\b(?:Noto|LXGW|WenKai|Sarasa|HarmonyOS|Chiron)\b.*\sSC$/;
|
|
68
|
+
|
|
69
|
+
export function isCjkFace(name: string): boolean {
|
|
70
|
+
return CJK_FACE.test(name) || CJK_REGION.test(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type FontKind = 'sans' | 'serif' | 'mono';
|
|
74
|
+
|
|
75
|
+
/** What the stack falls back to — the only thing it says about the face's design. */
|
|
76
|
+
export function fontKindOf(fontFamily: string): FontKind {
|
|
77
|
+
for (const name of parseFontFamily(fontFamily).map((n) => n.toLowerCase())) {
|
|
78
|
+
if (name === 'serif' || name === 'ui-serif') return 'serif';
|
|
79
|
+
if (name === 'monospace' || name === 'ui-monospace') return 'mono';
|
|
80
|
+
}
|
|
81
|
+
return 'sans';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const LATIN_STANDIN: Record<FontKind, string> = {
|
|
85
|
+
sans: 'Arial',
|
|
86
|
+
serif: 'Times New Roman',
|
|
87
|
+
mono: 'Courier New',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The faces Word should ask for, taken in the theme's own order: the first real
|
|
92
|
+
* face for Latin text, and the first CJK face for East Asian text. When the
|
|
93
|
+
* stack names no CJK face the East Asian slot is left empty, so Word picks its
|
|
94
|
+
* own for the language instead of being told to set Chinese in Arial.
|
|
95
|
+
*/
|
|
96
|
+
export function pickFonts(fontFamily: string): FontSet {
|
|
97
|
+
const names = parseFontFamily(fontFamily);
|
|
98
|
+
const faces = names.filter(
|
|
99
|
+
(name) => !NOT_A_FACE.has(name.toLowerCase()) && !EMOJI_FACE.test(name),
|
|
100
|
+
);
|
|
101
|
+
const ascii = faces[0] ?? LATIN_STANDIN[fontKindOf(fontFamily)];
|
|
102
|
+
const eastAsia = faces.find(isCjkFace);
|
|
103
|
+
return eastAsia ? { ascii, eastAsia } : { ascii };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The language tag Word uses for line breaking and proofing of East Asian
|
|
108
|
+
* text, read from the face names. Nothing is guessed from the text itself: Han
|
|
109
|
+
* characters alone do not say whether a document is Traditional or Simplified.
|
|
110
|
+
*/
|
|
111
|
+
export function eastAsiaLangFromFonts(faces: string[]): string | undefined {
|
|
112
|
+
for (const face of faces) {
|
|
113
|
+
if (/\bHK\b/.test(face)) return 'zh-HK';
|
|
114
|
+
if (/\bTC\b|JhengHei|MingLiU|DFKai|BiauKai|LiGothic|LiSung/i.test(face)) return 'zh-TW';
|
|
115
|
+
if (/\bSC\b|YaHei|SimSun|SimHei/i.test(face)) return 'zh-CN';
|
|
116
|
+
if (/\bJP\b|Hiragino|Meiryo|Mincho|Yu Gothic|MS P?Gothic/i.test(face)) return 'ja-JP';
|
|
117
|
+
if (/\bKR\b|Malgun|Batang|Gulim|Dotum|Nanum/i.test(face)) return 'ko-KR';
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const CJK_STANDIN: Record<string, { sans: string; serif: string; charset: string }> = {
|
|
123
|
+
'zh-TW': { sans: 'Microsoft JhengHei', serif: 'PMingLiU', charset: '88' },
|
|
124
|
+
'zh-HK': { sans: 'Microsoft JhengHei', serif: 'PMingLiU', charset: '88' },
|
|
125
|
+
'zh-CN': { sans: 'Microsoft YaHei', serif: 'SimSun', charset: '86' },
|
|
126
|
+
'ja-JP': { sans: 'Yu Gothic', serif: 'Yu Mincho', charset: '80' },
|
|
127
|
+
'ko-KR': { sans: 'Malgun Gothic', serif: 'Batang', charset: '81' },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export type FontDeclaration = {
|
|
131
|
+
name: string;
|
|
132
|
+
kind: FontKind;
|
|
133
|
+
/** Used for East Asian text, so it wants a CJK face in its place. */
|
|
134
|
+
eastAsia: boolean;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Where Word should look when the face is not installed. Without a family,
|
|
139
|
+
* Word substitutes Times New Roman for any missing face — a sans-serif theme
|
|
140
|
+
* would arrive in a serif on every machine that lacks the exact face.
|
|
141
|
+
*/
|
|
142
|
+
export function standInFor(
|
|
143
|
+
font: FontDeclaration,
|
|
144
|
+
eastAsiaLang: string | undefined,
|
|
145
|
+
): {
|
|
146
|
+
altName?: string;
|
|
147
|
+
charset: string;
|
|
148
|
+
family: 'swiss' | 'roman' | 'modern';
|
|
149
|
+
pitch: 'variable' | 'fixed';
|
|
150
|
+
} {
|
|
151
|
+
const family = font.kind === 'serif' ? 'roman' : font.kind === 'mono' ? 'modern' : 'swiss';
|
|
152
|
+
const pitch = font.kind === 'mono' ? 'fixed' : 'variable';
|
|
153
|
+
if (font.eastAsia) {
|
|
154
|
+
const cjk = eastAsiaLang ? CJK_STANDIN[eastAsiaLang] : undefined;
|
|
155
|
+
if (!cjk) return { charset: '00', family, pitch };
|
|
156
|
+
const altName = font.kind === 'serif' ? cjk.serif : cjk.sans;
|
|
157
|
+
return {
|
|
158
|
+
altName: altName === font.name ? undefined : altName,
|
|
159
|
+
charset: cjk.charset,
|
|
160
|
+
family,
|
|
161
|
+
pitch,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const altName = LATIN_STANDIN[font.kind];
|
|
165
|
+
return { altName: altName === font.name ? undefined : altName, charset: '00', family, pitch };
|
|
166
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { drawToPng } from '../rasterize';
|
|
2
|
+
import type { Media } from './model';
|
|
3
|
+
|
|
4
|
+
type Format = 'png' | 'jpeg' | 'gif';
|
|
5
|
+
|
|
6
|
+
function sniff(bytes: Uint8Array): Format | null {
|
|
7
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
8
|
+
return 'png';
|
|
9
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
|
|
10
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'gif';
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Picture = { bytes: Uint8Array; type: Format; drawn: boolean };
|
|
15
|
+
|
|
16
|
+
async function png(source: CanvasImageSource, size: { width: number; height: number }) {
|
|
17
|
+
const blob = await drawToPng(source, size);
|
|
18
|
+
return { bytes: new Uint8Array(await blob.arrayBuffer()), type: 'png' as const, drawn: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A picture's own bytes when Word reads the format; otherwise what the browser decoded. */
|
|
22
|
+
async function readImage(img: HTMLImageElement, source: string, size: DOMRect): Promise<Picture> {
|
|
23
|
+
// SVG, WebP, AVIF: formats Word may not open, so their bytes would be discarded anyway.
|
|
24
|
+
if (!/\.(?:svg|webp|avif)(?:[?#]|$)/i.test(source)) {
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(source);
|
|
27
|
+
if (response.ok) {
|
|
28
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
29
|
+
const type = sniff(bytes);
|
|
30
|
+
if (type) return { bytes, type, drawn: false };
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
/* Drawn below from what the page already decoded. */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return png(img, size);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Cropped, rounded, or filtered on the page: the file is not what the reader sees. */
|
|
40
|
+
function reshaped(img: HTMLImageElement): boolean {
|
|
41
|
+
const cs = getComputedStyle(img);
|
|
42
|
+
return (
|
|
43
|
+
cs.objectFit !== 'fill' ||
|
|
44
|
+
cs.borderRadius !== '0px' ||
|
|
45
|
+
cs.clipPath !== 'none' ||
|
|
46
|
+
cs.filter !== 'none'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function same(a: Uint8Array, b: Uint8Array): boolean {
|
|
51
|
+
if (a === b) return true;
|
|
52
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The package's pictures, each stored once however many times the document shows it. */
|
|
57
|
+
export class MediaStore {
|
|
58
|
+
readonly media: Media[] = [];
|
|
59
|
+
private readonly bySource = new Map<string, number>();
|
|
60
|
+
/** By byte length: two different pictures of exactly one size are rare, and told apart byte by byte. */
|
|
61
|
+
private readonly byLength = new Map<number, number[]>();
|
|
62
|
+
|
|
63
|
+
constructor(private readonly rasterize: (el: Element) => Promise<Uint8Array | null>) {}
|
|
64
|
+
|
|
65
|
+
/** The index of a picture of `el`, or null when none could be made. */
|
|
66
|
+
async of(el: Element, rect: DOMRect): Promise<number | null> {
|
|
67
|
+
const img = el instanceof HTMLImageElement ? el : null;
|
|
68
|
+
const source = img && !reshaped(img) ? img.currentSrc || img.src : '';
|
|
69
|
+
const known = source ? this.bySource.get(source) : undefined;
|
|
70
|
+
if (known !== undefined) return known;
|
|
71
|
+
|
|
72
|
+
let picture: Picture | null = null;
|
|
73
|
+
try {
|
|
74
|
+
if (img && source) picture = await readImage(img, source, rect);
|
|
75
|
+
else if (el instanceof HTMLCanvasElement) picture = await png(el, rect);
|
|
76
|
+
else {
|
|
77
|
+
const bytes = await this.rasterize(el);
|
|
78
|
+
picture = bytes && { bytes, type: 'png', drawn: true };
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// A cross-origin picture taints the canvas and cannot be read back.
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!picture) return null;
|
|
85
|
+
|
|
86
|
+
// Matched on what was drawn, not on what drew it: two canvases with the
|
|
87
|
+
// same markup can hold different charts, and one SVG in two colours is two
|
|
88
|
+
// pictures — while the logo in every section's footer is still one.
|
|
89
|
+
const { bytes, type } = picture;
|
|
90
|
+
const candidates = this.byLength.get(bytes.length) ?? [];
|
|
91
|
+
let index = candidates.find((i) => same(this.media[i].bytes, bytes));
|
|
92
|
+
if (index === undefined) {
|
|
93
|
+
index = this.media.length;
|
|
94
|
+
this.media.push({ name: `image${index + 1}.${type}`, contentType: `image/${type}`, bytes });
|
|
95
|
+
this.byLength.set(bytes.length, [...candidates, index]);
|
|
96
|
+
}
|
|
97
|
+
// Only the file itself stands for its URL. A drawing of it is the size it was
|
|
98
|
+
// drawn at, and the same logo larger on another page needs its own.
|
|
99
|
+
if (source && !picture.drawn) this.bySource.set(source, index);
|
|
100
|
+
return index;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { FontDeclaration } from './fonts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a document looks like once it has been read out of the DOM and before
|
|
5
|
+
* it is written as WordprocessingML.
|
|
6
|
+
*
|
|
7
|
+
* Everything is already in Word's units — twips (1/20 pt) for lengths,
|
|
8
|
+
* half-points for type sizes, EMU for drawings, six-digit hex for colours — so
|
|
9
|
+
* the writer is plain string assembly and can be tested without a browser.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type FontSet = {
|
|
13
|
+
/** Latin text: `w:ascii`, `w:hAnsi`, `w:cs`. */
|
|
14
|
+
ascii: string;
|
|
15
|
+
/** Han, kana, hangul: `w:eastAsia`. Absent when the theme names no CJK face. */
|
|
16
|
+
eastAsia?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RunStyle = {
|
|
20
|
+
fonts: FontSet;
|
|
21
|
+
/** Half-points. */
|
|
22
|
+
size: number;
|
|
23
|
+
bold: boolean;
|
|
24
|
+
italic: boolean;
|
|
25
|
+
underline: boolean;
|
|
26
|
+
strike: boolean;
|
|
27
|
+
color: string;
|
|
28
|
+
/** Background behind the run — inline code, a highlight. */
|
|
29
|
+
shading?: string;
|
|
30
|
+
vertAlign?: 'superscript' | 'subscript';
|
|
31
|
+
caps?: boolean;
|
|
32
|
+
smallCaps?: boolean;
|
|
33
|
+
/** Letter spacing, twips. */
|
|
34
|
+
spacing?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type LinkTarget = { url: string } | { anchor: string };
|
|
38
|
+
|
|
39
|
+
export type Inline =
|
|
40
|
+
| { type: 'text'; text: string; style: RunStyle; link?: LinkTarget }
|
|
41
|
+
/** `style` keeps an empty line in preformatted text at the text's own size. */
|
|
42
|
+
| { type: 'break'; style?: RunStyle }
|
|
43
|
+
| { type: 'tab' }
|
|
44
|
+
| { type: 'field'; instr: string; cached: string; style: RunStyle; link?: LinkTarget }
|
|
45
|
+
/** `style` is the marker as it rendered; its colour becomes the reference style's. */
|
|
46
|
+
| { type: 'footnote'; id: number; style: RunStyle }
|
|
47
|
+
| { type: 'image'; image: ImageRef; link?: LinkTarget };
|
|
48
|
+
|
|
49
|
+
export type ImageRef = {
|
|
50
|
+
/** Index into `DocxModel.media`. */
|
|
51
|
+
media: number;
|
|
52
|
+
/** EMU. */
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
alt: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type BorderStyle = 'single' | 'dashed' | 'dotted' | 'double';
|
|
59
|
+
|
|
60
|
+
export type Border = {
|
|
61
|
+
style: BorderStyle;
|
|
62
|
+
/** Eighths of a point. */
|
|
63
|
+
size: number;
|
|
64
|
+
color: string;
|
|
65
|
+
/** Distance from the text, points. */
|
|
66
|
+
space: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type Side = 'top' | 'right' | 'bottom' | 'left';
|
|
70
|
+
|
|
71
|
+
export type Sides = Record<Side, number>;
|
|
72
|
+
|
|
73
|
+
export type Borders = Partial<Record<Side, Border>>;
|
|
74
|
+
|
|
75
|
+
export type TabStop = {
|
|
76
|
+
align: 'left' | 'center' | 'right';
|
|
77
|
+
/** Twips from the text column's leading edge. */
|
|
78
|
+
pos: number;
|
|
79
|
+
leader?: 'dot' | 'hyphen' | 'underscore';
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ParagraphProps = {
|
|
83
|
+
align?: 'left' | 'center' | 'right' | 'both';
|
|
84
|
+
spaceBefore?: number;
|
|
85
|
+
spaceAfter?: number;
|
|
86
|
+
/** Line height, twips. `atLeast` unless `lineExact`. */
|
|
87
|
+
line?: number;
|
|
88
|
+
lineExact?: boolean;
|
|
89
|
+
indentLeft?: number;
|
|
90
|
+
indentRight?: number;
|
|
91
|
+
/** Negative is a hanging indent. */
|
|
92
|
+
firstLine?: number;
|
|
93
|
+
borders?: Borders;
|
|
94
|
+
shading?: string;
|
|
95
|
+
tabs?: TabStop[];
|
|
96
|
+
keepNext?: boolean;
|
|
97
|
+
pageBreakBefore?: boolean;
|
|
98
|
+
/** Right to left. */
|
|
99
|
+
bidi?: boolean;
|
|
100
|
+
/** Size of the paragraph mark, half-points — keeps an empty rule paragraph thin. */
|
|
101
|
+
markSize?: number;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type ParagraphRole =
|
|
105
|
+
| 'body'
|
|
106
|
+
| 'heading'
|
|
107
|
+
| 'title'
|
|
108
|
+
| 'subtitle'
|
|
109
|
+
| 'toc'
|
|
110
|
+
| 'caption'
|
|
111
|
+
| 'list'
|
|
112
|
+
| 'footnote'
|
|
113
|
+
| 'header'
|
|
114
|
+
| 'footer';
|
|
115
|
+
|
|
116
|
+
export type Paragraph = {
|
|
117
|
+
type: 'paragraph';
|
|
118
|
+
role: ParagraphRole;
|
|
119
|
+
/** Heading level 1–6, or contents level 1–9. */
|
|
120
|
+
level?: number;
|
|
121
|
+
inlines: Inline[];
|
|
122
|
+
props: ParagraphProps;
|
|
123
|
+
list?: { num: number; level: number };
|
|
124
|
+
bookmark?: string;
|
|
125
|
+
/** A field that spans paragraphs — the table of contents. */
|
|
126
|
+
field?: { begin?: string; end?: boolean };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type TableCell = {
|
|
130
|
+
blocks: Block[];
|
|
131
|
+
span: number;
|
|
132
|
+
merge?: 'restart' | 'continue';
|
|
133
|
+
width: number;
|
|
134
|
+
shading?: string;
|
|
135
|
+
borders: Borders;
|
|
136
|
+
/** Cell padding, twips. */
|
|
137
|
+
margins: Sides;
|
|
138
|
+
vAlign?: 'center' | 'bottom';
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type TableRow = { cells: TableCell[]; header: boolean };
|
|
142
|
+
|
|
143
|
+
export type Table = {
|
|
144
|
+
type: 'table';
|
|
145
|
+
/** Twips, one per grid column. */
|
|
146
|
+
columns: number[];
|
|
147
|
+
rows: TableRow[];
|
|
148
|
+
align?: 'center';
|
|
149
|
+
indent?: number;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type Block = Paragraph | Table;
|
|
153
|
+
|
|
154
|
+
export type PageSetup = {
|
|
155
|
+
/** Twips; wider than tall is landscape. */
|
|
156
|
+
width: number;
|
|
157
|
+
height: number;
|
|
158
|
+
margin: Sides;
|
|
159
|
+
/** Distance from the top edge of the sheet to the top of the header. */
|
|
160
|
+
header: number;
|
|
161
|
+
/** Distance from the bottom edge of the sheet to the bottom of the footer. */
|
|
162
|
+
footer: number;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export type Section = {
|
|
166
|
+
blocks: Block[];
|
|
167
|
+
header?: Paragraph[];
|
|
168
|
+
footer?: Paragraph[];
|
|
169
|
+
/** The section's first page, when its footer is not the running one there — empty is hidden. */
|
|
170
|
+
footerFirst?: Paragraph[];
|
|
171
|
+
page: PageSetup;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export type Footnote = { id: number; blocks: Block[] };
|
|
175
|
+
|
|
176
|
+
export type ListKind =
|
|
177
|
+
| 'disc'
|
|
178
|
+
| 'circle'
|
|
179
|
+
| 'square'
|
|
180
|
+
| 'decimal'
|
|
181
|
+
| 'lowerLetter'
|
|
182
|
+
| 'upperLetter'
|
|
183
|
+
| 'lowerRoman'
|
|
184
|
+
| 'upperRoman';
|
|
185
|
+
|
|
186
|
+
export type ListInstance = { id: number; kind: ListKind; level: number; start: number };
|
|
187
|
+
|
|
188
|
+
export type Media = { name: string; contentType: string; bytes: Uint8Array };
|
|
189
|
+
|
|
190
|
+
export type DocxModel = {
|
|
191
|
+
title: string;
|
|
192
|
+
subject?: string;
|
|
193
|
+
author?: string;
|
|
194
|
+
/** ISO 8601. */
|
|
195
|
+
created?: string;
|
|
196
|
+
sections: Section[];
|
|
197
|
+
footnotes: Footnote[];
|
|
198
|
+
lists: ListInstance[];
|
|
199
|
+
media: Media[];
|
|
200
|
+
/** Heading sizes from the design, half-points — for levels the document never uses. */
|
|
201
|
+
headingSizes?: number[];
|
|
202
|
+
/** Page colour, when the design's paper is not white. */
|
|
203
|
+
background?: string;
|
|
204
|
+
/** Every face the runs name, with what Word should use in its place. */
|
|
205
|
+
fonts?: FontDeclaration[];
|
|
206
|
+
};
|