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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS collapses white space across element boundaries; Word keeps every space
|
|
3
|
+
* it is given. What reaches a run has to be what the browser showed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import type { Inline, RunStyle } from './model';
|
|
8
|
+
import { capitalize, ParagraphBuilder } from './paragraph';
|
|
9
|
+
|
|
10
|
+
const style: RunStyle = {
|
|
11
|
+
fonts: { ascii: 'Inter' },
|
|
12
|
+
size: 21,
|
|
13
|
+
bold: false,
|
|
14
|
+
italic: false,
|
|
15
|
+
underline: false,
|
|
16
|
+
strike: false,
|
|
17
|
+
color: '000000',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function texts(inlines: Inline[]): string[] {
|
|
21
|
+
return inlines.map((inline) => (inline.type === 'text' ? inline.text : `<${inline.type}>`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ParagraphBuilder', () => {
|
|
25
|
+
it('keeps one space between spans and none at either end', () => {
|
|
26
|
+
const p = new ParagraphBuilder();
|
|
27
|
+
p.text(' Hello ', style, undefined, true);
|
|
28
|
+
p.text(' world ', style, undefined, true);
|
|
29
|
+
expect(texts(p.finish())).toEqual(['Hello ', 'world']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('collapses runs of spaces inside a span', () => {
|
|
33
|
+
const p = new ParagraphBuilder();
|
|
34
|
+
p.text('a b', style, undefined, true);
|
|
35
|
+
expect(texts(p.finish())).toEqual(['a b']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('leaves preformatted text as it is', () => {
|
|
39
|
+
const p = new ParagraphBuilder();
|
|
40
|
+
p.text(' indented ', style, undefined, false);
|
|
41
|
+
expect(texts(p.finish())).toEqual([' indented ']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('trims the space around a line break and a tab', () => {
|
|
45
|
+
const p = new ParagraphBuilder();
|
|
46
|
+
p.text('left ', style, undefined, true);
|
|
47
|
+
p.push({ type: 'tab' });
|
|
48
|
+
p.text(' right ', style, undefined, true);
|
|
49
|
+
p.push({ type: 'break' });
|
|
50
|
+
p.text(' next', style, undefined, true);
|
|
51
|
+
expect(texts(p.finish())).toEqual(['left', '<tab>', 'right', '<break>', 'next']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('puts a nested block on its own line, but never opens with a break', () => {
|
|
55
|
+
const p = new ParagraphBuilder();
|
|
56
|
+
p.requestBreak();
|
|
57
|
+
p.text('first', style, undefined, true);
|
|
58
|
+
p.requestBreak();
|
|
59
|
+
p.text('second', style, undefined, true);
|
|
60
|
+
p.requestBreak();
|
|
61
|
+
expect(texts(p.finish())).toEqual(['first', '<break>', 'second']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('knows the character before the next text, but not across a line break', () => {
|
|
65
|
+
const p = new ParagraphBuilder();
|
|
66
|
+
p.text('mac', style, undefined, true);
|
|
67
|
+
expect(p.lastChar()).toBe('c');
|
|
68
|
+
p.requestBreak();
|
|
69
|
+
expect(p.lastChar()).toBe('');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('counts only visible content as content', () => {
|
|
73
|
+
const p = new ParagraphBuilder();
|
|
74
|
+
p.text(' ', style, undefined, true);
|
|
75
|
+
p.push({ type: 'break' });
|
|
76
|
+
expect(p.empty).toBe(true);
|
|
77
|
+
p.push({ type: 'footnote', id: 1, style });
|
|
78
|
+
expect(p.empty).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('capitalize', () => {
|
|
83
|
+
it('capitalizes each word as the browser does, accents and apostrophes included', () => {
|
|
84
|
+
expect(capitalize('naïve café élan', ' ')).toBe('Naïve Café Élan');
|
|
85
|
+
expect(capitalize("don't stop", '')).toBe("Don't Stop");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('continues a word that began in an earlier element', () => {
|
|
89
|
+
expect(capitalize('book pro', 'c')).toBe('book Pro');
|
|
90
|
+
expect(capitalize('book', ' ')).toBe('Book');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Inline, LinkTarget, RunStyle } from './model';
|
|
2
|
+
|
|
3
|
+
const WORDS = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `text-transform: capitalize` as the browser applies it: the first letter of
|
|
7
|
+
* each word, where "naïve" and "don't" are one word each, and a word can begin
|
|
8
|
+
* in an earlier element — `mac<b>book</b>` is one — so `before` is the text's
|
|
9
|
+
* preceding character.
|
|
10
|
+
*/
|
|
11
|
+
export function capitalize(text: string, before: string): string {
|
|
12
|
+
const lead = before || ' ';
|
|
13
|
+
let out = '';
|
|
14
|
+
for (const { segment, index } of WORDS.segment(lead + text)) {
|
|
15
|
+
if (index < lead.length) {
|
|
16
|
+
out += segment.slice(lead.length - index);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const first = String.fromCodePoint(segment.codePointAt(0) ?? 32);
|
|
20
|
+
out += first.toUpperCase() + segment.slice(first.length);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function meaningful(inline: Inline): boolean {
|
|
26
|
+
if (inline.type === 'text') return inline.text.trim() !== '';
|
|
27
|
+
return inline.type !== 'break' && inline.type !== 'tab';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Item = { inline: Inline; collapse: boolean };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Inline content for one paragraph, with CSS white-space collapsing applied
|
|
34
|
+
* across element boundaries — the space between two spans is one space, and
|
|
35
|
+
* none at all at the start or end of a line.
|
|
36
|
+
*/
|
|
37
|
+
export class ParagraphBuilder {
|
|
38
|
+
private readonly items: Item[] = [];
|
|
39
|
+
private breakPending = false;
|
|
40
|
+
|
|
41
|
+
text(text: string, style: RunStyle, link: LinkTarget | undefined, collapse: boolean): void {
|
|
42
|
+
if (text) this.push({ type: 'text', text, style, link }, collapse);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
push(inline: Inline, collapse = false): void {
|
|
46
|
+
if (this.breakPending) {
|
|
47
|
+
this.breakPending = false;
|
|
48
|
+
if (!this.empty) this.items.push({ inline: { type: 'break' }, collapse: false });
|
|
49
|
+
}
|
|
50
|
+
this.items.push({ inline, collapse });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A block nested in inline content starts and ends its own line. */
|
|
54
|
+
requestBreak(): void {
|
|
55
|
+
this.breakPending = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get empty(): boolean {
|
|
59
|
+
return !this.items.some((item) => meaningful(item.inline));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The last character of text so far; empty at the start of a line. */
|
|
63
|
+
lastChar(): string {
|
|
64
|
+
if (this.breakPending) return '';
|
|
65
|
+
const last = this.items[this.items.length - 1]?.inline;
|
|
66
|
+
return last?.type === 'text' ? last.text.slice(-1) : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
finish(): Inline[] {
|
|
70
|
+
const out: Item[] = [];
|
|
71
|
+
const trimEnd = () => {
|
|
72
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
73
|
+
const item = out[i];
|
|
74
|
+
if (!item?.collapse || item.inline.type !== 'text') return;
|
|
75
|
+
const text = item.inline.text.replace(/ +$/, '');
|
|
76
|
+
if (text) {
|
|
77
|
+
out[i] = { inline: { ...item.inline, text }, collapse: true };
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
out.splice(i, 1);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let afterSpace = true;
|
|
85
|
+
for (const item of this.items) {
|
|
86
|
+
const { inline } = item;
|
|
87
|
+
if (inline.type === 'text' && item.collapse) {
|
|
88
|
+
let text = inline.text.replace(/ {2,}/g, ' ');
|
|
89
|
+
if (afterSpace) text = text.replace(/^ /, '');
|
|
90
|
+
if (!text) continue;
|
|
91
|
+
afterSpace = text.endsWith(' ');
|
|
92
|
+
out.push({ inline: { ...inline, text }, collapse: true });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (inline.type === 'break' || inline.type === 'tab') {
|
|
96
|
+
trimEnd();
|
|
97
|
+
afterSpace = true;
|
|
98
|
+
} else {
|
|
99
|
+
afterSpace = false;
|
|
100
|
+
}
|
|
101
|
+
out.push(item);
|
|
102
|
+
}
|
|
103
|
+
trimEnd();
|
|
104
|
+
while (out[out.length - 1]?.inline.type === 'break') out.pop();
|
|
105
|
+
return out.map((item) => item.inline);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `w:rPr` and `w:pPr`, written against a base. A style is written in full; a
|
|
3
|
+
* paragraph or run writes only what differs from its style, so restyling
|
|
4
|
+
* Heading 2 in Word restyles every heading instead of none of them.
|
|
5
|
+
*
|
|
6
|
+
* Child order follows the schema sequence. Word is strict about it: an element
|
|
7
|
+
* out of place is "unreadable content", not a warning.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Border, Borders, ParagraphProps, RunStyle, Sides, TabStop } from './model';
|
|
11
|
+
import { el } from './xml';
|
|
12
|
+
|
|
13
|
+
export type StylePara = {
|
|
14
|
+
align?: ParagraphProps['align'];
|
|
15
|
+
spaceBefore: number;
|
|
16
|
+
spaceAfter: number;
|
|
17
|
+
line?: number;
|
|
18
|
+
keepNext?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function runPropsXml(
|
|
22
|
+
run: RunStyle,
|
|
23
|
+
base: RunStyle | null,
|
|
24
|
+
opts: { eastAsiaHint?: boolean; lang?: string } = {},
|
|
25
|
+
): string {
|
|
26
|
+
const out: string[] = [];
|
|
27
|
+
|
|
28
|
+
const fontsDiffer =
|
|
29
|
+
!base || run.fonts.ascii !== base.fonts.ascii || run.fonts.eastAsia !== base.fonts.eastAsia;
|
|
30
|
+
if (fontsDiffer || opts.eastAsiaHint) {
|
|
31
|
+
out.push(
|
|
32
|
+
el('w:rFonts', {
|
|
33
|
+
'w:ascii': fontsDiffer ? run.fonts.ascii : undefined,
|
|
34
|
+
'w:hAnsi': fontsDiffer ? run.fonts.ascii : undefined,
|
|
35
|
+
'w:eastAsia': fontsDiffer ? run.fonts.eastAsia : undefined,
|
|
36
|
+
'w:cs': fontsDiffer ? run.fonts.ascii : undefined,
|
|
37
|
+
// Quotes and dashes sit in ranges both scripts claim. The hint is how
|
|
38
|
+
// Word knows that a “ inside Chinese text is set in the CJK face.
|
|
39
|
+
'w:hint': opts.eastAsiaHint ? 'eastAsia' : undefined,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const toggle = (name: string, value: boolean, was: boolean | undefined) => {
|
|
45
|
+
if (base ? value !== Boolean(was) : value) out.push(el(name, value ? {} : { 'w:val': '0' }));
|
|
46
|
+
};
|
|
47
|
+
toggle('w:b', run.bold, base?.bold);
|
|
48
|
+
toggle('w:bCs', run.bold, base?.bold);
|
|
49
|
+
toggle('w:i', run.italic, base?.italic);
|
|
50
|
+
toggle('w:iCs', run.italic, base?.italic);
|
|
51
|
+
toggle('w:caps', Boolean(run.caps), base?.caps);
|
|
52
|
+
toggle('w:smallCaps', Boolean(run.smallCaps), base?.smallCaps);
|
|
53
|
+
toggle('w:strike', run.strike, base?.strike);
|
|
54
|
+
|
|
55
|
+
if (!base || run.color !== base.color) out.push(el('w:color', { 'w:val': run.color }));
|
|
56
|
+
if ((run.spacing ?? 0) !== (base?.spacing ?? 0)) {
|
|
57
|
+
out.push(el('w:spacing', { 'w:val': run.spacing ?? 0 }));
|
|
58
|
+
}
|
|
59
|
+
if (!base || run.size !== base.size) {
|
|
60
|
+
out.push(el('w:sz', { 'w:val': run.size }), el('w:szCs', { 'w:val': run.size }));
|
|
61
|
+
}
|
|
62
|
+
if (base ? run.underline !== base.underline : run.underline) {
|
|
63
|
+
out.push(el('w:u', { 'w:val': run.underline ? 'single' : 'none' }));
|
|
64
|
+
}
|
|
65
|
+
if (run.shading !== base?.shading) out.push(shadingXml(run.shading ?? 'auto'));
|
|
66
|
+
if (run.vertAlign !== base?.vertAlign) {
|
|
67
|
+
out.push(el('w:vertAlign', { 'w:val': run.vertAlign ?? 'baseline' }));
|
|
68
|
+
}
|
|
69
|
+
if (opts.lang) out.push(opts.lang);
|
|
70
|
+
|
|
71
|
+
return out.length > 0 ? `<w:rPr>${out.join('')}</w:rPr>` : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function borderXml(side: string, border: Border): string {
|
|
75
|
+
return el(`w:${side}`, {
|
|
76
|
+
'w:val': border.style,
|
|
77
|
+
'w:sz': border.size,
|
|
78
|
+
'w:space': border.space,
|
|
79
|
+
'w:color': border.color,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function bordersXml(name: string, borders: Borders | undefined): string {
|
|
84
|
+
if (!borders) return '';
|
|
85
|
+
const sides = (['top', 'left', 'bottom', 'right'] as const)
|
|
86
|
+
.map((side) => {
|
|
87
|
+
const border = borders[side];
|
|
88
|
+
return border ? borderXml(side, border) : '';
|
|
89
|
+
})
|
|
90
|
+
.join('');
|
|
91
|
+
return sides ? `<${name}>${sides}</${name}>` : '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function shadingXml(fill: string | undefined): string {
|
|
95
|
+
return fill ? el('w:shd', { 'w:val': 'clear', 'w:color': 'auto', 'w:fill': fill }) : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A width in twips, as table properties spell one. */
|
|
99
|
+
export function dxa(name: string, width: number): string {
|
|
100
|
+
return el(name, { 'w:w': width, 'w:type': 'dxa' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function cellMarginsXml(name: string, margins: Partial<Sides>): string {
|
|
104
|
+
const sides = (['top', 'left', 'bottom', 'right'] as const)
|
|
105
|
+
.map((side) => {
|
|
106
|
+
const width = margins[side];
|
|
107
|
+
return width === undefined ? '' : dxa(`w:${side}`, width);
|
|
108
|
+
})
|
|
109
|
+
.join('');
|
|
110
|
+
return `<${name}>${sides}</${name}>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tabsXml(tabs: TabStop[] | undefined): string {
|
|
114
|
+
if (!tabs || tabs.length === 0) return '';
|
|
115
|
+
const sorted = [...tabs].sort((a, b) => a.pos - b.pos);
|
|
116
|
+
return `<w:tabs>${sorted
|
|
117
|
+
.map((tab) => el('w:tab', { 'w:val': tab.align, 'w:leader': tab.leader, 'w:pos': tab.pos }))
|
|
118
|
+
.join('')}</w:tabs>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function paraPropsXml(
|
|
122
|
+
props: ParagraphProps,
|
|
123
|
+
base: StylePara | null,
|
|
124
|
+
opts: {
|
|
125
|
+
pStyle?: string;
|
|
126
|
+
numPr?: { num: number; level: number };
|
|
127
|
+
keepLines?: boolean;
|
|
128
|
+
outlineLevel?: number;
|
|
129
|
+
sectPr?: string;
|
|
130
|
+
} = {},
|
|
131
|
+
): string {
|
|
132
|
+
const out: string[] = [];
|
|
133
|
+
if (opts.pStyle) out.push(el('w:pStyle', { 'w:val': opts.pStyle }));
|
|
134
|
+
|
|
135
|
+
const keepNext = Boolean(props.keepNext);
|
|
136
|
+
if (base ? keepNext !== Boolean(base.keepNext) : keepNext) {
|
|
137
|
+
out.push(el('w:keepNext', keepNext ? {} : { 'w:val': '0' }));
|
|
138
|
+
}
|
|
139
|
+
if (opts.keepLines) out.push(el('w:keepLines'));
|
|
140
|
+
if (props.pageBreakBefore) out.push(el('w:pageBreakBefore'));
|
|
141
|
+
if (opts.numPr) {
|
|
142
|
+
out.push(
|
|
143
|
+
`<w:numPr>${el('w:ilvl', { 'w:val': opts.numPr.level })}${el('w:numId', { 'w:val': opts.numPr.num })}</w:numPr>`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
out.push(bordersXml('w:pBdr', props.borders));
|
|
147
|
+
out.push(shadingXml(props.shading));
|
|
148
|
+
out.push(tabsXml(props.tabs));
|
|
149
|
+
if (props.bidi) out.push(el('w:bidi'));
|
|
150
|
+
|
|
151
|
+
const spacing: Record<string, number | string | undefined> = {};
|
|
152
|
+
const before = props.spaceBefore ?? 0;
|
|
153
|
+
const after = props.spaceAfter ?? 0;
|
|
154
|
+
if (!base || before !== base.spaceBefore) spacing['w:before'] = before;
|
|
155
|
+
if (!base || after !== base.spaceAfter) spacing['w:after'] = after;
|
|
156
|
+
if (props.line !== undefined && (props.lineExact || !base || props.line !== base.line)) {
|
|
157
|
+
spacing['w:line'] = props.line;
|
|
158
|
+
spacing['w:lineRule'] = props.lineExact ? 'exact' : 'atLeast';
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(spacing).length > 0) out.push(el('w:spacing', spacing));
|
|
161
|
+
|
|
162
|
+
// Zero is written when it is given: a numbered paragraph's own indent is what
|
|
163
|
+
// overrides the numbering level's.
|
|
164
|
+
const indent: Record<string, number | undefined> = {};
|
|
165
|
+
if (props.indentLeft !== undefined) indent['w:left'] = props.indentLeft;
|
|
166
|
+
if (props.indentRight) indent['w:right'] = props.indentRight;
|
|
167
|
+
if (props.firstLine !== undefined) {
|
|
168
|
+
if (props.firstLine < 0) indent['w:hanging'] = -props.firstLine;
|
|
169
|
+
else indent['w:firstLine'] = props.firstLine;
|
|
170
|
+
}
|
|
171
|
+
if (Object.keys(indent).length > 0) out.push(el('w:ind', indent));
|
|
172
|
+
|
|
173
|
+
const align = props.align ?? 'left';
|
|
174
|
+
if (base ? align !== (base.align ?? 'left') : align !== 'left') {
|
|
175
|
+
out.push(el('w:jc', { 'w:val': align }));
|
|
176
|
+
}
|
|
177
|
+
if (opts.outlineLevel !== undefined) out.push(el('w:outlineLvl', { 'w:val': opts.outlineLevel }));
|
|
178
|
+
if (props.markSize !== undefined) {
|
|
179
|
+
out.push(
|
|
180
|
+
`<w:rPr>${el('w:sz', { 'w:val': props.markSize })}${el('w:szCs', { 'w:val': props.markSize })}</w:rPr>`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (opts.sectPr) out.push(opts.sectPr);
|
|
184
|
+
|
|
185
|
+
const body = out.join('');
|
|
186
|
+
return body ? `<w:pPr>${body}</w:pPr>` : '';
|
|
187
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word styles, inferred from the document rather than declared by it.
|
|
3
|
+
*
|
|
4
|
+
* Each paragraph role votes, weighted by how much text it carries: whatever the
|
|
5
|
+
* body text mostly looks like becomes Normal, whatever the second-level
|
|
6
|
+
* headings mostly look like becomes Heading 2, and a paragraph only carries
|
|
7
|
+
* direct formatting where it departs from that. Read from the rendered page, so
|
|
8
|
+
* it is right for a document whatever the design, theme, or inline styles did.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Block, DocxModel, Paragraph, RunStyle } from './model';
|
|
12
|
+
import { cellMarginsXml, dxa, paraPropsXml, runPropsXml, type StylePara } from './props';
|
|
13
|
+
import { el, W_NS, XML_DECLARATION } from './xml';
|
|
14
|
+
|
|
15
|
+
export type StyleDef = {
|
|
16
|
+
id: string;
|
|
17
|
+
/** Word matches built-in styles by this name, so it stays lower-case where Word's is. */
|
|
18
|
+
name: string;
|
|
19
|
+
run: RunStyle;
|
|
20
|
+
para: StylePara;
|
|
21
|
+
outlineLevel?: number;
|
|
22
|
+
keepLines?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type StyleSheet = {
|
|
26
|
+
normal: StyleDef;
|
|
27
|
+
byId: Map<string, StyleDef>;
|
|
28
|
+
/** The footnote marker's colour; set once there are footnotes. */
|
|
29
|
+
footnoteColor?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type StyleKey = { id: string; name: string; outlineLevel?: number; keepLines?: boolean };
|
|
33
|
+
|
|
34
|
+
export function styleKeyOf(paragraph: Paragraph): StyleKey {
|
|
35
|
+
switch (paragraph.role) {
|
|
36
|
+
case 'heading': {
|
|
37
|
+
const level = Math.min(6, Math.max(1, paragraph.level ?? 1));
|
|
38
|
+
return {
|
|
39
|
+
id: `Heading${level}`,
|
|
40
|
+
name: `heading ${level}`,
|
|
41
|
+
outlineLevel: level - 1,
|
|
42
|
+
keepLines: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
case 'title':
|
|
46
|
+
return { id: 'Title', name: 'Title' };
|
|
47
|
+
case 'subtitle':
|
|
48
|
+
return { id: 'Subtitle', name: 'Subtitle' };
|
|
49
|
+
case 'toc': {
|
|
50
|
+
const level = Math.min(9, Math.max(1, paragraph.level ?? 1));
|
|
51
|
+
return { id: `TOC${level}`, name: `toc ${level}` };
|
|
52
|
+
}
|
|
53
|
+
case 'caption':
|
|
54
|
+
return { id: 'Caption', name: 'caption' };
|
|
55
|
+
case 'list':
|
|
56
|
+
return { id: 'ListParagraph', name: 'List Paragraph' };
|
|
57
|
+
case 'footnote':
|
|
58
|
+
return { id: 'FootnoteText', name: 'footnote text' };
|
|
59
|
+
case 'header':
|
|
60
|
+
return { id: 'Header', name: 'header' };
|
|
61
|
+
case 'footer':
|
|
62
|
+
return { id: 'Footer', name: 'footer' };
|
|
63
|
+
default:
|
|
64
|
+
return { id: 'Normal', name: 'Normal' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const FALLBACK_RUN: RunStyle = {
|
|
69
|
+
fonts: { ascii: 'Arial' },
|
|
70
|
+
size: 21,
|
|
71
|
+
bold: false,
|
|
72
|
+
italic: false,
|
|
73
|
+
underline: false,
|
|
74
|
+
strike: false,
|
|
75
|
+
color: '000000',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
class Tally<T> {
|
|
79
|
+
private readonly weights = new Map<string, { value: T; weight: number }>();
|
|
80
|
+
|
|
81
|
+
add(value: T, weight: number): void {
|
|
82
|
+
const key = JSON.stringify(value);
|
|
83
|
+
const entry = this.weights.get(key);
|
|
84
|
+
if (entry) entry.weight += weight;
|
|
85
|
+
else this.weights.set(key, { value, weight });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
winner(): T | undefined {
|
|
89
|
+
let best: { value: T; weight: number } | undefined;
|
|
90
|
+
for (const entry of this.weights.values()) {
|
|
91
|
+
if (!best || entry.weight > best.weight) best = entry;
|
|
92
|
+
}
|
|
93
|
+
return best?.value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Only what a style can sensibly own; underline, shading, and super/subscript stay local. */
|
|
98
|
+
function styleableRun(run: RunStyle): RunStyle {
|
|
99
|
+
return {
|
|
100
|
+
fonts: run.fonts,
|
|
101
|
+
size: run.size,
|
|
102
|
+
bold: run.bold,
|
|
103
|
+
italic: run.italic,
|
|
104
|
+
underline: false,
|
|
105
|
+
strike: false,
|
|
106
|
+
color: run.color,
|
|
107
|
+
caps: run.caps,
|
|
108
|
+
smallCaps: run.smallCaps,
|
|
109
|
+
spacing: run.spacing,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Votes = {
|
|
114
|
+
key: StyleKey;
|
|
115
|
+
run: Tally<RunStyle>;
|
|
116
|
+
align: Tally<StylePara['align']>;
|
|
117
|
+
before: Tally<number>;
|
|
118
|
+
after: Tally<number>;
|
|
119
|
+
line: Tally<number | undefined>;
|
|
120
|
+
keepNext: Tally<boolean>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function textLength(paragraph: Paragraph): number {
|
|
124
|
+
let length = 0;
|
|
125
|
+
for (const inline of paragraph.inlines) if (inline.type === 'text') length += inline.text.length;
|
|
126
|
+
return length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Every paragraph in the blocks, table cells included, and whether it sits in one. */
|
|
130
|
+
export function* paragraphsOf(blocks: Block[], inCell = false): Generator<[Paragraph, boolean]> {
|
|
131
|
+
for (const block of blocks) {
|
|
132
|
+
if (block.type === 'paragraph') {
|
|
133
|
+
yield [block, inCell];
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
for (const row of block.rows) {
|
|
137
|
+
for (const cell of row.cells) yield* paragraphsOf(cell.blocks, true);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function* allParagraphs(model: DocxModel): Generator<[Paragraph, boolean]> {
|
|
143
|
+
for (const section of model.sections) {
|
|
144
|
+
yield* paragraphsOf(section.blocks);
|
|
145
|
+
yield* paragraphsOf(section.header ?? []);
|
|
146
|
+
yield* paragraphsOf(section.footer ?? []);
|
|
147
|
+
yield* paragraphsOf(section.footerFirst ?? []);
|
|
148
|
+
}
|
|
149
|
+
for (const note of model.footnotes) yield* paragraphsOf(note.blocks);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function inferStyles(model: DocxModel): StyleSheet {
|
|
153
|
+
const votes = new Map<string, Votes>();
|
|
154
|
+
const markers = new Tally<string>();
|
|
155
|
+
|
|
156
|
+
for (const [paragraph, inCell] of allParagraphs(model)) {
|
|
157
|
+
const key = styleKeyOf(paragraph);
|
|
158
|
+
// Table text is usually set smaller than the body. Letting it vote would
|
|
159
|
+
// make a table-heavy report's Normal the size of its cells.
|
|
160
|
+
if (inCell && key.id === 'Normal') continue;
|
|
161
|
+
|
|
162
|
+
let entry = votes.get(key.id);
|
|
163
|
+
if (!entry) {
|
|
164
|
+
entry = {
|
|
165
|
+
key,
|
|
166
|
+
run: new Tally(),
|
|
167
|
+
align: new Tally(),
|
|
168
|
+
before: new Tally(),
|
|
169
|
+
after: new Tally(),
|
|
170
|
+
line: new Tally(),
|
|
171
|
+
keepNext: new Tally(),
|
|
172
|
+
};
|
|
173
|
+
votes.set(key.id, entry);
|
|
174
|
+
}
|
|
175
|
+
const weight = Math.max(1, textLength(paragraph));
|
|
176
|
+
for (const inline of paragraph.inlines) {
|
|
177
|
+
if (inline.type === 'text' && inline.text.trim()) {
|
|
178
|
+
entry.run.add(styleableRun(inline.style), inline.text.length);
|
|
179
|
+
}
|
|
180
|
+
if (inline.type === 'footnote') markers.add(inline.style.color, 1);
|
|
181
|
+
}
|
|
182
|
+
entry.align.add(paragraph.props.align, weight);
|
|
183
|
+
entry.before.add(paragraph.props.spaceBefore ?? 0, weight);
|
|
184
|
+
entry.after.add(paragraph.props.spaceAfter ?? 0, weight);
|
|
185
|
+
entry.line.add(paragraph.props.lineExact ? undefined : paragraph.props.line, weight);
|
|
186
|
+
entry.keepNext.add(Boolean(paragraph.props.keepNext), weight);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const define = (entry: Votes, fallbackRun: RunStyle): StyleDef => ({
|
|
190
|
+
id: entry.key.id,
|
|
191
|
+
name: entry.key.name,
|
|
192
|
+
run: entry.run.winner() ?? fallbackRun,
|
|
193
|
+
para: {
|
|
194
|
+
spaceBefore: entry.before.winner() ?? 0,
|
|
195
|
+
spaceAfter: entry.after.winner() ?? 0,
|
|
196
|
+
align: entry.align.winner(),
|
|
197
|
+
line: entry.line.winner(),
|
|
198
|
+
keepNext: entry.keepNext.winner(),
|
|
199
|
+
},
|
|
200
|
+
outlineLevel: entry.key.outlineLevel,
|
|
201
|
+
keepLines: entry.key.keepLines,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const normalVotes = votes.get('Normal');
|
|
205
|
+
const normal: StyleDef = normalVotes
|
|
206
|
+
? define(normalVotes, FALLBACK_RUN)
|
|
207
|
+
: { id: 'Normal', name: 'Normal', run: FALLBACK_RUN, para: { spaceBefore: 0, spaceAfter: 0 } };
|
|
208
|
+
|
|
209
|
+
const byId = new Map<string, StyleDef>([['Normal', normal]]);
|
|
210
|
+
for (const [id, entry] of votes) {
|
|
211
|
+
if (id !== 'Normal') byId.set(id, define(entry, normal.run));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Headings the document never used still get defined, so a reviewer who adds
|
|
215
|
+
// one in Word gets the document's heading face rather than Word's own.
|
|
216
|
+
for (let level = 1; level <= 3; level++) {
|
|
217
|
+
const id = `Heading${level}`;
|
|
218
|
+
if (byId.has(id)) continue;
|
|
219
|
+
const nearest = [1, 2, 3, 4, 5, 6]
|
|
220
|
+
.map((n) => byId.get(`Heading${n}`))
|
|
221
|
+
.find((def): def is StyleDef => def !== undefined);
|
|
222
|
+
const run = nearest?.run ?? { ...normal.run, bold: true };
|
|
223
|
+
byId.set(id, {
|
|
224
|
+
id,
|
|
225
|
+
name: `heading ${level}`,
|
|
226
|
+
run: { ...run, size: model.headingSizes?.[level - 1] ?? run.size },
|
|
227
|
+
para: nearest?.para ?? { spaceBefore: 240, spaceAfter: 120, keepNext: true },
|
|
228
|
+
outlineLevel: level - 1,
|
|
229
|
+
keepLines: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { normal, byId, footnoteColor: markers.winner() };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function hiddenStyle(type: string, id: string, name: string, priority: number, extra = ''): string {
|
|
237
|
+
return el(
|
|
238
|
+
'w:style',
|
|
239
|
+
{ 'w:type': type, 'w:default': '1', 'w:styleId': id },
|
|
240
|
+
[
|
|
241
|
+
el('w:name', { 'w:val': name }),
|
|
242
|
+
el('w:uiPriority', { 'w:val': priority }),
|
|
243
|
+
el('w:semiHidden'),
|
|
244
|
+
el('w:unhideWhenUsed'),
|
|
245
|
+
extra,
|
|
246
|
+
].join(''),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function stylesXml(sheet: StyleSheet, eastAsiaLang?: string): string {
|
|
251
|
+
const normal = sheet.normal;
|
|
252
|
+
const lang = el('w:lang', { 'w:val': 'en-US', 'w:eastAsia': eastAsiaLang });
|
|
253
|
+
|
|
254
|
+
const paragraphStyle = (def: StyleDef): string => {
|
|
255
|
+
const isNormal = def.id === 'Normal';
|
|
256
|
+
return el(
|
|
257
|
+
'w:style',
|
|
258
|
+
{ 'w:type': 'paragraph', 'w:default': isNormal ? '1' : undefined, 'w:styleId': def.id },
|
|
259
|
+
[
|
|
260
|
+
el('w:name', { 'w:val': def.name }),
|
|
261
|
+
isNormal ? '' : el('w:basedOn', { 'w:val': 'Normal' }),
|
|
262
|
+
isNormal || def.id.startsWith('TOC') ? '' : el('w:next', { 'w:val': 'Normal' }),
|
|
263
|
+
el('w:qFormat'),
|
|
264
|
+
paraPropsXml(def.para, null, { keepLines: def.keepLines, outlineLevel: def.outlineLevel }),
|
|
265
|
+
runPropsXml(def.run, isNormal ? null : normal.run),
|
|
266
|
+
].join(''),
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const styles = [
|
|
271
|
+
`<w:docDefaults><w:rPrDefault>${runPropsXml(normal.run, null, { lang })}</w:rPrDefault>`,
|
|
272
|
+
`<w:pPrDefault><w:pPr>${el('w:spacing', { 'w:after': 0, 'w:line': 240, 'w:lineRule': 'auto' })}</w:pPr></w:pPrDefault></w:docDefaults>`,
|
|
273
|
+
paragraphStyle(normal),
|
|
274
|
+
hiddenStyle('character', 'DefaultParagraphFont', 'Default Paragraph Font', 1),
|
|
275
|
+
hiddenStyle(
|
|
276
|
+
'table',
|
|
277
|
+
'TableNormal',
|
|
278
|
+
'Normal Table',
|
|
279
|
+
99,
|
|
280
|
+
`<w:tblPr>${dxa('w:tblInd', 0)}${cellMarginsXml('w:tblCellMar', { top: 0, left: 108, bottom: 0, right: 108 })}</w:tblPr>`,
|
|
281
|
+
),
|
|
282
|
+
hiddenStyle('numbering', 'NoList', 'No List', 99),
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
for (const def of sheet.byId.values()) {
|
|
286
|
+
if (def.id !== 'Normal') styles.push(paragraphStyle(def));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (sheet.footnoteColor) {
|
|
290
|
+
styles.push(
|
|
291
|
+
el(
|
|
292
|
+
'w:style',
|
|
293
|
+
{ 'w:type': 'character', 'w:styleId': 'FootnoteReference' },
|
|
294
|
+
[
|
|
295
|
+
el('w:name', { 'w:val': 'footnote reference' }),
|
|
296
|
+
el('w:basedOn', { 'w:val': 'DefaultParagraphFont' }),
|
|
297
|
+
el('w:uiPriority', { 'w:val': 99 }),
|
|
298
|
+
el('w:unhideWhenUsed'),
|
|
299
|
+
`<w:rPr>${el('w:color', { 'w:val': sheet.footnoteColor })}${el('w:vertAlign', { 'w:val': 'superscript' })}</w:rPr>`,
|
|
300
|
+
].join(''),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return `${XML_DECLARATION}<w:styles xmlns:w="${W_NS}">${styles.join('')}</w:styles>`;
|
|
306
|
+
}
|