@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
@@ -45,12 +45,14 @@ import type {
45
45
  MarkdownBreak,
46
46
  MarkdownFootnoteReference,
47
47
  MarkdownFootnoteDefinition,
48
+ MarkdownContainerDirective,
48
49
  } from '@bendyline/squisq/markdown';
49
50
 
50
51
  import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
51
52
  import type { OoxmlOpenOptions } from '../ooxml/reader.js';
52
53
  import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
53
54
  import { NS_WML, NS_R } from '../ooxml/namespaces.js';
55
+ import { baseDirOf, resolveTarget } from '../ooxml/readUtils.js';
54
56
  import type { ContentContainer } from '@bendyline/squisq/storage';
55
57
  import { buildContainer } from '../shared/container.js';
56
58
  import { extToMime } from '../shared/images.js';
@@ -102,7 +104,7 @@ export async function docxToMarkdownDoc(
102
104
  return { type: 'document', children: [] };
103
105
  }
104
106
 
105
- const blocks = await convertBody(body, ctx);
107
+ const blocks = await convertDocumentStories(body, ctx);
106
108
 
107
109
  return { type: 'document', children: blocks };
108
110
  }
@@ -148,7 +150,7 @@ export async function docxToContainer(
148
150
  const body = getFirstElement(documentXml, 'body');
149
151
  if (!body) return buildContainer('', []);
150
152
 
151
- const blocks = await convertBody(body, ctx);
153
+ const blocks = await convertDocumentStories(body, ctx);
152
154
  const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
153
155
 
154
156
  return buildContainer(stringifyMarkdown(markdownDoc), ctx.extractedImages);
@@ -173,6 +175,10 @@ interface ImportContext {
173
175
  numbering: Map<string, NumberingInfo>;
174
176
  /** Footnote bodies: footnoteId → Element */
175
177
  footnotes: Map<string, Element>;
178
+ /** Endnote bodies: endnoteId → Element */
179
+ endnotes: Map<string, Element>;
180
+ /** Part whose relationships are active while converting runs. */
181
+ currentPartPath: string;
176
182
  /** Reference to the OOXML package (for extracting images) */
177
183
  pkg: OoxmlPackage;
178
184
  /** Import options */
@@ -199,6 +205,8 @@ async function buildImportContext(
199
205
  documentRels: new Map(),
200
206
  numbering: new Map(),
201
207
  footnotes: new Map(),
208
+ endnotes: new Map(),
209
+ currentPartPath: 'word/document.xml',
202
210
  pkg,
203
211
  options,
204
212
  extractedImages: new Map(),
@@ -234,6 +242,9 @@ async function buildImportContext(
234
242
  // Parse footnotes.xml
235
243
  await parseFootnotes(pkg, ctx);
236
244
 
245
+ // Parse endnotes.xml
246
+ await parseEndnotes(pkg, ctx);
247
+
237
248
  return ctx;
238
249
  }
239
250
 
@@ -351,13 +362,97 @@ async function parseFootnotes(pkg: OoxmlPackage, ctx: ImportContext): Promise<vo
351
362
  }
352
363
  }
353
364
 
365
+ async function parseEndnotes(pkg: OoxmlPackage, ctx: ImportContext): Promise<void> {
366
+ const doc = await getPartXml(pkg, 'word/endnotes.xml');
367
+ if (!doc) return;
368
+
369
+ const endnoteEls = getAllElements(doc, 'endnote');
370
+ for (const note of endnoteEls) {
371
+ const id = getAttr(note, 'id');
372
+ const type = getAttr(note, 'type');
373
+ if (!id || type === 'separator' || type === 'continuationSeparator') continue;
374
+ ctx.endnotes.set(id, note);
375
+ }
376
+ }
377
+
354
378
  // ============================================
355
379
  // Body Conversion
356
380
  // ============================================
357
381
 
382
+ /**
383
+ * Convert the main story plus the related header/footer stories. Header and
384
+ * footer blocks live in named directives so Markdown consumers can distinguish
385
+ * them from body content and the DOCX exporter can put them back in the right
386
+ * OOXML parts.
387
+ */
388
+ async function convertDocumentStories(
389
+ body: Element,
390
+ ctx: ImportContext,
391
+ ): Promise<MarkdownBlockNode[]> {
392
+ const bodyBlocks = await convertBody(body, ctx);
393
+ const headers = await convertRelatedStories(body, ctx, 'header');
394
+ const footers = await convertRelatedStories(body, ctx, 'footer');
395
+ const noteDefinitions = await convertNoteDefinitions(ctx);
396
+ return [...bodyBlocks, ...headers, ...footers, ...noteDefinitions];
397
+ }
398
+
399
+ async function convertRelatedStories(
400
+ body: Element,
401
+ ctx: ImportContext,
402
+ kind: 'header' | 'footer',
403
+ ): Promise<MarkdownContainerDirective[]> {
404
+ const relationshipTypeSuffix = `/${kind}`;
405
+ const referenceName = `${kind}Reference`;
406
+ const referenceTypes = new Map<string, string>();
407
+ for (const reference of getAllElements(body, referenceName)) {
408
+ const id = reference.getAttributeNS(NS_R, 'id') ?? reference.getAttribute('r:id');
409
+ if (id) referenceTypes.set(id, getAttr(reference, 'type') ?? 'default');
410
+ }
411
+
412
+ const documentRelationships = ctx.documentRels;
413
+ const previousPartPath = ctx.currentPartPath;
414
+ const results: MarkdownContainerDirective[] = [];
415
+
416
+ for (const relationship of documentRelationships.values()) {
417
+ if (!relationship.type.endsWith(relationshipTypeSuffix)) continue;
418
+
419
+ const partPath = resolveTarget(baseDirOf('word/document.xml'), relationship.target);
420
+ const part = await getPartXml(ctx.pkg, partPath);
421
+ if (!part?.documentElement) continue;
422
+
423
+ const partRelationships = await getPartRelationships(ctx.pkg, partPath);
424
+ ctx.documentRels = new Map(partRelationships.map((rel) => [rel.id, rel]));
425
+ ctx.currentPartPath = partPath;
426
+ try {
427
+ const children = await convertBody(part.documentElement, ctx);
428
+ if (children.length === 0) continue;
429
+ results.push({
430
+ type: 'containerDirective',
431
+ name: `docx-${kind}`,
432
+ attributes: {
433
+ type: referenceTypes.get(relationship.id) ?? 'default',
434
+ source: partPath,
435
+ },
436
+ children,
437
+ });
438
+ } finally {
439
+ ctx.documentRels = documentRelationships;
440
+ ctx.currentPartPath = previousPartPath;
441
+ }
442
+ }
443
+
444
+ return results;
445
+ }
446
+
358
447
  async function convertBody(body: Element, ctx: ImportContext): Promise<MarkdownBlockNode[]> {
448
+ return convertBlockElements(Array.from(body.children), ctx);
449
+ }
450
+
451
+ async function convertBlockElements(
452
+ children: Element[],
453
+ ctx: ImportContext,
454
+ ): Promise<MarkdownBlockNode[]> {
359
455
  const result: MarkdownBlockNode[] = [];
360
- const children = Array.from(body.children);
361
456
 
362
457
  let i = 0;
363
458
  while (i < children.length) {
@@ -384,16 +479,30 @@ async function convertBody(body: Element, ctx: ImportContext): Promise<MarkdownB
384
479
  const table = await convertTable(el, ctx);
385
480
  if (table) result.push(table);
386
481
  i++;
482
+ } else if (localName === 'sdt') {
483
+ const content = getFirstChildElement(el, 'sdtContent');
484
+ if (content) result.push(...(await convertBlockElements(Array.from(content.children), ctx)));
485
+ i++;
486
+ } else if (
487
+ localName === 'ins' ||
488
+ localName === 'moveTo' ||
489
+ localName === 'customXml' ||
490
+ localName === 'smartTag' ||
491
+ localName === 'fldSimple'
492
+ ) {
493
+ result.push(...(await convertBlockElements(Array.from(el.children), ctx)));
494
+ i++;
495
+ } else if (localName === 'AlternateContent') {
496
+ const selected = getFirstChildElement(el, 'Choice') ?? getFirstChildElement(el, 'Fallback');
497
+ if (selected)
498
+ result.push(...(await convertBlockElements(Array.from(selected.children), ctx)));
499
+ i++;
387
500
  } else {
388
501
  // Skip unknown elements (sectPr, bookmarkStart, etc.)
389
502
  i++;
390
503
  }
391
504
  }
392
505
 
393
- // Append footnote definitions at the end
394
- const footnoteNodes = await convertFootnoteDefinitions(ctx);
395
- result.push(...footnoteNodes);
396
-
397
506
  return result;
398
507
  }
