@beyondwork/docx-react-component 1.0.102 → 1.0.103

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/core/commands/formatting-commands.ts +8 -7
  3. package/src/core/commands/paragraph-layout-commands.ts +11 -10
  4. package/src/core/commands/section-layout-commands.ts +7 -6
  5. package/src/core/commands/style-commands.ts +3 -2
  6. package/src/io/normalize/normalize-text.ts +6 -5
  7. package/src/io/ooxml/parse-anchor.ts +15 -15
  8. package/src/io/ooxml/parse-drawing.ts +5 -5
  9. package/src/io/ooxml/parse-fields.ts +16 -15
  10. package/src/io/ooxml/parse-font-table.ts +2 -1
  11. package/src/io/ooxml/parse-footnotes.ts +3 -2
  12. package/src/io/ooxml/parse-headers-footers.ts +7 -6
  13. package/src/io/ooxml/parse-main-document.ts +41 -40
  14. package/src/io/ooxml/parse-numbering.ts +3 -2
  15. package/src/io/ooxml/parse-object.ts +6 -6
  16. package/src/io/ooxml/parse-paragraph-formatting.ts +12 -11
  17. package/src/io/ooxml/parse-picture.ts +16 -16
  18. package/src/io/ooxml/parse-run-formatting.ts +11 -10
  19. package/src/io/ooxml/parse-settings.ts +2 -1
  20. package/src/io/ooxml/parse-shapes.ts +18 -17
  21. package/src/io/ooxml/parse-styles.ts +16 -16
  22. package/src/io/ooxml/parse-theme.ts +5 -4
  23. package/src/model/canonical-document.ts +835 -833
  24. package/src/runtime/formatting/document-lookup.ts +3 -2
  25. package/src/runtime/formatting/formatting-context.ts +66 -25
  26. package/src/runtime/formatting/index.ts +18 -0
  27. package/src/runtime/formatting/layout-inputs.ts +256 -0
  28. package/src/runtime/formatting/numbering/geometry.ts +13 -12
  29. package/src/runtime/formatting/style-cascade.ts +2 -1
  30. package/src/runtime/formatting/table-style-resolver.ts +8 -7
  31. package/src/runtime/surface-projection.ts +31 -36
  32. package/src/session/import/normalize.ts +2 -1
  33. package/src/session/import/source-package-evidence.ts +612 -1
@@ -7,7 +7,7 @@ import {
7
7
  type OpcPackagePart,
8
8
  type OpcRelationship,
9
9
  } from "../../io/ooxml/part-manifest.ts";
10
- import { parseXml } from "../../io/ooxml/xml-parser.ts";
10
+ import { parseXml, parseXmlWithOffsets } from "../../io/ooxml/xml-parser.ts";
11
11
  import type { XmlElementNode } from "../../io/ooxml/xml-element.ts";
12
12
  import { localName } from "../../io/ooxml/xml-attr-helpers.ts";
