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,581 @@
|
|
|
1
|
+
import { strToU8, type Zippable, zipSync } from 'fflate';
|
|
2
|
+
import { eastAsiaLangFromFonts, standInFor } from './fonts';
|
|
3
|
+
import type {
|
|
4
|
+
Block,
|
|
5
|
+
DocxModel,
|
|
6
|
+
Inline,
|
|
7
|
+
LinkTarget,
|
|
8
|
+
ListKind,
|
|
9
|
+
Media,
|
|
10
|
+
Paragraph,
|
|
11
|
+
RunStyle,
|
|
12
|
+
Section,
|
|
13
|
+
Table,
|
|
14
|
+
} from './model';
|
|
15
|
+
import { bordersXml, cellMarginsXml, dxa, paraPropsXml, runPropsXml, shadingXml } from './props';
|
|
16
|
+
import { inferStyles, type StyleSheet, styleKeyOf, stylesXml } from './styles';
|
|
17
|
+
import { el, W_NS, XML_DECLARATION, xmlAttr, xmlText } from './xml';
|
|
18
|
+
|
|
19
|
+
export const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
20
|
+
|
|
21
|
+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
22
|
+
const PART_NS = [
|
|
23
|
+
`xmlns:w="${W_NS}"`,
|
|
24
|
+
`xmlns:r="${REL}"`,
|
|
25
|
+
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"',
|
|
26
|
+
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"',
|
|
27
|
+
'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"',
|
|
28
|
+
].join(' ');
|
|
29
|
+
|
|
30
|
+
const CJK = /[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/;
|
|
31
|
+
|
|
32
|
+
/** One relationship list per part: a footer's picture is the footer's relationship, not the body's. */
|
|
33
|
+
class Rels {
|
|
34
|
+
private readonly entries: string[] = [];
|
|
35
|
+
private readonly known = new Map<string, string>();
|
|
36
|
+
|
|
37
|
+
/** `type` is a full URI, or a name under the officeDocument relationships. */
|
|
38
|
+
add(type: string, target: string, external = false): string {
|
|
39
|
+
const key = `${type}\n${target}`;
|
|
40
|
+
const existing = this.known.get(key);
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
const id = `rId${this.entries.length + 1}`;
|
|
43
|
+
this.entries.push(
|
|
44
|
+
el('Relationship', {
|
|
45
|
+
Id: id,
|
|
46
|
+
Type: type.includes(':') ? type : `${REL}/${type}`,
|
|
47
|
+
Target: target,
|
|
48
|
+
TargetMode: external ? 'External' : undefined,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
this.known.set(key, id);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get empty(): boolean {
|
|
56
|
+
return this.entries.length === 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
xml(): string {
|
|
60
|
+
return `${XML_DECLARATION}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${this.entries.join('')}</Relationships>`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Env = {
|
|
65
|
+
styles: StyleSheet;
|
|
66
|
+
media: Media[];
|
|
67
|
+
rels: Rels;
|
|
68
|
+
/** Drawing and bookmark ids are unique across every part of the package. */
|
|
69
|
+
ids: { drawing: number; bookmark: number };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function sameLink(a: LinkTarget | undefined, b: LinkTarget | undefined): boolean {
|
|
73
|
+
if (!a || !b) return a === b;
|
|
74
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function linkOf(inline: Inline): LinkTarget | undefined {
|
|
78
|
+
return 'link' in inline ? inline.link : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function textRun(text: string, style: RunStyle, base: RunStyle): string {
|
|
82
|
+
const rPr = runPropsXml(style, base, { eastAsiaHint: CJK.test(text) });
|
|
83
|
+
return `<w:r>${rPr}<w:t xml:space="preserve">${xmlText(text)}</w:t></w:r>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function referenceRPr(styles: StyleSheet): string {
|
|
87
|
+
return styles.footnoteColor
|
|
88
|
+
? `<w:rPr>${el('w:rStyle', { 'w:val': 'FootnoteReference' })}</w:rPr>`
|
|
89
|
+
: `<w:rPr>${el('w:vertAlign', { 'w:val': 'superscript' })}</w:rPr>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function drawingXml(inline: Extract<Inline, { type: 'image' }>, env: Env): string {
|
|
93
|
+
const media = env.media[inline.image.media];
|
|
94
|
+
if (!media) return '';
|
|
95
|
+
const rel = env.rels.add('image', `media/${media.name}`);
|
|
96
|
+
const id = ++env.ids.drawing;
|
|
97
|
+
const { width, height, alt } = inline.image;
|
|
98
|
+
const cx = Math.max(1, width);
|
|
99
|
+
const cy = Math.max(1, height);
|
|
100
|
+
return [
|
|
101
|
+
'<w:r><w:drawing>',
|
|
102
|
+
`<wp:inline distT="0" distB="0" distL="0" distR="0">`,
|
|
103
|
+
el('wp:extent', { cx, cy }),
|
|
104
|
+
el('wp:effectExtent', { l: 0, t: 0, r: 0, b: 0 }),
|
|
105
|
+
el('wp:docPr', { id, name: `Picture ${id}`, descr: alt || undefined }),
|
|
106
|
+
'<wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>',
|
|
107
|
+
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">',
|
|
108
|
+
'<pic:pic>',
|
|
109
|
+
`<pic:nvPicPr>${el('pic:cNvPr', { id, name: media.name })}<pic:cNvPicPr/></pic:nvPicPr>`,
|
|
110
|
+
`<pic:blipFill>${el('a:blip', { 'r:embed': rel })}<a:stretch><a:fillRect/></a:stretch></pic:blipFill>`,
|
|
111
|
+
`<pic:spPr><a:xfrm><a:off x="0" y="0"/>${el('a:ext', { cx, cy })}</a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>`,
|
|
112
|
+
'</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>',
|
|
113
|
+
].join('');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function inlineXml(inline: Inline, base: RunStyle, env: Env): string {
|
|
117
|
+
switch (inline.type) {
|
|
118
|
+
case 'text':
|
|
119
|
+
return textRun(inline.text, inline.style, base);
|
|
120
|
+
case 'break':
|
|
121
|
+
return `<w:r>${inline.style ? runPropsXml(inline.style, base) : ''}<w:br/></w:r>`;
|
|
122
|
+
case 'tab':
|
|
123
|
+
return '<w:r><w:tab/></w:r>';
|
|
124
|
+
case 'field':
|
|
125
|
+
return el(
|
|
126
|
+
'w:fldSimple',
|
|
127
|
+
{ 'w:instr': ` ${inline.instr} ` },
|
|
128
|
+
textRun(inline.cached, inline.style, base),
|
|
129
|
+
);
|
|
130
|
+
case 'footnote':
|
|
131
|
+
return `<w:r>${referenceRPr(env.styles)}${el('w:footnoteReference', { 'w:id': inline.id })}</w:r>`;
|
|
132
|
+
case 'image':
|
|
133
|
+
return drawingXml(inline, env);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Adjacent text in the same style is one run: Word writes it that way, and editing is saner. */
|
|
138
|
+
function coalesce(inlines: Inline[]): Inline[] {
|
|
139
|
+
const out: Inline[] = [];
|
|
140
|
+
for (const inline of inlines) {
|
|
141
|
+
const last = out[out.length - 1];
|
|
142
|
+
if (
|
|
143
|
+
inline.type === 'text' &&
|
|
144
|
+
last?.type === 'text' &&
|
|
145
|
+
sameLink(last.link, inline.link) &&
|
|
146
|
+
JSON.stringify(last.style) === JSON.stringify(inline.style)
|
|
147
|
+
) {
|
|
148
|
+
out[out.length - 1] = { ...last, text: last.text + inline.text };
|
|
149
|
+
} else {
|
|
150
|
+
out.push(inline);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function inlinesXml(inlines: Inline[], base: RunStyle, env: Env): string {
|
|
157
|
+
const merged = coalesce(inlines);
|
|
158
|
+
let out = '';
|
|
159
|
+
let i = 0;
|
|
160
|
+
while (i < merged.length) {
|
|
161
|
+
const first = merged[i];
|
|
162
|
+
if (!first) break;
|
|
163
|
+
const link = linkOf(first);
|
|
164
|
+
let runs = '';
|
|
165
|
+
while (i < merged.length) {
|
|
166
|
+
const inline = merged[i];
|
|
167
|
+
if (!inline || !sameLink(linkOf(inline), link)) break;
|
|
168
|
+
runs += inlineXml(inline, base, env);
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
if (!link) out += runs;
|
|
172
|
+
else if ('url' in link) {
|
|
173
|
+
out += `<w:hyperlink r:id="${env.rels.add('hyperlink', link.url, true)}" w:history="1">${runs}</w:hyperlink>`;
|
|
174
|
+
} else {
|
|
175
|
+
out += `<w:hyperlink w:anchor="${xmlAttr(link.anchor)}" w:history="1">${runs}</w:hyperlink>`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function paragraphXml(
|
|
182
|
+
paragraph: Paragraph,
|
|
183
|
+
env: Env,
|
|
184
|
+
opts: { sectPr?: string; lead?: string } = {},
|
|
185
|
+
): string {
|
|
186
|
+
const key = styleKeyOf(paragraph);
|
|
187
|
+
const style = env.styles.byId.get(key.id) ?? env.styles.normal;
|
|
188
|
+
const pPr = paraPropsXml(paragraph.props, style.para, {
|
|
189
|
+
pStyle: key.id === 'Normal' ? undefined : key.id,
|
|
190
|
+
numPr: paragraph.list,
|
|
191
|
+
sectPr: opts.sectPr,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
let body = opts.lead ?? '';
|
|
195
|
+
if (paragraph.field?.begin) {
|
|
196
|
+
body +=
|
|
197
|
+
'<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
|
|
198
|
+
`<w:r><w:instrText xml:space="preserve"> ${xmlText(paragraph.field.begin)} </w:instrText></w:r>` +
|
|
199
|
+
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>';
|
|
200
|
+
}
|
|
201
|
+
const bookmarkId = paragraph.bookmark ? env.ids.bookmark++ : undefined;
|
|
202
|
+
if (paragraph.bookmark) {
|
|
203
|
+
body += el('w:bookmarkStart', { 'w:id': bookmarkId, 'w:name': paragraph.bookmark });
|
|
204
|
+
}
|
|
205
|
+
body += inlinesXml(paragraph.inlines, style.run, env);
|
|
206
|
+
if (paragraph.bookmark) body += el('w:bookmarkEnd', { 'w:id': bookmarkId });
|
|
207
|
+
if (paragraph.field?.end) body += '<w:r><w:fldChar w:fldCharType="end"/></w:r>';
|
|
208
|
+
|
|
209
|
+
return `<w:p>${pPr}${body}</w:p>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function tableXml(table: Table, env: Env): string {
|
|
213
|
+
const width = table.columns.reduce((sum, column) => sum + column, 0);
|
|
214
|
+
const tblPr = [
|
|
215
|
+
dxa('w:tblW', width),
|
|
216
|
+
table.align ? el('w:jc', { 'w:val': table.align }) : '',
|
|
217
|
+
table.indent ? dxa('w:tblInd', table.indent) : '',
|
|
218
|
+
el('w:tblLayout', { 'w:type': 'fixed' }),
|
|
219
|
+
cellMarginsXml('w:tblCellMar', { left: 0, right: 0 }),
|
|
220
|
+
].join('');
|
|
221
|
+
const grid = table.columns.map((column) => el('w:gridCol', { 'w:w': column })).join('');
|
|
222
|
+
|
|
223
|
+
const rows = table.rows
|
|
224
|
+
.map((row) => {
|
|
225
|
+
const cells = row.cells
|
|
226
|
+
.map((cell) => {
|
|
227
|
+
const tcPr = [
|
|
228
|
+
dxa('w:tcW', cell.width),
|
|
229
|
+
cell.span > 1 ? el('w:gridSpan', { 'w:val': cell.span }) : '',
|
|
230
|
+
cell.merge
|
|
231
|
+
? el('w:vMerge', cell.merge === 'restart' ? { 'w:val': 'restart' } : {})
|
|
232
|
+
: '',
|
|
233
|
+
bordersXml('w:tcBorders', cell.borders),
|
|
234
|
+
shadingXml(cell.shading),
|
|
235
|
+
cellMarginsXml('w:tcMar', cell.margins),
|
|
236
|
+
cell.vAlign ? el('w:vAlign', { 'w:val': cell.vAlign }) : '',
|
|
237
|
+
].join('');
|
|
238
|
+
// A cell has to end in a paragraph, or Word will not open the file.
|
|
239
|
+
const last = cell.blocks[cell.blocks.length - 1];
|
|
240
|
+
const content =
|
|
241
|
+
blocksXml(cell.blocks, env) + (last?.type === 'paragraph' ? '' : '<w:p/>');
|
|
242
|
+
return `<w:tc><w:tcPr>${tcPr}</w:tcPr>${content}</w:tc>`;
|
|
243
|
+
})
|
|
244
|
+
.join('');
|
|
245
|
+
const trPr = row.header ? '<w:trPr><w:tblHeader/></w:trPr>' : '';
|
|
246
|
+
return `<w:tr>${trPr}${cells}</w:tr>`;
|
|
247
|
+
})
|
|
248
|
+
.join('');
|
|
249
|
+
|
|
250
|
+
return `<w:tbl><w:tblPr>${tblPr}</w:tblPr><w:tblGrid>${grid}</w:tblGrid>${rows}</w:tbl>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function blocksXml(blocks: Block[], env: Env): string {
|
|
254
|
+
let out = '';
|
|
255
|
+
let previous: Block | undefined;
|
|
256
|
+
for (const block of blocks) {
|
|
257
|
+
// Two tables with nothing between them are one table to Word.
|
|
258
|
+
if (block.type === 'table' && previous?.type === 'table') out += '<w:p/>';
|
|
259
|
+
out += block.type === 'paragraph' ? paragraphXml(block, env) : tableXml(block, env);
|
|
260
|
+
previous = block;
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
type Band = 'header' | 'footer';
|
|
266
|
+
|
|
267
|
+
type BandRefs = { header?: string; footer?: string; footerFirst?: string };
|
|
268
|
+
|
|
269
|
+
function sectPrXml(section: Section, refs: BandRefs): string {
|
|
270
|
+
const { page } = section;
|
|
271
|
+
return [
|
|
272
|
+
'<w:sectPr>',
|
|
273
|
+
refs.header ? el('w:headerReference', { 'w:type': 'default', 'r:id': refs.header }) : '',
|
|
274
|
+
refs.footer ? el('w:footerReference', { 'w:type': 'default', 'r:id': refs.footer }) : '',
|
|
275
|
+
refs.footerFirst
|
|
276
|
+
? el('w:footerReference', { 'w:type': 'first', 'r:id': refs.footerFirst })
|
|
277
|
+
: '',
|
|
278
|
+
el('w:type', { 'w:val': 'nextPage' }),
|
|
279
|
+
el('w:pgSz', {
|
|
280
|
+
'w:w': page.width,
|
|
281
|
+
'w:h': page.height,
|
|
282
|
+
'w:orient': page.width > page.height ? 'landscape' : undefined,
|
|
283
|
+
}),
|
|
284
|
+
el('w:pgMar', {
|
|
285
|
+
'w:top': page.margin.top,
|
|
286
|
+
'w:right': page.margin.right,
|
|
287
|
+
'w:bottom': page.margin.bottom,
|
|
288
|
+
'w:left': page.margin.left,
|
|
289
|
+
'w:header': page.header,
|
|
290
|
+
'w:footer': page.footer,
|
|
291
|
+
'w:gutter': 0,
|
|
292
|
+
}),
|
|
293
|
+
el('w:cols', { 'w:space': 720 }),
|
|
294
|
+
// Word's "different first page", which is what takes the `first` footer.
|
|
295
|
+
refs.footerFirst ? el('w:titlePg') : '',
|
|
296
|
+
el('w:docGrid', { 'w:linePitch': 360 }),
|
|
297
|
+
'</w:sectPr>',
|
|
298
|
+
].join('');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const BULLETS: Partial<Record<ListKind, string>> = { disc: '•', circle: '◦', square: '▪' };
|
|
302
|
+
|
|
303
|
+
function numberingXml(model: DocxModel): string {
|
|
304
|
+
const kinds = [...new Set(model.lists.map((list) => list.kind))];
|
|
305
|
+
const abstractId = new Map(kinds.map((kind, index) => [kind, index]));
|
|
306
|
+
|
|
307
|
+
const abstracts = kinds.map((kind) => {
|
|
308
|
+
const bullet = BULLETS[kind];
|
|
309
|
+
const levels = Array.from({ length: 9 }, (_, level) =>
|
|
310
|
+
el(
|
|
311
|
+
'w:lvl',
|
|
312
|
+
{ 'w:ilvl': level },
|
|
313
|
+
[
|
|
314
|
+
el('w:start', { 'w:val': 1 }),
|
|
315
|
+
el('w:numFmt', { 'w:val': bullet ? 'bullet' : kind }),
|
|
316
|
+
el('w:lvlText', { 'w:val': bullet ?? `%${level + 1}.` }),
|
|
317
|
+
el('w:lvlJc', { 'w:val': 'left' }),
|
|
318
|
+
`<w:pPr>${el('w:ind', { 'w:left': 720 * (level + 1), 'w:hanging': 360 })}</w:pPr>`,
|
|
319
|
+
].join(''),
|
|
320
|
+
),
|
|
321
|
+
).join('');
|
|
322
|
+
return el(
|
|
323
|
+
'w:abstractNum',
|
|
324
|
+
{ 'w:abstractNumId': abstractId.get(kind) },
|
|
325
|
+
`${el('w:multiLevelType', { 'w:val': 'hybridMultilevel' })}${levels}`,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Every list gets its own instance with an explicit start. Instances of one
|
|
330
|
+
// abstract definition otherwise continue each other's count, and the second
|
|
331
|
+
// numbered list in a document would open at 4.
|
|
332
|
+
const instances = model.lists.map((list) =>
|
|
333
|
+
el(
|
|
334
|
+
'w:num',
|
|
335
|
+
{ 'w:numId': list.id },
|
|
336
|
+
[
|
|
337
|
+
el('w:abstractNumId', { 'w:val': abstractId.get(list.kind) }),
|
|
338
|
+
el(
|
|
339
|
+
'w:lvlOverride',
|
|
340
|
+
{ 'w:ilvl': list.level },
|
|
341
|
+
el('w:startOverride', { 'w:val': list.start }),
|
|
342
|
+
),
|
|
343
|
+
].join(''),
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return `${XML_DECLARATION}<w:numbering xmlns:w="${W_NS}">${abstracts.join('')}${instances.join('')}</w:numbering>`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function fontTableXml(model: DocxModel, lang: string | undefined): string {
|
|
351
|
+
const fonts = (model.fonts ?? []).map((font) => {
|
|
352
|
+
const standIn = standInFor(font, lang);
|
|
353
|
+
return el(
|
|
354
|
+
'w:font',
|
|
355
|
+
{ 'w:name': font.name },
|
|
356
|
+
[
|
|
357
|
+
standIn.altName ? el('w:altName', { 'w:val': standIn.altName }) : '',
|
|
358
|
+
el('w:charset', { 'w:val': standIn.charset }),
|
|
359
|
+
el('w:family', { 'w:val': standIn.family }),
|
|
360
|
+
el('w:pitch', { 'w:val': standIn.pitch }),
|
|
361
|
+
].join(''),
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
return `${XML_DECLARATION}<w:fonts xmlns:w="${W_NS}">${fonts.join('')}</w:fonts>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function settingsXml(model: DocxModel): string {
|
|
368
|
+
const footnotes =
|
|
369
|
+
model.footnotes.length > 0
|
|
370
|
+
? '<w:footnotePr><w:footnote w:id="-1"/><w:footnote w:id="0"/></w:footnotePr>'
|
|
371
|
+
: '';
|
|
372
|
+
return [
|
|
373
|
+
XML_DECLARATION,
|
|
374
|
+
`<w:settings xmlns:w="${W_NS}">`,
|
|
375
|
+
'<w:zoom w:percent="100"/>',
|
|
376
|
+
// Without it Word keeps the page colour to itself and shows white paper.
|
|
377
|
+
model.background ? '<w:displayBackgroundShape/>' : '',
|
|
378
|
+
'<w:defaultTabStop w:val="720"/>',
|
|
379
|
+
'<w:characterSpacingControl w:val="doNotCompress"/>',
|
|
380
|
+
footnotes,
|
|
381
|
+
// Without this Word opens the file in compatibility mode and lays it out
|
|
382
|
+
// with Word 2007's rules.
|
|
383
|
+
'<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>',
|
|
384
|
+
'</w:settings>',
|
|
385
|
+
].join('');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function w3cdtf(date: Date): string {
|
|
389
|
+
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function corePropsXml(model: DocxModel, now: Date): string {
|
|
393
|
+
const created =
|
|
394
|
+
model.created && !Number.isNaN(Date.parse(model.created)) ? new Date(model.created) : now;
|
|
395
|
+
return [
|
|
396
|
+
XML_DECLARATION,
|
|
397
|
+
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
|
398
|
+
`<dc:title>${xmlText(model.title)}</dc:title>`,
|
|
399
|
+
model.subject ? `<dc:subject>${xmlText(model.subject)}</dc:subject>` : '',
|
|
400
|
+
model.author ? `<dc:creator>${xmlText(model.author)}</dc:creator>` : '',
|
|
401
|
+
`<dcterms:created xsi:type="dcterms:W3CDTF">${w3cdtf(created)}</dcterms:created>`,
|
|
402
|
+
`<dcterms:modified xsi:type="dcterms:W3CDTF">${w3cdtf(now)}</dcterms:modified>`,
|
|
403
|
+
'</cp:coreProperties>',
|
|
404
|
+
].join('');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const CONTENT = 'application/vnd.openxmlformats-officedocument';
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The model as a `.docx`: WordprocessingML parts in an OPC zip. Hand-written
|
|
411
|
+
* rather than a dependency for the same reason as the Markdown and CSV readers
|
|
412
|
+
* — core ships to every user — and the part of OOXML a document needs is small.
|
|
413
|
+
*/
|
|
414
|
+
export function writeDocx(model: DocxModel, now = new Date()): Uint8Array {
|
|
415
|
+
const styles = inferStyles(model);
|
|
416
|
+
// Which language the CJK text is in, as the faces say; the text alone does
|
|
417
|
+
// not tell Traditional from Simplified.
|
|
418
|
+
const lang = eastAsiaLangFromFonts(
|
|
419
|
+
(model.fonts ?? []).filter((font) => font.eastAsia).map((font) => font.name),
|
|
420
|
+
);
|
|
421
|
+
const ids = { drawing: 0, bookmark: 0 };
|
|
422
|
+
const documentRels = new Rels();
|
|
423
|
+
const files: Zippable = {};
|
|
424
|
+
const overrides: string[] = [];
|
|
425
|
+
const part = (name: string, contentType: string, xml: string) => {
|
|
426
|
+
files[name] = strToU8(xml);
|
|
427
|
+
overrides.push(el('Override', { PartName: `/${name}`, ContentType: contentType }));
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
documentRels.add('styles', 'styles.xml');
|
|
431
|
+
documentRels.add('settings', 'settings.xml');
|
|
432
|
+
|
|
433
|
+
// Word carries a section's header and footer into the next section unless
|
|
434
|
+
// that section names its own, so once any section has one, the sections
|
|
435
|
+
// without it get an explicitly empty one.
|
|
436
|
+
const used = {
|
|
437
|
+
header: model.sections.some((section) => section.header),
|
|
438
|
+
footer: model.sections.some((section) => section.footer),
|
|
439
|
+
};
|
|
440
|
+
const written = { header: 0, footer: 0 };
|
|
441
|
+
const previous: Partial<Record<Band, string>> = {};
|
|
442
|
+
const bandPart = (band: Band, paragraphs: Paragraph[] | undefined): string => {
|
|
443
|
+
const rels = new Rels();
|
|
444
|
+
const content = paragraphs?.length
|
|
445
|
+
? blocksXml(paragraphs, { styles, media: model.media, rels, ids })
|
|
446
|
+
: '<w:p/>';
|
|
447
|
+
written[band] += 1;
|
|
448
|
+
const name = `${band}${written[band]}.xml`;
|
|
449
|
+
const root = band === 'header' ? 'w:hdr' : 'w:ftr';
|
|
450
|
+
part(
|
|
451
|
+
`word/${name}`,
|
|
452
|
+
`${CONTENT}.wordprocessingml.${band}+xml`,
|
|
453
|
+
`${XML_DECLARATION}<${root} ${PART_NS}>${content}</${root}>`,
|
|
454
|
+
);
|
|
455
|
+
if (!rels.empty) files[`word/_rels/${name}.rels`] = strToU8(rels.xml());
|
|
456
|
+
return documentRels.add(band, name);
|
|
457
|
+
};
|
|
458
|
+
const bandFor = (band: Band, paragraphs: Paragraph[] | undefined): string | undefined => {
|
|
459
|
+
if (!used[band]) return undefined;
|
|
460
|
+
// The same as the section before: leaving the reference out is Word's own
|
|
461
|
+
// "link to previous", so a reviewer edits one footer, not one per section.
|
|
462
|
+
const key = JSON.stringify(paragraphs ?? []);
|
|
463
|
+
if (previous[band] === key) return undefined;
|
|
464
|
+
previous[band] = key;
|
|
465
|
+
return bandPart(band, paragraphs);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const env: Env = { styles, media: model.media, rels: documentRels, ids };
|
|
469
|
+
let body = '';
|
|
470
|
+
model.sections.forEach((section, index) => {
|
|
471
|
+
const header = bandFor('header', section.header);
|
|
472
|
+
const footer = bandFor('footer', section.footer);
|
|
473
|
+
const footerFirst = section.footerFirst && bandPart('footer', section.footerFirst);
|
|
474
|
+
const sectPr = sectPrXml(section, { header, footer, footerFirst });
|
|
475
|
+
const blocks = section.blocks;
|
|
476
|
+
const last = blocks[blocks.length - 1];
|
|
477
|
+
|
|
478
|
+
if (index === model.sections.length - 1) {
|
|
479
|
+
body += blocksXml(blocks, env);
|
|
480
|
+
if (last?.type !== 'paragraph') body += '<w:p/>';
|
|
481
|
+
body += sectPr;
|
|
482
|
+
} else if (last?.type === 'paragraph') {
|
|
483
|
+
// A section ends at the paragraph that carries its properties.
|
|
484
|
+
body += blocksXml(blocks.slice(0, -1), env);
|
|
485
|
+
body += paragraphXml(last, env, { sectPr });
|
|
486
|
+
} else {
|
|
487
|
+
body += `${blocksXml(blocks, env)}<w:p><w:pPr>${sectPr}</w:pPr></w:p>`;
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
part(
|
|
492
|
+
'word/document.xml',
|
|
493
|
+
`${CONTENT}.wordprocessingml.document.main+xml`,
|
|
494
|
+
`${XML_DECLARATION}<w:document ${PART_NS}>${model.background ? el('w:background', { 'w:color': model.background }) : ''}<w:body>${body}</w:body></w:document>`,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
if (model.footnotes.length > 0) {
|
|
498
|
+
const rels = new Rels();
|
|
499
|
+
const noteEnv: Env = { styles, media: model.media, rels, ids };
|
|
500
|
+
const separator = (kind: 'separator' | 'continuationSeparator', id: number) =>
|
|
501
|
+
el(
|
|
502
|
+
'w:footnote',
|
|
503
|
+
{ 'w:type': kind, 'w:id': id },
|
|
504
|
+
`<w:p><w:pPr>${el('w:spacing', { 'w:after': 0, 'w:line': 240, 'w:lineRule': 'auto' })}</w:pPr><w:r><w:${kind}/></w:r></w:p>`,
|
|
505
|
+
);
|
|
506
|
+
const lead = `<w:r>${referenceRPr(styles)}<w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> </w:t></w:r>`;
|
|
507
|
+
const notes = model.footnotes.map((note) => {
|
|
508
|
+
const [first, ...rest] = note.blocks;
|
|
509
|
+
const content =
|
|
510
|
+
first?.type === 'paragraph'
|
|
511
|
+
? paragraphXml(first, noteEnv, { lead }) + blocksXml(rest, noteEnv)
|
|
512
|
+
: `<w:p>${lead}</w:p>${blocksXml(note.blocks, noteEnv)}`;
|
|
513
|
+
return el('w:footnote', { 'w:id': note.id }, content);
|
|
514
|
+
});
|
|
515
|
+
part(
|
|
516
|
+
'word/footnotes.xml',
|
|
517
|
+
`${CONTENT}.wordprocessingml.footnotes+xml`,
|
|
518
|
+
`${XML_DECLARATION}<w:footnotes ${PART_NS}>${separator('separator', -1)}${separator('continuationSeparator', 0)}${notes.join('')}</w:footnotes>`,
|
|
519
|
+
);
|
|
520
|
+
if (!rels.empty) files['word/_rels/footnotes.xml.rels'] = strToU8(rels.xml());
|
|
521
|
+
documentRels.add('footnotes', 'footnotes.xml');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (model.lists.length > 0) {
|
|
525
|
+
part('word/numbering.xml', `${CONTENT}.wordprocessingml.numbering+xml`, numberingXml(model));
|
|
526
|
+
documentRels.add('numbering', 'numbering.xml');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
part('word/styles.xml', `${CONTENT}.wordprocessingml.styles+xml`, stylesXml(styles, lang));
|
|
530
|
+
if (model.fonts?.length) {
|
|
531
|
+
part(
|
|
532
|
+
'word/fontTable.xml',
|
|
533
|
+
`${CONTENT}.wordprocessingml.fontTable+xml`,
|
|
534
|
+
fontTableXml(model, lang),
|
|
535
|
+
);
|
|
536
|
+
documentRels.add('fontTable', 'fontTable.xml');
|
|
537
|
+
}
|
|
538
|
+
part('word/settings.xml', `${CONTENT}.wordprocessingml.settings+xml`, settingsXml(model));
|
|
539
|
+
part(
|
|
540
|
+
'docProps/core.xml',
|
|
541
|
+
'application/vnd.openxmlformats-package.core-properties+xml',
|
|
542
|
+
corePropsXml(model, now),
|
|
543
|
+
);
|
|
544
|
+
part(
|
|
545
|
+
'docProps/app.xml',
|
|
546
|
+
`${CONTENT}.extended-properties+xml`,
|
|
547
|
+
`${XML_DECLARATION}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>MoSage</Application></Properties>`,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const extensions = new Map<string, string>();
|
|
551
|
+
for (const media of model.media) {
|
|
552
|
+
const extension = media.name.slice(media.name.lastIndexOf('.') + 1);
|
|
553
|
+
extensions.set(extension, media.contentType);
|
|
554
|
+
// Pictures are already compressed; deflating them again only costs time.
|
|
555
|
+
files[`word/media/${media.name}`] = [media.bytes, { level: 0 }];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
files['word/_rels/document.xml.rels'] = strToU8(documentRels.xml());
|
|
559
|
+
const packageRels = new Rels();
|
|
560
|
+
packageRels.add('officeDocument', 'word/document.xml');
|
|
561
|
+
packageRels.add(
|
|
562
|
+
'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties',
|
|
563
|
+
'docProps/core.xml',
|
|
564
|
+
);
|
|
565
|
+
packageRels.add('extended-properties', 'docProps/app.xml');
|
|
566
|
+
files['_rels/.rels'] = strToU8(packageRels.xml());
|
|
567
|
+
|
|
568
|
+
const defaults = [
|
|
569
|
+
el('Default', {
|
|
570
|
+
Extension: 'rels',
|
|
571
|
+
ContentType: 'application/vnd.openxmlformats-package.relationships+xml',
|
|
572
|
+
}),
|
|
573
|
+
el('Default', { Extension: 'xml', ContentType: 'application/xml' }),
|
|
574
|
+
...[...extensions].map(([Extension, ContentType]) => el('Default', { Extension, ContentType })),
|
|
575
|
+
];
|
|
576
|
+
const contentTypes = strToU8(
|
|
577
|
+
`${XML_DECLARATION}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">${defaults.join('')}${overrides.join('')}</Types>`,
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
return zipSync({ '[Content_Types].xml': contentTypes, ...files });
|
|
581
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XML 1.0 forbids most C0 controls even when escaped, and Word refuses a part
|
|
3
|
+
* that contains one — a stray vertical tab pasted into a paragraph would make the
|
|
4
|
+
* whole file unreadable. Lone surrogates are dropped for the same reason.
|
|
5
|
+
*/
|
|
6
|
+
const INVALID_XML =
|
|
7
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching the characters XML forbids is the point
|
|
8
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
9
|
+
|
|
10
|
+
export const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
11
|
+
|
|
12
|
+
export function xmlText(value: string): string {
|
|
13
|
+
return value
|
|
14
|
+
.replace(INVALID_XML, '')
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function xmlAttr(value: string): string {
|
|
21
|
+
return xmlText(value).replace(/"/g, '"');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An element; numbers are rounded, since every measure WordprocessingML takes is whole. */
|
|
25
|
+
export function el(
|
|
26
|
+
name: string,
|
|
27
|
+
attrs: Record<string, string | number | undefined> = {},
|
|
28
|
+
children?: string,
|
|
29
|
+
): string {
|
|
30
|
+
let open = `<${name}`;
|
|
31
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
32
|
+
if (value === undefined) continue;
|
|
33
|
+
open += ` ${key}="${typeof value === 'number' ? Math.round(value) : xmlAttr(value)}"`;
|
|
34
|
+
}
|
|
35
|
+
if (children === undefined || children === '') return `${open}/>`;
|
|
36
|
+
return `${open}>${children}</${name}>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
|