teamshare-bridge 0.21.13 → 0.21.14

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,851 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildDocxFromTemplate = buildDocxFromTemplate;
4
+ /**
5
+ * Template merge for Word commits (2026-09).
6
+ *
7
+ * The browser cannot rewrite a `.docx` losslessly, and rebuilding one from
8
+ * HTML throws away the document's identity: its theme, named styles, styles
9
+ * inheritance, numbering definitions, headers/footers and section properties
10
+ * (page size, margins, columns). So we do not rebuild - we take the ORIGINAL
11
+ * package, replace the children of `<w:body>`, and leave every other part
12
+ * byte-identical.
13
+ *
14
+ * original.docx (zip)
15
+ * ├── word/document.xml <- ONLY the body children are rewritten
16
+ * ├── word/theme/theme1.xml untouched -> theme survives
17
+ * ├── word/styles.xml untouched -> named styles + inheritance survive
18
+ * ├── word/numbering.xml untouched -> list definitions survive
19
+ * ├── word/header1.xml, footer1.xml untouched -> letterhead survives
20
+ * ├── word/settings.xml, fontTable.xml, media/* untouched
21
+ * └── w:sectPr (tail of document.xml) preserved -> page setup survives
22
+ *
23
+ * The generated paragraphs reference the DOCUMENT'S OWN styleIds (discovered
24
+ * from styles.xml by style name), so "Heading 1" stays the document's
25
+ * Heading 1 and keeps its theme fonts and colours.
26
+ *
27
+ * Anything the merge would have to drop is detected beforehand
28
+ * (`docx-inspect.ts`) and the document is refused instead - we never edit a
29
+ * file we would silently damage.
30
+ */
31
+ const xml_escape_1 = require("./xml-escape");
32
+ const MAIN_PART = 'word/document.xml';
33
+ const STYLES_PART = 'word/styles.xml';
34
+ const NUMBERING_PART = 'word/numbering.xml';
35
+ const RELS_PART = 'word/_rels/document.xml.rels';
36
+ const CONTENT_TYPES_PART = '[Content_Types].xml';
37
+ const EMU_PER_PX = 9525;
38
+ // ===========================================================================
39
+ // Minimal HTML parsing (the TipTap / mammoth subset)
40
+ // ===========================================================================
41
+ const VOID_TAGS = new Set([
42
+ 'br',
43
+ 'img',
44
+ 'hr',
45
+ 'meta',
46
+ 'link',
47
+ 'input',
48
+ 'col',
49
+ 'source',
50
+ 'area',
51
+ 'base',
52
+ ]);
53
+ function parseAttrs(raw) {
54
+ const attrs = {};
55
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
56
+ let m;
57
+ while ((m = re.exec(raw))) {
58
+ attrs[m[1].toLowerCase()] = m[3] ?? m[4] ?? m[5] ?? '';
59
+ }
60
+ return attrs;
61
+ }
62
+ function unescapeHtml(text) {
63
+ return text
64
+ .replace(/&nbsp;/g, ' ')
65
+ .replace(/&lt;/g, '<')
66
+ .replace(/&gt;/g, '>')
67
+ .replace(/&quot;/g, '"')
68
+ .replace(/&#39;/g, "'")
69
+ .replace(/&amp;/g, '&');
70
+ }
71
+ /**
72
+ * Tiny stack-based parser for well-formed HTML. TipTap serializes a
73
+ * ProseMirror document (always balanced) and mammoth emits balanced HTML, so
74
+ * a full parser is unnecessary.
75
+ */
76
+ function parseHtml(html) {
77
+ const root = { tag: '#root', attrs: {}, children: [] };
78
+ const stack = [root];
79
+ const re = /<!--[\s\S]*?-->|<[^>]*>|[^<]+/g;
80
+ let m;
81
+ while ((m = re.exec(html))) {
82
+ const token = m[0];
83
+ if (token.startsWith('<!--'))
84
+ continue;
85
+ if (token.startsWith('</')) {
86
+ const tag = token.slice(2, -1).trim().toLowerCase();
87
+ for (let i = stack.length - 1; i > 0; i -= 1) {
88
+ if (stack[i].tag === tag) {
89
+ stack.length = i;
90
+ break;
91
+ }
92
+ }
93
+ continue;
94
+ }
95
+ if (token.startsWith('<')) {
96
+ const inner = token.slice(1, -1);
97
+ const selfClosing = inner.endsWith('/');
98
+ const body = selfClosing ? inner.slice(0, -1) : inner;
99
+ const split = body.search(/\s/);
100
+ const tag = (split === -1 ? body : body.slice(0, split)).toLowerCase();
101
+ if (!tag || tag === '!' || tag === '?')
102
+ continue;
103
+ const node = {
104
+ tag,
105
+ attrs: parseAttrs(split === -1 ? '' : body.slice(split)),
106
+ children: [],
107
+ };
108
+ stack[stack.length - 1].children.push(node);
109
+ if (!selfClosing && !VOID_TAGS.has(tag))
110
+ stack.push(node);
111
+ continue;
112
+ }
113
+ const text = unescapeHtml(token);
114
+ stack[stack.length - 1].children.push({
115
+ tag: '#text',
116
+ attrs: {},
117
+ children: [],
118
+ text,
119
+ });
120
+ }
121
+ return root;
122
+ }
123
+ function parseStyleManifest(stylesXml) {
124
+ const byName = new Map();
125
+ let paragraphDefault = null;
126
+ let tableStyleId = null;
127
+ if (!stylesXml)
128
+ return { byName, paragraphDefault, tableStyleId };
129
+ const styleRe = /<w:style\b([^>]*)>([\s\S]*?)<\/w:style>/g;
130
+ let m;
131
+ while ((m = styleRe.exec(stylesXml))) {
132
+ const head = m[1];
133
+ const inner = m[2];
134
+ const type = /w:type="([^"]+)"/.exec(head)?.[1];
135
+ const styleId = /w:styleId="([^"]+)"/.exec(head)?.[1];
136
+ if (!styleId)
137
+ continue;
138
+ const name = /<w:name\s+w:val="([^"]*)"/.exec(inner)?.[1];
139
+ if (name)
140
+ byName.set(name.trim().toLowerCase(), styleId);
141
+ if (type === 'paragraph' && /w:default="1"/.test(head)) {
142
+ paragraphDefault = styleId;
143
+ }
144
+ if (type === 'table' && !tableStyleId)
145
+ tableStyleId = styleId;
146
+ if (type === 'table' && styleId.toLowerCase() === 'tablegrid') {
147
+ tableStyleId = styleId;
148
+ }
149
+ }
150
+ return { byName, paragraphDefault, tableStyleId };
151
+ }
152
+ /**
153
+ * A regenerable table-of-contents field. It replaces the original TOC (which
154
+ * lives in the body and would otherwise be dropped). `w:dirty="true"` makes
155
+ * Word offer to refresh it on open, so nothing is actually lost - the TOC is
156
+ * rebuilt from the edited headings.
157
+ */
158
+ function tocField() {
159
+ return ('<w:p><w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
160
+ '<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>' +
161
+ '<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
162
+ '<w:r><w:t>Right-click and choose &quot;Update Field&quot; to build the table of contents.</w:t></w:r>' +
163
+ '<w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>');
164
+ }
165
+ /** True when the body has a table-of-contents field. */
166
+ function hasTocField(documentXml) {
167
+ const instrs = [
168
+ ...documentXml.matchAll(/<w:instrText[^>]*>([\s\S]*?)<\/w:instrText>/g),
169
+ ].map((m) => m[1]);
170
+ const simple = [
171
+ ...documentXml.matchAll(/<w:fldSimple[^>]*w:instr="([^"]*)"/g),
172
+ ].map((m) => m[1]);
173
+ return [...instrs, ...simple].some((instr) => /^\s*TOC\b/i.test(instr));
174
+ }
175
+ /** Extract the trailing `<w:sectPr>` (page setup + header/footer references). */
176
+ function extractSectPrTail(documentXml) {
177
+ const bodyStart = documentXml.indexOf('<w:body');
178
+ if (bodyStart === -1)
179
+ return '';
180
+ const body = documentXml.slice(bodyStart);
181
+ const lastOpen = body.lastIndexOf('<w:sectPr');
182
+ if (lastOpen === -1)
183
+ return '';
184
+ const close = body.indexOf('</w:sectPr>', lastOpen);
185
+ if (close === -1)
186
+ return '';
187
+ return body.slice(lastOpen, close + '</w:sectPr>'.length);
188
+ }
189
+ function parseNumbering(numberingXml) {
190
+ const info = {
191
+ bulletNumId: null,
192
+ orderedNumId: null,
193
+ abstractNumIds: [],
194
+ numIds: [],
195
+ };
196
+ if (!numberingXml)
197
+ return info;
198
+ const abstractRe = /<w:abstractNum\b[^>]*w:abstractNumId="([^"]+)"[^>]*>([\s\S]*?)<\/w:abstractNum>/g;
199
+ const fmtById = new Map();
200
+ let m;
201
+ while ((m = abstractRe.exec(numberingXml))) {
202
+ info.abstractNumIds.push(m[1]);
203
+ const fmt = /<w:numFmt\s+w:val="([^"]+)"/.exec(m[2])?.[1];
204
+ if (fmt)
205
+ fmtById.set(m[1], fmt);
206
+ }
207
+ const numRe = /<w:num\b[^>]*w:numId="([^"]+)"[^>]*>([\s\S]*?)<\/w:num>/g;
208
+ while ((m = numRe.exec(numberingXml))) {
209
+ info.numIds.push(m[1]);
210
+ const abstractId = /<w:abstractNumId\s+w:val="([^"]+)"/.exec(m[2])?.[1];
211
+ const fmt = abstractId ? fmtById.get(abstractId) : undefined;
212
+ if (fmt === 'bullet' && !info.bulletNumId)
213
+ info.bulletNumId = m[1];
214
+ if (fmt === 'decimal' && !info.orderedNumId)
215
+ info.orderedNumId = m[1];
216
+ }
217
+ return info;
218
+ }
219
+ /** Inject a bullet + decimal list definition when the document has none. */
220
+ function injectListNumbering(numberingXml) {
221
+ const bulletId = '900001';
222
+ const orderedId = '900002';
223
+ const bulletAbstract = '900001';
224
+ const orderedAbstract = '900002';
225
+ const lvl = (fmt, text, font) => `<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="${fmt}"/><w:lvlText w:val="${text}"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>${font ? `<w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}"/></w:rPr>` : ''}</w:lvl>`;
226
+ const abstracts = `<w:abstractNum w:abstractNumId="${bulletAbstract}"><w:multiLevelType w:val="hybridMultilevel"/>${lvl('bullet', '\u2022', 'Symbol')}</w:abstractNum>` +
227
+ `<w:abstractNum w:abstractNumId="${orderedAbstract}"><w:multiLevelType w:val="hybridMultilevel"/>${lvl('decimal', '%1.')}</w:abstractNum>`;
228
+ const nums = `<w:num w:numId="${bulletId}"><w:abstractNumId w:val="${bulletAbstract}"/></w:num>` +
229
+ `<w:num w:numId="${orderedId}"><w:abstractNumId w:val="${orderedAbstract}"/></w:num>`;
230
+ if (!numberingXml) {
231
+ return {
232
+ xml: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${abstracts}${nums}</w:numbering>`,
233
+ bulletNumId: bulletId,
234
+ orderedNumId: orderedId,
235
+ };
236
+ }
237
+ // Schema order: every w:abstractNum must precede the first w:num.
238
+ const withAbstracts = numberingXml.replace('</w:numbering>', `${abstracts}</w:numbering>`);
239
+ const numIndex = withAbstracts.indexOf('<w:num ');
240
+ const out = numIndex === -1
241
+ ? withAbstracts.replace('</w:numbering>', `${nums}</w:numbering>`)
242
+ : withAbstracts.slice(0, numIndex) + nums + withAbstracts.slice(numIndex);
243
+ return { xml: out, bulletNumId: bulletId, orderedNumId: orderedId };
244
+ }
245
+ // ===========================================================================
246
+ // Inline + block rendering
247
+ // ===========================================================================
248
+ function styleOf(node) {
249
+ const out = {};
250
+ for (const part of (node.attrs.style ?? '').split(';')) {
251
+ const [k, v] = part.split(':');
252
+ if (k && v)
253
+ out[k.trim().toLowerCase()] = v.trim();
254
+ }
255
+ return out;
256
+ }
257
+ function normalizeColor(value) {
258
+ const hex = value.trim().replace('#', '');
259
+ if (/^[0-9a-f]{6}$/i.test(hex))
260
+ return hex.toUpperCase();
261
+ if (/^[0-9a-f]{3}$/i.test(hex)) {
262
+ return hex
263
+ .split('')
264
+ .map((c) => c + c)
265
+ .join('')
266
+ .toUpperCase();
267
+ }
268
+ return null;
269
+ }
270
+ function runProperties(fmt) {
271
+ const parts = [];
272
+ if (fmt.code) {
273
+ parts.push('<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>');
274
+ }
275
+ if (fmt.bold)
276
+ parts.push('<w:b/>');
277
+ if (fmt.italic)
278
+ parts.push('<w:i/>');
279
+ if (fmt.underline)
280
+ parts.push('<w:u w:val="single"/>');
281
+ if (fmt.strike)
282
+ parts.push('<w:strike/>');
283
+ if (fmt.color)
284
+ parts.push(`<w:color w:val="${fmt.color}"/>`);
285
+ if (fmt.highlight)
286
+ parts.push(`<w:highlight w:val="${fmt.highlight}"/>`);
287
+ return parts.length ? `<w:rPr>${parts.join('')}</w:rPr>` : '';
288
+ }
289
+ function textRun(text, fmt) {
290
+ if (!text)
291
+ return '';
292
+ const preserve = /^\s|\s$/.test(text) ? ' xml:space="preserve"' : '';
293
+ return `<w:r>${runProperties(fmt)}<w:t${preserve}>${(0, xml_escape_1.escapeXml)(text)}</w:t></w:r>`;
294
+ }
295
+ const HEADING_TAGS = {
296
+ h1: 1,
297
+ h2: 2,
298
+ h3: 3,
299
+ h4: 4,
300
+ h5: 5,
301
+ h6: 6,
302
+ };
303
+ /** Walk inline content, emitting `<w:r>` (+ hyperlinks and drawings). */
304
+ function renderInline(nodes, fmt, ctx) {
305
+ let out = '';
306
+ for (const node of nodes) {
307
+ if (node.tag === '#text') {
308
+ out += textRun(node.text ?? '', fmt);
309
+ continue;
310
+ }
311
+ const style = styleOf(node);
312
+ const next = { ...fmt };
313
+ switch (node.tag) {
314
+ case 'br':
315
+ out += `<w:r>${runProperties(fmt)}<w:br/></w:r>`;
316
+ continue;
317
+ case 'img':
318
+ out += renderImage(node, ctx);
319
+ continue;
320
+ case 'strong':
321
+ case 'b':
322
+ next.bold = true;
323
+ break;
324
+ case 'em':
325
+ case 'i':
326
+ next.italic = true;
327
+ break;
328
+ case 'u':
329
+ next.underline = true;
330
+ break;
331
+ case 's':
332
+ case 'del':
333
+ case 'strike':
334
+ next.strike = true;
335
+ break;
336
+ case 'code':
337
+ next.code = true;
338
+ break;
339
+ case 'mark':
340
+ next.highlight = 'yellow';
341
+ break;
342
+ case 'sub':
343
+ case 'sup':
344
+ // No editor-level distinction worth regenerating; keep the text.
345
+ break;
346
+ case 'span':
347
+ case 'font': {
348
+ const color = style['color'] ? normalizeColor(style['color']) : null;
349
+ if (color)
350
+ next.color = color;
351
+ break;
352
+ }
353
+ case 'a': {
354
+ const href = node.attrs.href;
355
+ if (href && !href.startsWith('#')) {
356
+ const relId = ctx.nextRelId();
357
+ ctx.newRels.push({
358
+ id: relId,
359
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
360
+ target: href,
361
+ external: true,
362
+ });
363
+ out += `<w:hyperlink r:id="${relId}">${renderInline(node.children, next, ctx)}</w:hyperlink>`;
364
+ continue;
365
+ }
366
+ if (href && href.startsWith('#')) {
367
+ ctx.notes.push('internal anchor link flattened to plain text');
368
+ }
369
+ break;
370
+ }
371
+ default:
372
+ break;
373
+ }
374
+ out += renderInline(node.children, next, ctx);
375
+ }
376
+ return out;
377
+ }
378
+ /** Convert an `<img>` into an inline picture (data URIs and http(s) URLs). */
379
+ function renderImage(node, ctx) {
380
+ const src = node.attrs.src ?? '';
381
+ if (!src)
382
+ return '';
383
+ // Images are embedded by the async pass BEFORE rendering; the placeholder is
384
+ // resolved there. See `embeddedImages` in buildDocxFromTemplate.
385
+ const ref = ctx.imageRefs?.get(src);
386
+ if (!ref) {
387
+ ctx.notes.push('an image could not be embedded and was omitted');
388
+ return '';
389
+ }
390
+ const cx = Math.round(ref.widthPx * EMU_PER_PX);
391
+ const cy = Math.round(ref.heightPx * EMU_PER_PX);
392
+ const id = ref.docPrId;
393
+ return (`<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" distT="0" distB="0" distL="0" distR="0">` +
394
+ `<wp:extent cx="${cx}" cy="${cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/>` +
395
+ `<wp:docPr id="${id}" name="Picture ${id}"/>` +
396
+ `<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">` +
397
+ `<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">` +
398
+ `<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">` +
399
+ `<pic:nvPicPr><pic:cNvPr id="${id}" name="Picture ${id}"/><pic:cNvPicPr/></pic:nvPicPr>` +
400
+ `<pic:blipFill><a:blip r:embed="${ref.relId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
401
+ `<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
402
+ `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>` +
403
+ `</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`);
404
+ }
405
+ function alignmentProps(node) {
406
+ const align = styleOf(node)['text-align'];
407
+ if (!align)
408
+ return '';
409
+ const map = {
410
+ left: 'left',
411
+ right: 'right',
412
+ center: 'center',
413
+ justify: 'both',
414
+ };
415
+ const jc = map[align];
416
+ return jc ? `<w:jc w:val="${jc}"/>` : '';
417
+ }
418
+ function paragraph(styleId, extraPr, runs) {
419
+ const pr = `${styleId ? `<w:pStyle w:val="${(0, xml_escape_1.escapeXml)(styleId)}"/>` : ''}${extraPr}`;
420
+ return `<w:p>${pr ? `<w:pPr>${pr}</w:pPr>` : ''}${runs}</w:p>`;
421
+ }
422
+ function listParagraph(listStyleId, numId, runs) {
423
+ const numPr = numId
424
+ ? `<w:numPr><w:ilvl w:val="0"/><w:numId w:val="${numId}"/></w:numPr>`
425
+ : '';
426
+ const pr = `${listStyleId ? `<w:pStyle w:val="${(0, xml_escape_1.escapeXml)(listStyleId)}"/>` : ''}${numPr}`;
427
+ return `<w:p>${pr ? `<w:pPr>${pr}</w:pPr>` : ''}${runs}</w:p>`;
428
+ }
429
+ function renderTable(node, ctx) {
430
+ const rows = [];
431
+ const collectRows = (n) => {
432
+ for (const child of n.children) {
433
+ if (child.tag === 'tr')
434
+ rows.push(renderRow(child, ctx));
435
+ else if (child.tag !== '#text')
436
+ collectRows(child);
437
+ }
438
+ };
439
+ collectRows(node);
440
+ if (rows.length === 0)
441
+ return '';
442
+ const style = ctx.tableStyleId
443
+ ? `<w:tblStyle w:val="${(0, xml_escape_1.escapeXml)(ctx.tableStyleId)}"/>`
444
+ : '';
445
+ return (`<w:tbl><w:tblPr>${style}<w:tblW w:w="0" w:type="auto"/><w:tblBorders>` +
446
+ `<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
447
+ `<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
448
+ `<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
449
+ `<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
450
+ `<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
451
+ `<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
452
+ `</w:tblBorders></w:tblPr><w:tblGrid/>${rows.join('')}</w:tbl>`);
453
+ }
454
+ function renderRow(node, ctx) {
455
+ const cells = [];
456
+ for (const cell of node.children) {
457
+ if (cell.tag !== 'td' && cell.tag !== 'th')
458
+ continue;
459
+ const bold = cell.tag === 'th' ? { bold: true } : {};
460
+ const runs = renderInline(cell.children, bold, ctx);
461
+ const shade = cell.tag === 'th'
462
+ ? `<w:shd w:val="clear" w:color="auto" w:fill="F1F5F9"/>`
463
+ : '';
464
+ cells.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/>${shade}</w:tcPr><w:p>${runs}</w:p></w:tc>`);
465
+ }
466
+ return `<w:tr>${cells.join('')}</w:tr>`;
467
+ }
468
+ /**
469
+ * Render block content to a flat list of top-level OOXML elements (paragraphs
470
+ * and tables). Returning a list - rather than one joined string - lets the
471
+ * caller re-insert section breaks at their matching positions.
472
+ */
473
+ function renderBlocks(nodes, ctx, listDepth = 0) {
474
+ const out = [];
475
+ for (const node of nodes) {
476
+ if (node.tag === '#text') {
477
+ const text = (node.text ?? '').trim();
478
+ if (text)
479
+ out.push(paragraph(ctx.bodyStyleId, '', textRun(node.text ?? '', {})));
480
+ continue;
481
+ }
482
+ const heading = HEADING_TAGS[node.tag];
483
+ if (heading) {
484
+ // Prefer the document's own heading style so the theme's fonts and
485
+ // colours apply automatically.
486
+ const styleId = ctx.paragraphStyle(`heading ${heading}`) ??
487
+ ctx.paragraphStyle(`heading${heading}`) ??
488
+ null;
489
+ out.push(paragraph(styleId, '', renderInline(node.children, {}, ctx)));
490
+ continue;
491
+ }
492
+ switch (node.tag) {
493
+ case 'p':
494
+ case 'div':
495
+ out.push(paragraph(ctx.bodyStyleId, alignmentProps(node), renderInline(node.children, {}, ctx)));
496
+ break;
497
+ case 'blockquote':
498
+ out.push(paragraph(ctx.quoteStyleId ?? ctx.bodyStyleId, ctx.quoteStyleId ? '' : '<w:ind w:left="720"/>', renderInline(node.children, {}, ctx)));
499
+ break;
500
+ case 'pre': {
501
+ const text = collectText(node);
502
+ for (const line of text.split('\n')) {
503
+ out.push(paragraph(null, '<w:shd w:val="clear" w:color="auto" w:fill="F1F5F9"/>', textRun(line, { code: true })));
504
+ }
505
+ break;
506
+ }
507
+ case 'ul':
508
+ case 'ol': {
509
+ const numId = node.tag === 'ol' ? ctx.orderedNumId : ctx.bulletNumId;
510
+ const items = node.children.filter((c) => c.tag === 'li');
511
+ for (const li of items) {
512
+ out.push(listParagraph(ctx.listStyleId, numId, renderInline(li.children, {}, ctx)));
513
+ }
514
+ break;
515
+ }
516
+ case 'table':
517
+ out.push(renderTable(node, ctx));
518
+ break;
519
+ case 'hr':
520
+ out.push(paragraph(null, '<w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr>', ''));
521
+ break;
522
+ case 'li':
523
+ out.push(listParagraph(ctx.listStyleId, ctx.bulletNumId, renderInline(node.children, {}, ctx)));
524
+ break;
525
+ default:
526
+ // Unknown wrapper: recurse so nested content survives.
527
+ if (node.children.length) {
528
+ out.push(...renderBlocks(node.children, ctx, listDepth));
529
+ }
530
+ break;
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+ /**
536
+ * Re-insert the original section breaks (`w:sectPr` blocks that lived inside
537
+ * the body) so a multi-section document keeps its section count, per-section
538
+ * page setup and per-section header/footer references. Positions are scaled to
539
+ * the new paragraph count, since the body itself was rewritten.
540
+ */
541
+ function placeSectionBreaks(elements, breaks) {
542
+ if (breaks.length === 0)
543
+ return elements;
544
+ const total = elements.length;
545
+ const out = [];
546
+ let next = 0;
547
+ for (let i = 0; i < total; i += 1) {
548
+ while (next < breaks.length &&
549
+ Math.round(((next + 1) / (breaks.length + 1)) * total) <= i) {
550
+ out.push(`<w:p><w:pPr>${breaks[next]}</w:pPr></w:p>`);
551
+ next += 1;
552
+ }
553
+ out.push(elements[i]);
554
+ }
555
+ while (next < breaks.length) {
556
+ out.push(`<w:p><w:pPr>${breaks[next]}</w:pPr></w:p>`);
557
+ next += 1;
558
+ }
559
+ return out;
560
+ }
561
+ /** Ordered `w:sectPr` blocks in the body; the last one is body-level. */
562
+ function extractSectionBreaks(documentXml) {
563
+ const all = documentXml.match(/<w:sectPr[\s\S]*?<\/w:sectPr>/g) ?? [];
564
+ if (all.length === 0)
565
+ return { breaks: [], trailing: '' };
566
+ return { breaks: all.slice(0, -1), trailing: all[all.length - 1] };
567
+ }
568
+ function collectText(node) {
569
+ if (node.tag === '#text')
570
+ return node.text ?? '';
571
+ if (node.tag === 'br')
572
+ return '\n';
573
+ return node.children.map(collectText).join('');
574
+ }
575
+ function imageDimensions(buf) {
576
+ // PNG
577
+ if (buf.length > 24 && buf.readUInt32BE(0) === 0x89504e47) {
578
+ return { widthPx: buf.readUInt32BE(16), heightPx: buf.readUInt32BE(20) };
579
+ }
580
+ // GIF
581
+ if (buf.length > 10 && buf.slice(0, 3).toString('ascii') === 'GIF') {
582
+ return { widthPx: buf.readUInt16LE(6), heightPx: buf.readUInt16LE(8) };
583
+ }
584
+ // JPEG
585
+ if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
586
+ let offset = 2;
587
+ while (offset + 9 < buf.length) {
588
+ if (buf[offset] !== 0xff) {
589
+ offset += 1;
590
+ continue;
591
+ }
592
+ const marker = buf[offset + 1];
593
+ const size = buf.readUInt16BE(offset + 2);
594
+ if ((marker >= 0xc0 && marker <= 0xc3) ||
595
+ (marker >= 0xc5 && marker <= 0xc7) ||
596
+ (marker >= 0xc9 && marker <= 0xcb)) {
597
+ return {
598
+ heightPx: buf.readUInt16BE(offset + 5),
599
+ widthPx: buf.readUInt16BE(offset + 7),
600
+ };
601
+ }
602
+ offset += 2 + size;
603
+ }
604
+ }
605
+ return { widthPx: 600, heightPx: 400 };
606
+ }
607
+ function extensionForMime(mime, fallback = 'png') {
608
+ if (mime.includes('png'))
609
+ return 'png';
610
+ if (mime.includes('jpeg') || mime.includes('jpg'))
611
+ return 'jpeg';
612
+ if (mime.includes('gif'))
613
+ return 'gif';
614
+ if (mime.includes('webp'))
615
+ return 'png';
616
+ if (mime.includes('svg'))
617
+ return 'png';
618
+ if (mime.includes('bmp'))
619
+ return 'bmp';
620
+ return fallback;
621
+ }
622
+ const MIME_BY_EXT = {
623
+ png: 'image/png',
624
+ jpeg: 'image/jpeg',
625
+ jpg: 'image/jpeg',
626
+ gif: 'image/gif',
627
+ bmp: 'image/bmp',
628
+ tiff: 'image/tiff',
629
+ };
630
+ /** Resolve every `<img src>` to bytes, ready to be embedded. */
631
+ async function collectImages(html, ctx) {
632
+ const refs = new Map();
633
+ const sources = new Set();
634
+ const imgRe = /<img\b[^>]*\bsrc\s*=\s*("([^"]*)"|'([^']*)')/gi;
635
+ let m;
636
+ while ((m = imgRe.exec(html))) {
637
+ const src = m[2] ?? m[3] ?? '';
638
+ if (src)
639
+ sources.add(src);
640
+ }
641
+ let index = 0;
642
+ for (const src of sources) {
643
+ try {
644
+ let bytes;
645
+ let mime = 'image/png';
646
+ const dataUri = /^data:([^;,]+);base64,(.*)$/i.exec(src);
647
+ if (dataUri) {
648
+ mime = dataUri[1];
649
+ bytes = Buffer.from(dataUri[2], 'base64');
650
+ }
651
+ else if (/^https?:\/\//i.test(src)) {
652
+ const res = await fetch(src, { signal: AbortSignal.timeout(30_000) });
653
+ if (!res.ok)
654
+ throw new Error(`HTTP ${res.status}`);
655
+ mime = res.headers.get('content-type') ?? 'image/png';
656
+ bytes = Buffer.from(await res.arrayBuffer());
657
+ }
658
+ else {
659
+ throw new Error('unsupported image source');
660
+ }
661
+ index += 1;
662
+ const ext = extensionForMime(mime);
663
+ const relId = ctx.nextRelId();
664
+ const path = `word/media/ts-image${index}.${ext}`;
665
+ ctx.newMedia.push({ path, bytes });
666
+ ctx.newRels.push({
667
+ id: relId,
668
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
669
+ target: `media/ts-image${index}.${ext}`,
670
+ external: false,
671
+ });
672
+ const { widthPx, heightPx } = imageDimensions(bytes);
673
+ refs.set(src, { relId, docPrId: index, widthPx, heightPx });
674
+ }
675
+ catch {
676
+ ctx.notes.push('an image could not be embedded and was omitted');
677
+ }
678
+ }
679
+ return refs;
680
+ }
681
+ // ===========================================================================
682
+ // Package assembly
683
+ // ===========================================================================
684
+ /** Ensure `[Content_Types].xml` declares the image extensions we added. */
685
+ function ensureContentTypes(xml, extensions) {
686
+ if (extensions.length === 0)
687
+ return xml;
688
+ const base = xml ??
689
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>';
690
+ let out = base;
691
+ for (const ext of extensions) {
692
+ const re = new RegExp(`<Default\\s+Extension="${ext}"`, 'i');
693
+ if (re.test(out))
694
+ continue;
695
+ const mime = MIME_BY_EXT[ext] ?? 'application/octet-stream';
696
+ out = out.replace('</Types>', `<Default Extension="${ext}" ContentType="${mime}"/></Types>`);
697
+ }
698
+ return out;
699
+ }
700
+ function parseRels(xml) {
701
+ const base = xml ??
702
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
703
+ let maxId = 0;
704
+ const re = /Id="rId(\d+)"/g;
705
+ let m;
706
+ while ((m = re.exec(base))) {
707
+ maxId = Math.max(maxId, Number(m[1]));
708
+ }
709
+ const prefix = xml ? 'rId' : 'rId';
710
+ return { xml: base, maxId, prefix };
711
+ }
712
+ /**
713
+ * Merge the edited HTML body into the original package.
714
+ *
715
+ * Throws on any structural problem - the caller fails the job and keeps the
716
+ * draft rather than writing a degraded file.
717
+ */
718
+ async function buildDocxFromTemplate(original, html) {
719
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
720
+ const JSZipCtor = require('jszip');
721
+ const zip = await JSZipCtor.loadAsync(original);
722
+ const mainEntry = zip.file(MAIN_PART);
723
+ if (!mainEntry) {
724
+ throw new Error('The document has no word/document.xml (not a valid .docx)');
725
+ }
726
+ const documentXml = await mainEntry.async('string');
727
+ const stylesEntry = zip.file(STYLES_PART);
728
+ const stylesXml = stylesEntry ? await stylesEntry.async('string') : null;
729
+ const numberingEntry = zip.file(NUMBERING_PART);
730
+ const numberingXml = numberingEntry ? await numberingEntry.async('string') : null;
731
+ const relsEntry = zip.file(RELS_PART);
732
+ const relsXml = relsEntry ? await relsEntry.async('string') : null;
733
+ const contentTypesEntry = zip.file(CONTENT_TYPES_PART);
734
+ const contentTypesXml = contentTypesEntry
735
+ ? await contentTypesEntry.async('string')
736
+ : null;
737
+ const manifest = parseStyleManifest(stylesXml);
738
+ const numbering = parseNumbering(numberingXml);
739
+ let finalNumberingXml = numberingXml;
740
+ let bulletNumId = numbering.bulletNumId;
741
+ let orderedNumId = numbering.orderedNumId;
742
+ if ((!bulletNumId || !orderedNumId) && /<(ul|ol)[\s>]/i.test(html)) {
743
+ const injected = injectListNumbering(numberingXml);
744
+ finalNumberingXml = injected.xml;
745
+ bulletNumId = bulletNumId ?? injected.bulletNumId;
746
+ orderedNumId = orderedNumId ?? injected.orderedNumId;
747
+ }
748
+ const rels = parseRels(relsXml);
749
+ let relCounter = rels.maxId;
750
+ const ctx = {
751
+ paragraphStyle: (name) => manifest.byName.get(name.toLowerCase()) ?? null,
752
+ bodyStyleId: manifest.paragraphDefault,
753
+ listStyleId: manifest.byName.get('list paragraph') ?? null,
754
+ tableStyleId: manifest.tableStyleId,
755
+ quoteStyleId: manifest.byName.get('quote') ?? manifest.byName.get('intense quote') ?? null,
756
+ bulletNumId,
757
+ orderedNumId,
758
+ newRels: [],
759
+ newMedia: [],
760
+ imageRefs: new Map(),
761
+ notes: [],
762
+ nextRelId: () => {
763
+ relCounter += 1;
764
+ return `rId${relCounter}`;
765
+ },
766
+ };
767
+ ctx.imageRefs = await collectImages(html, ctx);
768
+ const tree = parseHtml(html);
769
+ const toc = hasTocField(documentXml);
770
+ const { breaks: sectionBreaks, trailing: sectPr } = extractSectionBreaks(documentXml);
771
+ const elements = placeSectionBreaks(renderBlocks(tree.children, ctx), sectionBreaks);
772
+ const bodyInner = (toc ? tocField() : '') + elements.join('');
773
+ const openTagMatch = /^[\s\S]*?<w:body\b[^>]*>/.exec(documentXml);
774
+ const openTag = openTagMatch
775
+ ? openTagMatch[0]
776
+ : documentXml.slice(0, documentXml.indexOf('<w:body') + '<w:body>'.length);
777
+ const newDocumentXml = `${openTag}${bodyInner}${sectPr}</w:body></w:document>`;
778
+ // --- assemble the package -------------------------------------------------
779
+ const out = new JSZipCtor();
780
+ const preserved = [];
781
+ const dropped = [];
782
+ for (const [path, entry] of Object.entries(zip.files)) {
783
+ if (entry.dir)
784
+ continue;
785
+ if (path === MAIN_PART) {
786
+ out.file(path, newDocumentXml);
787
+ continue;
788
+ }
789
+ if (path === NUMBERING_PART && finalNumberingXml) {
790
+ out.file(path, finalNumberingXml);
791
+ continue;
792
+ }
793
+ if (path === RELS_PART) {
794
+ // written after the render, with the new relationships appended
795
+ continue;
796
+ }
797
+ // Every other part is carried over byte-for-byte.
798
+ if (path === STYLES_PART)
799
+ preserved.push('named styles');
800
+ else if (path.startsWith('word/theme/'))
801
+ preserved.push('theme');
802
+ else if (path.startsWith('word/header') || path.startsWith('word/footer')) {
803
+ preserved.push('headers and footers');
804
+ }
805
+ else if (path === 'word/settings.xml')
806
+ preserved.push('document settings');
807
+ out.file(path, await entry.async('nodebuffer'));
808
+ }
809
+ if (sectPr)
810
+ preserved.push('page setup, margins and columns');
811
+ if (sectionBreaks.length > 0) {
812
+ preserved.push(`sections (${sectionBreaks.length + 1})`);
813
+ }
814
+ if (toc)
815
+ preserved.push('table of contents (regenerated field)');
816
+ // Relationships (original + new link/image rels).
817
+ const newRelXml = ctx.newRels
818
+ .map((rel) => `<Relationship Id="${rel.id}" Type="${rel.type}" Target="${(0, xml_escape_1.escapeXml)(rel.target)}"${rel.external ? ' TargetMode="External"' : ''}/>`)
819
+ .join('');
820
+ const mergedRels = rels.xml.replace('</Relationships>', `${newRelXml}</Relationships>`);
821
+ out.file(RELS_PART, mergedRels);
822
+ // Media parts.
823
+ for (const media of ctx.newMedia) {
824
+ out.file(media.path, media.bytes);
825
+ }
826
+ // Content types for the image extensions we introduced.
827
+ const newExtensions = Array.from(new Set(ctx.newMedia.map((m) => m.path.split('.').pop() ?? 'png')));
828
+ const mergedTypes = ensureContentTypes(contentTypesXml, newExtensions);
829
+ if (mergedTypes)
830
+ out.file(CONTENT_TYPES_PART, mergedTypes);
831
+ // Honest report of body-level details that cannot survive a body rewrite.
832
+ if (/<w:bookmarkStart/.test(documentXml)) {
833
+ dropped.push('hidden bookmark anchors');
834
+ }
835
+ if (/<w:hyperlink\b[^>]*w:anchor="/.test(documentXml)) {
836
+ dropped.push('internal links to bookmarks');
837
+ }
838
+ const bytes = (await out.generateAsync({
839
+ type: 'nodebuffer',
840
+ compression: 'DEFLATE',
841
+ }));
842
+ return {
843
+ bytes,
844
+ report: {
845
+ preserved: Array.from(new Set(preserved)),
846
+ dropped: Array.from(new Set([...dropped, ...ctx.notes])).filter((d) => !d.startsWith('internal anchor link')),
847
+ notes: ctx.notes,
848
+ },
849
+ };
850
+ }
851
+ //# sourceMappingURL=docx-template.js.map