399
508
 
@@ -447,9 +556,15 @@ async function convertParagraph(
447
556
  async function convertRuns(
448
557
  paragraphEl: Element,
449
558
  ctx: ImportContext,
559
+ ): Promise<MarkdownInlineNode[]> {
560
+ return mergeAdjacentText(await convertInlineElements(Array.from(paragraphEl.children), ctx));
561
+ }
562
+
563
+ async function convertInlineElements(
564
+ children: Element[],
565
+ ctx: ImportContext,
450
566
  ): Promise<MarkdownInlineNode[]> {
451
567
  const result: MarkdownInlineNode[] = [];
452
- const children = Array.from(paragraphEl.children);
453
568
 
454
569
  for (const child of children) {
455
570
  const localName = child.localName;
@@ -460,11 +575,34 @@ async function convertRuns(
460
575
  } else if (localName === 'hyperlink') {
461
576
  const link = await convertHyperlink(child, ctx);
462
577
  if (link) result.push(link);
578
+ } else if (localName === 'sdt') {
579
+ const content = getFirstChildElement(child, 'sdtContent');
580
+ if (content) {
581
+ result.push(...(await convertInlineElements(Array.from(content.children), ctx)));
582
+ }
583
+ } else if (
584
+ localName === 'ins' ||
585
+ localName === 'moveTo' ||
586
+ localName === 'customXml' ||
587
+ localName === 'smartTag' ||
588
+ localName === 'fldSimple'
589
+ ) {
590
+ result.push(...(await convertInlineElements(Array.from(child.children), ctx)));
591
+ } else if (localName === 'AlternateContent') {
592
+ // Choice and Fallback represent the same content for different Word
593
+ // versions. Reading both duplicates every text box and legacy image.
594
+ const selected =
595
+ getFirstChildElement(child, 'Choice') ?? getFirstChildElement(child, 'Fallback');
596
+ if (selected) {
597
+ result.push(...(await convertInlineElements(Array.from(selected.children), ctx)));
598
+ }
599
+ } else if (localName === 'drawing' || localName === 'pict') {
600
+ result.push(...(await convertDrawingContent(child, ctx)));
463
601
  }
464
602
  // Skip pPr, bookmarkStart, bookmarkEnd, etc.
465
603
  }
466
604
 
467
- return mergeAdjacentText(result);
605
+ return result;
468
606
  }
469
607
 
470
608
  async function convertRun(runEl: Element, ctx: ImportContext): Promise<MarkdownInlineNode[]> {
@@ -494,8 +632,13 @@ async function convertRun(runEl: Element, ctx: ImportContext): Promise<MarkdownI
494
632
  }
495
633
  result.push(node);
496
634
  }