13
13
  import {
@@ -50,6 +50,95 @@ export interface SourcePackageRelationshipEvidence {
50
50
  targetExists: boolean;
51
51
  }
52
52
 
53
+ export interface SourcePackageLocationEvidence {
54
+ sourceId: string;
55
+ partPath: string;
56
+ storyKind: SourcePackageStoryKind;
57
+ element: string;
58
+ ordinal: number;
59
+ startOffset?: number;
60
+ endOffset?: number;
61
+ }
62
+
63
+ export interface SourcePackageStoryPartDescriptor {
64
+ sourceId: string;
65
+ partPath: string;
66
+ storyKind: SourcePackageStoryKind;
67
+ relationshipId?: string;
68
+ relationshipType?: string;
69
+ relationshipSourcePartPath?: string | null;
70
+ }
71
+
72
+ export interface SourcePackageHeaderFooterReferenceEvidence {
73
+ kind: "header" | "footer";
74
+ relationshipId: string;
75
+ type?: string;
76
+ resolvedTarget?: string;
77
+ targetExists?: boolean;
78
+ }
79
+
80
+ export interface SourcePackageSectionDescriptorEvidence
81
+ extends SourcePackageLocationEvidence {
82
+ headerFooterReferences: SourcePackageHeaderFooterReferenceEvidence[];
83
+ }
84
+
85
+ export interface SourcePackageObjectExtentEvidence
86
+ extends SourcePackageLocationEvidence {
87
+ kind: "drawing" | "vml-shape" | "object";
88
+ display: "inline" | "floating" | "unknown";
89
+ preserveOnly: true;
90
+ fallbackHint:
91
+ | "drawing-inline"
92
+ | "drawing-floating"
93
+ | "vml-shape"
94
+ | "ole-object"
95
+ | "opaque-object";
96
+ extentEmu?: { width: number; height: number };
97
+ extentTwips?: { width: number; height: number };
98
+ relationshipIds: string[];
99
+ shapeId?: string;
100
+ shapeType?: string;
101
+ vmlStyle?: string;
102
+ }
103
+
104
+ export interface SourcePackageRelationshipGroupEvidence {
105
+ headerFooters: SourcePackageRelationshipEvidence[];
106
+ media: SourcePackageRelationshipEvidence[];
107
+ charts: SourcePackageRelationshipEvidence[];
108
+ embedded: SourcePackageRelationshipEvidence[];
109
+ customXml: SourcePackageRelationshipEvidence[];
110
+ settings: SourcePackageRelationshipEvidence[];
111
+ numbering: SourcePackageRelationshipEvidence[];
112
+ comments: SourcePackageRelationshipEvidence[];
113
+ }
114
+
115
+ export interface SourcePackagePartGroupEvidence {
116
+ media: SourcePackageStoryPartDescriptor[];
117
+ images: SourcePackageStoryPartDescriptor[];
118
+ charts: SourcePackageStoryPartDescriptor[];
119
+ embedded: SourcePackageStoryPartDescriptor[];
120
+ customXml: SourcePackageStoryPartDescriptor[];
121
+ settings: SourcePackageStoryPartDescriptor[];
122
+ numbering: SourcePackageStoryPartDescriptor[];
123
+ comments: SourcePackageStoryPartDescriptor[];
124
+ themes: SourcePackageStoryPartDescriptor[];
125
+ }
126
+
127
+ export interface SourcePackageLayoutInventoryEvidence {
128
+ storyParts: SourcePackageStoryPartDescriptor[];
129
+ partGroups: SourcePackagePartGroupEvidence;
130
+ relationships: SourcePackageRelationshipGroupEvidence;
131
+ sections: SourcePackageSectionDescriptorEvidence[];
132
+ tables: SourcePackageLocationEvidence[];
133
+ fields: SourcePackageLocationEvidence[];
134
+ drawings: SourcePackageLocationEvidence[];
135
+ vmlShapes: SourcePackageLocationEvidence[];
136
+ contentControls: SourcePackageLocationEvidence[];
137
+ bookmarks: SourcePackageLocationEvidence[];
138
+ notes: SourcePackageLocationEvidence[];
139
+ objects: SourcePackageObjectExtentEvidence[];
140
+ }
141
+
53
142
  export interface SourcePackageEvidence {
54
143
  schemaVersion: 1;
55
144
  docId: string;
@@ -78,6 +167,7 @@ export interface SourcePackageEvidence {
78
167
  };
79
168
  parts: SourcePackagePartEvidence[];
80
169
  relationships: SourcePackageRelationshipEvidence[];
170
+ layoutInventory: SourcePackageLayoutInventoryEvidence;
81
171
  parseErrors: Array<{ partPath: string; message: string }>;
82
172
  }
83
173
 
@@ -162,6 +252,13 @@ export function createSourcePackageEvidence(
162
252
  (part) => part.storyKind !== "relationships" && part.storyKind !== "content-types",
163
253
  );
164
254
  const aggregateCounts = aggregateElementCounts(parts.map((part) => part.counts));
255
+ const layoutInventory = collectLayoutInventory(
256
+ sourcePackage,
257
+ parts,
258
+ relationships,
259
+ mainDocumentPartPath,
260
+ parseErrors,
261
+ );
165
262
 
166
263
  return {
167
264
  schemaVersion: 1,
@@ -192,6 +289,7 @@ export function createSourcePackageEvidence(
192
289
  },
193
290
  parts,
194
291
  relationships,
292
+ layoutInventory,
195
293
  parseErrors,
196
294
  };
197
295
  }
