@bendyline/squisq-formats 2.0.1 → 2.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.
Files changed (45) hide show
  1. package/dist/{chunk-2KPARF2P.js → chunk-74GO3FVS.js} +3 -3
  2. package/dist/{chunk-WC7WULGV.js → chunk-7ARKUCQT.js} +9 -1
  3. package/dist/chunk-7ARKUCQT.js.map +1 -0
  4. package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
  5. package/dist/chunk-DTDF6QDP.js.map +1 -0
  6. package/dist/{chunk-QRVN6A6E.js → chunk-EW54IRRS.js} +4 -4
  7. package/dist/{chunk-U32AG3G3.js → chunk-KURGXM4I.js} +2 -2
  8. package/dist/{chunk-CRAVSMPZ.js → chunk-NNHKUXKA.js} +292 -70
  9. package/dist/chunk-NNHKUXKA.js.map +1 -0
  10. package/dist/{chunk-VSYHZECT.js → chunk-QFLDYKCR.js} +2 -2
  11. package/dist/{chunk-ABVI556T.js → chunk-RLU7UFYU.js} +5 -5
  12. package/dist/{chunk-XKUMNGBW.js → chunk-SC67HYQJ.js} +2 -2
  13. package/dist/{chunk-VJJM2SSH.js → chunk-UGYF5AZE.js} +3 -3
  14. package/dist/{chunk-HTW2M27H.js → chunk-WQSHGBLN.js} +1 -1
  15. package/dist/docx/index.js +4 -3
  16. package/dist/{extract-H6RXJMHP.js → extract-OJ7ZQV6P.js} +4 -4
  17. package/dist/html/index.js +1 -1
  18. package/dist/index.js +11 -11
  19. package/dist/infer/index.js +4 -4
  20. package/dist/{layouts-QVPK3ZCU.js → layouts-5VDIRPIJ.js} +3 -3
  21. package/dist/ooxml/index.js +3 -3
  22. package/dist/pptx/index.js +4 -4
  23. package/dist/registry/index.js +1 -1
  24. package/dist/xlsx/index.js +3 -3
  25. package/package.json +2 -2
  26. package/src/__tests__/docxExport.test.ts +34 -0
  27. package/src/__tests__/docxImport.test.ts +121 -0
  28. package/src/__tests__/html.test.ts +4 -0
  29. package/src/docx/export.ts +113 -5
  30. package/src/docx/import.ts +324 -69
  31. package/src/html/htmlTemplate.ts +2 -2
  32. package/src/ooxml/namespaces.ts +21 -0
  33. package/dist/chunk-CRAVSMPZ.js.map +0 -1
  34. package/dist/chunk-LXYLOOST.js.map +0 -1
  35. package/dist/chunk-WC7WULGV.js.map +0 -1
  36. /package/dist/{chunk-2KPARF2P.js.map → chunk-74GO3FVS.js.map} +0 -0
  37. /package/dist/{chunk-QRVN6A6E.js.map → chunk-EW54IRRS.js.map} +0 -0
  38. /package/dist/{chunk-U32AG3G3.js.map → chunk-KURGXM4I.js.map} +0 -0
  39. /package/dist/{chunk-VSYHZECT.js.map → chunk-QFLDYKCR.js.map} +0 -0
  40. /package/dist/{chunk-ABVI556T.js.map → chunk-RLU7UFYU.js.map} +0 -0
  41. /package/dist/{chunk-XKUMNGBW.js.map → chunk-SC67HYQJ.js.map} +0 -0
  42. /package/dist/{chunk-VJJM2SSH.js.map → chunk-UGYF5AZE.js.map} +0 -0
  43. /package/dist/{chunk-HTW2M27H.js.map → chunk-WQSHGBLN.js.map} +0 -0
  44. /package/dist/{extract-H6RXJMHP.js.map → extract-OJ7ZQV6P.js.map} +0 -0
  45. /package/dist/{layouts-QVPK3ZCU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
@@ -4,17 +4,17 @@ import {
4
4
  pptxToContainer,
5
5
  pptxToDoc,
6
6
  pptxToMarkdownDoc
7
- } from "../chunk-ABVI556T.js";
7
+ } from "../chunk-RLU7UFYU.js";
8
8
  import {
9
9
  analyzePptxLayouts,
10
10
  inspectPptxLayouts
11
- } from "../chunk-VSYHZECT.js";
11
+ } from "../chunk-QFLDYKCR.js";
12
12
  import "../chunk-6M7Z25LA.js";