497
- } else if (localName === 'br') {
635
+ } else if (localName === 'br' || localName === 'cr') {
498
636
  result.push({ type: 'break' } satisfies MarkdownBreak);
637
+ } else if (localName === 'tab') {
638
+ // Tabs are visible separators in Word. A literal tab is unstable when
639
+ // serialized through Markdown (it may become indentation), so retain the
640
+ // word boundary as a regular space.
641
+ result.push({ type: 'text', value: ' ' } satisfies MarkdownText);
499
642
  } else if (localName === 'footnoteReference') {
500
643
  const fnId = getAttr(child, 'id');
501
644
  if (fnId && fnId !== '0' && fnId !== '-1') {
@@ -504,15 +647,113 @@ async function convertRun(runEl: Element, ctx: ImportContext): Promise<MarkdownI
504
647
  identifier: `fn${fnId}`,
505
648
  } satisfies MarkdownFootnoteReference);
506
649
  }
507
- } else if (localName === 'drawing' || localName === 'pict') {
508
- const img = await extractImage(child, ctx);
509
- if (img) result.push(img);
650
+ } else if (localName === 'endnoteReference') {
651
+ const noteId = getAttr(child, 'id');
652
+ if (noteId && noteId !== '0' && noteId !== '-1') {
653
+ result.push({
654
+ type: 'footnoteReference',
655
+ identifier: `endnote${noteId}`,
656
+ } satisfies MarkdownFootnoteReference);
657
+ }
658
+ } else if (localName === 'drawing' || localName === 'pict' || localName === 'object') {
659
+ result.push(...(await convertDrawingContent(child, ctx)));
660
+ } else if (localName === 'AlternateContent') {
661
+ const selected =
662
+ getFirstChildElement(child, 'Choice') ?? getFirstChildElement(child, 'Fallback');
663
+ if (selected) {
664
+ result.push(...(await convertInlineElements(Array.from(selected.children), ctx)));
665
+ }
510
666
  }
