@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.
- package/README.md +114 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/assets/README.md +65 -0
- package/assets/md2nativedocx.opendocument +171 -0
- package/assets/reference.docx +0 -0
- package/assets/reference.odt +0 -0
- package/bin/md2nativedocx.mjs +209 -0
- package/package.json +62 -4
- package/src/chartParts.mjs +177 -0
- package/src/cliSupport.mjs +30 -0
- package/src/convert.d.mts +97 -0
- package/src/convert.mjs +274 -0
- package/src/envOptions.mjs +88 -0
- package/src/postprocess.mjs +628 -0
- package/src/pptxExport.mjs +102 -0
- package/src/referenceDocBuilder.mjs +521 -0
- package/src/unclosedFences.mjs +48 -0
- package/src/wordCheck.mjs +125 -0
- package/src/writeOutputSafely.mjs +69 -0
- package/src/zipUtils.mjs +38 -0
|
@@ -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
|
+
}
|