13
13
  import "../chunk-PN52A5AA.js";
14
14
  import "../chunk-YRT7GQ5Y.js";
15
- import "../chunk-U32AG3G3.js";
15
+ import "../chunk-KURGXM4I.js";
16
16
  import "../chunk-U4MRIFKL.js";
17
- import "../chunk-WC7WULGV.js";
17
+ import "../chunk-7ARKUCQT.js";
18
18
  import "../chunk-FE6OJV6O.js";
19
19
  import "../chunk-SSUPBUF5.js";
20
20
  import "../chunk-MLX2BOJC.js";
@@ -5,7 +5,7 @@ import {
5
5
  defaultFormats,
6
6
  defaultRegistry,
7
7
  prepareConversion
8
- } from "../chunk-HTW2M27H.js";
8
+ } from "../chunk-WQSHGBLN.js";
9
9
  import {
10
10
  ConversionError
11
11
  } from "../chunk-ODL3SSPT.js";
@@ -3,11 +3,11 @@ import {
3
3
  markdownDocToXlsx,
4
4
  xlsxToDoc,
5
5
  xlsxToMarkdownDoc
6
- } from "../chunk-VJJM2SSH.js";
6
+ } from "../chunk-UGYF5AZE.js";
7
7
  import "../chunk-PN52A5AA.js";
8
- import "../chunk-U32AG3G3.js";
8
+ import "../chunk-KURGXM4I.js";
9
9
  import "../chunk-U4MRIFKL.js";
10
- import "../chunk-WC7WULGV.js";
10
+ import "../chunk-7ARKUCQT.js";
11
11
  import "../chunk-FE6OJV6O.js";
12
12
  export {
13
13
  docToXlsx,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-formats",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Document format converters — DOCX, PDF, OOXML import/export",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -100,7 +100,7 @@
100
100
  },
101
101
  "dependencies": {
102
102
  "@xmldom/xmldom": "0.9.10",
103
- "@bendyline/squisq": "2.0.1",
103
+ "@bendyline/squisq": "2.1.0",
104
104
  "jszip": "3.10.1",
105
105
  "pdf-lib": "1.17.1",
106
106
  "pdfjs-dist": "4.10.38"
@@ -22,6 +22,7 @@ import type {
22
22
  MarkdownEmphasis,
23
23
  MarkdownInlineCode,
24
24
  MarkdownLink,
25
+ MarkdownContainerDirective,
25
26
  } from '@bendyline/squisq/markdown';
26
27
 
27
28
  import { markdownDocToDocx, docToDocx } from '../docx/export';
@@ -81,6 +82,39 @@ describe('markdownDocToDocx', () => {
81
82
  expect(body).toBeDefined();
82
83
  });
83
84
 
85
+ it('exports DOCX story directives to header and footer parts', async () => {
86
+ const doc: MarkdownDocument = {
87
+ type: 'document',
88
+ children: [
89
+ { type: 'paragraph', children: [{ type: 'text', value: 'Body' }] },
90
+ {
91
+ type: 'containerDirective',
92
+ name: 'docx-header',
93
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Header' }] }],
94
+ } satisfies MarkdownContainerDirective,
95
+ {
96
+ type: 'containerDirective',
97
+ name: 'docx-footer',
98
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Footer' }] }],
99
+ } satisfies MarkdownContainerDirective,
100
+ ],
101
+ };
102
+
103
+ const zip = await exportAndParse(doc);
104
+ const documentXml = await zip.file('word/document.xml')!.async('text');
105
+ const headerXml = await zip.file('word/header1.xml')!.async('text');
106
+ const footerXml = await zip.file('word/footer1.xml')!.async('text');
107
+ const relationships = await zip.file('word/_rels/document.xml.rels')!.async('text');
108
+
109
+ expect(documentXml).toContain('<w:headerReference');
110
+ expect(documentXml).toContain('<w:footerReference');
111
+ expect(documentXml).not.toContain('Header');
112
+ expect(headerXml).toContain('Header');
113
+ expect(footerXml).toContain('Footer');
114
+ expect(relationships).toContain('/relationships/header');
115
+ expect(relationships).toContain('/relationships/footer');
116
+ });
117
+
84
118
  // ============================================
85
119
  // Headings
86
120
  // ============================================
@@ -14,9 +14,13 @@ import {
14
14
  REL_STYLES,
15
15
  REL_NUMBERING,
16
16
  REL_HYPERLINK,
17
+ REL_HEADER,
18
+ REL_FOOTER,
17
19
  CONTENT_TYPE_DOCX_DOCUMENT,
18
20
  CONTENT_TYPE_DOCX_STYLES,
19
21
  CONTENT_TYPE_DOCX_NUMBERING,
22
+ CONTENT_TYPE_DOCX_HEADER,
23
+ CONTENT_TYPE_DOCX_FOOTER,
20
24
  } from '../ooxml/namespaces';
21
25
  import { xmlDeclaration } from '../ooxml/xmlUtils';
22
26
  import { docxToMarkdownDoc, docxToDoc } from '../docx/import';
@@ -31,6 +35,7 @@ import type {
31
35
  MarkdownStrikethrough,
32
36
  MarkdownLink,
33
37
  MarkdownInlineNode,
38
+ MarkdownContainerDirective,
34
39
  } from '@bendyline/squisq/markdown';
35
40
 
36
41
  // ============================================
@@ -47,6 +52,7 @@ interface DocxFixtureOptions {
47
52
  target: string;
48
53
  targetMode?: 'External';
49
54
  }>;
55
+ extraParts?: Array<{ path: string; content: string; contentType: string }>;
50
56
  }