511
667
  }
512
668
 
513
669
  return result;
514
670
  }
515
671
 
672
+ async function convertDrawingContent(
673
+ el: Element,
674
+ ctx: ImportContext,
675
+ ): Promise<MarkdownInlineNode[]> {
676
+ const result: MarkdownInlineNode[] = [];
677
+
678
+ // A positioned Word shape may be a text box, an image, or both. Text box
679
+ // paragraphs are nested inside the drawing rather than being paragraph
680
+ // siblings, so the normal body walker never sees them.
681
+ const textBoxes = findDescendants(el, 'txbxContent');
682
+ for (const textBox of textBoxes) {
683
+ const inlines = await flattenContainerToInlines(textBox, ctx);
684
+ appendInlineGroup(result, inlines);
685
+ }
686
+
687
+ const image = await extractImage(el, ctx);
688
+ if (image) result.push(image);
689
+ if (textBoxes.length > 0 && result.length > 0) {
690
+ // Positioned text boxes are independent visual regions. Several can be
691
+ // anchored in the same otherwise-empty paragraph (for example, labels on
692
+ // a number line); hard boundaries prevent their text from collapsing into
693
+ // one synthetic word during Markdown serialization.
694
+ if (result[0]?.type !== 'break') result.unshift({ type: 'break' } satisfies MarkdownBreak);
695
+ if (result[result.length - 1]?.type !== 'break') {
696
+ result.push({ type: 'break' } satisfies MarkdownBreak);
697
+ }
698
+ }
699
+ return result;
700
+ }
701
+
702
+ async function flattenContainerToInlines(
703
+ container: Element,
704
+ ctx: ImportContext,
705
+ ): Promise<MarkdownInlineNode[]> {
706
+ const result: MarkdownInlineNode[] = [];
707
+
708
+ for (const child of Array.from(container.children)) {
709
+ if (child.localName === 'p') {
710
+ appendInlineGroup(result, await convertRuns(child, ctx));
711
+ } else if (child.localName === 'tbl') {
712
+ appendInlineGroup(result, await flattenTableToInlines(child, ctx));
713
+ } else if (child.localName === 'sdt') {
714
+ const content = getFirstChildElement(child, 'sdtContent');
715
+ if (content) appendInlineGroup(result, await flattenContainerToInlines(content, ctx));
716
+ } else if (
717
+ child.localName === 'ins' ||
718
+ child.localName === 'moveTo' ||
719
+ child.localName === 'customXml' ||
720
+ child.localName === 'smartTag' ||
721
+ child.localName === 'fldSimple'
722
+ ) {
723
+ appendInlineGroup(result, await flattenContainerToInlines(child, ctx));
724
+ } else if (child.localName === 'AlternateContent') {
725
+ const selected =
726
+ getFirstChildElement(child, 'Choice') ?? getFirstChildElement(child, 'Fallback');
727
+ if (selected) appendInlineGroup(result, await flattenContainerToInlines(selected, ctx));
728
+ } else if (child.localName === 'r' || child.localName === 'hyperlink') {
729
+ appendInlineGroup(result, await convertInlineElements([child], ctx));
730
+ }
731
+ }
732
+
733
+ return mergeAdjacentText(result);
734
+ }
735
+
736
+ async function flattenTableToInlines(
737
+ table: Element,
738
+ ctx: ImportContext,
739
+ ): Promise<MarkdownInlineNode[]> {
740
+ const result: MarkdownInlineNode[] = [];
741
+ for (const row of getAllChildElements(table, 'tr')) {
742
+ for (const cell of getAllChildElements(row, 'tc')) {
743
+ appendInlineGroup(result, await flattenContainerToInlines(cell, ctx));
744
+ }
745
+ }
746
+ return mergeAdjacentText(result);
747
+ }
748
+
749
+ function appendInlineGroup(target: MarkdownInlineNode[], group: MarkdownInlineNode[]): void {
750
+ if (group.length === 0) return;
751
+ if (target.length > 0 && target[target.length - 1]?.type !== 'break') {
752
+ target.push({ type: 'break' } satisfies MarkdownBreak);
753
+ }
754
+ target.push(...group);
755
+ }
756
+
516
757
  interface RunFormat {
517
758
  bold: boolean;
518
759
  italic: boolean;
@@ -565,12 +806,7 @@ async function convertHyperlink(el: Element, ctx: ImportContext): Promise<Markdo
565
806
  if (anchor) url = `#${anchor}`;
566
807
  }
