@md2nativedocx/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Post-process the .docx produced by Pandoc so Word accepts our drawings.
3
+ *
4
+ * Two corrections are applied to `word/document.xml`:
5
+ *
6
+ * 1. **Extended namespace declarations on the root.** Pandoc does not declare
7
+ * `wpc`/`wpg`/`wps` on `w:document`. Our translator declares them inline on
8
+ * the elements that use them, which is valid XML but is not what Word
9
+ * emits, and Word resolves the markup-compatibility (`mc`) processing model
10
+ * against the *root* declarations. Word itself always declares them on the
11
+ * root, so we match that.
12
+ *
13
+ * 2. **Document-wide unique drawing ids.** Word treats `wp:docPr/@id` and every
14
+ * `cNvPr/@id` as one id space per document and reports the file as corrupt
15
+ * when two collide. The translator is a pure function that numbers each
16
+ * fragment from 1 independently, so a document with two diagrams (or one
17
+ * diagram plus a Pandoc-inserted image) always collides until they are
18
+ * renumbered here — the one place that sees the whole document.
19
+ *
20
+ * Security (AGENTS.md):
21
+ * * Rule #7 (Pandoc authors the package; post-processing limited to an
22
+ * allowlist): this replaces exactly one entry (`word/document.xml`) in the
23
+ * archive Pandoc produced — it is not a reimplementation of ZIP/docx
24
+ * packaging. It is required for the output to be Word-conformant.
25
+ * * No external relationship is added (rule #3) and no user-controlled text is
26
+ * interpolated: the edits are namespace declarations on the root tag and
27
+ * rewrites of numeric `id` attributes.
28
+ * * The rewrites are regex-scoped to `id="<digits>"` on a fixed list of
29
+ * element names. A full XML parser would be sturdier but would mean a new
30
+ * dependency (rule #6); the input here is machine-generated by Pandoc and by
31
+ * our own translator, not arbitrary XML.
32
+ *
33
+ * A third function, {@link injectSmartArtParts}, goes further than the two
34
+ * corrections above — it adds brand-new ZIP entries (`word/diagrams/*.xml`),
35
+ * not just same-path replacements. Signed off separately
36
+ * (`docs/specs/FUTURE_mmd2smartart_SPEC.md` §8, confirmed with the maintainer
37
+ * 2026-09-03), and listed in rule #7's allowlist since its 2026-10-07
38
+ * rewording. See its own doc comment for why it exists and what
39
+ * it does; it is a no-op whenever a document has no SmartArt diagram in it.
40
+ */
41
+
42
+ import { readFileSync, existsSync } from 'node:fs';
43
+ import { join, resolve } from 'node:path';
44
+ import { patchSettings } from './referenceDocBuilder.mjs';
45
+ import { AdmZip, readZipEntry, setZipEntry } from './zipUtils.mjs';
46
+
47
+ /** Extended namespaces Word declares on the document root, prefix -> URI. */
48
+ const EXTENDED_NS = {
49
+ 'xmlns:wpc': 'http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas',
50
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
51
+ 'xmlns:wp14': 'http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing',
52
+ 'xmlns:wpg': 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup',
53
+ 'xmlns:wps': 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape',
54
+ };
55
+
56
+ /**
57
+ * Prefixes listed in `mc:Ignorable` — the extensions Word may safely skip.
58
+ * `wpc`/`wpg`/`wps` are deliberately absent: they carry the diagram itself, so
59
+ * declaring them ignorable would licence Word to drop it.
60
+ *
61
+ * Every prefix listed here MUST also be declared via `xmlns:` on the root
62
+ * (`EXTENDED_NS` above, or Pandoc's own declarations) — the markup-compatibility
63
+ * spec (ECMA-376 Part 3) requires an `mc:Ignorable` prefix to resolve to an
64
+ * in-scope namespace. This used to list the full boilerplate a real Word
65
+ * document advertises (`w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl
66
+ * w16du wp14`), copied from a real Word document's root without also copying
67
+ * the xmlns declarations those extra prefixes need — nine undeclared
68
+ * prefixes, invalid per spec. Word's parser enforces this strictly and
69
+ * reports the file as needing repair; LibreOffice does not, which is why this
70
+ * went unnoticed through every LibreOffice-headless and structural-XML check
71
+ * (found 2026-09-02, opening a generated .docx in real Word for the first
72
+ * time). `wp14` is the only one of that list we actually declare.
73
+ */
74
+ const IGNORABLE = 'wp14';
75
+
76
+ /** Elements whose `id` attribute *defines* a drawing object id. */
77
+ const ID_DEFINING_ELEMENTS = ['wp:docPr', 'wpg:cNvPr', 'wps:cNvPr', 'pic:cNvPr'];
78
+
79
+ /** Elements whose `id` attribute *references* a drawing object id. */
80
+ const ID_REFERENCING_ELEMENTS = ['a:stCxn', 'a:endCxn'];
81
+
82
+ /**
83
+ * Inject the extended namespace declarations onto the root `w:document`
84
+ * element. Returns the modified XML.
85
+ *
86
+ * The check is scoped to the root's opening tag: the document body legitimately
87
+ * contains inline `xmlns:wpg=` declarations on the drawing elements, so a
88
+ * whole-document search would conclude the namespaces are already present and
89
+ * silently leave the root untouched.
90
+ */
91
+ export function injectNamespaces(documentXml) {
92
+ const rootStart = documentXml.indexOf('<w:document');
93
+ if (rootStart < 0) {
94
+ throw new Error('md2nativedocx: could not find <w:document> root element');
95
+ }
96
+ const tagEnd = documentXml.indexOf('>', rootStart);
97
+ if (tagEnd < 0) {
98
+ throw new Error('md2nativedocx: malformed <w:document> root element');
99
+ }
100
+ const openTag = documentXml.slice(rootStart, tagEnd + 1);
101
+
102
+ const additions = Object.entries(EXTENDED_NS)
103
+ .filter(([attr]) => !openTag.includes(`${attr}=`))
104
+ .map(([attr, uri]) => ` ${attr}="${uri}"`)
105
+ .join('');
106
+ const ignorable = openTag.includes('mc:Ignorable=') ? '' : ` mc:Ignorable="${IGNORABLE}"`;
107
+ if (!additions && !ignorable) return documentXml;
108
+
109
+ const newOpenTag = `${openTag.slice(0, -1)}${additions}${ignorable}>`;
110
+ return documentXml.slice(0, rootStart) + newOpenTag + documentXml.slice(tagEnd + 1);
111
+ }
112
+
113
+ /**
114
+ * Renumber every drawing object id in the document so no two collide.
115
+ *
116
+ * Ids are remapped per `w:drawing` block, because `a:stCxn`/`a:endCxn` reference
117
+ * shapes within their own drawing: a block-local old -> new map keeps each
118
+ * connector attached to the same pair of shapes while a counter shared across
119
+ * blocks guarantees document-wide uniqueness.
120
+ */
121
+ export function renumberDrawingIds(documentXml) {
122
+ let nextId = 1;
123
+ const defPattern = new RegExp(`<(${ID_DEFINING_ELEMENTS.join('|')})\\s+id="(\\d+)"`, 'g');
124
+ const refPattern = new RegExp(`<(${ID_REFERENCING_ELEMENTS.join('|')})\\s+id="(\\d+)"`, 'g');
125
+
126
+ return documentXml.replace(/<w:drawing>[\s\S]*?<\/w:drawing>/g, (block) => {
127
+ // Pass 1: assign a fresh id to each definition in this drawing.
128
+ const remap = new Map();
129
+ for (const match of block.matchAll(defPattern)) {
130
+ const oldId = match[2];
131
+ if (!remap.has(oldId)) remap.set(oldId, String(nextId++));
132
+ }
133
+ if (remap.size === 0) return block;
134
+
135
+ // Pass 2: rewrite definitions and the connector references that point at
136
+ // them. An unmapped reference is left alone rather than guessed at.
137
+ return block
138
+ .replace(defPattern, (whole, tag, oldId) => `<${tag} id="${remap.get(oldId)}"`)
139
+ .replace(refPattern, (whole, tag, oldId) =>
140
+ remap.has(oldId) ? `<${tag} id="${remap.get(oldId)}"` : whole,
141
+ );
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Emoji/badge color rendering (spec §1.15/§2.5, "Lot 2") — Word substitutes a
147
+ * fallback font for ✅/⚠️/❌ and friends unless a run is explicitly set to an
148
+ * emoji-capable font ("Segoe UI Emoji"), otherwise flattening them to
149
+ * indistinguishable grey glyphs.
150
+ *
151
+ * Splits at grapheme-cluster boundaries (`Intl.Segmenter`, not naive
152
+ * per-character scanning) rather than forcing the whole run's font: Pandoc
153
+ * puts an entire mixed sentence in one `<w:r>` (confirmed empirically — see
154
+ * `TODO.md`), so forcing "Segoe UI Emoji" on the whole run would also
155
+ * change the font of the surrounding prose, not just the emoji. Grapheme
156
+ * clusters matter because an emoji is frequently more than one Unicode code
157
+ * point (a base character plus a variation selector, skin-tone modifier, or
158
+ * ZWJ sequence, e.g. "⚠️" = U+26A0 + U+FE0F) — splitting on raw code points
159
+ * would separate a modifier from its base and could detach it visually.
160
+ *
161
+ * `\p{Extended_Pictographic}` (a standard Unicode property, ES2018+, no new
162
+ * dependency) is the classification the spec itself left as "a precise list
163
+ * to establish" — this covers essentially every emoji in one regex; the one
164
+ * confirmed gap is keycap sequences (`1️⃣`, `#️⃣`), whose base characters
165
+ * (digits, `#`, `*`) aren't pictographic themselves. Regional-indicator
166
+ * flag pairs (e.g. "🇫🇷") need a second explicit check: `Intl.Segmenter`
167
+ * correctly groups the pair into one grapheme cluster, but neither half
168
+ * matches `Extended_Pictographic` on its own.
169
+ *
170
+ * Only transforms a run shaped exactly `<w:r>(<w:rPr>...</w:rPr>)?<w:t ...>
171
+ * text</w:t></w:r>` — the shape Pandoc actually emits for plain and
172
+ * bold/italic text alike (confirmed empirically, both with and without
173
+ * emoji). Anything else (a run wrapping a `<w:drawing>`, multiple `<w:t>`
174
+ * elements, a `<w:br/>`, ...) is left untouched rather than guessed at,
175
+ * same conservative-regex-scope reasoning as `renumberDrawingIds` above.
176
+ */
177
+ const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
178
+ // A single grapheme cluster made entirely of regional-indicator symbols (a
179
+ // flag, e.g. "🇫🇷") — used to classify one already-segmented cluster.
180
+ const REGIONAL_INDICATOR_CLUSTER_RE = /^[\u{1F1E6}-\u{1F1FF}]+$/u;
181
+ // Existence check (not anchored) — used only to decide whether a whole run's
182
+ // text is worth segmenting at all.
183
+ const REGIONAL_INDICATOR_ANYWHERE_RE = /[\u{1F1E6}-\u{1F1FF}]/u;
184
+ const EMOJI_FONT = 'Segoe UI Emoji';
185
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
186
+
187
+ function isEmojiGrapheme(cluster) {
188
+ if (REGIONAL_INDICATOR_CLUSTER_RE.test(cluster)) return true;
189
+ for (const ch of cluster) {
190
+ if (EXTENDED_PICTOGRAPHIC_RE.test(ch)) return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Force *emoji* presentation (not text/monochrome presentation) on a single
197
+ * bare pictographic code point by appending VARIATION SELECTOR-16 (U+FE0F).
198
+ *
199
+ * **Origin story, corrected 2026-09-06 — read this before trusting the name
200
+ * of this function.** First real-Word test (✅/⚠️/❌/🚀, one document): ⚠️/🚀
201
+ * rendered in color, ✅/❌ stayed monochrome despite identical `w:rFonts`
202
+ * forcing. Hypothesis at the time: ⚠️ already had U+FE0F in its own text and
203
+ * 🚀 has no monochrome glyph to begin with, so *adding* U+FE0F to bare ✅/❌
204
+ * should fix them. **A broader 31-symbol real-Word review disproved this**:
205
+ * ✔️/✖️ (U+2714/U+2716) already carried an explicit U+FE0F in the *original*
206
+ * source and still rendered monochrome — proof the selector alone does not
207
+ * reliably force color in this Word/Segoe UI Emoji combination, contrary to
208
+ * Unicode's own `Default_Emoji_Presentation` model. The actual empirical
209
+ * split (same review) doesn't line up with Unicode block or any property
210
+ * this code can query: monochrome = {U+2705 ✅, U+274C ❌, U+2714 ✔,
211
+ * U+2716 ✖, U+2B50 ⭐, U+2611 ☑} (plus keycap sequences, a separate,
212
+ * already-known gap below); every other tested symbol — including several
213
+ * in the *same* Miscellaneous Symbols block as ☑ (U+2600 ☀, U+2601 ☁,
214
+ * U+26A0 ⚠, U+2699 ⚙, U+2708 ✈, U+260E ☎, U+26A1 ⚡) — rendered in color.
215
+ * This looks like a fixed, Microsoft-curated list of legacy Wingdings-
216
+ * heritage "checklist" symbols Word/Segoe UI Emoji keeps monochrome by
217
+ * design (plausibly for compatibility with documents that use them as
218
+ * plain checkbox/bullet glyphs, not as colorful emoji) — not a font-
219
+ * resolution bug this project's OOXML output can fix by itself. **This
220
+ * function is kept anyway** (harmless, more explicit/correct Unicode text
221
+ * regardless of whether Word's glyph selection responds to it), but do NOT
222
+ * assume it "fixes" ✅/❌/✔/✖/⭐/☑ — it doesn't, as far as real-Word testing
223
+ * has shown. See TODO.md for the full, up-to-date empirical table and
224
+ * whether anyone has since found an actual workaround.
225
+ *
226
+ * Scoped deliberately narrow — only a grapheme cluster that is *exactly one*
227
+ * Unicode code point and doesn't already end in a variation selector: a
228
+ * multi-code-point cluster is either already disambiguated (⚠️'s existing
229
+ * U+FE0F) or a composed sequence (ZWJ combos, regional-indicator flag
230
+ * pairs) where inserting U+FE0F elsewhere isn't a documented, tested fix
231
+ * and risks breaking cluster recognition instead.
232
+ */
233
+ const VARIATION_SELECTOR_16 = '\uFE0F';
234
+ function ensureEmojiPresentation(cluster) {
235
+ const codePoints = [...cluster];
236
+ if (codePoints.length !== 1 || !EXTENDED_PICTOGRAPHIC_RE.test(cluster)) return cluster;
237
+ return cluster + VARIATION_SELECTOR_16;
238
+ }
239
+
240
+ /** Split `text` into consecutive `{ text, emoji }` segments, merging adjacent
241
+ * grapheme clusters of the same classification. */
242
+ function segmentEmoji(text) {
243
+ const segments = [];
244
+ for (const { segment } of GRAPHEME_SEGMENTER.segment(text)) {
245
+ const emoji = isEmojiGrapheme(segment);
246
+ const normalized = emoji ? ensureEmojiPresentation(segment) : segment;
247
+ const last = segments[segments.length - 1];
248
+ if (last && last.emoji === emoji) {
249
+ last.text += normalized;
250
+ } else {
251
+ segments.push({ text: normalized, emoji });
252
+ }
253
+ }
254
+ return segments;
255
+ }
256
+
257
+ /** Force the emoji font into `rPrInner` (a run's `<w:rPr>` inner XML, or `''`
258
+ * if it had none) — replaces an existing `<w:rFonts/>` in place, or prepends
259
+ * a new one (CT_RPr's schema requires `rFonts` to be its first child, when
260
+ * present, so a naive append could produce an invalid element order). */
261
+ function withEmojiFont(rPrInner) {
262
+ const rFonts = `<w:rFonts w:ascii="${EMOJI_FONT}" w:hAnsi="${EMOJI_FONT}" w:eastAsia="${EMOJI_FONT}" w:cs="${EMOJI_FONT}"/>`;
263
+ return /<w:rFonts\b[^>]*\/>/.test(rPrInner) ? rPrInner.replace(/<w:rFonts\b[^>]*\/>/, rFonts) : rFonts + rPrInner;
264
+ }
265
+
266
+ const EMOJI_RUN_RE = /<w:r>(?:<w:rPr>([\s\S]*?)<\/w:rPr>)?<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t><\/w:r>/g;
267
+
268
+ export function forceEmojiColorFont(documentXml) {
269
+ return documentXml.replace(EMOJI_RUN_RE, (whole, rPrInner, text) => {
270
+ if (!EXTENDED_PICTOGRAPHIC_RE.test(text) && !REGIONAL_INDICATOR_ANYWHERE_RE.test(text)) return whole;
271
+ const segments = segmentEmoji(text);
272
+ if (!segments.some((s) => s.emoji)) return whole;
273
+ return segments
274
+ .map(({ text: segText, emoji }) => {
275
+ const rPr = emoji
276
+ ? `<w:rPr>${withEmojiFont(rPrInner ?? '')}</w:rPr>`
277
+ : rPrInner !== undefined
278
+ ? `<w:rPr>${rPrInner}</w:rPr>`
279
+ : '';
280
+ return `<w:r>${rPr}<w:t xml:space="preserve">${segText}</w:t></w:r>`;
281
+ })
282
+ .join('');
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Move a Pandoc-generated TOC field (spec §1.10/§2.2, "Lot 3", `--toc`) from
288
+ * where Pandoc always puts it — the very top of the body, before any
289
+ * heading — to right after the document's first Heading1 paragraph, per the
290
+ * spec's own wording ("sommaire automatique sous le H1"). Confirmed
291
+ * empirically that Pandoc's placement needed this fix at all: a real
292
+ * `pandoc --toc` run puts the `<w:sdt>` TOC field before the title, not
293
+ * under it, and Pandoc has no flag to change that.
294
+ *
295
+ * A no-op (returns `documentXml` unchanged) when there is no TOC field
296
+ * (`--toc` wasn't used) or no Heading1 paragraph to anchor after — in the
297
+ * latter case the TOC is left at Pandoc's own default position rather than
298
+ * silently dropped. Safe to always run, TOC or not: this is why
299
+ * `postProcessDocx` composes it unconditionally instead of taking a flag.
300
+ */
301
+ export function repositionTocAfterTitle(documentXml) {
302
+ const tocMatch = documentXml.match(/<w:sdt>[\s\S]*?<w:docPartGallery w:val="Table of Contents"[\s\S]*?<\/w:sdt>/);
303
+ if (!tocMatch) return documentXml;
304
+ const tocBlock = tocMatch[0];
305
+ const withoutToc = documentXml.replace(tocBlock, '');
306
+ const headingMatch = withoutToc.match(/<w:p>[\s\S]*?<w:pStyle w:val="Heading1"[\s\S]*?<\/w:p>/);
307
+ if (!headingMatch) return documentXml;
308
+ const insertAt = withoutToc.indexOf(headingMatch[0]) + headingMatch[0].length;
309
+ return withoutToc.slice(0, insertAt) + tocBlock + withoutToc.slice(insertAt);
310
+ }
311
+
312
+ /**
313
+ * Landscape table sections (spec §1.9/§2.3, "Lot 5") — cleanup for a trap
314
+ * found empirically (ADR 0005), not documented by the spec itself: the Lua
315
+ * filter (`md2nativedocx.lua`) emits one `<w:sectPr>`-only paragraph before
316
+ * every matched `Header` and one after its `Table`, independently for each
317
+ * match. Two situations leave one of those paragraphs closing a section with
318
+ * literally no other content in it, which Word/LibreOffice both render as an
319
+ * extra blank page:
320
+ *
321
+ * 1. Two matches directly adjacent in the source (no content between one
322
+ * Table and the next Header) — the "close" paragraph after the first
323
+ * Table ends up immediately next to the "open" paragraph before the
324
+ * second Header, with nothing real between them.
325
+ * 2. A matched Table is the very last content in the document — its
326
+ * "close" (landscape) paragraph ends up immediately before the body's
327
+ * own final `<w:sectPr>`, with nothing between them either.
328
+ *
329
+ * Both are fixed by a single shared building block: a `<w:sectPr>`-only
330
+ * paragraph (`<w:p><w:pPr><w:sectPr>...</w:sectPr></w:pPr></w:p>`) is a
331
+ * shape Pandoc itself never emits (confirmed empirically — its own section
332
+ * marker is always a bare `<w:sectPr>` directly under `<w:body>`, never
333
+ * wrapped in a paragraph), so matching it structurally is unambiguous: it
334
+ * can only be one of ours. Both functions tolerate `<w:bookmarkStart/End>`
335
+ * tags in the gap (Pandoc auto-brackets headers with these; confirmed by the
336
+ * spike that they can land between two of our synthetic paragraphs) without
337
+ * treating that gap as real content.
338
+ */
339
+
340
+ // The captured group must only ever match `<w:pgSz/>`/`<w:pgMar/>`-shaped
341
+ // self-closing children (what `md2nativedocx.lua` actually emits) — NOT
342
+ // `[\s\S]*?`. A lazy dot-all group has no way to know it should stop at the
343
+ // paragraph's own `</w:sectPr></w:pPr></w:p>` rather than a later one
344
+ // further down the document; found the hard way (a real corrupted .docx)
345
+ // when it silently swallowed an entire Header+Table in between two of our
346
+ // paragraphs, matching this pattern's *later* occurrence instead of the
347
+ // intended one. A self-closing-only child element can never contain a
348
+ // nested `<w:p>`/`<w:tbl>`, which is what keeps the match from crossing into
349
+ // real content.
350
+ const SECTPR_INNER = '(?:<w:[A-Za-z]+(?:\\s[^>]*)?\\/>)*';
351
+ const SECTPR_ONLY_PARA = `<w:p><w:pPr><w:sectPr>(${SECTPR_INNER})<\\/w:sectPr><\\/w:pPr><\\/w:p>`;
352
+ const BOOKMARK_GAP = '((?:\\s*<w:bookmark(?:Start|End)\\b[^>]*\\/>)*\\s*)';
353
+
354
+ /**
355
+ * Case 1 above: collapse two `<w:sectPr>`-only paragraphs that end up
356
+ * directly adjacent (only bookmark tags/whitespace between them) into
357
+ * nothing, keeping the gap. Deleting both removes the empty section they
358
+ * would otherwise bracket, merging the sections on either side of the pair
359
+ * into one continuous section — closed by whatever real `<w:sectPr>`
360
+ * eventually follows (another one of ours, or the document's own final
361
+ * one). Looped (not a single global replace) so a run of 3+ adjacent
362
+ * matches (e.g. three consecutive landscape tables) collapses completely,
363
+ * not just pairwise.
364
+ */
365
+ export function collapseAdjacentSectionBreaks(documentXml) {
366
+ const re = new RegExp(SECTPR_ONLY_PARA + BOOKMARK_GAP + SECTPR_ONLY_PARA, 'g');
367
+ let out = documentXml;
368
+ let prev;
369
+ do {
370
+ prev = out;
371
+ out = out.replace(re, '$2');
372
+ } while (out !== prev);
373
+ return out;
374
+ }
375
+
376
+ /**
377
+ * Case 2 above: when a `<w:sectPr>`-only paragraph is the last thing before
378
+ * the body's own final `<w:sectPr>` (self-closed or not — a custom
379
+ * `reference.docx`/Lot 1 page settings can leave it populated), merge that
380
+ * paragraph's settings into the body-final `<w:sectPr>` and drop the
381
+ * paragraph. The section it described (a matched Table with nothing after
382
+ * it in the document) becomes the document's actual last section instead of
383
+ * a separate, contentless one. Must run after {@link collapseAdjacentSectionBreaks}
384
+ * so a run of adjacent matches at the very end of the document is already
385
+ * reduced to one paragraph by the time this looks for it.
386
+ */
387
+ export function collapseTrailingLandscapeSection(documentXml) {
388
+ const re = new RegExp(
389
+ SECTPR_ONLY_PARA + BOOKMARK_GAP + '<w:sectPr(?:\\s*\\/>|>[\\s\\S]*?<\\/w:sectPr>)\\s*<\\/w:body>',
390
+ );
391
+ const match = documentXml.match(re);
392
+ if (!match) return documentXml;
393
+ return documentXml.replace(re, `$2<w:sectPr>$1</w:sectPr></w:body>`);
394
+ }
395
+
396
+ /**
397
+ * Post-process a .docx file in place: read `word/document.xml`, apply the
398
+ * corrections above, and write the entry back into the archive. When `toc`
399
+ * is true (spec §1.10/§2.2, "Lot 3"), also patches `word/settings.xml` to
400
+ * add `<w:updateFields w:val="true" />` — confirmed empirically that this
401
+ * has to happen here, on the *final* generated `.docx`, rather than on the
402
+ * reference doc (`referenceDocBuilder.mjs`'s `buildReferenceDoc`): Pandoc
403
+ * synthesizes its own `word/settings.xml` from scratch and does not carry
404
+ * over the reference document's, unlike `sectPr`/`theme1.xml`/`styles.xml`.
405
+ * When `emojiFont` is true (default — spec §1.15/§2.5, "Lot 2"), also forces
406
+ * the emoji color font onto any run containing a pictographic character.
407
+ *
408
+ * @param docxPath - Path to the .docx to patch.
409
+ * @param options.toc - Whether a TOC was requested for this export.
410
+ * @param options.emojiFont - Whether to force the emoji color font (default `true`).
411
+ */
412
+ export function postProcessDocx(docxPath, { toc = false, emojiFont = true } = {}) {
413
+ const archive = resolve(docxPath);
414
+ const zip = new AdmZip(archive);
415
+
416
+ const originalDocument = readZipEntry(zip, 'word/document.xml');
417
+ let patchedDocument = repositionTocAfterTitle(renumberDrawingIds(injectNamespaces(originalDocument)));
418
+ patchedDocument = collapseTrailingLandscapeSection(collapseAdjacentSectionBreaks(patchedDocument));
419
+ if (emojiFont) patchedDocument = forceEmojiColorFont(patchedDocument);
420
+ let changed = false;
421
+ if (patchedDocument !== originalDocument) {
422
+ setZipEntry(zip, 'word/document.xml', patchedDocument);
423
+ changed = true;
424
+ }
425
+
426
+ if (toc) {
427
+ const originalSettings = readZipEntry(zip, 'word/settings.xml');
428
+ const patchedSettings = patchSettings(originalSettings, { toc });
429
+ if (patchedSettings !== originalSettings) {
430
+ setZipEntry(zip, 'word/settings.xml', patchedSettings);
431
+ changed = true;
432
+ }
433
+ }
434
+
435
+ if (!changed) return;
436
+ // Replace only the entries that actually changed; every other part of
437
+ // Pandoc's archive is left byte-for-byte untouched.
438
+ zip.writeZip(archive);
439
+ }
440
+
441
+ /**
442
+ * SmartArt package wiring — the "notable postprocess.mjs extension" flagged
443
+ * in `docs/specs/FUTURE_mmd2smartart_SPEC.md` §8 as needing sign-off before writing
444
+ * (given 2026-09-03): adds brand-new ZIP entries, not just a same-path
445
+ * replacement like {@link postProcessDocx} above.
446
+ *
447
+ * Why this exists at all: `packages/pandoc-filter/bin/md2nativedocx-core.mjs`
448
+ * cannot add `.docx` package parts or relationships itself (Pandoc's Lua
449
+ * filter API has no such hook, `docs/specs/FUTURE_mmd2smartart_SPEC.md` §2) — it can
450
+ * only write the *body* XML Pandoc embeds where the ```mermaid block was
451
+ * (`@md2nativedocx/core`'s `buildSmartArtDrawingXml`). So for each SmartArt
452
+ * diagram it instead writes the diagram's 4 XML parts to a temp directory
453
+ * (`smartArtDir`, an env var the CLI sets before invoking Pandoc) under a
454
+ * random id, and embeds placeholder relationship ids
455
+ * (`SMARTART_PLACEHOLDER:<id>:dm` etc.) in `r:dm`/`r:lo`/`r:qs`/`r:cs` —
456
+ * never valid Word relationship ids, just a correlation key. This function
457
+ * is the package-aware step that runs after Pandoc has produced the whole
458
+ * `.docx`: it finds those placeholders, mints real `rId`s, writes the real
459
+ * parts into `word/diagrams/`, and wires up `[Content_Types].xml` +
460
+ * `word/_rels/document.xml.rels` to match — completing exactly the same 4
461
+ * relationships/parts a real Word-authored SmartArt diagram would have
462
+ * (spec §3).
463
+ */
464
+ // The id is whatever the core bridge chose (randomUUID() in production, so
465
+ // hex+dashes in practice) -- matched as "anything but the ':' delimiter"
466
+ // rather than assuming that specific alphabet, so this isn't silently
467
+ // fragile against a different id scheme later.
468
+ const SMARTART_PLACEHOLDER_RE = /SMARTART_PLACEHOLDER:([^:]+):(dm|lo|qs|cs)/g;
469
+
470
+ const SMARTART_RELTYPE = {
471
+ dm: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData',
472
+ lo: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout',
473
+ qs: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle',
474
+ cs: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors',
475
+ };
476
+ const SMARTART_CONTENTTYPE = {
477
+ dm: 'application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml',
478
+ lo: 'application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml',
479
+ qs: 'application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml',
480
+ cs: 'application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml',
481
+ };
482
+ /** `data`/`layout`/`quickStyle`/`colors` — the on-disk part-name stem per kind (`qs` -> `quickStyle1.xml`, not `qs1.xml`). */
483
+ const SMARTART_PART_STEM = { dm: 'data', lo: 'layout', qs: 'quickStyle', cs: 'colors' };
484
+ /** The 4 files `md2nativedocx-core.mjs` writes per diagram id, one per relIds kind. */
485
+ const SMARTART_SOURCE_FILE = { dm: 'data.xml', lo: 'layout.xml', qs: 'quickStyle.xml', cs: 'colors.xml' };
486
+
487
+ /** The optional fifth part: the pre-rendered `dsp:drawing` Word and LibreOffice show as the diagram's cached rendering. */
488
+ const SMARTART_DRAWING_RELTYPE = 'http://schemas.microsoft.com/office/2007/relationships/diagramDrawing';
489
+ const SMARTART_DRAWING_CONTENTTYPE = 'application/vnd.ms-office.drawingml.diagramDrawing+xml';
490
+
491
+ /** Every distinct placeholder diagram id referenced in `documentXml`, in first-seen order. */
492
+ function findSmartArtPlaceholderIds(documentXml) {
493
+ const ids = [];
494
+ const seen = new Set();
495
+ for (const match of documentXml.matchAll(SMARTART_PLACEHOLDER_RE)) {
496
+ const id = match[1];
497
+ if (!seen.has(id)) {
498
+ seen.add(id);
499
+ ids.push(id);
500
+ }
501
+ }
502
+ return ids;
503
+ }
504
+
505
+ /** The next unused `word/diagrams/data{N}.xml` number, scanning the archive's existing entries (1 if none). */
506
+ function nextSmartArtPartNumber(zip) {
507
+ let max = 0;
508
+ for (const entry of zip.getEntries()) {
509
+ const match = /^word\/diagrams\/data(\d+)\.xml$/.exec(entry.entryName);
510
+ if (match) max = Math.max(max, Number(match[1]));
511
+ }
512
+ return max + 1;
513
+ }
514
+
515
+ /** A relationship `Id` not already present in `relsXml`, built from `prefix` + an incrementing counter. */
516
+ function nextFreeRelId(relsXml, prefix) {
517
+ let n = 1;
518
+ while (relsXml.includes(`Id="${prefix}${n}"`)) n += 1;
519
+ return `${prefix}${n}`;
520
+ }
521
+
522
+ /**
523
+ * Complete the SmartArt wiring for every placeholder diagram found in
524
+ * `docxPath`'s `word/document.xml` — a no-op (no extraction, no rezip) if
525
+ * none are present, which covers every export that never dispatched to
526
+ * SmartArt in the first place. Must run after {@link postProcessDocx} (this
527
+ * reads the already-namespace-fixed/id-renumbered `word/document.xml`).
528
+ *
529
+ * @param docxPath - Path to the .docx to patch.
530
+ * @param smartArtDir - The directory `MD2NATIVEDOCX_SMARTART_DIR` pointed
531
+ * the core bridge at; each placeholder id names a `<smartArtDir>/<id>/`
532
+ * subdirectory holding that diagram's `data.xml`/`layout.xml`/
533
+ * `colors.xml`/`quickStyle.xml`.
534
+ */
535
+ export function injectSmartArtParts(docxPath, smartArtDir) {
536
+ const archive = resolve(docxPath);
537
+ const zip = new AdmZip(archive);
538
+ let documentXml = readZipEntry(zip, 'word/document.xml');
539
+
540
+ const ids = findSmartArtPlaceholderIds(documentXml);
541
+ if (ids.length === 0) return;
542
+
543
+ if (!smartArtDir) {
544
+ throw new Error(
545
+ 'md2nativedocx: document.xml references SmartArt placeholders but no smartArtDir was given'
546
+ );
547
+ }
548
+
549
+ let contentTypes = readZipEntry(zip, '[Content_Types].xml');
550
+ let relsXml = readZipEntry(zip, 'word/_rels/document.xml.rels');
551
+
552
+ let nextPartNumber = nextSmartArtPartNumber(zip);
553
+ const newParts = [];
554
+
555
+ for (const id of ids) {
556
+ const sourceDir = join(smartArtDir, id);
557
+ if (!existsSync(sourceDir)) {
558
+ throw new Error(`md2nativedocx: no SmartArt parts found for diagram "${id}" in ${smartArtDir}`);
559
+ }
560
+
561
+ const n = nextPartNumber++;
562
+
563
+ // Optional 5th part (`dsp:drawing`): written by the bridge only when SmartArt drawings are on. Its
564
+ // relationship id lives inside the *data* part (`dsp:dataModelExt/@relId`), not in document.xml.
565
+ let drawingRelId = null;
566
+ const drawingFile = join(sourceDir, 'drawing.xml');
567
+ if (existsSync(drawingFile)) {
568
+ drawingRelId = nextFreeRelId(relsXml, `rIdSmartArt${n}DR`);
569
+ relsXml = relsXml.replace(
570
+ '</Relationships>',
571
+ `<Relationship Id="${drawingRelId}" Type="${SMARTART_DRAWING_RELTYPE}" Target="diagrams/drawing${n}.xml" /></Relationships>`
572
+ );
573
+ contentTypes = contentTypes.replace(
574
+ '</Types>',
575
+ `<Override PartName="/word/diagrams/drawing${n}.xml" ContentType="${SMARTART_DRAWING_CONTENTTYPE}" /></Types>`
576
+ );
577
+ newParts.push([`word/diagrams/drawing${n}.xml`, readFileSync(drawingFile, 'utf8')]);
578
+ }
579
+
580
+ /** @type {Record<'dm'|'lo'|'qs'|'cs', string>} */
581
+ const relIds = {};
582
+ for (const kind of /** @type {const} */ (['dm', 'lo', 'qs', 'cs'])) {
583
+ const partName = `${SMARTART_PART_STEM[kind]}${n}.xml`;
584
+ const partPath = `word/diagrams/${partName}`;
585
+ let xml = readFileSync(join(sourceDir, SMARTART_SOURCE_FILE[kind]), 'utf8');
586
+ if (kind === 'dm') {
587
+ const token = `SMARTART_PLACEHOLDER:${id}:dr`;
588
+ if (drawingRelId) xml = xml.split(token).join(drawingRelId);
589
+ else if (xml.includes(token)) {
590
+ throw new Error(`md2nativedocx: SmartArt diagram "${id}" references a drawing part that was not written`);
591
+ }
592
+ }
593
+
594
+ const relId = nextFreeRelId(relsXml, `rIdSmartArt${n}${kind.toUpperCase()}`);
595
+ relIds[kind] = relId;
596
+ relsXml = relsXml.replace(
597
+ '</Relationships>',
598
+ `<Relationship Id="${relId}" Type="${SMARTART_RELTYPE[kind]}" Target="diagrams/${partName}" /></Relationships>`
599
+ );
600
+ contentTypes = contentTypes.replace(
601
+ '</Types>',
602
+ `<Override PartName="/${partPath}" ContentType="${SMARTART_CONTENTTYPE[kind]}" /></Types>`
603
+ );
604
+
605
+ newParts.push([partPath, xml]);
606
+ }
607
+
608
+ for (const kind of /** @type {const} */ (['dm', 'lo', 'qs', 'cs'])) {
609
+ documentXml = documentXml.split(`SMARTART_PLACEHOLDER:${id}:${kind}`).join(relIds[kind]);
610
+ }
611
+ }
612
+
613
+ // No rule #3 check here (unlike the from-scratch spike scripts this
614
+ // logic descends from): `relsXml` is the *real* document's existing
615
+ // rels file, which may legitimately already contain external
616
+ // relationships Pandoc itself created for Markdown hyperlinks --
617
+ // unrelated to anything this function adds. The 4 relationships added
618
+ // above are always internal (`Target="diagrams/..."`, no `TargetMode`
619
+ // attribute at all), by construction, not by a runtime check.
620
+ setZipEntry(zip, 'word/document.xml', documentXml);
621
+ setZipEntry(zip, '[Content_Types].xml', contentTypes);
622
+ setZipEntry(zip, 'word/_rels/document.xml.rels', relsXml);
623
+ for (const [partPath, xml] of newParts) {
624
+ setZipEntry(zip, partPath, xml);
625
+ }
626
+
627
+ zip.writeZip(archive);
628
+ }