51
57
 
52
58
  async function buildTestDocx(options: DocxFixtureOptions): Promise<ArrayBuffer> {
@@ -81,6 +87,10 @@ async function buildTestDocx(options: DocxFixtureOptions): Promise<ArrayBuffer>
81
87
  pkg.addPart('word/numbering.xml', options.numberingXml, CONTENT_TYPE_DOCX_NUMBERING);
82
88
  }
83
89
 
90
+ for (const part of options.extraParts ?? []) {
91
+ pkg.addPart(part.path, part.content, part.contentType);
92
+ }
93
+
84
94
  // Root relationship
85
95
  pkg.addRelationship('', {
86
96
  id: 'rId1',
@@ -164,6 +174,25 @@ describe('docxToMarkdownDoc', () => {
164
174
  );
165
175
  });
166
176
 
177
+ it('imports visible content inside controls, revisions, and text boxes once', async () => {
178
+ const data = await buildTestDocx({
179
+ bodyXml:
180
+ `<w:p>` +
181
+ `<w:r><w:t>Body </w:t></w:r>` +
182
+ `<w:sdt><w:sdtContent><w:r><w:t>controlled </w:t></w:r></w:sdtContent></w:sdt>` +
183
+ `<w:ins><w:r><w:t>inserted </w:t></w:r></w:ins>` +
184
+ `<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">` +
185
+ `<mc:Choice><w:drawing><w:txbxContent><w:p><w:r><w:t>text box</w:t></w:r></w:p></w:txbxContent></w:drawing></mc:Choice>` +
186
+ `<mc:Fallback><w:pict><w:txbxContent><w:p><w:r><w:t>fallback duplicate</w:t></w:r></w:p></w:txbxContent></w:pict></mc:Fallback>` +
187
+ `</mc:AlternateContent>` +
188
+ `</w:p>`,
189
+ });
190
+
191
+ const paragraph = docText(await docxToMarkdownDoc(data));
192
+ expect(paragraph).toContain('Body controlled inserted text box');
193
+ expect(paragraph).not.toContain('fallback duplicate');
194
+ });
195
+
167
196
  it('skips empty paragraphs', async () => {
168
197
  const data = await buildTestDocx({
169
198
  bodyXml: `<w:p><w:pPr/></w:p><w:p><w:r><w:t>Text</w:t></w:r></w:p>`,
@@ -331,6 +360,27 @@ describe('docxToMarkdownDoc', () => {
331
360
  expect(list.ordered).toBe(true);
332
361
  });
333
362
 
363
+ it('retains nested list text after an empty parent numbering paragraph', async () => {
364
+ const numberingXml =
365
+ xmlDeclaration() +
366
+ `<w:numbering xmlns:w="${NS_WML}">` +
367
+ `<w:abstractNum w:abstractNumId="3">` +
368
+ `<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/></w:lvl>` +
369
+ `<w:lvl w:ilvl="1"><w:numFmt w:val="decimal"/></w:lvl>` +
370
+ `</w:abstractNum>` +
371
+ `<w:num w:numId="3"><w:abstractNumId w:val="3"/></w:num>` +
372
+ `</w:numbering>`;
373
+ const data = await buildTestDocx({
374
+ bodyXml:
375
+ `<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr></w:p>` +
376
+ `<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="3"/></w:numPr></w:pPr>` +
377
+ `<w:r><w:t>Nested content survives</w:t></w:r></w:p>`,
378
+ numberingXml,
379
+ });
380
+
381
+ expect(docText(await docxToMarkdownDoc(data))).toContain('Nested content survives');
382
+ });
383
+
334
384
  // ============================================
335
385
  // Tables
336
386
  // ============================================
@@ -363,6 +413,57 @@ describe('docxToMarkdownDoc', () => {
363
413
  expect((cell10.children[0] as MarkdownText).value).toBe('1');
364
414
  });
365
415
 
416
+ it('flattens nested table contents without losing their text', async () => {
417
+ const data = await buildTestDocx({
418
+ bodyXml:
419
+ `<w:tbl><w:tr><w:tc>` +
420
+ `<w:p><w:r><w:t>Outer</w:t></w:r></w:p>` +
421
+ `<w:tbl><w:tr>` +
422
+ `<w:tc><w:p><w:r><w:t>Nested A</w:t></w:r></w:p></w:tc>` +
423
+ `<w:tc><w:p><w:r><w:t>Nested B</w:t></w:r></w:p></w:tc>` +
424
+ `</w:tr></w:tbl>` +
425
+ `</w:tc></w:tr></w:tbl>`,
426
+ });
427
+
428
+ expect(docText(await docxToMarkdownDoc(data))).toContain('Outer Nested A Nested B');
429
+ });
430
+
431
+ it('imports headers and footers as explicit DOCX story directives', async () => {
432
+ const headerXml =
433
+ xmlDeclaration() +
434
+ `<w:hdr xmlns:w="${NS_WML}"><w:p><w:r><w:t>Header text</w:t></w:r></w:p></w:hdr>`;
435
+ const footerXml =
436
+ xmlDeclaration() +
437
+ `<w:ftr xmlns:w="${NS_WML}"><w:p><w:r><w:t>Footer text</w:t></w:r></w:p></w:ftr>`;
438
+ const data = await buildTestDocx({
439
+ bodyXml:
440
+ `<w:p><w:r><w:t>Body text</w:t></w:r></w:p>` +
441
+ `<w:sectPr><w:headerReference w:type="default" r:id="rIdHeader"/>` +
442
+ `<w:footerReference w:type="default" r:id="rIdFooter"/></w:sectPr>`,
443
+ documentRels: [
444
+ { id: 'rIdHeader', type: REL_HEADER, target: 'header1.xml' },
445
+ { id: 'rIdFooter', type: REL_FOOTER, target: 'footer1.xml' },
446
+ ],
447
+ extraParts: [
448
+ { path: 'word/header1.xml', content: headerXml, contentType: CONTENT_TYPE_DOCX_HEADER },
449
+ { path: 'word/footer1.xml', content: footerXml, contentType: CONTENT_TYPE_DOCX_FOOTER },
450
+ ],
451
+ });
452
+
453
+ const doc = await docxToMarkdownDoc(data);
454
+ const header = doc.children.find(
455
+ (node): node is MarkdownContainerDirective =>
456
+ node.type === 'containerDirective' && node.name === 'docx-header',
457
+ );
458
+ const footer = doc.children.find(
459
+ (node): node is MarkdownContainerDirective =>
460
+ node.type === 'containerDirective' && node.name === 'docx-footer',
461
+ );
462
+ expect(header?.attributes?.type).toBe('default');
463
+ expect(footer?.attributes?.type).toBe('default');
464
+ expect(docText(doc)).toContain('Body text Header text Footer text');
465
+ });
466
+
366
467
  // ============================================
367
468
  // Empty Document
368
469
  // ============================================
@@ -387,8 +488,28 @@ describe('docxToMarkdownDoc', () => {
387
488
  const para = doc.children[0] as MarkdownParagraph;
388
489
  expect(para.children.some((c: MarkdownInlineNode) => c.type === 'break')).toBe(true);
389
490
  });
491
+
492
+ it('retains tabs as visible word boundaries', async () => {
493
+ const data = await buildTestDocx({
494
+ bodyXml: `<w:p><w:r><w:t>Before</w:t><w:tab/><w:t>After</w:t></w:r></w:p>`,
495
+ });
496
+
497
+ expect(docText(await docxToMarkdownDoc(data))).toBe('Before After');
498
+ });
390
499
  });
391
500
 
501
+ function docText(doc: Awaited<ReturnType<typeof docxToMarkdownDoc>>): string {
502
+ const text: string[] = [];
503
+ const visit = (node: unknown): void => {
504
+ if (!node || typeof node !== 'object') return;
505
+ const value = node as { type?: string; value?: string; children?: unknown[] };
506
+ if ((value.type === 'text' || value.type === 'code') && value.value) text.push(value.value);
507
+ value.children?.forEach(visit);
508
+ };
509
+ visit(doc);
510
+ return text.join(' ').replace(/\s+/g, ' ').trim();
511
+ }
512
+
392
513
  // ============================================
393
514
  // docxToDoc convenience wrapper
394
515
  // ============================================
@@ -300,6 +300,10 @@ describe('docToHtml', () => {
300
300
  const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT, mode: 'static' });
301
301
 
302
302
  expect(html).toContain('"static"');
303
+ // The page rendition owns its own background/scrolling — the host shim
304
+ // must not hardcode a white background that fights themed pages.
305
+ expect(html).toContain('#squisq-root{display:block}');
306
+ expect(html).not.toContain('background:#fff');
303
307
  });
304
308
 
305
309
  it('inlines images as base64 data URIs', () => {
@@ -62,12 +62,16 @@ import {
62
62
  REL_HYPERLINK,
63
63
  REL_IMAGE,
64
64
  REL_FOOTNOTES,
65
+ REL_HEADER,
66
+ REL_FOOTER,
65
67
  CONTENT_TYPE_DOCX_DOCUMENT,
66
68
  CONTENT_TYPE_DOCX_STYLES,
67
69
  CONTENT_TYPE_DOCX_NUMBERING,
68
70
  CONTENT_TYPE_DOCX_SETTINGS,
69
71
  CONTENT_TYPE_DOCX_FONT_TABLE,
70
72
  CONTENT_TYPE_DOCX_FOOTNOTES,
73
+ CONTENT_TYPE_DOCX_HEADER,
74
+ CONTENT_TYPE_DOCX_FOOTER,
71
75
  } from '../ooxml/namespaces.js';
72
76
  import {
73
77
  DEPTH_TO_STYLE_ID,
@@ -135,8 +139,35 @@ export async function markdownDocToDocx(
135
139
  ? options
136
140
  : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
137
141
  const ctx = new ExportContext(resolvedOptions, doc);
138
- const bodyXml = convertBlocks(doc.children, ctx);
139
- return buildDocxPackage(bodyXml, ctx, resolvedOptions);
142
+ const stories = partitionDocumentStories(doc.children);
143
+ ctx.setRelationshipSource('document');
144
+ const bodyXml = convertBlocks(stories.body, ctx);
145
+ ctx.setRelationshipSource('header');
146
+ const headerXml = convertBlocks(stories.header, ctx);
147
+ ctx.setRelationshipSource('footer');
148
+ const footerXml = convertBlocks(stories.footer, ctx);
149
+ ctx.setRelationshipSource('document');
150
+ return buildDocxPackage(bodyXml, headerXml, footerXml, ctx, resolvedOptions);
151
+ }
152
+
153
+ interface DocumentStories {
154
+ body: MarkdownBlockNode[];
155
+ header: MarkdownBlockNode[];
156
+ footer: MarkdownBlockNode[];
157
+ }
158
+
159
+ function partitionDocumentStories(nodes: MarkdownBlockNode[]): DocumentStories {
160
+ const stories: DocumentStories = { body: [], header: [], footer: [] };
161
+ for (const node of nodes) {
162
+ if (node.type === 'containerDirective' && node.name === 'docx-header') {
163
+ stories.header.push(...node.children);
164
+ } else if (node.type === 'containerDirective' && node.name === 'docx-footer') {
165
+ stories.footer.push(...node.children);
166
+ } else {
167
+ stories.body.push(node);
168
+ }
169
+ }
170
+ return stories;
140
171
  }
141
172
 
142
173
  /**
@@ -172,8 +203,11 @@ class ExportContext {
172
203
  type: string;
173
204
  target: string;
174
205
  targetMode?: 'External';
206
+ source: DocxStoryPart;
175
207
  }> = [];
176
208
 
209
+ private relationshipSource: DocxStoryPart = 'document';
210
+
177
211
  /** Numbering definitions (abstract + num) */
178
212
  readonly numberingDefs: NumberingDef[] = [];
179
213
 
@@ -257,6 +291,10 @@ class ExportContext {
257
291
  return `rId${this.nextRelId++}`;
258
292
  }
259
293
 
294
+ setRelationshipSource(source: DocxStoryPart): void {
295
+ this.relationshipSource = source;
296
+ }
297
+
260
298
  /** Add a hyperlink relationship and return the rId */
261
299
  addHyperlink(url: string): string {
262
300
  const id = this.allocRelId();
@@ -265,6 +303,7 @@ class ExportContext {
265
303
  type: REL_HYPERLINK,
266
304
  target: url,
267
305
  targetMode: 'External',
306
+ source: this.relationshipSource,
268
307
  });
269
308
  return id;
270
309
  }
@@ -284,6 +323,7 @@ class ExportContext {
284
323
  id: relId,
285
324
  type: REL_IMAGE,
286
325
  target: `media/${filename}`,
326
+ source: this.relationshipSource,
287
327
  });
288
328
 
289
329
  return { relId, docPrId };
@@ -309,6 +349,8 @@ class ExportContext {
309
349
  }
310
350
  }
311
351
 
352
+ type DocxStoryPart = 'document' | 'header' | 'footer';
353
+
312
354
  interface NumberingDef {
313
355
  numId: number;
314
356
  ordered: boolean;
@@ -384,6 +426,10 @@ function convertBlock(node: MarkdownBlockNode, ctx: ExportContext, listDepth: nu
384
426
  return convertMathBlock(node);
385
427
  case 'footnoteDefinition':
386
428
  return convertFootnoteDefinition(node, ctx);
429
+ case 'containerDirective':
430
+ // Non-DOCX directives have no native Word wrapper. Preserve their
431
+ // contents rather than silently dropping the entire subtree.
432
+ return convertBlocks(node.children, ctx);
387
433
  default:
388
434
  // Definition lists, directives, link definitions — skip or emit as plain text
389
435
  return '';
@@ -858,6 +904,8 @@ function convertFootnoteRef(node: MarkdownFootnoteReference, ctx: ExportContext)
858
904
 
859
905
  async function buildDocxPackage(
860
906
  bodyXml: string,
907
+ headerXml: string,
908
+ footerXml: string,
861
909
  ctx: ExportContext,
862
910
  options: DocxExportOptions,
863
911
  ): Promise<ArrayBuffer> {
@@ -870,11 +918,27 @@ async function buildDocxPackage(
870
918
  const settingsRelId = `rId${relCounter++}`;
871
919
  const fontTableRelId = `rId${relCounter++}`;
872
920
  const footnotesRelId = `rId${relCounter++}`;
921
+ const headerRelId = `rId${relCounter++}`;
922
+ const footerRelId = `rId${relCounter++}`;
923
+ const hasHeader = headerXml.trim().length > 0;
924
+ const hasFooter = footerXml.trim().length > 0;
873
925
 
874
926
  // --- word/document.xml ---
875
- const documentXml = buildDocumentXml(bodyXml, ctx.backgroundColor);
927
+ const documentXml = buildDocumentXml(
928
+ bodyXml,
929
+ ctx.backgroundColor,
930
+ hasHeader ? headerRelId : undefined,
931
+ hasFooter ? footerRelId : undefined,
932
+ );
876
933
  pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
877
934
 
935
+ if (hasHeader) {
936
+ pkg.addPart('word/header1.xml', buildStoryXml('header', headerXml), CONTENT_TYPE_DOCX_HEADER);
937
+ }
938
+ if (hasFooter) {
939
+ pkg.addPart('word/footer1.xml', buildStoryXml('footer', footerXml), CONTENT_TYPE_DOCX_FOOTER);
940
+ }
941
+
878
942
  // --- word/styles.xml ---
879
943
  const stylesXml = buildStylesXml(options, ctx);
880
944
  pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
@@ -942,9 +1006,31 @@ async function buildDocxPackage(
942
1006
  });
943
1007
  }
944
1008
 
1009
+ if (hasHeader) {
1010
+ pkg.addRelationship('word/document.xml', {
1011
+ id: headerRelId,
1012
+ type: REL_HEADER,
1013
+ target: 'header1.xml',
1014
+ });
1015
+ }
1016
+
1017
+ if (hasFooter) {
1018
+ pkg.addRelationship('word/document.xml', {
1019
+ id: footerRelId,
1020
+ type: REL_FOOTER,
1021
+ target: 'footer1.xml',
1022
+ });
1023
+ }
1024
+
945
1025
  // --- Dynamic relationships (hyperlinks, images) ---
946
1026
  for (const rel of ctx.relationships) {
947
- pkg.addRelationship('word/document.xml', {
1027
+ const sourcePart =
1028
+ rel.source === 'header'
1029
+ ? 'word/header1.xml'
1030
+ : rel.source === 'footer'
1031
+ ? 'word/footer1.xml'
1032
+ : 'word/document.xml';
1033
+ pkg.addRelationship(sourcePart, {
948
1034
  id: rel.id,
949
1035
  type: rel.type,
950
1036
  target: rel.target,
@@ -975,11 +1061,19 @@ async function buildDocxPackage(
975
1061
  // XML Part Generators
976
1062
  // ============================================
977
1063
 
978
- function buildDocumentXml(bodyXml: string, backgroundColor?: string): string {
1064
+ function buildDocumentXml(
1065
+ bodyXml: string,
1066
+ backgroundColor?: string,
1067
+ headerRelId?: string,
1068
+ footerRelId?: string,
1069
+ ): string {
979
1070
  // `<w:background>` must be the FIRST child of `<w:document>` per the
980
1071
  // WordprocessingML schema. Word also requires `<w:displayBackgroundShape/>`
981
1072
  // in settings.xml before the bg actually paints (see buildSettingsXml).
982
1073
  const backgroundEl = backgroundColor ? `<w:background w:color="${backgroundColor}"/>` : '';
1074
+ const storyReferences =
1075
+ (headerRelId ? `<w:headerReference w:type="default" r:id="${headerRelId}"/>` : '') +
1076
+ (footerRelId ? `<w:footerReference w:type="default" r:id="${footerRelId}"/>` : '');
983
1077
  return (
984
1078
  xmlDeclaration() +
985
1079
  `<w:document` +
@@ -997,6 +1091,7 @@ function buildDocumentXml(bodyXml: string, backgroundColor?: string): string {
997
1091
  `<w:body>` +
998
1092
  bodyXml +
999
1093
  `<w:sectPr>` +
1094
+ storyReferences +
1000
1095
  `<w:pgSz w:w="12240" w:h="15840"/>` +
1001
1096
  `<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>` +
1002
1097
  `<w:cols w:space="720"/>` +
@@ -1006,6 +1101,19 @@ function buildDocumentXml(bodyXml: string, backgroundColor?: string): string {
1006
1101
  );
1007
1102
  }
1008
1103
 
1104
+ function buildStoryXml(kind: 'header' | 'footer', bodyXml: string): string {
1105
+ const element = kind === 'header' ? 'hdr' : 'ftr';
1106
+ return (
1107
+ xmlDeclaration() +
1108
+ `<w:${element}` +
1109
+ ` xmlns:w="${NS_WML}"` +
1110
+ ` xmlns:r="${NS_R}"` +
1111
+ ` xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">` +
1112
+ bodyXml +
1113
+ `</w:${element}>`
1114
+ );
1115
+ }
1116
+
1009
1117
  function buildStylesXml(options: DocxExportOptions, ctx: ExportContext): string {
1010
1118
  const font = ctx.font;
1011
1119
  const headingFont = ctx.headingFont;