@@ -308,6 +406,365 @@ function countElements(root: XmlElementNode): SourcePackageElementCounts {
308
406
  return counts;
309
407
  }
310
408
 
409
+ function collectLayoutInventory(
410
+ sourcePackage: OpcPackage,
411
+ parts: SourcePackagePartEvidence[],
412
+ relationships: SourcePackageRelationshipEvidence[],
413
+ mainDocumentPartPath: string | undefined,
414
+ parseErrors: SourcePackageEvidence["parseErrors"],
415
+ ): SourcePackageLayoutInventoryEvidence {
416
+ const storyParts = collectStoryPartDescriptors(parts, relationships);
417
+ const partGroups = groupParts(storyParts, parts);
418
+ const relationshipGroups = groupRelationships(relationships);
419
+ const locations: Pick<
420
+ SourcePackageLayoutInventoryEvidence,
421
+ | "sections"
422
+ | "tables"
423
+ | "fields"
424
+ | "drawings"
425
+ | "vmlShapes"
426
+ | "contentControls"
427
+ | "bookmarks"
428
+ | "notes"
429
+ | "objects"
430
+ > = {
431
+ sections: [],
432
+ tables: [],
433
+ fields: [],
434
+ drawings: [],
435
+ vmlShapes: [],
436
+ contentControls: [],
437
+ bookmarks: [],
438
+ notes: [],
439
+ objects: [],
440
+ };
441
+
442
+ const partEvidenceByPath = new Map(parts.map((part) => [part.path, part]));
443
+ const sourceRelationshipByPart = relationships.reduce((map, relationship) => {
444
+ if (!relationship.sourcePartPath) return map;
445
+ const list = map.get(relationship.sourcePartPath) ?? [];
446
+ list.push(relationship);
447
+ map.set(relationship.sourcePartPath, list);
448
+ return map;
449
+ }, new Map<string, SourcePackageRelationshipEvidence[]>());
450
+
451
+ for (const part of sourcePackage.parts.values()) {
452
+ const partEvidence = partEvidenceByPath.get(part.path);
453
+ const storyKind = partEvidence?.storyKind ?? classifyPart(part, mainDocumentPartPath);
454
+ if (!shouldParseXmlPart(part, storyKind)) {
455
+ continue;
456
+ }
457
+
458
+ let root: XmlElementNode;
459
+ try {
460
+ root = parseXmlWithOffsets(new TextDecoder("utf-8").decode(part.bytes));
461
+ } catch (error) {
462
+ parseErrors.push({
463
+ partPath: part.path,
464
+ message: error instanceof Error ? error.message : String(error),
465
+ });
466
+ continue;
467
+ }
468
+
469
+ collectPartLocations(
470
+ root,
471
+ {
472
+ partPath: part.path,
473
+ storyKind,
474
+ relationships: sourceRelationshipByPart.get(part.path) ?? [],
475
+ },
476
+ locations,
477
+ );
478
+ }
479
+
480
+ return {
481
+ storyParts,
482
+ partGroups,
483
+ relationships: relationshipGroups,
484
+ ...locations,
485
+ };
486
+ }
487
+
488
+ function collectStoryPartDescriptors(
489
+ parts: SourcePackagePartEvidence[],
490
+ relationships: SourcePackageRelationshipEvidence[],
491
+ ): SourcePackageStoryPartDescriptor[] {
492
+ const descriptorByPath = new Map<string, SourcePackageStoryPartDescriptor>();
493
+ for (const part of parts) {
494
+ if (!isLayoutRelevantStoryKind(part.storyKind)) continue;
495
+ descriptorByPath.set(part.path, {
496
+ sourceId: partSourceId(part.path),
497
+ partPath: part.path,
498
+ storyKind: part.storyKind,
499
+ });
500
+ }
501
+
502
+ for (const relationship of relationships) {
503
+ if (relationship.targetMode !== "internal") continue;
504
+ const resolvedPath = normalizePartPath(relationship.resolvedTarget);
505
+ const descriptor = descriptorByPath.get(resolvedPath);
506
+ if (!descriptor) continue;
507
+ descriptorByPath.set(resolvedPath, {
508
+ ...descriptor,
509
+ relationshipId: relationship.relationshipId,
510
+ relationshipType: relationship.relationshipType,
511
+ relationshipSourcePartPath: relationship.sourcePartPath,
512
+ });
513
+ }
514
+
515
+ return [...descriptorByPath.values()].sort((left, right) =>
516
+ left.partPath.localeCompare(right.partPath),
517
+ );
518
+ }
519
+
520
+ function groupParts(
521
+ descriptors: SourcePackageStoryPartDescriptor[],
522
+ parts: SourcePackagePartEvidence[],
523
+ ): SourcePackagePartGroupEvidence {
524
+ const contentTypeByPath = new Map(parts.map((part) => [part.path, part.contentType]));
525
+ const byKind = (kind: SourcePackageStoryKind): SourcePackageStoryPartDescriptor[] =>
526
+ descriptors.filter((part) => part.storyKind === kind);
527
+
528
+ return {
529
+ media: byKind("media"),
530
+ images: byKind("media").filter((part) =>
531
+ contentTypeByPath.get(part.partPath)?.startsWith("image/") === true,
532
+ ),
533
+ charts: byKind("chart"),
534
+ embedded: byKind("embedded"),
535
+ customXml: byKind("custom-xml"),
536
+ settings: byKind("settings"),
537
+ numbering: byKind("numbering"),
538
+ comments: byKind("comments"),
539
+ themes: byKind("theme"),
540
+ };
541
+ }
542
+
543
+ function groupRelationships(
544
+ relationships: SourcePackageRelationshipEvidence[],
545
+ ): SourcePackageRelationshipGroupEvidence {
546
+ const byRole = (role: string): SourcePackageRelationshipEvidence[] =>
547
+ relationships.filter((relationship) => relationshipRole(relationship) === role);
548
+
549
+ return {
550
+ headerFooters: relationships.filter((relationship) => {
551
+ const role = relationshipRole(relationship);
552
+ return role === "header" || role === "footer";
553
+ }),
554
+ media: byRole("image"),
555
+ charts: byRole("chart"),
556
+ embedded: relationships.filter((relationship) => {
557
+ const role = relationshipRole(relationship);
558
+ return role === "oleObject" || role === "package";
559
+ }),
560
+ customXml: byRole("customXml"),
561
+ settings: byRole("settings"),
562
+ numbering: byRole("numbering"),
563
+ comments: byRole("comments"),
564
+ };
565
+ }
566
+
567
+ function collectPartLocations(
568
+ root: XmlElementNode,
569
+ context: {
570
+ partPath: string;
571
+ storyKind: SourcePackageStoryKind;
572
+ relationships: SourcePackageRelationshipEvidence[];
573
+ },
574
+ locations: Pick<
575
+ SourcePackageLayoutInventoryEvidence,
576
+ | "sections"
577
+ | "tables"
578
+ | "fields"
579
+ | "drawings"
580
+ | "vmlShapes"
581
+ | "contentControls"
582
+ | "bookmarks"
583
+ | "notes"
584
+ | "objects"
585
+ >,
586
+ ): void {
587
+ const ordinals = new Map<string, number>();
588
+ const nextOrdinal = (element: string): number => {
589
+ const next = (ordinals.get(element) ?? 0) + 1;
590
+ ordinals.set(element, next);
591
+ return next;
592
+ };
593
+
594
+ const visit = (node: XmlElementNode): void => {
595
+ const name = localName(node.name);
596
+ switch (name) {
597
+ case "sectPr":
598
+ locations.sections.push({
599
+ ...locationFor(context, node, name, nextOrdinal(name)),
600
+ headerFooterReferences: collectHeaderFooterReferences(node, context.relationships),
601
+ });
602
+ break;
603
+ case "tbl":
604
+ locations.tables.push(locationFor(context, node, name, nextOrdinal(name)));
605
+ break;
606
+ case "fldSimple":
607
+ case "fldChar":
608
+ case "instrText":
609
+ locations.fields.push(locationFor(context, node, name, nextOrdinal(name)));
610
+ break;
611
+ case "drawing": {
612
+ const ordinal = nextOrdinal(name);
613
+ locations.drawings.push(locationFor(context, node, name, ordinal));
614
+ break;
615
+ }
616
+ case "inline":
617
+ case "anchor": {
618
+ const ordinal = nextOrdinal(name);
619
+ locations.objects.push(drawingObjectFor(context, node, name, ordinal));
620
+ break;
621
+ }
622
+ case "shape": {
623
+ const ordinal = nextOrdinal(name);
624
+ locations.vmlShapes.push(locationFor(context, node, name, ordinal));
625
+ locations.objects.push(vmlObjectFor(context, node, ordinal));
626
+ break;
627
+ }
628
+ case "object": {
629
+ const ordinal = nextOrdinal(name);
630
+ locations.objects.push(objectFor(context, node, ordinal));
631
+ break;
632
+ }
633
+ case "sdt":
634
+ locations.contentControls.push(locationFor(context, node, name, nextOrdinal(name)));
635
+ break;
636
+ case "bookmarkStart":
637
+ locations.bookmarks.push(locationFor(context, node, name, nextOrdinal(name)));
638
+ break;
639
+ case "footnote":
640
+ case "endnote":
641
+ locations.notes.push(locationFor(context, node, name, nextOrdinal(name)));
642
+ break;
643
+ }
644
+
645
+ for (const child of node.children) {
646
+ if (child.type === "element") {
647
+ visit(child);
648
+ }
649
+ }
650
+ };
651
+
652
+ visit(root);
653
+ }
654
+
655
+ function locationFor(
656
+ context: {
657
+ partPath: string;
658
+ storyKind: SourcePackageStoryKind;
659
+ },
660
+ node: XmlElementNode,
661
+ element: string,
662
+ ordinal: number,
663
+ ): SourcePackageLocationEvidence {
664
+ return {
665
+ sourceId: elementSourceId(context.partPath, element, ordinal),
666
+ partPath: context.partPath,
667
+ storyKind: context.storyKind,
668
+ element,
669
+ ordinal,
670
+ ...(node.start !== undefined ? { startOffset: node.start } : {}),
671
+ ...(node.end !== undefined ? { endOffset: node.end } : {}),
672
+ };
673
+ }
674
+
675
+ function drawingObjectFor(
676
+ context: {
677
+ partPath: string;
678
+ storyKind: SourcePackageStoryKind;
679
+ },
680
+ node: XmlElementNode,
681
+ element: string,
682
+ ordinal: number,
683
+ ): SourcePackageObjectExtentEvidence {
684
+ const extentEmu = readDrawingExtentEmu(node);
685
+ return {
686
+ ...locationFor(context, node, element, ordinal),
687
+ kind: "drawing",
688
+ display: element === "anchor" ? "floating" : "inline",
689
+ preserveOnly: true,
690
+ fallbackHint: element === "anchor" ? "drawing-floating" : "drawing-inline",
691
+ ...(extentEmu ? { extentEmu, extentTwips: extentEmuToTwips(extentEmu) } : {}),
692
+ relationshipIds: collectRelationshipIds(node),
693
+ };
694
+ }
695
+
696
+ function vmlObjectFor(
697
+ context: {
698
+ partPath: string;
699
+ storyKind: SourcePackageStoryKind;
700
+ },
701
+ node: XmlElementNode,
702
+ ordinal: number,
703
+ ): SourcePackageObjectExtentEvidence {
704
+ const extentTwips = readVmlExtentTwips(node.attributes.style);
705
+ return {
706
+ ...locationFor(context, node, "shape", ordinal),
707
+ kind: "vml-shape",
708
+ display: "unknown",
709
+ preserveOnly: true,
710
+ fallbackHint: "vml-shape",
711
+ ...(extentTwips ? { extentTwips, extentEmu: extentTwipsToEmu(extentTwips) } : {}),
712
+ relationshipIds: collectRelationshipIds(node),
713
+ ...(node.attributes.id ? { shapeId: node.attributes.id } : {}),
714
+ ...(node.attributes.type ? { shapeType: node.attributes.type } : {}),
715
+ ...(node.attributes.style ? { vmlStyle: node.attributes.style } : {}),
716
+ };
717
+ }
718
+
719
+ function objectFor(
720
+ context: {
721
+ partPath: string;
722
+ storyKind: SourcePackageStoryKind;
723
+ },
724
+ node: XmlElementNode,
725
+ ordinal: number,
726
+ ): SourcePackageObjectExtentEvidence {
727
+ return {
728
+ ...locationFor(context, node, "object", ordinal),
729
+ kind: "object",
730
+ display: "unknown",
731
+ preserveOnly: true,
732
+ fallbackHint: hasDescendant(node, "OLEObject") ? "ole-object" : "opaque-object",
733
+ relationshipIds: collectRelationshipIds(node),
734
+ };
735
+ }
736
+
737
+ function collectHeaderFooterReferences(
738
+ node: XmlElementNode,
739
+ relationships: SourcePackageRelationshipEvidence[],
740
+ ): SourcePackageHeaderFooterReferenceEvidence[] {
741
+ const byId = new Map(relationships.map((relationship) => [relationship.relationshipId, relationship]));
742
+ const refs: SourcePackageHeaderFooterReferenceEvidence[] = [];
743
+ const visit = (current: XmlElementNode): void => {
744
+ const name = localName(current.name);
745
+ if (name === "headerReference" || name === "footerReference") {
746
+ const relationshipId = readRelationshipId(current);
747
+ if (relationshipId) {
748
+ const relationship = byId.get(relationshipId);
749
+ refs.push({
750
+ kind: name === "headerReference" ? "header" : "footer",
751
+ relationshipId,
752
+ ...(current.attributes["w:type"] ?? current.attributes.type
753
+ ? { type: current.attributes["w:type"] ?? current.attributes.type }
754
+ : {}),
755
+ ...(relationship ? { resolvedTarget: relationship.resolvedTarget } : {}),
756
+ ...(relationship ? { targetExists: relationship.targetExists } : {}),
757
+ });
758
+ }
759
+ }
760
+ for (const child of current.children) {
761
+ if (child.type === "element") visit(child);
762
+ }
763
+ };
764
+ visit(node);
765
+ return refs;
766
+ }
767
+
311
768
  function collectRelationships(
312
769
  sourcePackage: OpcPackage,
313
770
  ): SourcePackageRelationshipEvidence[] {
@@ -403,3 +860,157 @@ function countParts(
403
860
  ): number {
404
861
  return parts.filter((part) => part.storyKind === storyKind).length;
405
862
  }
863
+
864
+ function isLayoutRelevantStoryKind(storyKind: SourcePackageStoryKind): boolean {
865
+ return (
866
+ storyKind === "main-document" ||
867
+ storyKind === "header" ||
868
+ storyKind === "footer" ||
869
+ storyKind === "footnotes" ||
870
+ storyKind === "endnotes" ||
871
+ storyKind === "comments" ||
872
+ storyKind === "numbering" ||
873
+ storyKind === "settings" ||
874
+ storyKind === "theme" ||
875
+ storyKind === "chart" ||
876
+ storyKind === "media" ||
877
+ storyKind === "embedded" ||
878
+ storyKind === "custom-xml"
879
+ );
880
+ }
881
+
882
+ function relationshipRole(relationship: SourcePackageRelationshipEvidence): string {
883
+ const slashIndex = relationship.relationshipType.lastIndexOf("/");
884
+ return slashIndex >= 0
885
+ ? relationship.relationshipType.slice(slashIndex + 1)
886
+ : relationship.relationshipType;
887
+ }
888
+
889
+ function partSourceId(partPath: string): string {
890
+ return `part:${partPath}`;
891
+ }
892
+
893
+ function elementSourceId(partPath: string, element: string, ordinal: number): string {
894
+ return `${partSourceId(partPath)}#${element}:${ordinal}`;
895
+ }
896
+
897
+ function readDrawingExtentEmu(
898
+ node: XmlElementNode,
899
+ ): { width: number; height: number } | undefined {
900
+ const extent = findFirstDescendantByLocalName(node, "extent");
901
+ const width = extent ? readIntAttribute(extent, "cx") : undefined;
902
+ const height = extent ? readIntAttribute(extent, "cy") : undefined;
903
+ if (width !== undefined && height !== undefined) {
904
+ return { width, height };
905
+ }
906
+
907
+ const transform = findFirstDescendantByLocalName(node, "xfrm");
908
+ const ext = transform ? findFirstDescendantByLocalName(transform, "ext") : undefined;
909
+ const fallbackWidth = ext ? readIntAttribute(ext, "cx") : undefined;
910
+ const fallbackHeight = ext ? readIntAttribute(ext, "cy") : undefined;
911
+ if (fallbackWidth !== undefined && fallbackHeight !== undefined) {
912
+ return { width: fallbackWidth, height: fallbackHeight };
913
+ }
914
+ return undefined;
915
+ }
916
+
917
+ function readVmlExtentTwips(
918
+ style: string | undefined,
919
+ ): { width: number; height: number } | undefined {
920
+ if (!style) return undefined;
921
+ const width = readCssLengthTwips(style, "width");
922
+ const height = readCssLengthTwips(style, "height");
923
+ if (width !== undefined && height !== undefined) {
924
+ return { width, height };
925
+ }
926
+ return undefined;
927
+ }
928
+
929
+ function readCssLengthTwips(style: string, property: "width" | "height"): number | undefined {
930
+ const match = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([0-9.]+)\\s*(pt|in|cm|mm|px)?`, "iu")
931
+ .exec(style);
932
+ if (!match) return undefined;
933
+ const value = Number.parseFloat(match[1] ?? "");
934
+ if (!Number.isFinite(value)) return undefined;
935
+ const unit = (match[2] ?? "pt").toLowerCase();
936
+ switch (unit) {
937
+ case "pt":
938
+ return Math.round(value * 20);
939
+ case "in":
940
+ return Math.round(value * 1440);
941
+ case "cm":
942
+ return Math.round(value * 567);
943
+ case "mm":
944
+ return Math.round(value * 56.7);
945
+ case "px":
946
+ return Math.round(value * 15);
947
+ default:
948
+ return undefined;
949
+ }
950
+ }
951
+
952
+ function extentEmuToTwips(
953
+ extent: { width: number; height: number },
954
+ ): { width: number; height: number } {
955
+ return {
956
+ width: Math.round(extent.width / 635),
957
+ height: Math.round(extent.height / 635),
958
+ };
959
+ }
960
+
961
+ function extentTwipsToEmu(
962
+ extent: { width: number; height: number },
963
+ ): { width: number; height: number } {
964
+ return {
965
+ width: Math.round(extent.width * 635),
966
+ height: Math.round(extent.height * 635),
967
+ };
968
+ }
969
+
970
+ function collectRelationshipIds(node: XmlElementNode): string[] {
971
+ const ids = new Set<string>();
972
+ const visit = (current: XmlElementNode): void => {
973
+ const id = readRelationshipId(current);
974
+ if (id) ids.add(id);
975
+ for (const child of current.children) {
976
+ if (child.type === "element") visit(child);
977
+ }
978
+ };
979
+ visit(node);
980
+ return [...ids].sort();
981
+ }
982
+
983
+ function readRelationshipId(node: XmlElementNode): string | undefined {
984
+ return (
985
+ node.attributes["r:id"] ??
986
+ node.attributes.id ??
987
+ node.attributes["r:embed"] ??
988
+ node.attributes.embed ??
989
+ node.attributes["r:link"] ??
990
+ node.attributes.link
991
+ );
992
+ }
993
+
994
+ function findFirstDescendantByLocalName(
995
+ node: XmlElementNode,
996
+ name: string,
997
+ ): XmlElementNode | undefined {
998
+ for (const child of node.children) {
999
+ if (child.type !== "element") continue;
1000
+ if (localName(child.name) === name) return child;
1001
+ const nested = findFirstDescendantByLocalName(child, name);
1002
+ if (nested) return nested;
1003
+ }
1004
+ return undefined;
1005
+ }
1006
+
1007
+ function hasDescendant(node: XmlElementNode, name: string): boolean {
1008
+ return findFirstDescendantByLocalName(node, name) !== undefined;
1009
+ }
1010
+
1011
+ function readIntAttribute(node: XmlElementNode, name: string): number | undefined {
1012
+ const raw = node.attributes[name];
1013
+ if (raw === undefined) return undefined;
1014
+ const value = Number.parseInt(raw, 10);
1015
+ return Number.isFinite(value) ? value : undefined;
1016
+ }