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.
Files changed (121) hide show
  1. package/README.md +16 -15
  2. package/bin.js +2 -0
  3. package/dist/build-C7NW_3Pk.js +14 -0
  4. package/dist/check-CP4873Wx.js +41 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +228 -0
  7. package/dist/config-DPm1BBAb.js +2619 -0
  8. package/dist/config-TlTe7Ona.d.ts +24 -0
  9. package/dist/context-BqsdSrAQ.js +1084 -0
  10. package/dist/dev-Biz42qlu.js +17 -0
  11. package/dist/diagram-xlVDekYk.js +763 -0
  12. package/dist/export-Bi6nuxjT.js +31 -0
  13. package/dist/import-D2jNB07F.js +25 -0
  14. package/dist/index.d.ts +455 -0
  15. package/dist/index.js +693 -0
  16. package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
  17. package/dist/preview-CLm51aRt.js +19 -0
  18. package/dist/sdk-DjpX6mCv.js +51 -0
  19. package/dist/vite/index.d.ts +25 -0
  20. package/dist/vite/index.js +2 -0
  21. package/env.d.ts +83 -0
  22. package/package.json +59 -12
  23. package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
  24. package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
  25. package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
  26. package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
  27. package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
  28. package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
  29. package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
  30. package/src/app/app.tsx +42 -0
  31. package/src/app/components/data-table.tsx +196 -0
  32. package/src/app/components/design-panel/design-panel.tsx +318 -0
  33. package/src/app/components/design-panel/design-provider.tsx +121 -0
  34. package/src/app/components/design-panel/use-design.ts +85 -0
  35. package/src/app/components/diagram.tsx +76 -0
  36. package/src/app/components/doc-assets.tsx +129 -0
  37. package/src/app/components/doc-search.tsx +248 -0
  38. package/src/app/components/doc-sidebar.tsx +162 -0
  39. package/src/app/components/flow-page.tsx +93 -0
  40. package/src/app/components/footnote.tsx +204 -0
  41. package/src/app/components/image-placeholder.tsx +50 -0
  42. package/src/app/components/inspector/inspector.tsx +518 -0
  43. package/src/app/components/numbering.tsx +224 -0
  44. package/src/app/components/page-frame.tsx +70 -0
  45. package/src/app/components/sidebar/folder-item.tsx +212 -0
  46. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  47. package/src/app/components/sidebar/sidebar.tsx +252 -0
  48. package/src/app/components/table-of-contents.tsx +93 -0
  49. package/src/app/components/theme-toggle.tsx +50 -0
  50. package/src/app/components/themes/markdown.tsx +249 -0
  51. package/src/app/components/themes/theme-preview.tsx +74 -0
  52. package/src/app/components/ui/menu.tsx +143 -0
  53. package/src/app/index.html +12 -0
  54. package/src/app/lib/agent-bridge.ts +140 -0
  55. package/src/app/lib/assets.ts +151 -0
  56. package/src/app/lib/design-presets.ts +109 -0
  57. package/src/app/lib/design.ts +88 -0
  58. package/src/app/lib/diagnostics.ts +282 -0
  59. package/src/app/lib/doc-preview.tsx +29 -0
  60. package/src/app/lib/docs.ts +26 -0
  61. package/src/app/lib/docx/extract.ts +1623 -0
  62. package/src/app/lib/docx/fonts.test.ts +136 -0
  63. package/src/app/lib/docx/fonts.ts +166 -0
  64. package/src/app/lib/docx/media.ts +102 -0
  65. package/src/app/lib/docx/model.ts +206 -0
  66. package/src/app/lib/docx/paragraph.test.ts +92 -0
  67. package/src/app/lib/docx/paragraph.ts +107 -0
  68. package/src/app/lib/docx/props.ts +187 -0
  69. package/src/app/lib/docx/styles.ts +306 -0
  70. package/src/app/lib/docx/units.ts +35 -0
  71. package/src/app/lib/docx/write.test.ts +507 -0
  72. package/src/app/lib/docx/write.ts +581 -0
  73. package/src/app/lib/docx/xml.ts +39 -0
  74. package/src/app/lib/export-docx.ts +289 -0
  75. package/src/app/lib/export-dom.ts +318 -0
  76. package/src/app/lib/export-html.ts +156 -0
  77. package/src/app/lib/export-image.ts +70 -0
  78. package/src/app/lib/export-pdf.ts +165 -0
  79. package/src/app/lib/flow-measure.test.ts +31 -0
  80. package/src/app/lib/flow-measure.ts +183 -0
  81. package/src/app/lib/flow.test.ts +110 -0
  82. package/src/app/lib/flow.ts +136 -0
  83. package/src/app/lib/folders.ts +192 -0
  84. package/src/app/lib/footnotes.test.tsx +102 -0
  85. package/src/app/lib/footnotes.ts +94 -0
  86. package/src/app/lib/inspector/fiber.ts +99 -0
  87. package/src/app/lib/labels.test.ts +18 -0
  88. package/src/app/lib/labels.ts +181 -0
  89. package/src/app/lib/outline.ts +118 -0
  90. package/src/app/lib/page-context.tsx +43 -0
  91. package/src/app/lib/page-range.test.ts +95 -0
  92. package/src/app/lib/page-range.ts +90 -0
  93. package/src/app/lib/print-ready.ts +69 -0
  94. package/src/app/lib/rasterize.ts +173 -0
  95. package/src/app/lib/scan.ts +26 -0
  96. package/src/app/lib/sdk.test.ts +32 -0
  97. package/src/app/lib/sdk.ts +115 -0
  98. package/src/app/lib/themes.ts +31 -0
  99. package/src/app/lib/use-doc-module.ts +53 -0
  100. package/src/app/lib/use-doc-pages.ts +147 -0
  101. package/src/app/lib/utils.ts +6 -0
  102. package/src/app/lib/view-mode.test.ts +91 -0
  103. package/src/app/lib/view-mode.ts +104 -0
  104. package/src/app/main.tsx +14 -0
  105. package/src/app/routes/assets.tsx +257 -0
  106. package/src/app/routes/doc.tsx +877 -0
  107. package/src/app/routes/home-shell.tsx +203 -0
  108. package/src/app/routes/home.tsx +269 -0
  109. package/src/app/routes/themes.tsx +121 -0
  110. package/src/app/styles.css +97 -0
  111. package/src/app/virtual.d.ts +30 -0
  112. package/template/AGENTS.md +1 -1
  113. package/template/README.md +24 -52
  114. package/template/docs/getting-started/index.tsx +2 -2
  115. package/template/mosage.config.ts +1 -1
  116. package/template/package.json +1 -1
  117. package/template/tsconfig.json +1 -1
  118. package/README.zh-TW.md +0 -28
  119. /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
  120. /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
  121. /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
