@beyondwork/docx-react-component 1.0.57 → 1.0.59

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +1149 -8
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +120 -39
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +165 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +544 -35
  87. package/src/runtime/document-search.ts +176 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +183 -0
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/scope-resolver.ts +60 -0
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +293 -18
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +258 -44
  107. package/src/ui/editor-runtime-boundary.ts +13 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +158 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +105 -17
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +13 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +52 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Lane 7c Slice 7c.1 — `<w:object>` parser (OLE preserve-only).
3
+ *
4
+ * Extracts OLE embedded objects from a `<w:object>` subtree and returns
5
+ * a typed `OleEmbedNode`. Non-OLE `<w:object>` elements (legacy VML
6
+ * drawings wrapped in `<w:object>` without `<o:OLEObject>`) return
7
+ * `undefined` so the caller can fall through to the generic
8
+ * opaque-fragment path in `src/preservation/store.ts`.
9
+ *
10
+ * Preservation contract (see `docs/wiki/images-and-media.md`):
11
+ * - `rawXml` is the authoritative round-trip source and is re-emitted
12
+ * verbatim by the serializer.
13
+ * - The binary embedding part (e.g. `/word/embeddings/oleObject1.bin`)
14
+ * is preserved automatically via `collectPreservedPackageParts` in
15
+ * `src/io/docx-session.ts` — the relationship id stored on the node
16
+ * is the handle.
17
+ * - Placeholder chrome (badge with ProgID + filename) is Lane 6b's
18
+ * responsibility post-v2.0; this module emits only data.
19
+ */
20
+
21
+ import type { OleEmbedNode } from "../../model/canonical-document.ts";
22
+ import type { OpcRelationship } from "./part-manifest.ts";
23
+ import type { XmlElementNode } from "./xml-element.ts";
24
+ import { resolveOleRelationship } from "./parse-ole-relationship.ts";
25
+
26
+ /**
27
+ * Parse a `<w:object>` element into an `OleEmbedNode` if it contains an
28
+ * `<o:OLEObject>` child with a resolvable relationship. Returns
29
+ * `undefined` when the element is not an OLE embedding — the caller
30
+ * should then fall back to opaque-fragment preservation.
31
+ *
32
+ * `rawXml` must be the full verbatim source of the `<w:object>` element
33
+ * (the caller typically slices `sourceXml.slice(node.start, node.end)`).
34
+ * It is not recomputed from the parsed tree because the grab-bag
35
+ * reconstruction is not byte-identical and this slice preserves rawXml
36
+ * bytes as the authoritative round-trip source.
37
+ *
38
+ * The canonical id is derived from the relationship id + an optional
39
+ * occurrence counter so callers that encounter multiple `<w:object>`
40
+ * elements sharing a relationship id still get stable, unique node
41
+ * identifiers. When `occurrenceIndex` is omitted the id is simply
42
+ * `ole-embed-${relationshipId}`.
43
+ */
44
+ export function parseObject(
45
+ node: XmlElementNode,
46
+ rawXml: string,
47
+ relationships: readonly OpcRelationship[],
48
+ occurrenceIndex?: number,
49
+ ): OleEmbedNode | undefined {
50
+ const oleChild = findDirectChild(node, "OLEObject");
51
+ if (!oleChild) return undefined;
52
+
53
+ const progId = readOleAttribute(oleChild, "ProgID");
54
+ const classId = readOleAttribute(oleChild, "ClassID");
55
+ const shapeId = readOleAttribute(oleChild, "ShapeID");
56
+ const relationshipIdRaw = readRelationshipIdAttribute(oleChild);
57
+
58
+ const resolved = resolveOleRelationship(relationshipIdRaw, relationships);
59
+ if (!resolved) {
60
+ // No resolvable internal relationship — treat as non-OLE. The
61
+ // generic opaque-fragment path preserves the subtree via rawXml;
62
+ // promoting to OleEmbedNode would lose the handoff semantics the
63
+ // post-v2.0 chrome slice depends on.
64
+ return undefined;
65
+ }
66
+
67
+ const metadata: OleEmbedNode["metadata"] = {};
68
+ if (resolved.originalFilename) {
69
+ metadata.originalFilename = resolved.originalFilename;
70
+ }
71
+ if (classId) {
72
+ metadata.classId = classId;
73
+ }
74
+ if (shapeId) {
75
+ metadata.shapeId = shapeId;
76
+ }
77
+
78
+ const id =
79
+ typeof occurrenceIndex === "number"
80
+ ? `ole-embed-${resolved.relationshipId}-${occurrenceIndex}`
81
+ : `ole-embed-${resolved.relationshipId}`;
82
+
83
+ const result: OleEmbedNode = {
84
+ type: "ole_embed",
85
+ id,
86
+ embedType: "oleObject",
87
+ relationshipId: resolved.relationshipId,
88
+ metadata,
89
+ rawXml,
90
+ };
91
+ if (progId) {
92
+ result.progId = progId;
93
+ }
94
+ return result;
95
+ }
96
+
97
+ function findDirectChild(
98
+ node: XmlElementNode,
99
+ localName: string,
100
+ ): XmlElementNode | undefined {
101
+ for (const child of node.children) {
102
+ if (child.type !== "element") continue;
103
+ const colon = child.name.indexOf(":");
104
+ const local = colon < 0 ? child.name : child.name.slice(colon + 1);
105
+ if (local === localName) return child;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Read a `<o:OLEObject>` attribute by local name. OLE attributes use
112
+ * the VML / Office-drawing namespaces which may appear bare, prefixed
113
+ * as `o:`, or with another prefix depending on the source document.
114
+ */
115
+ function readOleAttribute(
116
+ node: XmlElementNode,
117
+ localName: string,
118
+ ): string | undefined {
119
+ for (const [key, value] of Object.entries(node.attributes)) {
120
+ const colon = key.indexOf(":");
121
+ const local = colon < 0 ? key : key.slice(colon + 1);
122
+ if (local === localName) return value;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /**
128
+ * Read the `r:id` attribute on `<o:OLEObject>` that references the
129
+ * embedding relationship. The attribute lives in the Office-
130
+ * relationships namespace and must have an explicit prefix on
131
+ * `<o:OLEObject>` — a bare `id` is `ObjectID`-style bookkeeping, not a
132
+ * relationship reference. We accept `r:id` or any other prefix pointing
133
+ * at the same namespace URI, but reject bare `id` so we don't
134
+ * misinterpret non-relationship attributes.
135
+ */
136
+ function readRelationshipIdAttribute(
137
+ node: XmlElementNode,
138
+ ): string | undefined {
139
+ for (const [key, value] of Object.entries(node.attributes)) {
140
+ const colon = key.indexOf(":");
141
+ if (colon < 0) continue; // skip bare `id` / `ObjectID` / `ShapeID`
142
+ if (key.slice(colon + 1) === "id") {
143
+ return value;
144
+ }
145
+ }
146
+ return undefined;
147
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Lane 7c Slice 7c.1 — OLE embedded-object relationship resolver.
3
+ *
4
+ * Resolves an `r:id` referenced from an `<o:OLEObject>` element against
5
+ * the parent part's relationship list. The binary package part (e.g.
6
+ * `/word/embeddings/oleObject1.bin`) is preserved automatically by the
7
+ * OPC reader → `collectPreservedPackageParts` pipeline in
8
+ * `src/io/docx-session.ts` — this helper only reports where the binary
9
+ * lives so parse-object can populate metadata on the canonical
10
+ * `OleEmbedNode`.
11
+ *
12
+ * Non-OLE `<w:object>` elements (legacy VML-wrapped objects without
13
+ * `<o:OLEObject>`) must NOT use this helper; they flow through the
14
+ * generic opaque-fragment path.
15
+ */
16
+
17
+ import type { OpcRelationship } from "./part-manifest.ts";
18
+
19
+ /**
20
+ * OLE-embedding relationship target classes defined by OOXML. Used only
21
+ * for logging / validator wording today; the preservation contract is
22
+ * insensitive to the exact relationship type as long as the binary part
23
+ * survives.
24
+ */
25
+ export const OLE_RELATIONSHIP_TYPES: ReadonlySet<string> = new Set([
26
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject",
27
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package",
28
+ ]);
29
+
30
+ export interface ResolvedOleRelationship {
31
+ relationshipId: string;
32
+ /**
33
+ * Relative target as declared in the relationship XML (e.g.
34
+ * `"embeddings/oleObject1.bin"` or `"embeddings/Microsoft_Excel_Worksheet.xlsx"`).
35
+ * The absolute package path is resolved downstream by
36
+ * `resolveRelationshipTarget` against the source part's folder.
37
+ */
38
+ target: string;
39
+ relationshipType: string;
40
+ /**
41
+ * Extracted filename from the target path (without folder prefix). Used
42
+ * as a human-facing label in preserve-only diagnostics. Undefined when
43
+ * the target is unparseable.
44
+ */
45
+ originalFilename?: string;
46
+ }
47
+
48
+ /**
49
+ * Look up the relationship record for an r:id referenced by an
50
+ * `<o:OLEObject>` element. Returns undefined when the id is absent or
51
+ * the referenced record does not look like an OLE embedding — callers
52
+ * treat that as "fall through to generic opaque-fragment preservation".
53
+ *
54
+ * This helper is intentionally read-only: it does not mutate
55
+ * relationship records or allocate preservation store entries. Binary
56
+ * preservation is a separate pipeline stage.
57
+ */
58
+ export function resolveOleRelationship(
59
+ relationshipId: string | undefined,
60
+ relationships: readonly OpcRelationship[],
61
+ ): ResolvedOleRelationship | undefined {
62
+ if (!relationshipId) return undefined;
63
+ const record = relationships.find((rel) => rel.id === relationshipId);
64
+ if (!record) return undefined;
65
+ if (record.targetMode !== "internal") return undefined;
66
+
67
+ return {
68
+ relationshipId: record.id,
69
+ target: record.target,
70
+ relationshipType: record.type,
71
+ originalFilename: extractFilename(record.target),
72
+ };
73
+ }
74
+
75
+ function extractFilename(target: string): string | undefined {
76
+ if (!target) return undefined;
77
+ const slashIdx = target.lastIndexOf("/");
78
+ const backslashIdx = target.lastIndexOf("\\");
79
+ const idx = Math.max(slashIdx, backslashIdx);
80
+ const name = idx >= 0 ? target.slice(idx + 1) : target;
81
+ return name.length > 0 ? name : undefined;
82
+ }
@@ -23,6 +23,9 @@ import {
23
23
  type PropertyGrabBagDescriptor,
24
24
  } from "./property-grab-bag.ts";
25
25
 
26
+ const TAB_ALIGN_VOCAB = new Set<TabStop["align"]>(["left", "center", "right", "decimal", "num", "bar", "clear"]);
27
+ const TAB_LEADER_VOCAB = new Set<TabStop["leader"]>(["none", "dot", "hyphen", "underscore", "heavy", "middleDot"]);
28
+
26
29
  /**
27
30
  * Modelled direct children of `<w:pPr>` that `readParagraphProperties` below
28
31
  * dispatches into typed fields on `CanonicalParagraphFormatting`. Anything
@@ -92,16 +95,10 @@ function readTabStops(node: XmlElementNode): TabStop[] | undefined {
92
95
  if (!Number.isFinite(pos)) continue;
93
96
  const val = (c.attributes["w:val"] ?? c.attributes.val ?? "left").toLowerCase();
94
97
  const leader = (c.attributes["w:leader"] ?? c.attributes.leader ?? "none").toLowerCase();
95
- const align: TabStop["align"] =
96
- val === "center" || val === "right" || val === "decimal" || val === "bar" || val === "num" || val === "clear"
97
- ? (val as TabStop["align"])
98
- : "left";
99
- const mappedLeader: TabStop["leader"] | undefined =
100
- leader === "dot" || leader === "hyphen" || leader === "underscore" || leader === "heavy"
101
- ? (leader as TabStop["leader"])
102
- : leader === "middledot"
103
- ? "middleDot"
104
- : undefined;
98
+ const alignCandidate = val as TabStop["align"];
99
+ const align: TabStop["align"] = TAB_ALIGN_VOCAB.has(alignCandidate) ? alignCandidate : "left";
100
+ const leaderNorm = leader === "middledot" ? "middleDot" : (leader as TabStop["leader"]);
101
+ const mappedLeader: TabStop["leader"] | undefined = TAB_LEADER_VOCAB.has(leaderNorm) ? leaderNorm : undefined;
105
102
  tabs.push({
106
103
  position: pos,
107
104
  align,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * parse-picture-sdt.ts — CO4.5
3
+ *
4
+ * Public adapter for parsing a `<w:sdt>` element that wraps picture or shape
5
+ * drawing content. Thin wrapper over `parseSdtXml` from parse-main-document.ts,
6
+ * which threads drawing-frame opts (relationships, mediaParts) through the full
7
+ * block-parse chain so `<w:drawing>` inside SDT content becomes a
8
+ * `DrawingFrameNode` inside the paragraph's inline children.
9
+ *
10
+ * The typical OOXML structure is:
11
+ * <w:sdt> → <w:sdtContent> → <w:p> → <w:r> → <w:drawing>
12
+ * The SDT's canonical child is a ParagraphNode; the drawing lives inside the
13
+ * paragraph's inline content as a DrawingFrameNode.
14
+ */
15
+
16
+ import type { OpcRelationship } from "./part-manifest.ts";
17
+ import type { InlineMediaPart } from "./parse-inline-media.ts";
18
+ import type { ChartPartLookup } from "./parse-complex-content.ts";
19
+ import { type XmlElementNode, localName } from "./_mini-xml.ts";
20
+ import { parseSdtXml } from "./parse-main-document.ts";
21
+ import type { ParsedBlockNode } from "./parse-main-document.ts";
22
+
23
+ export type { ParsedBlockNode };
24
+
25
+ /**
26
+ * Parse a raw `<w:sdt>` XML string with full drawing support.
27
+ *
28
+ * Returns the canonical block node — typically a `ParsedSdtNode` whose
29
+ * children are `ParsedParagraphNode` items carrying `DrawingFrameNode`
30
+ * inline content. Falls back to `opaque_block` if the fragment cannot be
31
+ * parsed (malformed XML, no `<w:sdt>` root, etc.).
32
+ */
33
+ export function parsePictureSdt(
34
+ rawXml: string,
35
+ relationships: readonly OpcRelationship[] = [],
36
+ mediaParts: ReadonlyMap<string, InlineMediaPart> = new Map(),
37
+ sourcePartPath?: string,
38
+ chartPartLookup?: ChartPartLookup,
39
+ ): ParsedBlockNode {
40
+ return parseSdtXml(
41
+ rawXml,
42
+ relationships,
43
+ mediaParts,
44
+ sourcePartPath ?? "/word/document.xml",
45
+ chartPartLookup,
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Cheap predicate: returns true when any paragraph inside `<w:sdtContent>`
51
+ * contains a `<w:drawing>` or `<w:pict>` child (directly or inside a run).
52
+ *
53
+ * Accepts either the `<w:sdt>` element directly or the `__root__` wrapper
54
+ * returned by `parseXml()`.
55
+ */
56
+ export function sdtContentHasDrawing(node: XmlElementNode): boolean {
57
+ const sdt =
58
+ node.name === "__root__"
59
+ ? node.children.find((c): c is XmlElementNode => c.type === "element")
60
+ : node;
61
+ if (!sdt) return false;
62
+
63
+ const contentNode = sdt.children.find(
64
+ (c): c is XmlElementNode =>
65
+ c.type === "element" && localName(c.name) === "sdtContent",
66
+ );
67
+ if (!contentNode) return false;
68
+ for (const child of contentNode.children) {
69
+ if (child.type !== "element") continue;
70
+ if (localName(child.name) !== "p") continue;
71
+ for (const pChild of (child as XmlElementNode).children) {
72
+ if (pChild.type !== "element") continue;
73
+ const pChildName = localName((pChild as XmlElementNode).name);
74
+ if (pChildName === "drawing" || pChildName === "pict") return true;
75
+ if (pChildName === "r") {
76
+ for (const rChild of (pChild as XmlElementNode).children) {
77
+ if (rChild.type !== "element") continue;
78
+ const rChildName = localName((rChild as XmlElementNode).name);
79
+ if (rChildName === "drawing" || rChildName === "pict") return true;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return false;
85
+ }
@@ -1,18 +1,30 @@
1
1
  import type { PictureContent } from "../../model/canonical-document.ts";
2
-
3
- interface XmlElementNode {
4
- type: "element";
5
- name: string;
6
- attributes: Record<string, string>;
7
- children: XmlNode[];
8
- }
9
-
10
- interface XmlTextNode {
11
- type: "text";
12
- text: string;
13
- }
14
-
15
- type XmlNode = XmlElementNode | XmlTextNode;
2
+ import {
3
+ type XmlElementNode,
4
+ findFirstChild,
5
+ findFirstDescendant,
6
+ } from "./_mini-xml.ts";
7
+
8
+ const SAFE_HEX_COLOR_RE = /^(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
9
+ const SAFE_SCHEME_COLOR_SLOTS = new Set([
10
+ "dk1",
11
+ "lt1",
12
+ "dk2",
13
+ "lt2",
14
+ "accent1",
15
+ "accent2",
16
+ "accent3",
17
+ "accent4",
18
+ "accent5",
19
+ "accent6",
20
+ "hlink",
21
+ "folhlink",
22
+ "tx1",
23
+ "tx2",
24
+ "bg1",
25
+ "bg2",
26
+ "phclr",
27
+ ]);
16
28
 
17
29
  /**
18
30
  * Parse a pic:pic element (child of a:graphicData) into PictureContent.
@@ -25,13 +37,18 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
25
37
  const pic = findFirstDescendant(graphicDataEl, "pic");
26
38
  if (!pic) return null;
27
39
 
28
- // blipFill/blip r:embed
40
+ // blipFill/blip — Phase 4.4 G4: accept r:embed (embedded, primary) OR r:link
41
+ // (externally-linked image). Previously only r:embed was honored, silently
42
+ // dropping valid linked images.
29
43
  const blipFill = findFirstChild(pic, "blipFill");
30
44
  if (!blipFill) return null;
31
45
  const blip = findFirstChild(blipFill, "blip");
32
46
  if (!blip) return null;
33
- const blipRef = blip.attributes["r:embed"] ?? blip.attributes.embed ?? "";
47
+ const blipEmbed = blip.attributes["r:embed"] ?? blip.attributes.embed;
48
+ const blipLink = blip.attributes["r:link"] ?? blip.attributes.link;
49
+ const blipRef = blipEmbed ?? blipLink ?? "";
34
50
  if (!blipRef) return null;
51
+ const isLinked = !blipEmbed && !!blipLink;
35
52
 
36
53
  // srcRect (percentage crop: 0..100000 = 0..100%)
37
54
  const srcRectEl = findFirstChild(blipFill, "srcRect");
@@ -44,10 +61,15 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
44
61
  }
45
62
  : undefined;
46
63
 
47
- // stretch
64
+ // stretch vs tile — mutually exclusive per OOXML spec. Prefer stretch
65
+ // when both appear (matches Word's observed serializer behavior).
48
66
  const stretchEl = findFirstChild(blipFill, "stretch");
49
67
  const stretch = stretchEl ? true : undefined;
50
68
 
69
+ // Phase 4.5 G7 — a:tile (blipFill tile repeat)
70
+ const tileEl = findFirstChild(blipFill, "tile");
71
+ const tile = tileEl ? readTileAttrs(tileEl) : undefined;
72
+
51
73
  // spPr/xfrm — rotation and flip
52
74
  const spPr = findFirstChild(pic, "spPr");
53
75
  const xfrm = spPr ? findFirstChild(spPr, "xfrm") : undefined;
@@ -60,48 +82,107 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
60
82
  const prstGeom = spPr ? findFirstChild(spPr, "prstGeom") : undefined;
61
83
  const presetGeom = prstGeom?.attributes.prst;
62
84
 
85
+ // N11.b — effectLst: softEdge, outerShdw, glow
86
+ const effectLst = spPr ? findFirstChild(spPr, "effectLst") : undefined;
87
+ const softEdgeEl = effectLst ? findFirstChild(effectLst, "softEdge") : undefined;
88
+ const softEdgeRadius = softEdgeEl ? readEmuAttr(softEdgeEl, "rad") : undefined;
89
+ const outerShdwEl = effectLst ? findFirstChild(effectLst, "outerShdw") : undefined;
90
+ const outerShadow = outerShdwEl
91
+ ? parseOuterShadow(outerShdwEl)
92
+ : undefined;
93
+ const glowEl = effectLst ? findFirstChild(effectLst, "glow") : undefined;
94
+ const glow = glowEl ? parseGlow(glowEl) : undefined;
95
+
63
96
  const result: PictureContent = { type: "picture", blipRef };
97
+ if (isLinked) result.isLinked = true;
64
98
  if (srcRect) result.srcRect = srcRect;
65
99
  if (stretch !== undefined) result.stretch = stretch;
100
+ if (tile !== undefined) result.tile = tile;
66
101
  if (rotation !== undefined) result.rotation = rotation;
67
102
  if (flipH !== undefined) result.flipH = flipH;
68
103
  if (flipV !== undefined) result.flipV = flipV;
69
104
  if (presetGeom) result.presetGeom = presetGeom;
105
+ if (softEdgeRadius !== undefined) result.softEdgeRadius = softEdgeRadius;
106
+ if (outerShadow) result.outerShadow = outerShadow;
107
+ if (glow) result.glow = glow;
70
108
  return result;
71
109
  }
72
110
 
73
- function readPercentAttr(el: XmlElementNode, name: string): number {
74
- const v = el.attributes[name];
75
- if (v === undefined) return 0;
76
- return parseInt(v, 10) || 0;
77
- }
78
-
79
- function readBoolAttr(el: XmlElementNode, name: string): boolean | undefined {
111
+ function readEmuAttr(el: XmlElementNode, name: string): number | undefined {
80
112
  const v = el.attributes[name];
81
113
  if (v === undefined) return undefined;
82
- return v !== "0" && v !== "false";
114
+ const n = parseInt(v, 10);
115
+ return Number.isFinite(n) ? n : undefined;
83
116
  }
84
117
 
85
- function findFirstChild(node: XmlElementNode, local: string): XmlElementNode | undefined {
86
- for (const child of node.children) {
87
- if (child.type === "element" && localName(child.name) === local) return child;
118
+ function parseColorFromEl(el: XmlElementNode): { color: string; colorType: "srgbClr" | "schemeClr" } | null {
119
+ const srgb = findFirstChild(el, "srgbClr");
120
+ if (srgb) {
121
+ const color = (srgb.attributes.val ?? "000000").trim();
122
+ if (!SAFE_HEX_COLOR_RE.test(color)) return null;
123
+ return { color: color.toUpperCase(), colorType: "srgbClr" };
124
+ }
125
+ const scheme = findFirstChild(el, "schemeClr");
126
+ if (scheme) {
127
+ const slot = (scheme.attributes.val ?? "dk1").trim().toLowerCase();
128
+ if (!SAFE_SCHEME_COLOR_SLOTS.has(slot)) return null;
129
+ return { color: slot, colorType: "schemeClr" };
88
130
  }
89
- return undefined;
131
+ return null;
132
+ }
133
+
134
+ function parseOuterShadow(
135
+ el: XmlElementNode,
136
+ ): PictureContent["outerShadow"] {
137
+ const blurRad = readEmuAttr(el, "blurRad") ?? 0;
138
+ const dist = readEmuAttr(el, "dist") ?? 0;
139
+ const dir = parseInt(el.attributes.dir ?? "0", 10) || 0;
140
+ const colorInfo = parseColorFromEl(el);
141
+ if (!colorInfo) return undefined;
142
+ return { blurRad, dist, dir, color: colorInfo.color, colorType: colorInfo.colorType };
90
143
  }
91
144
 
92
- function findFirstDescendant(node: XmlElementNode, local: string): XmlElementNode | undefined {
93
- for (const child of node.children) {
94
- if (child.type !== "element") continue;
95
- if (localName(child.name) === local) return child;
96
- const found = findFirstDescendant(child, local);
97
- if (found) return found;
145
+ function parseGlow(el: XmlElementNode): PictureContent["glow"] {
146
+ const radius = readEmuAttr(el, "rad") ?? 0;
147
+ const colorInfo = parseColorFromEl(el);
148
+ if (!colorInfo) return undefined;
149
+ return { radius, color: colorInfo.color, colorType: colorInfo.colorType };
150
+ }
151
+
152
+ function readTileAttrs(
153
+ tileEl: XmlElementNode,
154
+ ): NonNullable<PictureContent["tile"]> | undefined {
155
+ const result: NonNullable<PictureContent["tile"]> = {};
156
+ const intAttrs = ["tx", "ty", "sx", "sy"] as const;
157
+ for (const key of intAttrs) {
158
+ const v = tileEl.attributes[key];
159
+ if (v !== undefined) {
160
+ const n = parseInt(v, 10);
161
+ if (Number.isFinite(n)) result[key] = n;
162
+ }
98
163
  }
99
- return undefined;
164
+ const flip = tileEl.attributes.flip;
165
+ if (flip === "x" || flip === "y" || flip === "xy") result.flip = flip;
166
+ const algn = tileEl.attributes.algn;
167
+ if (algn) result.algn = algn;
168
+ return Object.keys(result).length > 0 ? result : {};
100
169
  }
101
170
 
102
- function localName(name: string): string {
103
- const i = name.indexOf(":");
104
- return i >= 0 ? name.slice(i + 1) : name;
171
+ function readPercentAttr(el: XmlElementNode, name: string): number {
172
+ const v = el.attributes[name];
173
+ if (v === undefined) return 0;
174
+ return parseInt(v, 10) || 0;
175
+ }
176
+
177
+ function readBoolAttr(el: XmlElementNode, name: string): boolean | undefined {
178
+ // Phase 1.3 B5 — check unprefixed, wp:-prefixed, and a:-prefixed variants.
179
+ // Serialized OOXML occasionally keeps DrawingML attrs in the a: namespace
180
+ // (e.g. `<a:xfrm a:flipH="1"/>`) even though xfrm itself is in the a: NS.
181
+ // parse-anchor.ts::readBoolAttr uses the same pattern; staying consistent.
182
+ const v = el.attributes[name] ?? el.attributes[`wp:${name}`] ?? el.attributes[`a:${name}`];
183
+ if (v === undefined) return undefined;
184
+ return v !== "0" && v !== "false";
105
185
  }
106
186
 
187
+ /** @deprecated Phase 6.2 — re-export from _mini-xml.ts; call sites should migrate to XmlElementNode directly. */
107
188
  export { type XmlElementNode as PictureXmlElement };