@md2nativedocx/cli 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `.pptx` branch of the CLI (`md2nativedocx deck.md -o deck.pptx`): one slide per ```mermaid block.
3
+ *
4
+ * Pandoc is not involved: `@md2nativedocx/pptx` rewrites the diagram shapes core already produces
5
+ * (see docs/adr/0010-pptx-production-translator-plan.md). Non-diagram Markdown is ignored.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { basename, extname } from 'node:path';
10
+ import { CliError, resolveSafePath } from './cliSupport.mjs';
11
+ import { ConversionError } from './convert.mjs';
12
+ import { writeOutputSafely } from './writeOutputSafely.mjs';
13
+
14
+ const USAGE = `Usage: md2nativedocx <input.md> -o <output.pptx> [--show-source]
15
+
16
+ --show-source Put each diagram's Mermaid source in a panel beside it
17
+ `;
18
+
19
+ function parsePptxArgs(argv) {
20
+ const args = { input: null, output: null, showSource: false };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (a === '-o' || a === '--output') {
24
+ args.output = argv[++i];
25
+ if (!args.output) throw new CliError('Missing value for --output', 2);
26
+ } else if (a === '--show-source') {
27
+ args.showSource = true;
28
+ } else if (a === '-h' || a === '--help') {
29
+ args.help = true;
30
+ } else if (a.startsWith('-')) {
31
+ throw new CliError(`Unknown option: ${a}`, 2);
32
+ } else if (args.input === null) {
33
+ args.input = a;
34
+ } else {
35
+ throw new CliError(`Unexpected argument: ${a}`, 2);
36
+ }
37
+ }
38
+ return args;
39
+ }
40
+
41
+ /** Run the deck export; resolves to the process exit code. */
42
+ export async function runPptxCli(argv, cwd) {
43
+ try {
44
+ const args = parsePptxArgs(argv);
45
+ if (args.help) {
46
+ process.stdout.write(USAGE);
47
+ return 0;
48
+ }
49
+ if (!args.input) throw new CliError('Missing input file.', 2);
50
+ const input = resolveSafePath(args.input, cwd);
51
+ const output = resolveSafePath(args.output, cwd);
52
+ if (!existsSync(input)) throw new CliError(`Input file not found: ${args.input}`, 1);
53
+
54
+ const { exportPptx, PptxConversionError } = await import('@md2nativedocx/pptx');
55
+ let result;
56
+ try {
57
+ result = await exportPptx(readFileSync(input, 'utf8'), { title: basename(input), showSource: args.showSource });
58
+ } catch (err) {
59
+ if (err instanceof PptxConversionError) throw new CliError(`md2nativedocx: ${err.message}`, 1);
60
+ throw err;
61
+ }
62
+ try {
63
+ writeOutputSafely(output, result.buffer);
64
+ } catch (err) {
65
+ if (err instanceof ConversionError) throw new CliError(`md2nativedocx: ${err.message}`, 2);
66
+ throw err;
67
+ }
68
+ writePptxLog(input, output, result.warnings, result.slideCount);
69
+ if (result.warnings.length > 0) {
70
+ process.stdout.write(`Warnings: ${result.warnings.length}\n`);
71
+ for (const w of result.warnings) process.stderr.write(`md2nativedocx: ${w}\n`);
72
+ }
73
+ process.stdout.write(`Wrote ${basename(output)} (${result.slideCount} slide${result.slideCount === 1 ? '' : 's'})\n`);
74
+ return 0;
75
+ } catch (err) {
76
+ if (err instanceof CliError) {
77
+ process.stderr.write(`${err.message}\n`);
78
+ return err.exitCode;
79
+ }
80
+ process.stderr.write(`md2nativedocx: pptx export failed: ${err instanceof Error ? err.message : String(err)}\n`);
81
+ return 1;
82
+ }
83
+ }
84
+
85
+ /** Plain-text log next to the deck (`deck.pptx` -> `deck.log`), same header as the .docx export log so
86
+ * the VS Code extension reads the warning count and opens it the same way. */
87
+ function writePptxLog(input, output, warnings, slideCount) {
88
+ const logPath = output.slice(0, -extname(output).length) + '.log';
89
+ const lines = [
90
+ 'md2nativedocx export log',
91
+ `Date: ${new Date().toISOString()}`,
92
+ `Input: ${input}`,
93
+ `Output: ${output}`,
94
+ `Warnings: ${warnings.length}`,
95
+ '',
96
+ ...(warnings.length > 0 ? warnings.map((w) => `- ${w.replace(/^md2nativedocx:\s*(warning:\s*)?/, '')}`) : ['No warnings.']),
97
+ '',
98
+ `Slides: ${slideCount}`,
99
+ '',
100
+ ];
101
+ writeOutputSafely(logPath, lines.join('\n'));
102
+ }
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Build a `reference.docx` patched for the page/typography settings a user
3
+ * picked (`docs/specs/export_customization_SPEC.md` §1.1-1.8/1.14, §2.1 —
4
+ * "Lot 1"), starting from the bundled static template
5
+ * (`packages/cli/assets/reference.docx`) or a custom one the caller already
6
+ * resolved.
7
+ *
8
+ * Same `unzip`/`zip` shell-out pattern `postprocess.mjs` already uses for
9
+ * the final `.docx` (rule `AGENTS.md` "nouvelle dépendance → escalade" —
10
+ * this needs none): extract only the entries a given option set actually
11
+ * touches, patch them with scoped string/regex edits (not a full XML
12
+ * parser — same rationale as `postprocess.mjs`: the input is a known,
13
+ * machine-authored template, not arbitrary XML), rezip in place.
14
+ *
15
+ * Confirmed empirically (not assumed) that this is worth doing at all:
16
+ * Pandoc's `--reference-doc` carries the reference document's own
17
+ * `<w:sectPr>` (page size/margins) into the generated `.docx` verbatim —
18
+ * tested with a distinctive custom `w:pgSz`/`w:pgMar` round-tripped through
19
+ * a real `pandoc` invocation. `styles.xml`'s `w:docDefaults` is likewise
20
+ * confirmed to reach ordinary body paragraphs: Pandoc emits `BodyText`/
21
+ * `FirstParagraph` for regular paragraphs, and neither style (nor `Normal`
22
+ * itself, its ultimate base) overrides `w:line`/`w:jc`, so both cascade
23
+ * down from `w:docDefaults` unless a run/paragraph in the Markdown source
24
+ * overrides them explicitly.
25
+ *
26
+ * Two of the 10 Lot 1 catalog entries are *not* a simple patch here and are
27
+ * deliberately out of scope for this module (tracked separately in
28
+ * TODO.md): 1.10 (TOC — a Pandoc flag + `settings.xml` patch) and 1.11
29
+ * (table style presets — underspecified in the spec, no concrete preset
30
+ * names to switch between yet). 1.9 (dedicated landscape section for
31
+ * tables, "Lot 5") is mostly a new Lua filter concern (spec §2.3,
32
+ * `md2nativedocx.lua`) but this module still has one narrow stake in it:
33
+ * {@link buildReferenceDoc}'s `landscapeTables` option forces an explicit
34
+ * `sectPr` even with no other page option set, so the Lua filter's "return
35
+ * to portrait" paragraphs have a concrete page size/margins to agree with
36
+ * (see its own doc comment for why). 1.13 (footer page numbers) needed a
37
+ * brand-new `word/footer*.xml` part + relationship + content-type override,
38
+ * closer in shape to `postprocess.mjs`'s `injectSmartArtParts` — implemented
39
+ * here anyway (`patchRelsForFooter`/`patchContentTypesForFooter` below)
40
+ * since {@link buildReferenceDoc} was already the right place to assemble it.
41
+ */
42
+
43
+ import { mkdtempSync, rmSync } from 'node:fs';
44
+ import { tmpdir } from 'node:os';
45
+ import { join } from 'node:path';
46
+ import { AdmZip, readZipEntry, setZipEntry } from './zipUtils.mjs';
47
+
48
+ /** Page dimensions in twips (1/20 pt), portrait orientation (w < h). Standard
49
+ * OOXML values used verbatim by Word/LibreOffice — not an assumption, unlike
50
+ * the margin presets below. */
51
+ export const PAGE_SIZES_TWIPS = {
52
+ A3: { w: 16838, h: 23811 },
53
+ A4: { w: 11906, h: 16838 },
54
+ Letter: { w: 12240, h: 15840 },
55
+ Legal: { w: 12240, h: 20160 },
56
+ };
57
+
58
+ /**
59
+ * Word margin presets in twips — Word's own actual built-in values (1 inch
60
+ * = 1440 twips exactly), **not** the 2.5cm/1417-twip approximation the spec's
61
+ * catalog (§1.3) originally stated for a metric locale. That approximation
62
+ * was flagged from the start as an unverified assumption; **disconfirmed by
63
+ * a real Word test (2026-09-06)**: with the old 1417/1077 values, Word's own
64
+ * Page Setup dialog showed the margins as "Personnalisées" (Custom), not
65
+ * "Modérées" (Moderate) — proof the values didn't match any of Word's named
66
+ * presets. Twips are locale-independent (Word always stores/recognizes the
67
+ * same internal values regardless of whether its UI displays cm or inches),
68
+ * so the fix is the same set of numbers everywhere, not a locale-specific
69
+ * one: Normal/Wide use whole inches (1"=1440), Moderate's left/right use
70
+ * 0.75in=1080, matching Word's real built-in "Normal"/"Moderate"/"Wide"
71
+ * margin presets (verified against Word's own Page Setup dialog). `narrow`
72
+ * (0.5in=720) already matched and needed no change.
73
+ */
74
+ export const MARGIN_PRESETS_TWIPS = {
75
+ normal: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
76
+ narrow: { top: 720, right: 720, bottom: 720, left: 720 },
77
+ moderate: { top: 1440, right: 1080, bottom: 1440, left: 1080 },
78
+ wide: { top: 1440, right: 2880, bottom: 1440, left: 2880 },
79
+ };
80
+
81
+ /** `w:spacing/@w:line` (in 240ths of a line) for each named preset. `'default'`
82
+ * (or an absent/unrecognized token) means "leave the template's own 1.08 —
83
+ * Word's actual current default, not one of these four presets — alone",
84
+ * handled by {@link resolveLineSpacing} returning `null` rather than an
85
+ * entry here. */
86
+ export const LINE_SPACING_TOKENS = {
87
+ single: { line: 240, rule: 'auto' },
88
+ '1.15': { line: 276, rule: 'auto' },
89
+ '1.5': { line: 360, rule: 'auto' },
90
+ double: { line: 480, rule: 'auto' },
91
+ };
92
+
93
+ const XML_ATTR_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
94
+
95
+ /** Escape a string for use inside an XML attribute value (rule #2,
96
+ * AGENTS.md) — `headingFont`/`bodyFont` are free-text VS Code settings, not
97
+ * validated against a fixed list, so they reach `a:latin/@typeface`
98
+ * user-controlled. */
99
+ function escapeXmlAttr(value) {
100
+ return String(value).replace(/[&<>"']/g, (ch) => XML_ATTR_ESCAPES[ch]);
101
+ }
102
+
103
+ /** Validate a color as 6 hex digits, uppercased; `null` if invalid. Mirrors
104
+ * `ooxml-translator.ts`'s `hexColor` — same reasoning: escaping alone would
105
+ * still let a non-hex value through into `a:srgbClr/@val`. */
106
+ function validHexColor(value) {
107
+ return /^[0-9A-Fa-f]{6}$/.test(value) ? value.toUpperCase() : null;
108
+ }
109
+
110
+ /** Resolve a page size + orientation into portrait/landscape twip
111
+ * dimensions. Unrecognized `pageSize` falls back to A4 (spec §1.1 default). */
112
+ export function resolvePageSize(pageSize, orientation) {
113
+ const base = PAGE_SIZES_TWIPS[pageSize] ?? PAGE_SIZES_TWIPS.A4;
114
+ return orientation === 'landscape'
115
+ ? { w: base.h, h: base.w, orientation: 'landscape' }
116
+ : { w: base.w, h: base.h, orientation: 'portrait' };
117
+ }
118
+
119
+ const DEFAULT_PAGE_SIZE = resolvePageSize('A4', 'portrait');
120
+
121
+ /** Resolve a margin preset name, or `custom` + `customCm` ({top,right,bottom,left}
122
+ * in centimeters). An invalid/incomplete custom value falls back to the
123
+ * `normal` preset rather than writing a malformed or absurd page margin. */
124
+ export function resolveMargins(margins, customCm) {
125
+ if (margins === 'custom' && customCm) {
126
+ const toTwip = (cm) => Math.round((Number(cm) * 1440) / 2.54);
127
+ const resolved = {
128
+ top: toTwip(customCm.top),
129
+ right: toTwip(customCm.right),
130
+ bottom: toTwip(customCm.bottom),
131
+ left: toTwip(customCm.left),
132
+ };
133
+ const sane = Object.values(resolved).every((v) => Number.isFinite(v) && v > 0 && v < 50000);
134
+ if (sane) return resolved;
135
+ }
136
+ return MARGIN_PRESETS_TWIPS[margins] ?? MARGIN_PRESETS_TWIPS.normal;
137
+ }
138
+
139
+ /** Resolve a line-spacing token to `{line, rule}`, or `null` for `'default'`
140
+ * (leave the template's current value untouched) or an unrecognized token. */
141
+ export function resolveLineSpacing(token) {
142
+ if (!token || token === 'default') return null;
143
+ return LINE_SPACING_TOKENS[token] ?? null;
144
+ }
145
+
146
+ /** Font size in points -> `w:sz`/`w:szCs` half-points, clamped to a sane
147
+ * 6-72pt range (spec §1.6 only exposes 9-14, but this is a defensive floor/
148
+ * ceiling, not the UI-facing limit). `undefined` for a non-finite/non-positive
149
+ * input. */
150
+ function resolveFontSizeHalfPt(pt) {
151
+ const n = Number(pt);
152
+ if (!Number.isFinite(n) || n <= 0) return undefined;
153
+ return Math.min(Math.max(Math.round(n * 2), 12), 144);
154
+ }
155
+
156
+ /**
157
+ * Patch `word/theme/theme1.xml`: heading/body font typefaces and the accent1
158
+ * color. Each field is independently optional — an absent one leaves that
159
+ * part of the theme untouched.
160
+ */
161
+ export function patchTheme(themeXml, { headingFont, bodyFont, accentColor } = {}) {
162
+ let out = themeXml;
163
+ if (headingFont) {
164
+ out = out.replace(
165
+ /(<a:majorFont><a:latin typeface=")[^"]*("\s*\/>)/,
166
+ `$1${escapeXmlAttr(headingFont)}$2`,
167
+ );
168
+ }
169
+ if (bodyFont) {
170
+ out = out.replace(
171
+ /(<a:minorFont><a:latin typeface=")[^"]*("\s*\/>)/,
172
+ `$1${escapeXmlAttr(bodyFont)}$2`,
173
+ );
174
+ }
175
+ if (accentColor) {
176
+ const safe = validHexColor(accentColor);
177
+ if (safe) {
178
+ out = out.replace(
179
+ /(<a:accent1><a:srgbClr val=")[0-9A-Fa-f]{6}("\s*\/><\/a:accent1>)/,
180
+ `$1${safe}$2`,
181
+ );
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** `w:jc`'s (`ST_Jc`) own valid values that this project's `justify` setting
188
+ * maps onto 1:1 — `'left'` (the setting's default) intentionally has no
189
+ * entry here since it means "don't write a `w:jc` at all, Word's own
190
+ * default is already left-aligned", same as before `right`/`center` existed. */
191
+ const JUSTIFY_VALUES = new Set(['both', 'right', 'center']);
192
+
193
+ /**
194
+ * Patch `word/styles.xml`'s `<w:docDefaults>` block (base font size, line
195
+ * spacing, default justification) and, independently, the literal `w:val`
196
+ * color fallback on every style that references the theme's `accent1`
197
+ * (`w:themeColor="accent1"`) plus the `Table` style's header-row shading.
198
+ *
199
+ * The `docDefaults` part is scoped to that one block (extracted, transformed,
200
+ * spliced back) so a `w:sz`/`w:spacing` elsewhere in the stylesheet (e.g. a
201
+ * heading style's own override) is never touched. The accent/table-header
202
+ * parts operate on the whole document since the styles they touch
203
+ * (`Heading1`-`Heading9`, `Hyperlink`, `Table`) live outside `docDefaults`.
204
+ */
205
+ export function patchStyles(stylesXml, { fontSizeHalfPt, lineSpacing, justify, accentColor, tableHeaderColor } = {}) {
206
+ let out = stylesXml;
207
+
208
+ const match = out.match(/<w:docDefaults>[\s\S]*?<\/w:docDefaults>/);
209
+ if (match) {
210
+ let block = match[0];
211
+
212
+ if (fontSizeHalfPt !== undefined) {
213
+ block = block
214
+ .replace(/<w:sz w:val="\d+"\s*\/>/, `<w:sz w:val="${fontSizeHalfPt}" />`)
215
+ .replace(/<w:szCs w:val="\d+"\s*\/>/, `<w:szCs w:val="${fontSizeHalfPt}" />`);
216
+ }
217
+
218
+ if (lineSpacing) {
219
+ block = block.replace(
220
+ /<w:spacing w:after="(\d+)" w:line="\d+" w:lineRule="auto"\s*\/>/,
221
+ `<w:spacing w:after="$1" w:line="${lineSpacing.line}" w:lineRule="${lineSpacing.rule}" />`,
222
+ );
223
+ }
224
+
225
+ if (JUSTIFY_VALUES.has(justify)) {
226
+ block = block.replace(/(<w:pPrDefault>\s*<w:pPr>)/, `$1<w:jc w:val="${justify}" />`);
227
+ }
228
+
229
+ out = out.replace(match[0], block);
230
+ }
231
+
232
+ if (accentColor) {
233
+ out = patchAccentColorFallbacks(out, accentColor);
234
+ }
235
+ if (tableHeaderColor) {
236
+ out = patchTableHeaderColor(out, tableHeaderColor);
237
+ }
238
+
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * `Heading1`-`Heading9`/`Hyperlink`/`Title`/`TOCHeading` each carry a
244
+ * `<w:color w:themeColor="accent1" w:val="4472C4" .../>` — `w:val` is a
245
+ * cached literal fallback for a renderer that doesn't resolve theme colors
246
+ * live. Real Word resolves `w:themeColor` dynamically (so patching just
247
+ * `theme1.xml`'s `a:accent1`, which {@link patchTheme} already does, is in
248
+ * principle enough there), but **LibreOffice does not**: rendering a
249
+ * document with a patched theme but an unpatched literal fallback, the
250
+ * heading/hyperlink color visibly stayed the old default blue — confirmed by
251
+ * sampling actual rendered pixel colors, not just reading the XML. Patching
252
+ * every such literal `w:val` here (regardless of renderer) makes the
253
+ * accent color correct everywhere, not just in implementations that bother
254
+ * with live theme resolution.
255
+ *
256
+ * `Title`/`TOCHeading` use a *shaded* variant (`w:themeShade`, a darker tint
257
+ * of accent1) — recomputing Word's exact shade algorithm isn't attempted
258
+ * here; those two also just get the flat custom color instead of a computed
259
+ * darker tint, a deliberate, documented simplification (consistent color,
260
+ * slightly less tonal variety, rather than risking a wrong-guessed formula).
261
+ */
262
+ function patchAccentColorFallbacks(stylesXml, accentColor) {
263
+ const safe = validHexColor(accentColor);
264
+ if (!safe) return stylesXml;
265
+ return stylesXml.replace(
266
+ /<w:color\s+[^>]*\bw:themeColor="accent1"[^>]*\/>/g,
267
+ (tag) => tag.replace(/w:val="[0-9A-Fa-f]{6}"/, `w:val="${safe}"`),
268
+ );
269
+ }
270
+
271
+ /**
272
+ * Shades the header row of every table (the `Table` style's
273
+ * `<w:tblStylePr w:type="firstRow">` conditional formatting — the same block
274
+ * Pandoc's default template already ships with just a bottom border, no
275
+ * fill) with a user-chosen background color. Adds a `<w:shd>` if none is
276
+ * present yet, or replaces an existing one — idempotent either way.
277
+ */
278
+ function patchTableHeaderColor(stylesXml, tableHeaderColor) {
279
+ const safe = validHexColor(tableHeaderColor);
280
+ if (!safe) return stylesXml;
281
+ return stylesXml.replace(
282
+ /(<w:style[^>]*\bw:styleId="Table"[^>]*>[\s\S]*?<w:tblStylePr w:type="firstRow">[\s\S]*?<w:tcPr>)([\s\S]*?)(<\/w:tcPr>)/,
283
+ (whole, open, inner, close) => {
284
+ const shd = `<w:shd w:val="clear" w:color="auto" w:fill="${safe}"/>`;
285
+ // Schema order inside w:tcPr puts w:shd after w:tcBorders and before w:vAlign.
286
+ let nextInner;
287
+ if (/<w:shd\b[^>]*\/>/.test(inner)) nextInner = inner.replace(/<w:shd\b[^>]*\/>/, shd);
288
+ else if (/<w:vAlign\b/.test(inner)) nextInner = inner.replace(/<w:vAlign\b/, `${shd}<w:vAlign`);
289
+ else nextInner = `${inner}${shd}`;
290
+ return `${open}${nextInner}${close}`;
291
+ },
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Patch `word/document.xml`'s final (only) `<w:sectPr />` into a full
297
+ * section with explicit page size/orientation/margins, and optionally a
298
+ * `<w:footerReference>` (spec §1.13, "Lot 1" fast-follow — `footerRId` is the
299
+ * relationship id {@link patchRelsForFooter} minted for `word/footer1.xml`).
300
+ * `pgSize`/`margins` are each `null` when not requested — falls back to A4
301
+ * portrait / the `normal` preset respectively for whichever one the caller
302
+ * didn't ask to change, since a `<w:sectPr>` needs both to be well-formed
303
+ * regardless of which specific option triggered building one at all.
304
+ * `footerReference` must be the first child when present — confirmed
305
+ * against a real Pandoc-processed `sectPr` (`TODO.md`), not assumed.
306
+ */
307
+ export function patchSectPr(documentXml, { pgSize, margins, footerRId } = {}) {
308
+ if (!pgSize && !margins && !footerRId) return documentXml;
309
+ const size = pgSize ?? DEFAULT_PAGE_SIZE;
310
+ const mar = margins ?? MARGIN_PRESETS_TWIPS.normal;
311
+ const orientAttr = size.orientation === 'landscape' ? ' w:orient="landscape"' : '';
312
+ const footerRef = footerRId ? `<w:footerReference w:type="default" r:id="${footerRId}"/>` : '';
313
+ const sectPr =
314
+ `<w:sectPr>${footerRef}<w:pgSz w:w="${size.w}" w:h="${size.h}"${orientAttr}/>` +
315
+ `<w:pgMar w:top="${mar.top}" w:right="${mar.right}" w:bottom="${mar.bottom}" w:left="${mar.left}" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr>`;
316
+ return documentXml.replace(/<w:sectPr\s*\/>/, sectPr);
317
+ }
318
+
319
+ /**
320
+ * Minimal footer part (spec §1.13): a single centered `PAGE` field. Written
321
+ * as a brand-new `word/footer1.xml` package part — confirmed empirically
322
+ * that, unlike `settings.xml` (see `patchSettings`'s doc comment), Pandoc
323
+ * *does* carry over a reference document's footer part/relationship/
324
+ * `footerReference` verbatim: a canary footer with a `PAGE` field, added to
325
+ * a copy of `reference.docx` and round-tripped through a real `pandoc`
326
+ * invocation, survived into the generated `.docx` and rendered a page
327
+ * number when opened in LibreOffice.
328
+ */
329
+ export const FOOTER_PAGE_NUMBER_XML =
330
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
331
+ '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
332
+ '<w:p><w:pPr><w:jc w:val="center"/></w:pPr>' +
333
+ '<w:r><w:fldChar w:fldCharType="begin"/></w:r>' +
334
+ '<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>' +
335
+ '<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
336
+ '<w:r><w:t>1</w:t></w:r>' +
337
+ '<w:r><w:fldChar w:fldCharType="end"/></w:r>' +
338
+ '</w:p></w:ftr>';
339
+
340
+ /** Smallest unused `rId` in a `.rels` file's `Id="rIdN"` attributes (`rId1`
341
+ * if there are none) — `reference.docx`'s own relationships already go up
342
+ * to `rId8` plus a decorative `rId30` (an external hyperlink in its
343
+ * placeholder body content, never reaching real output), so a fixed id
344
+ * would risk colliding. */
345
+ function nextRelationshipId(relsXml) {
346
+ const ids = [...relsXml.matchAll(/Id="rId(\d+)"/g)].map((m) => Number(m[1]));
347
+ const max = ids.length ? Math.max(...ids) : 0;
348
+ return `rId${max + 1}`;
349
+ }
350
+
351
+ /** Add the footer relationship to `word/_rels/document.xml.rels`. Returns
352
+ * `{ xml, rId }` — `rId` is what {@link patchSectPr}'s `footerRId` and
353
+ * `patchContentTypesForFooter` need to stay consistent. */
354
+ export function patchRelsForFooter(relsXml) {
355
+ const rId = nextRelationshipId(relsXml);
356
+ const rel =
357
+ '<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" ' +
358
+ `Id="${rId}" Target="footer1.xml" />`;
359
+ return { xml: relsXml.replace('</Relationships>', `${rel}</Relationships>`), rId };
360
+ }
361
+
362
+ /** Declare `word/footer1.xml`'s content type in `[Content_Types].xml`.
363
+ * Idempotent (checked by path, not just presence of the word "footer") in
364
+ * case a future base template already ships a footer of its own. */
365
+ export function patchContentTypesForFooter(contentTypesXml) {
366
+ if (contentTypesXml.includes('/word/footer1.xml')) return contentTypesXml;
367
+ const override =
368
+ '<Override PartName="/word/footer1.xml" ' +
369
+ 'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" />';
370
+ return contentTypesXml.replace('</Types>', `${override}</Types>`);
371
+ }
372
+
373
+ /**
374
+ * Patch `word/settings.xml` to add `<w:updateFields w:val="true" />` when a
375
+ * TOC is requested (spec §1.10/§2.2) — without it, a Pandoc-generated TOC
376
+ * field shows stale/empty content until the user manually refreshes it
377
+ * (F9); with it, Word itself offers to update fields on open. No-op (and
378
+ * idempotent) when `toc` is falsy or the flag is already present.
379
+ *
380
+ * Applied by `postprocess.mjs` to the **final generated `.docx`**'s own
381
+ * `settings.xml`, not to the reference doc here — confirmed empirically
382
+ * that Pandoc synthesizes its own `word/settings.xml` from scratch and does
383
+ * NOT carry over the reference document's (unlike `theme1.xml`/
384
+ * `styles.xml`/`document.xml`'s `sectPr`, all confirmed carried over).
385
+ * Exported from this module anyway since it's the same kind of pure,
386
+ * independently-testable XML patch as the others here — just consumed by a
387
+ * different caller.
388
+ */
389
+ export function patchSettings(settingsXml, { toc } = {}) {
390
+ if (!toc || /<w:updateFields\b/.test(settingsXml)) return settingsXml;
391
+ return settingsXml.replace(/(<w:settings\b[^>]*>)/, `$1\n <w:updateFields w:val="true" />`);
392
+ }
393
+
394
+ /**
395
+ * Build a patched `reference.docx` from `basePath` for the given raw option
396
+ * values (as they arrive from CLI env vars / VS Code settings — every field
397
+ * optional, `undefined` meaning "user didn't touch this one").
398
+ *
399
+ * Returns `null` when every option is unset (the common case — no export
400
+ * customization active): callers should keep using `basePath` unchanged,
401
+ * unzip/zip is skipped entirely. Otherwise returns `{ path, dir }`: `path` is
402
+ * the patched `.docx` (pass it to Pandoc's `--reference-doc`), `dir` is the
403
+ * scratch directory the caller must `rmSync(dir, { recursive: true, force:
404
+ * true })` once Pandoc has run (mirrors how `md2nativedocx.mjs` already
405
+ * cleans up its `smartArtDir` scratch directory).
406
+ */
407
+ export function buildReferenceDoc(basePath, rawOptions = {}) {
408
+ const {
409
+ pageSize, orientation, margins, marginsCustomCm,
410
+ headingFont, bodyFont, fontSizePt, lineSpacing, justify, accentColor, tableHeaderColor,
411
+ footerPageNumber, landscapeTables,
412
+ } = rawOptions;
413
+
414
+ const needsFooter = footerPageNumber === true;
415
+ // Lot 5 (spec §1.9/§2.3) also forces an explicit sectPr, even with no
416
+ // pageSize/orientation/margins of its own: the Lua filter's "return to
417
+ // portrait" paragraphs need a concrete, known page size/margins to write
418
+ // — Pandoc's own unpatched default (an empty `<w:sectPr/>`, which resolves
419
+ // to whatever Letter/A4 default the local Pandoc/LibreOffice install
420
+ // falls back to) is not something this process can introspect, and a
421
+ // mismatch between that and this module's own A4/normal fallback would
422
+ // make the non-table sections of the document silently switch page size
423
+ // the moment landscape tables are turned on (found manually testing this
424
+ // lot end to end). So enabling landscapeTables alone pins the whole
425
+ // document to the same A4/normal-margins default `resolvePageSize`/
426
+ // `resolveMargins` already use elsewhere — a real, deliberate behavior
427
+ // change flagged in TODO.md, not a silent one: a user who wants a
428
+ // different page size must combine this toggle with the Lot 1 page size
429
+ // setting, same as any other Lot 1 option already requires today.
430
+ const needsSectPr =
431
+ pageSize !== undefined || orientation !== undefined || margins !== undefined || needsFooter || landscapeTables === true;
432
+ const needsTheme = headingFont !== undefined || bodyFont !== undefined || accentColor !== undefined;
433
+ const resolvedLineSpacing = resolveLineSpacing(lineSpacing);
434
+ const needsStyles =
435
+ fontSizePt !== undefined ||
436
+ resolvedLineSpacing !== null ||
437
+ JUSTIFY_VALUES.has(justify) ||
438
+ accentColor !== undefined ||
439
+ tableHeaderColor !== undefined;
440
+
441
+ if (!needsSectPr && !needsTheme && !needsStyles) return null;
442
+
443
+ const dir = mkdtempSync(join(tmpdir(), 'md2nativedocx-refdoc-'));
444
+ const workDocx = join(dir, 'reference.docx');
445
+
446
+ try {
447
+ const zip = new AdmZip(basePath);
448
+
449
+ if (needsTheme) {
450
+ const theme = readZipEntry(zip, 'word/theme/theme1.xml');
451
+ setZipEntry(zip, 'word/theme/theme1.xml', patchTheme(theme, { headingFont, bodyFont, accentColor }));
452
+ }
453
+ if (needsStyles) {
454
+ const styles = readZipEntry(zip, 'word/styles.xml');
455
+ const fontSizeHalfPt = fontSizePt !== undefined ? resolveFontSizeHalfPt(fontSizePt) : undefined;
456
+ setZipEntry(
457
+ zip,
458
+ 'word/styles.xml',
459
+ patchStyles(styles, {
460
+ fontSizeHalfPt,
461
+ lineSpacing: resolvedLineSpacing,
462
+ justify,
463
+ accentColor,
464
+ tableHeaderColor,
465
+ }),
466
+ );
467
+ }
468
+
469
+ let footerRId;
470
+ if (needsFooter) {
471
+ const rels = readZipEntry(zip, 'word/_rels/document.xml.rels');
472
+ const patched = patchRelsForFooter(rels);
473
+ footerRId = patched.rId;
474
+ setZipEntry(zip, 'word/_rels/document.xml.rels', patched.xml);
475
+
476
+ const contentTypes = readZipEntry(zip, '[Content_Types].xml');
477
+ setZipEntry(zip, '[Content_Types].xml', patchContentTypesForFooter(contentTypes));
478
+
479
+ setZipEntry(zip, 'word/footer1.xml', FOOTER_PAGE_NUMBER_XML);
480
+ }
481
+
482
+ if (needsSectPr) {
483
+ const documentXml = readZipEntry(zip, 'word/document.xml');
484
+ const pgSize =
485
+ pageSize !== undefined || orientation !== undefined || landscapeTables === true
486
+ ? resolvePageSize(pageSize, orientation)
487
+ : null;
488
+ const mar = margins !== undefined || landscapeTables === true ? resolveMargins(margins, marginsCustomCm) : null;
489
+ setZipEntry(zip, 'word/document.xml', patchSectPr(documentXml, { pgSize, margins: mar, footerRId }));
490
+ }
491
+
492
+ zip.writeZip(workDocx);
493
+ return { path: workDocx, dir };
494
+ } catch (err) {
495
+ rmSync(dir, { recursive: true, force: true });
496
+ throw err;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Usable page area in EMU for `TranslateOptions.maxDrawingCx`/`maxDrawingCy`
502
+ * (`export_customization_SPEC.md` §2.4) — the same page size/orientation/
503
+ * margins resolution as {@link buildReferenceDoc}, but expressed as the
504
+ * drawable area a diagram must fit, in EMU (1 twip = 635 EMU) rather than as
505
+ * XML to patch. Independent of whether a reference doc was actually built:
506
+ * even a `md2nativedocx.referenceDocument` custom-template user still gets a
507
+ * diagram sized for *some* explicit page area rather than silently keeping
508
+ * the Letter-portrait default when they never touched Lot 1's settings at
509
+ * all — so this only returns `null` (meaning "keep the translator's own
510
+ * built-in default") when literally none of pageSize/orientation/margins
511
+ * were set, same condition as `needsSectPr` above.
512
+ */
513
+ export function resolveMaxDrawingExtentEmu({ pageSize, orientation, margins, marginsCustomCm } = {}) {
514
+ if (pageSize === undefined && orientation === undefined && margins === undefined) return null;
515
+ const size = resolvePageSize(pageSize, orientation);
516
+ const mar = margins !== undefined ? resolveMargins(margins, marginsCustomCm) : MARGIN_PRESETS_TWIPS.normal;
517
+ const TWIP_TO_EMU = 635;
518
+ const cx = (size.w - mar.left - mar.right) * TWIP_TO_EMU;
519
+ const cy = (size.h - mar.top - mar.bottom) * TWIP_TO_EMU;
520
+ return { cx: Math.max(1, cx), cy: Math.max(1, cy) };
521
+ }
@@ -0,0 +1,48 @@
1
+ // Unclosed ```mermaid fence detection (TODO §R1.5). Pandoc turns an unterminated fence into a code block
2
+ // running to the end of the file, without any warning: the diagram and everything after it silently stop
3
+ // being converted. This is a pre-Pandoc check so the export log can say so.
4
+ //
5
+ // Mirrors `packages/vscode-extension/src/mermaidBlocks.ts` (`findUnclosedMermaidFences`): the extension
6
+ // is TypeScript and bundles this package as a vendored copy, so the few lines are kept in both places.
7
+
8
+ const ANY_FENCE = /^(`{3,}|~{3,})/;
9
+ const OPEN_MERMAID = /^(`{3,})\s*mermaid\s*$/i;
10
+
11
+ /**
12
+ * Lines (1-based) of every ```mermaid fence that is never closed.
13
+ * Other fenced blocks are skipped wholesale, as Pandoc does, so a mermaid fence shown inside a longer
14
+ * ````-fenced example is not reported.
15
+ * @param {string} text
16
+ * @returns {number[]}
17
+ */
18
+ export function findUnclosedMermaidFenceLines(text) {
19
+ const lines = text.split(/\r\n|\r|\n/);
20
+ const unclosed = [];
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const trimmed = (lines[i] ?? '').trim();
23
+ const mermaid = OPEN_MERMAID.exec(trimmed);
24
+ const marker = mermaid?.[1] ?? ANY_FENCE.exec(trimmed)?.[1];
25
+ if (!marker) continue;
26
+ const close = new RegExp(`^${marker}\\s*$`);
27
+ let j = i + 1;
28
+ while (j < lines.length && !close.test((lines[j] ?? '').trim())) j++;
29
+ if (j >= lines.length) {
30
+ // An unterminated fence runs to the end of the file: nothing after it is parsed either.
31
+ if (mermaid) unclosed.push(i + 1);
32
+ break;
33
+ }
34
+ i = j;
35
+ }
36
+ return unclosed;
37
+ }
38
+
39
+ /**
40
+ * Export-log warnings for every unclosed ```mermaid fence in `text`.
41
+ * @param {string} text
42
+ * @returns {string[]}
43
+ */
44
+ export function unclosedFenceWarnings(text) {
45
+ return findUnclosedMermaidFenceLines(text).map(
46
+ (line) => `warning: line ${line}: the \`\`\`mermaid block is never closed, so Pandoc treats everything from there to the end of the file as plain code and no diagram is converted. Add a closing \`\`\` line.`,
47
+ );
48
+ }