@@ -0,0 +1,35 @@
1
+ /** Word's units, from CSS pixels at 96 dpi. */
2
+
3
+ export function px(value: string): number {
4
+ const n = Number.parseFloat(value);
5
+ return Number.isFinite(n) ? n : 0;
6
+ }
7
+
8
+ /** Twips, 1/20 pt: lengths, spacing, indents. */
9
+ export function twips(pixels: number): number {
10
+ return Math.round(pixels * 15);
11
+ }
12
+
13
+ export function twipsFromMm(mm: number): number {
14
+ return Math.round((mm / 25.4) * 1440);
15
+ }
16
+
17
+ /** Half-points: type sizes. */
18
+ export function halfPoints(pixels: number): number {
19
+ return Math.max(2, Math.round(pixels * 1.5));
20
+ }
21
+
22
+ /** Points: how far a border stands off from the text. */
23
+ export function points(pixels: number): number {
24
+ return Math.round(pixels * 0.75);
25
+ }
26
+
27
+ /** EMU: a picture's extent. */
28
+ export function emu(pixels: number): number {
29
+ return Math.round(pixels * 9525);
30
+ }
31
+
32
+ /** Eighths of a point: border widths, within the range Word accepts. */
33
+ export function eighths(pixels: number): number {
34
+ return Math.min(96, Math.max(2, Math.round(pixels * 6)));
35
+ }
@@ -0,0 +1,507 @@
1
+ /**
2
+ * The writer is string assembly, so what can go wrong is a file Word refuses
3
+ * to open: an unbalanced tag, a part without its relationship or content type,
4
+ * a footer that bleeds into the next section, a style that is never defined.
5
+ * These read the zip back and check the parts the way Word would find them.
6
+ */
7
+
8
+ import { strFromU8, unzipSync } from 'fflate';
9
+ import { describe, expect, it } from 'vitest';
10
+ import type { DocxModel, Paragraph, RunStyle, Section } from './model';
11
+ import { writeDocx } from './write';
12
+
13
+ const body: RunStyle = {
14
+ fonts: { ascii: 'Inter', eastAsia: 'Noto Sans TC' },
15
+ size: 21,
16
+ bold: false,
17
+ italic: false,
18
+ underline: false,
19
+ strike: false,
20
+ color: '16181D',
21
+ };
22
+ const heading: RunStyle = { ...body, size: 42, bold: true };
23
+
24
+ const A4 = {
25
+ width: 11906,
26
+ height: 16838,
27
+ margin: { top: 1140, right: 1140, bottom: 1140, left: 1140 },
28
+ header: 600,
29
+ footer: 600,
30
+ };
31
+
32
+ function para(text: string, extra: Partial<Paragraph> = {}, style = body): Paragraph {
33
+ return {
34
+ type: 'paragraph',
35
+ role: 'body',
36
+ inlines: [{ type: 'text', text, style }],
37
+ props: { spaceBefore: 180, line: 326 },
38
+ ...extra,
39
+ };
40
+ }
41
+
42
+ function section(blocks: Section['blocks'], extra: Partial<Section> = {}): Section {
43
+ return { blocks, page: A4, ...extra };
44
+ }
45
+
46
+ function model(extra: Partial<DocxModel> = {}): DocxModel {
47
+ return {
48
+ title: 'Report',
49
+ sections: [section([para('Hello')])],
50
+ footnotes: [],
51
+ lists: [],
52
+ media: [],
53
+ ...extra,
54
+ };
55
+ }
56
+
57
+ function unzip(bytes: Uint8Array): Record<string, string> {
58
+ const out: Record<string, string> = {};
59
+ for (const [name, data] of Object.entries(unzipSync(bytes))) {
60
+ out[name] = name.startsWith('word/media/') ? '' : strFromU8(data);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** Tags balance and nothing between them needs escaping — Word's first check. */
66
+ function expectWellFormed(xml: string): void {
67
+ const stack: string[] = [];
68
+ let last = 0;
69
+ for (const match of xml.matchAll(
70
+ /<\?[^?]*\?>|<(\/?)([A-Za-z_][\w:.-]*)((?:\s+[\w:.-]+="[^"<]*")*)\s*(\/?)>/g,
71
+ )) {
72
+ const between = xml.slice(last, match.index);
73
+ expect(between).not.toMatch(/[<>]|&(?!amp;|lt;|gt;|quot;)/);
74
+ last = (match.index ?? 0) + match[0].length;
75
+ if (match[0].startsWith('<?')) continue;
76
+ const [, closing, name, , selfClosing] = match;
77
+ if (selfClosing) continue;
78
+ if (closing) expect(stack.pop()).toBe(name);
79
+ else stack.push(name as string);
80
+ }
81
+ expect(xml.slice(last)).toBe('');
82
+ expect(stack).toEqual([]);
83
+ }
84
+
85
+ describe('writeDocx', () => {
86
+ it('writes a package Word can open: parts, relationships, content types', () => {
87
+ const files = unzip(writeDocx(model()));
88
+ expect(Object.keys(files)).toEqual(
89
+ expect.arrayContaining([
90
+ '[Content_Types].xml',
91
+ '_rels/.rels',
92
+ 'word/document.xml',
93
+ 'word/styles.xml',
94
+ 'word/settings.xml',
95
+ 'word/_rels/document.xml.rels',
96
+ 'docProps/core.xml',
97
+ 'docProps/app.xml',
98
+ ]),
99
+ );
100
+ for (const [name, xml] of Object.entries(files)) {
101
+ if (name.endsWith('.xml') || name.endsWith('.rels')) expectWellFormed(xml);
102
+ }
103
+ expect(files['[Content_Types].xml']).toContain('PartName="/word/document.xml"');
104
+ expect(files['word/_rels/document.xml.rels']).toContain('Target="styles.xml"');
105
+ expect(files['docProps/core.xml']).toContain('<dc:title>Report</dc:title>');
106
+ // Word opens anything without this in compatibility mode.
107
+ expect(files['word/settings.xml']).toContain('w:name="compatibilityMode"');
108
+ });
109
+
110
+ it('turns the most common body text into Normal and writes only departures from it', () => {
111
+ const doc = model({
112
+ sections: [
113
+ section([
114
+ para('Most of the body text looks like this.'),
115
+ para('And so does this paragraph, which is long.'),
116
+ {
117
+ type: 'paragraph',
118
+ role: 'body',
119
+ inlines: [
120
+ { type: 'text', text: 'Plain, then ', style: body },
121
+ { type: 'text', text: 'bold', style: { ...body, bold: true } },
122
+ ],
123
+ props: { spaceBefore: 180, line: 326 },
124
+ },
125
+ ]),
126
+ ],
127
+ });
128
+ const files = unzip(writeDocx(doc));
129
+ const styles = files['word/styles.xml'] ?? '';
130
+ const normal =
131
+ /<w:style w:type="paragraph" w:default="1" w:styleId="Normal">.*?<\/w:style>/.exec(
132
+ styles,
133
+ )?.[0] ?? '';
134
+ expect(normal).toContain('<w:sz w:val="21"/>');
135
+ expect(normal).toContain('w:ascii="Inter"');
136
+ expect(normal).toContain('w:eastAsia="Noto Sans TC"');
137
+ expect(normal).toContain('w:before="180"');
138
+ expect(normal).toContain('w:line="326" w:lineRule="atLeast"');
139
+
140
+ const document = files['word/document.xml'] ?? '';
141
+ // Body paragraphs carry no style of their own and no restated formatting.
142
+ expect(document).not.toContain('<w:pStyle w:val="Normal"/>');
143
+ expect(document).not.toContain('<w:sz ');
144
+ expect(document.match(/<w:b\/>/g)).toHaveLength(1);
145
+ });
146
+
147
+ it('gives headings the built-in heading styles, so Word’s navigation pane lists them', () => {
148
+ const doc = model({
149
+ sections: [
150
+ section([
151
+ para('Findings', { role: 'heading', level: 1, bookmark: '_od_findings' }, heading),
152
+ para('Body.'),
153
+ ]),
154
+ ],
155
+ headingSizes: [42, 30, 24],
156
+ });
157
+ const files = unzip(writeDocx(doc));
158
+ const styles = files['word/styles.xml'] ?? '';
159
+ expect(styles).toContain('<w:name w:val="heading 1"/>');
160
+ expect(styles).toMatch(/w:styleId="Heading1">.*?<w:outlineLvl w:val="0"\/>/);
161
+ // A level the document never used still exists, sized from the design.
162
+ expect(styles).toMatch(/w:styleId="Heading2">.*?<w:sz w:val="30"\/>/);
163
+ const document = files['word/document.xml'] ?? '';
164
+ expect(document).toContain('<w:pStyle w:val="Heading1"/>');
165
+ expect(document).toContain('<w:bookmarkStart w:id="0" w:name="_od_findings"/>');
166
+ });
167
+
168
+ it('ends each section on its last paragraph and sizes the sheet', () => {
169
+ const doc = model({
170
+ sections: [
171
+ section([para('Cover')]),
172
+ section([para('Body')], { page: { ...A4, width: 16838, height: 11906 } }),
173
+ ],
174
+ });
175
+ const document = unzip(writeDocx(doc))['word/document.xml'] ?? '';
176
+ // The first section's properties ride on its last paragraph.
177
+ expect(document).toMatch(
178
+ /<w:p><w:pPr><w:sectPr>(?:(?!<\/w:p>).)*<\/w:sectPr><\/w:pPr><w:r><w:t[^>]*>Cover</,
179
+ );
180
+ expect(document).toContain('<w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>');
181
+ expect(document).toMatch(/<\/w:p><w:sectPr>.*<\/w:sectPr><\/w:body>/);
182
+ expect(document.match(/<w:sectPr>/g)).toHaveLength(2);
183
+ });
184
+
185
+ it('writes a footer with live page fields, and an empty one where a section has none', () => {
186
+ const footer: Paragraph = {
187
+ type: 'paragraph',
188
+ role: 'footer',
189
+ inlines: [
190
+ { type: 'text', text: 'Page ', style: body },
191
+ { type: 'field', instr: 'PAGE', cached: '1', style: body },
192
+ { type: 'text', text: ' of ', style: body },
193
+ { type: 'field', instr: 'NUMPAGES', cached: '9', style: body },
194
+ ],
195
+ props: {},
196
+ };
197
+ const doc = model({
198
+ sections: [section([para('Body')], { footer: [footer] }), section([para('Appendix')])],
199
+ });
200
+ const files = unzip(writeDocx(doc));
201
+ expect(files['word/footer1.xml']).toContain('<w:fldSimple w:instr=" PAGE ">');
202
+ expect(files['word/footer1.xml']).toContain('<w:fldSimple w:instr=" NUMPAGES ">');
203
+ // Word would otherwise carry the first section's footer into the second.
204
+ expect(files['word/footer2.xml']).toContain('<w:p/>');
205
+ const document = files['word/document.xml'] ?? '';
206
+ expect(document.match(/<w:footerReference /g)).toHaveLength(2);
207
+ expect(files['[Content_Types].xml']).toContain('PartName="/word/footer2.xml"');
208
+ });
209
+
210
+ it('links a repeated header or footer to the previous section instead of copying it', () => {
211
+ const line = (text: string, role: 'header' | 'footer'): Paragraph => ({
212
+ type: 'paragraph',
213
+ role,
214
+ inlines: [{ type: 'text', text, style: body }],
215
+ props: {},
216
+ });
217
+ const doc = model({
218
+ sections: [
219
+ section([para('Cover')]),
220
+ section([para('One')], {
221
+ header: [line('Running head', 'header')],
222
+ footer: [line('Foot', 'footer')],
223
+ }),
224
+ section([para('Two')], {
225
+ header: [line('Running head', 'header')],
226
+ footer: [line('Foot', 'footer')],
227
+ }),
228
+ ],
229
+ });
230
+ const files = unzip(writeDocx(doc));
231
+ expect(files['word/header2.xml']).toContain('Running head');
232
+ expect(files['word/header3.xml']).toBeUndefined();
233
+ const document = files['word/document.xml'] ?? '';
234
+ // Cover: explicitly empty. First body section: its own. Second: inherits.
235
+ expect(document.match(/<w:headerReference /g)).toHaveLength(2);
236
+ expect(document.match(/<w:footerReference /g)).toHaveLength(2);
237
+ expect(files['word/styles.xml']).toContain('<w:name w:val="header"/>');
238
+ });
239
+
240
+ it('declares every face with a stand-in, so a missing font keeps its kind', () => {
241
+ const doc = model({
242
+ fonts: [
243
+ { name: 'Inter', kind: 'sans', eastAsia: false },
244
+ { name: 'Noto Serif TC', kind: 'serif', eastAsia: true },
245
+ ],
246
+ });
247
+ const files = unzip(writeDocx(doc));
248
+ const table = files['word/fontTable.xml'] ?? '';
249
+ expect(table).toContain('<w:font w:name="Inter"><w:altName w:val="Arial"/>');
250
+ expect(table).toContain(
251
+ '<w:altName w:val="PMingLiU"/><w:charset w:val="88"/><w:family w:val="roman"/>',
252
+ );
253
+ expect(files['word/_rels/document.xml.rels']).toContain('Target="fontTable.xml"');
254
+ });
255
+
256
+ it('writes footnotes as Word footnotes, with the separators Word expects', () => {
257
+ const doc = model({
258
+ sections: [
259
+ section([
260
+ {
261
+ type: 'paragraph',
262
+ role: 'body',
263
+ inlines: [
264
+ { type: 'text', text: 'A claim', style: body },
265
+ {
266
+ type: 'footnote',
267
+ id: 1,
268
+ style: { ...body, color: '2563EB', vertAlign: 'superscript' },
269
+ },
270
+ ],
271
+ props: {},
272
+ },
273
+ ]),
274
+ ],
275
+ footnotes: [{ id: 1, blocks: [para('The source.', { role: 'footnote' })] }],
276
+ });
277
+ const files = unzip(writeDocx(doc));
278
+ expect(files['word/document.xml']).toContain('<w:footnoteReference w:id="1"/>');
279
+ const notes = files['word/footnotes.xml'] ?? '';
280
+ expect(notes).toContain('w:type="separator" w:id="-1"');
281
+ expect(notes).toContain('w:type="continuationSeparator" w:id="0"');
282
+ expect(notes).toMatch(/<w:footnote w:id="1">.*<w:footnoteRef\/>.*The source\./);
283
+ expect(files['word/settings.xml']).toContain('<w:footnotePr>');
284
+ expect(files['word/styles.xml']).toMatch(/w:styleId="FootnoteReference">.*w:val="2563EB"/);
285
+ });
286
+
287
+ it('numbers each list from its own start', () => {
288
+ const doc = model({
289
+ sections: [
290
+ section([
291
+ para('one', { role: 'list', list: { num: 1, level: 0 } }),
292
+ para('two', { role: 'list', list: { num: 2, level: 0 } }),
293
+ ]),
294
+ ],
295
+ lists: [
296
+ { id: 1, kind: 'decimal', level: 0, start: 1 },
297
+ { id: 2, kind: 'disc', level: 0, start: 1 },
298
+ ],
299
+ });
300
+ const files = unzip(writeDocx(doc));
301
+ const numbering = files['word/numbering.xml'] ?? '';
302
+ expect(numbering).toContain('<w:numFmt w:val="decimal"/>');
303
+ expect(numbering).toContain('<w:numFmt w:val="bullet"/>');
304
+ expect(numbering).toMatch(/<w:num w:numId="1">.*?<w:startOverride w:val="1"\/>/);
305
+ expect(files['word/document.xml']).toContain(
306
+ '<w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr>',
307
+ );
308
+ expect(files['word/_rels/document.xml.rels']).toContain('Target="numbering.xml"');
309
+ });
310
+
311
+ it('writes tables with spans, merged rows, a repeating header, and a paragraph between tables', () => {
312
+ const cell = (text: string, extra = {}) => ({
313
+ blocks: [para(text)],
314
+ span: 1,
315
+ width: 3000,
316
+ borders: {},
317
+ margins: { top: 60, left: 120, bottom: 60, right: 120 },
318
+ ...extra,
319
+ });
320
+ const table = {
321
+ type: 'table' as const,
322
+ columns: [3000, 3000],
323
+ rows: [
324
+ { header: true, cells: [cell('Head', { span: 2, width: 6000 })] },
325
+ { header: false, cells: [cell('A', { merge: 'restart' as const }), cell('B')] },
326
+ { header: false, cells: [cell('', { merge: 'continue' as const, blocks: [] }), cell('C')] },
327
+ ],
328
+ };
329
+ const doc = model({ sections: [section([table, table])] });
330
+ const document = unzip(writeDocx(doc))['word/document.xml'] ?? '';
331
+ expect(document).toContain('<w:gridCol w:w="3000"/>');
332
+ expect(document).toContain('<w:gridSpan w:val="2"/>');
333
+ expect(document).toContain('<w:vMerge w:val="restart"/>');
334
+ expect(document).toContain('<w:vMerge/>');
335
+ expect(document).toContain('<w:trPr><w:tblHeader/></w:trPr>');
336
+ // An empty merged cell still ends in a paragraph.
337
+ expect(document).toMatch(/<w:vMerge\/>.*?<\/w:tcPr><w:p\/><\/w:tc>/);
338
+ expect(document).toContain('</w:tbl><w:p/><w:tbl>');
339
+ });
340
+
341
+ it('embeds pictures with a relationship and a content type', () => {
342
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]);
343
+ const doc = model({
344
+ sections: [
345
+ section([
346
+ {
347
+ type: 'paragraph',
348
+ role: 'body',
349
+ inlines: [
350
+ { type: 'image', image: { media: 0, width: 952500, height: 476250, alt: 'Chart' } },
351
+ ],
352
+ props: {},
353
+ },
354
+ ]),
355
+ ],
356
+ media: [{ name: 'image1.png', contentType: 'image/png', bytes: png }],
357
+ });
358
+ const zipped = unzipSync(writeDocx(doc));
359
+ expect(zipped['word/media/image1.png']).toEqual(png);
360
+ const files = unzip(writeDocx(doc));
361
+ expect(files['[Content_Types].xml']).toContain(
362
+ '<Default Extension="png" ContentType="image/png"/>',
363
+ );
364
+ expect(files['word/_rels/document.xml.rels']).toContain('Target="media/image1.png"');
365
+ expect(files['word/document.xml']).toContain('<wp:extent cx="952500" cy="476250"/>');
366
+ expect(files['word/document.xml']).toContain('descr="Chart"');
367
+ });
368
+
369
+ it('links out through a relationship and in through a bookmark', () => {
370
+ const doc = model({
371
+ sections: [
372
+ section([
373
+ {
374
+ type: 'paragraph',
375
+ role: 'body',
376
+ inlines: [
377
+ {
378
+ type: 'text',
379
+ text: 'site',
380
+ style: body,
381
+ link: { url: 'https://example.com/?a=1&b=2' },
382
+ },
383
+ { type: 'text', text: ' and ', style: body },
384
+ { type: 'text', text: 'Figure 2', style: body, link: { anchor: '_od_fig' } },
385
+ ],
386
+ props: {},
387
+ },
388
+ ]),
389
+ ],
390
+ });
391
+ const files = unzip(writeDocx(doc));
392
+ expect(files['word/_rels/document.xml.rels']).toContain(
393
+ 'Target="https://example.com/?a=1&amp;b=2" TargetMode="External"',
394
+ );
395
+ expect(files['word/document.xml']).toContain('<w:hyperlink w:anchor="_od_fig" w:history="1">');
396
+ });
397
+
398
+ it('escapes text and drops characters XML cannot carry', () => {
399
+ const files = unzip(writeDocx(model({ sections: [section([para('a < b & c\u0007')])] })));
400
+ expect(files['word/document.xml']).toContain('a &lt; b &amp; c</w:t>');
401
+ });
402
+
403
+ it('wraps the contents in a TOC field Word rebuilds on update', () => {
404
+ const entry = (text: string, extra: Partial<Paragraph>) =>
405
+ para(text, { role: 'toc', level: 1, ...extra });
406
+ const doc = model({
407
+ sections: [
408
+ section([
409
+ entry('Method', { field: { begin: 'TOC \\o "1-2" \\h \\z \\u' } }),
410
+ entry('Findings', { field: { end: true } }),
411
+ ]),
412
+ ],
413
+ });
414
+ const document = unzip(writeDocx(doc))['word/document.xml'] ?? '';
415
+ expect(document).toMatch(
416
+ /<w:fldChar w:fldCharType="begin" w:dirty="true"\/>.*TOC \\o "1-2" \\h \\z \\u .*<w:fldChar w:fldCharType="separate"\/>.*Method.*Findings.*<w:fldChar w:fldCharType="end"\/>/,
417
+ );
418
+ expect(unzip(writeDocx(doc))['word/styles.xml']).toContain('<w:name w:val="toc 1"/>');
419
+ });
420
+
421
+ it('keeps headings with what follows through their style, not cancelling it per paragraph', () => {
422
+ const keep = { keepNext: true };
423
+ const doc = model({
424
+ sections: [
425
+ section([
426
+ para('Findings', { role: 'heading', level: 1, props: keep }, heading),
427
+ para('Body.'),
428
+ para('Method', { role: 'heading', level: 1, props: keep }, heading),
429
+ para('More.'),
430
+ ]),
431
+ ],
432
+ });
433
+ const files = unzip(writeDocx(doc));
434
+ expect(files['word/styles.xml']).toMatch(/w:styleId="Heading1">.*?<w:keepNext\/>/);
435
+ expect(files['word/document.xml']).not.toContain('<w:keepNext w:val="0"/>');
436
+ });
437
+
438
+ it('starts a page where the document asked for one, and sets right-to-left text as such', () => {
439
+ const doc = model({
440
+ sections: [
441
+ section([
442
+ para('One'),
443
+ para('Two', { props: { pageBreakBefore: true } }),
444
+ para('שלום', { props: { bidi: true } }),
445
+ ]),
446
+ ],
447
+ });
448
+ const document = unzip(writeDocx(doc))['word/document.xml'] ?? '';
449
+ expect(document).toMatch(/<w:pPr><w:pageBreakBefore\/>(?:(?!<\/w:p>).)*>Two</);
450
+ expect(document).toMatch(/<w:pPr><w:bidi\/>(?:(?!<\/w:p>).)*>שלום</);
451
+ });
452
+
453
+ it('writes a list item flush with the text as flush, not at the numbering level’s indent', () => {
454
+ const doc = model({
455
+ sections: [
456
+ section([
457
+ para('one', {
458
+ role: 'list',
459
+ list: { num: 1, level: 0 },
460
+ props: { indentLeft: 0, firstLine: 0 },
461
+ }),
462
+ ]),
463
+ ],
464
+ lists: [{ id: 1, kind: 'disc', level: 0, start: 1 }],
465
+ });
466
+ expect(unzip(writeDocx(doc))['word/document.xml']).toContain(
467
+ '<w:ind w:left="0" w:firstLine="0"/>',
468
+ );
469
+ });
470
+
471
+ it('gives a section a first-page footer when its opening page shows another, or none', () => {
472
+ const line = (text: string): Paragraph => ({
473
+ type: 'paragraph',
474
+ role: 'footer',
475
+ inlines: [{ type: 'text', text, style: body }],
476
+ props: {},
477
+ });
478
+ const doc = model({
479
+ sections: [section([para('Body')], { footer: [line('Page')], footerFirst: [] })],
480
+ });
481
+ const files = unzip(writeDocx(doc));
482
+ const document = files['word/document.xml'] ?? '';
483
+ expect(document).toMatch(/<w:footerReference w:type="first" r:id="rId\d+"\/>/);
484
+ expect(document).toMatch(/<w:cols [^>]*\/><w:titlePg\/><w:docGrid /);
485
+ expect(files['word/footer1.xml']).toContain('>Page<');
486
+ expect(files['word/footer2.xml']).toContain('<w:p/>');
487
+ });
488
+
489
+ it('shows the page colour, which Word hides unless the settings ask for it', () => {
490
+ const files = unzip(writeDocx(model({ background: '101216' })));
491
+ expect(files['word/document.xml']).toContain('<w:background w:color="101216"/>');
492
+ expect(files['word/settings.xml']).toContain(
493
+ '<w:zoom w:percent="100"/><w:displayBackgroundShape/><w:defaultTabStop',
494
+ );
495
+ expect(unzip(writeDocx(model()))['word/settings.xml']).not.toContain('displayBackgroundShape');
496
+ });
497
+
498
+ it('marks CJK runs so Word sets their punctuation in the East Asian face', () => {
499
+ const doc = model({
500
+ sections: [section([para('「引號」與標點'), para('Latin text only, and more of it')])],
501
+ fonts: [{ name: 'Noto Sans TC', kind: 'sans', eastAsia: true }],
502
+ });
503
+ const files = unzip(writeDocx(doc));
504
+ expect(files['word/document.xml']).toContain('<w:rFonts w:hint="eastAsia"/>');
505
+ expect(files['word/styles.xml']).toContain('<w:lang w:val="en-US" w:eastAsia="zh-TW"/>');
506
+ });
507
+ });