567
808
 
568
- const inlines: MarkdownInlineNode[] = [];
569
- for (const child of Array.from(el.children)) {
570
- if (child.localName === 'r') {
571
- inlines.push(...(await convertRun(child, ctx)));
572
- }
573
- }
809
+ const inlines = await convertInlineElements(Array.from(el.children), ctx);
574
810
 
575
811
  if (inlines.length === 0) return null;
576
812
 
@@ -586,30 +822,26 @@ async function convertHyperlink(el: Element, ctx: ImportContext): Promise<Markdo
586
822
  // ============================================
587
823
 
588
824
  async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownImage | null> {
589
- // Find <a:blip r:embed="rIdX"/> anywhere in the drawing tree
825
+ // DrawingML uses <a:blip r:embed="...">; older Word/VML documents use
826
+ // <v:imagedata r:id="...">. Supporting both also recovers many scanned
827
+ // forms and older templates in the corpus.
590
828
  const blip = findDescendant(el, 'blip');
591
- if (!blip) {
592
- return { type: 'image', url: '', alt: 'Image' };
593
- }
594
-
595
- const rId = blip.getAttributeNS(NS_R, 'embed') ?? blip.getAttribute('r:embed');
596
- if (!rId) {
597
- return { type: 'image', url: '', alt: 'Image' };
598
- }
829
+ const imageData = findDescendant(el, 'imagedata');
830
+ const rId = blip
831
+ ? (blip.getAttributeNS(NS_R, 'embed') ?? blip.getAttribute('r:embed'))
832
+ : (imageData?.getAttributeNS(NS_R, 'id') ??
833
+ imageData?.getAttribute('r:id') ??
834
+ imageData?.getAttribute('o:relid'));
835
+ if (!rId) return null;
599
836
 
600
837
  const rel = ctx.documentRels.get(rId);
601
- if (!rel) {
602
- return { type: 'image', url: '', alt: 'Image' };
603
- }
838
+ if (!rel || rel.targetMode === 'External') return null;
604
839
 
605
- // Resolve the target path relative to word/
606
- const target = rel.target.startsWith('/') ? rel.target.slice(1) : `word/${rel.target}`;
840
+ const target = resolveTarget(baseDirOf(ctx.currentPartPath), rel.target);
607
841
 
608
842
  // Extract binary data from the zip
609
843
  const data = await getPartBinary(ctx.pkg, target);
610
- if (!data) {
611
- return { type: 'image', url: '', alt: 'Image' };
612
- }
844
+ if (!data) return null;
613
845
 
614
846
  // Determine extension and MIME type
615
847
  const dot = target.lastIndexOf('.');
@@ -625,7 +857,13 @@ async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownIm
625
857
 
626
858
  // Try to extract alt text from the drawing's docPr element
627
859
  const docPr = findDescendant(el, 'docPr');
628
- const alt = docPr?.getAttribute('descr') || docPr?.getAttribute('title') || 'Image';
860
+ const shape = findDescendant(el, 'shape');
861
+ const alt =
862
+ docPr?.getAttribute('descr') ||
863
+ docPr?.getAttribute('title') ||
864
+ shape?.getAttribute('alt') ||
865
+ shape?.getAttribute('title') ||
866
+ 'Image';
629
867
 
630
868
  return {
631
869
  type: 'image',
@@ -644,6 +882,16 @@ function findDescendant(el: Element, localName: string): Element | null {
644
882
  return null;
645
883
  }
646
884
 
885
+ /** Recursively find every descendant element with the given local name. */
886
+ function findDescendants(el: Element, localName: string): Element[] {
887
+ const results: Element[] = [];
888
+ for (const child of Array.from(el.children)) {
889
+ if (child.localName === localName) results.push(child);
890
+ results.push(...findDescendants(child, localName));
891
+ }
892
+ return results;
893
+ }
894
+
647
895
  // ============================================
648
896
  // List Collection
649
897
  // ============================================
@@ -700,6 +948,12 @@ async function collectList(
700
948
  children: nested.items,
701
949
  };
702
950
  lastItem.children.push(nestedList);
951
+ } else {
952
+ // Some Word producers emit an empty parent-level numbering
953
+ // paragraph before the first real (more deeply indented) item. With
954
+ // no parent item to attach to, retain those items at this level
955
+ // instead of silently discarding the entire nested subtree.
956
+ items.push(...nested.items);
703
957
  }
704
958
  i += nested.consumed;
705
959
  consumed += nested.consumed;
@@ -827,17 +1081,10 @@ async function convertTableCell(
827
1081
  ctx: ImportContext,
828
1082
  isHeader: boolean,
829
1083
  ): Promise<MarkdownTableCell> {
830
- const inlines: MarkdownInlineNode[] = [];
831
-
832
- // A cell can contain multiple paragraphs; flatten them with breaks
833
- const paragraphs = getAllChildElements(tcEl, 'p');
834
- for (let pi = 0; pi < paragraphs.length; pi++) {
835
- if (pi > 0) {
836
- inlines.push({ type: 'break' } as MarkdownBreak);
837
- }
838
- const runs = await convertRuns(paragraphs[pi], ctx);
839
- inlines.push(...runs);
840
- }
1084
+ // Cells may contain paragraphs, content controls, and recursively nested
1085
+ // tables (a common layout technique in Word forms). Markdown tables cannot
1086
+ // nest, so preserve all cell text in reading order separated by hard breaks.
1087
+ const inlines = await flattenContainerToInlines(tcEl, ctx);
841
1088
 
842
1089
  return {
843
1090
  type: 'tableCell',
@@ -850,32 +1097,40 @@ async function convertTableCell(
850
1097
  // Footnote Definition Conversion
851
1098
  // ============================================
852
1099
 
853
- async function convertFootnoteDefinitions(
1100
+ async function convertNoteDefinitions(ctx: ImportContext): Promise<MarkdownFootnoteDefinition[]> {
1101
+ return [
1102
+ ...(await convertNotePartDefinitions(ctx, ctx.footnotes, 'fn', 'word/footnotes.xml')),
1103
+ ...(await convertNotePartDefinitions(ctx, ctx.endnotes, 'endnote', 'word/endnotes.xml')),
1104
+ ];
1105
+ }
1106
+
1107
+ async function convertNotePartDefinitions(
854
1108
  ctx: ImportContext,
1109
+ notes: Map<string, Element>,
1110
+ identifierPrefix: string,
1111
+ partPath: string,
855
1112
  ): Promise<MarkdownFootnoteDefinition[]> {
856
1113
  const results: MarkdownFootnoteDefinition[] = [];
857
-
858
- for (const [id, el] of ctx.footnotes) {
859
- const children: MarkdownBlockNode[] = [];
860
- const paragraphs = getAllChildElements(el, 'p');
861
-
862
- for (const p of paragraphs) {
863
- const inlines = await convertRuns(p, ctx);
864
- if (inlines.length > 0) {
865
- children.push({
866
- type: 'paragraph',
867
- children: inlines,
868
- } satisfies MarkdownParagraph);
1114
+ const previousRelationships = ctx.documentRels;
1115
+ const previousPartPath = ctx.currentPartPath;
1116
+ const partRelationships = await getPartRelationships(ctx.pkg, partPath);
1117
+ ctx.documentRels = new Map(partRelationships.map((rel) => [rel.id, rel]));
1118
+ ctx.currentPartPath = partPath;
1119
+
1120
+ try {
1121
+ for (const [id, el] of notes) {
1122
+ const children = await convertBody(el, ctx);
1123
+ if (children.length > 0) {
1124
+ results.push({
1125
+ type: 'footnoteDefinition',
1126
+ identifier: `${identifierPrefix}${id}`,
1127
+ children,
1128
+ });
869
1129
  }
870
1130
  }
871
-
872
- if (children.length > 0) {
873
- results.push({
874
- type: 'footnoteDefinition',
875
- identifier: `fn${id}`,
876
- children,
877
- });
878
- }
1131
+ } finally {
1132
+ ctx.documentRels = previousRelationships;
1133
+ ctx.currentPartPath = previousPartPath;
879
1134
  }
880
1135
 
881
1136
  return results;
@@ -260,7 +260,7 @@ export function generateInlineHtml(doc: Doc, options: HtmlExportOptions): string
260
260
  *,*::before,*::after{box-sizing:border-box}
261
261
  html,body{margin:0;padding:0;height:100%;background:#1a1a2e;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif}
262
262
  #squisq-root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
263
- ${mode === 'static' ? '#squisq-root{align-items:flex-start;overflow-y:auto;background:#fff;color:#1f2937}' : ''}
263
+ ${mode === 'static' ? '#squisq-root{display:block}' : ''}
264
264
  </style>
265
265
  </head>
266
266
  <body>
@@ -328,7 +328,7 @@ export function generateExternalHtml(
328
328
  *,*::before,*::after{box-sizing:border-box}
329
329
  html,body{margin:0;padding:0;height:100%;background:#1a1a2e;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif}
330
330
  #squisq-root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
331
- ${mode === 'static' ? '#squisq-root{align-items:flex-start;overflow-y:auto;background:#fff;color:#1f2937}' : ''}
331
+ ${mode === 'static' ? '#squisq-root{display:block}' : ''}
332
332
  </style>
333
333
  </head>
334
334
  <body>
@@ -59,6 +59,18 @@ export const REL_IMAGE =
59
59
  export const REL_FOOTNOTES =
60
60
  'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes';
61
61
 
62
+ /** Relationship type: Endnotes */
63
+ export const REL_ENDNOTES =
64
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes';
65
+
66
+ /** Relationship type: Header */
67
+ export const REL_HEADER =
68
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header';
69
+
70
+ /** Relationship type: Footer */
71
+ export const REL_FOOTER =
72
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer';
73
+
62
74
  /** Relationship type: Theme */
63
75
  export const REL_THEME =
64
76
  'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme';
@@ -156,6 +168,15 @@ export const CONTENT_TYPE_DOCX_FONT_TABLE =
156
168
  export const CONTENT_TYPE_DOCX_FOOTNOTES =
157
169
  'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml';
158
170
 
171
+ export const CONTENT_TYPE_DOCX_ENDNOTES =
172
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml';
173
+
174
+ export const CONTENT_TYPE_DOCX_HEADER =
175
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';
176
+
177
+ export const CONTENT_TYPE_DOCX_FOOTER =
178
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml';
179
+
159
180
  export const CONTENT_TYPE_PPTX_PRESENTATION =
160
181
  'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml';
161
182