@beyondwork/docx-react-component 1.0.58 → 1.0.60

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 +2 -2
  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 +980 -10
  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 +4 -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 +72 -42
  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/workflow-payload.ts +6 -1
  69. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  70. package/src/io/ooxml/xml-parser.ts +183 -0
  71. package/src/legal/bookmarks.ts +1 -1
  72. package/src/legal/cross-references.ts +1 -1
  73. package/src/legal/defined-terms.ts +1 -1
  74. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  75. package/src/legal/signature-blocks.ts +1 -1
  76. package/src/model/canonical-document.ts +159 -6
  77. package/src/model/chart-types.ts +439 -0
  78. package/src/model/snapshot.ts +5 -1
  79. package/src/review/store/comment-remapping.ts +24 -11
  80. package/src/review/store/revision-actions.ts +482 -2
  81. package/src/review/store/revision-store.ts +15 -0
  82. package/src/review/store/revision-types.ts +76 -0
  83. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  84. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  85. package/src/runtime/diagnostics/build-diagnostic.ts +153 -0
  86. package/src/runtime/diagnostics/code-metadata-table.ts +230 -0
  87. package/src/runtime/document-runtime.ts +821 -54
  88. package/src/runtime/document-search.ts +115 -0
  89. package/src/runtime/edit-ops/index.ts +18 -2
  90. package/src/runtime/footnote-resolver.ts +130 -0
  91. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  92. package/src/runtime/layout/layout-engine-version.ts +37 -1
  93. package/src/runtime/layout/page-graph.ts +14 -1
  94. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  95. package/src/runtime/numbering-prefix.ts +17 -0
  96. package/src/runtime/query-scopes.ts +108 -10
  97. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  98. package/src/runtime/revision-runtime.ts +27 -1
  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 +290 -21
  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 +187 -43
  107. package/src/ui/editor-runtime-boundary.ts +10 -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 +0 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -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 +87 -25
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -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 +29 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -11,6 +11,7 @@
11
11
  import * as React from "react";
12
12
 
13
13
  import { TwShortcutHint, type ShortcutKey } from "./tw-shortcut-hint";
14
+ import { FOCUS_RING_CLASSES } from "../theme/tokens";
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Dedupe context
@@ -222,7 +223,7 @@ function ContextMenuRow({ item, platform }: ContextMenuRowProps): React.JSX.Elem
222
223
  "text-[13px] text-[var(--color-text-primary)] text-left",
223
224
  "hover:bg-[var(--color-bg-hover)]",
224
225
  "disabled:opacity-40 disabled:cursor-not-allowed",
225
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-accent)]",
226
+ FOCUS_RING_CLASSES,
226
227
  ].join(" ")}
227
228
  >
228
229
  <span className="flex items-center gap-2 min-w-0">
@@ -2,6 +2,7 @@ import React from "react";
2
2
 
3
3
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
4
4
  import type { ActiveImageContext } from "../../ui/headless/selection-tool-types";
5
+ import { EMU_PER_INCH } from "../../runtime/units.ts";
5
6
 
6
7
  export interface TwImageContextToolbarProps {
7
8
  activeImage: ActiveImageContext;
@@ -17,10 +18,10 @@ export interface TwImageContextToolbarProps {
17
18
  }
18
19
 
19
20
  const IMAGE_SIZE_PRESETS = [
20
- { label: "Small image", widthEmu: 1828800, heightEmu: 914400 },
21
- { label: "Medium image", widthEmu: 2743200, heightEmu: 1371600 },
22
- { label: "Large image", widthEmu: 3657600, heightEmu: 1828800 },
23
- ] as const;
21
+ { label: "Small image", widthEmu: 2 * EMU_PER_INCH, heightEmu: 1 * EMU_PER_INCH },
22
+ { label: "Medium image", widthEmu: 3 * EMU_PER_INCH, heightEmu: 1.5 * EMU_PER_INCH },
23
+ { label: "Large image", widthEmu: 4 * EMU_PER_INCH, heightEmu: 2 * EMU_PER_INCH },
24
+ ];
24
25
 
25
26
  const NUDGE_EMU = 228600;
26
27
 
@@ -1,5 +1,7 @@
1
1
  import React, { type ReactNode } from "react";
2
2
 
3
+ import { FOCUS_RING_CLASSES } from "../theme/tokens";
4
+
3
5
  export interface TwModeDockAction {
4
6
  id: string;
5
7
  label: string;
@@ -15,8 +17,10 @@ export interface TwModeDockProps {
15
17
  className?: string;
16
18
  }
17
19
 
18
- const focusRingClass =
19
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
20
+ // TwModeDock is a deprecated harness-only surface per designsystem §6.26,
21
+ // but its focus ring still participates in the canonical §4.7 contract —
22
+ // import the shared token constant so a future ring update cascades here.
23
+ const focusRingClass = FOCUS_RING_CLASSES;
20
24
 
21
25
  export function TwModeDock(props: TwModeDockProps) {
22
26
  const actions = (props.actions ?? []).slice(0, 3);
@@ -0,0 +1,111 @@
1
+ /**
2
+ * useContainerBreakpoint — rAF-coalesced ResizeObserver hook that
3
+ * resolves to a named breakpoint based on a monitored element's
4
+ * `contentRect.width`.
5
+ *
6
+ * Motivation: the shipped `responsive-chrome.ts` compact-mode hook is
7
+ * viewport-driven (single threshold), so a toolbar inside a narrow
8
+ * split-pane renders at full width even when its container is small.
9
+ * Container-driven breakpoints fix this — the toolbar responds to its
10
+ * own box, not the page.
11
+ *
12
+ * Perf discipline (CLAUDE.md §Performance Invariants #1 + #7):
13
+ * - The ResizeObserver callback MUST NOT call getBoundingClientRect
14
+ * or any offset/client read. It only reads `entry.contentRect.width`
15
+ * (which is already laid-out and cached by the browser's observer
16
+ * implementation).
17
+ * - State updates are coalesced inside `requestAnimationFrame` so
18
+ * multiple rapid resizes collapse into one render, and the new
19
+ * measurement never drives a synchronous paint.
20
+ *
21
+ * Usage:
22
+ *
23
+ * const ref = useRef<HTMLDivElement | null>(null);
24
+ * const bp = useContainerBreakpoint(ref, { compact: 0, wide: 720 });
25
+ * return <div ref={ref} data-breakpoint={bp}>...</div>;
26
+ *
27
+ * Thresholds map `width` to the highest-named breakpoint whose
28
+ * threshold is ≤ width. Every map MUST include `0` as one of its
29
+ * thresholds so every width resolves to some name.
30
+ */
31
+
32
+ import { useEffect, useMemo, useRef, useState } from "react";
33
+
34
+ export type BreakpointMap = Readonly<Record<string, number>>;
35
+
36
+ /**
37
+ * Resolve a width to the highest-named breakpoint whose threshold is
38
+ * ≤ width. Exported for unit testing and for callers that already own
39
+ * the width and just want the mapping logic.
40
+ */
41
+ export function resolveBreakpoint<T extends BreakpointMap>(
42
+ width: number,
43
+ thresholds: T,
44
+ ): keyof T | null {
45
+ let best: keyof T | null = null;
46
+ let bestThreshold = -Infinity;
47
+ for (const name of Object.keys(thresholds) as (keyof T)[]) {
48
+ const t = thresholds[name] as number;
49
+ if (width >= t && t > bestThreshold) {
50
+ best = name;
51
+ bestThreshold = t;
52
+ }
53
+ }
54
+ return best;
55
+ }
56
+
57
+ export function useContainerBreakpoint<T extends BreakpointMap>(
58
+ ref: React.RefObject<HTMLElement | null>,
59
+ thresholds: T,
60
+ initial: keyof T | null = null,
61
+ ): keyof T | null {
62
+ const [breakpoint, setBreakpoint] = useState<keyof T | null>(initial);
63
+ const rafRef = useRef<number | null>(null);
64
+ const latestWidthRef = useRef<number | null>(null);
65
+
66
+ // R8 — stringify thresholds into a stable deps key so inline
67
+ // literals (e.g. `{ compact: 0, wide: 720 }` declared in JSX) do
68
+ // not destroy + recreate the ResizeObserver on every render. The
69
+ // stringify cost is a single pass over a ~3-key object when the
70
+ // caller's identity changes, which is what we want.
71
+ const depsKey = useMemo(() => JSON.stringify(thresholds), [thresholds]);
72
+ // Thresholds snapshot that the effect reads — captured at subscription
73
+ // time so the observer callback uses the values that corresponded to
74
+ // the current subscription.
75
+ const thresholdsRef = useRef(thresholds);
76
+ thresholdsRef.current = thresholds;
77
+
78
+ useEffect(() => {
79
+ const el = ref.current;
80
+ if (!el || typeof ResizeObserver === "undefined") return;
81
+
82
+ const observer = new ResizeObserver((entries) => {
83
+ // Read-only access to contentRect; no layout-triggering calls.
84
+ const entry = entries[entries.length - 1];
85
+ if (!entry) return;
86
+ latestWidthRef.current = entry.contentRect.width;
87
+ if (rafRef.current !== null) return;
88
+ rafRef.current = requestAnimationFrame(() => {
89
+ rafRef.current = null;
90
+ const w = latestWidthRef.current;
91
+ if (w === null) return;
92
+ const next = resolveBreakpoint(w, thresholdsRef.current);
93
+ setBreakpoint((prev) => (prev === next ? prev : next));
94
+ });
95
+ });
96
+
97
+ observer.observe(el);
98
+ return () => {
99
+ observer.disconnect();
100
+ if (rafRef.current !== null) {
101
+ cancelAnimationFrame(rafRef.current);
102
+ rafRef.current = null;
103
+ }
104
+ };
105
+ // depsKey is the stable projection of `thresholds`; `ref` is an
106
+ // object reference that callers typically hold stable via useRef.
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [ref, depsKey]);
109
+
110
+ return breakpoint;
111
+ }
@@ -25,7 +25,6 @@ import type {
25
25
  } from "../../api/public-types";
26
26
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
27
27
  import { TwScopeCardLayer } from "./tw-scope-card-layer";
28
- import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
29
28
  import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
30
29
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
31
30
  import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
@@ -219,14 +218,6 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
219
218
  data-testid={testId ?? "chrome-overlay"}
220
219
  role="presentation"
221
220
  >
222
- {pageStackScrollRoot !== undefined ? (
223
- <TwPageStackOverlayLayer
224
- facet={facet}
225
- scrollRoot={pageStackScrollRoot}
226
- renderFrameRevision={renderFrameRevision ?? 0}
227
- visiblePageIndexRange={visiblePageIndexRange ?? null}
228
- />
229
- ) : null}
230
221
  {pageStackScrollRoot !== undefined ? (
231
222
  <TwPageStackChromeLayer
232
223
  facet={facet}
@@ -102,6 +102,7 @@ export function TwObjectSelectionOverlay({
102
102
  <div
103
103
  ref={overlayRef}
104
104
  style={boxStyle}
105
+ data-chrome-overlay=""
105
106
  data-object-selection=""
106
107
  data-object-id={grabbedObjectId}
107
108
  aria-label="Selected object"
@@ -628,13 +628,12 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
628
628
  height: `${rect.heightPx}px`,
629
629
  left: 0,
630
630
  right: 0,
631
- // The overlay paints decorative paper-edge treatment (border
632
- // + soft shadow) around each page's vertical slice. It sits
633
- // ABOVE the PM surface in stacking order so we deliberately
634
- // leave `backgroundColor` unset a filled overlay would
635
- // occlude PM text. Border + box-shadow alone give the
636
- // 'distinct paper' perception without covering content.
637
- backgroundColor: "transparent",
631
+ // N1 (L8 Phase D): this component is placed at z-0 BEFORE
632
+ // the z-10 PM wrapper inside `wre-page-surface`, so an opaque
633
+ // page background here sits behind PM text rather than on top
634
+ // of it. White card + border + shadow gives the 'N distinct
635
+ // papers on a gray canvas' appearance.
636
+ backgroundColor: "var(--color-page-bg, white)",
638
637
  border: "1px solid var(--color-page-border, rgba(148,163,184,0.2))",
639
638
  borderRadius: "var(--radius-page, 4px)",
640
639
  boxShadow:
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  isSupportedShapeGeometry,
10
10
  renderShapeSvg,
11
+ type GradientFill,
11
12
  type ShapeFill,
12
13
  type ShapeLine,
13
14
  } from "./shape-renderer.ts";
@@ -83,6 +84,16 @@ function safeHexColor(raw: string | null | undefined): string | null {
83
84
  return HEX_COLOR_RE.test(raw) ? `#${raw}` : null;
84
85
  }
85
86
 
87
+ /** Strict CSS hex validator for inline style sinks. Accepts 3/4/6/8-digit hex with optional leading #. */
88
+ function safeFilterHexColor(raw: string | null | undefined): string | null {
89
+ if (!raw || raw === "auto") return null;
90
+ const trimmed = raw.trim();
91
+ if (!/^#?(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
92
+ return null;
93
+ }
94
+ return `#${trimmed.replace(/^#/, "").toUpperCase()}`;
95
+ }
96
+
86
97
  /** Validate a CSS color value (may already include #). Returns the value or null. */
87
98
  function safeCssColor(raw: string | null | undefined): string | null {
88
99
  if (!raw) return null;
@@ -124,6 +135,20 @@ function resolveMarkerJustificationCss(raw: string | null): string {
124
135
  }
125
136
  }
126
137
 
138
+ function resolveMarkerAlignCss(raw: string | null): string {
139
+ switch (raw) {
140
+ case "left":
141
+ return "left";
142
+ case "center":
143
+ return "center";
144
+ case "right":
145
+ case "both":
146
+ case "distribute":
147
+ default:
148
+ return "right";
149
+ }
150
+ }
151
+
127
152
  /**
128
153
  * ProseMirror schema for the supported live surface slice.
129
154
  *
@@ -147,8 +172,10 @@ export const editorSchema = new Schema({
147
172
  numberingPrefix: { default: null },
148
173
  numberingSuffix: { default: null },
149
174
  numberingMarkerWidth: { default: null },
175
+ numberingMarkerStart: { default: null },
150
176
  numberingMarkerJustification: { default: null },
151
177
  numberingMarkerRunProperties: { default: null },
178
+ numberingPicBulletSrc: { default: null },
152
179
  alignment: { default: null },
153
180
  spacingBefore: { default: null },
154
181
  spacingAfter: { default: null },
@@ -217,7 +244,7 @@ export const editorSchema = new Schema({
217
244
  else if (lineSpacing && lineRule === "exact") styles.push(`line-height: ${lineSpacing / 20}pt`);
218
245
  else if (lineSpacing && lineRule === "atLeast") styles.push(`min-height: ${lineSpacing / 20}pt`);
219
246
  const indentLeft = node.attrs.indentLeft as number | null;
220
- if (indentLeft) styles.push(`padding-left: ${indentLeft / 20}pt`);
247
+ if (indentLeft !== null) styles.push(`padding-left: ${indentLeft / 20}pt`);
221
248
  const indentRight = node.attrs.indentRight as number | null;
222
249
  if (indentRight) styles.push(`padding-right: ${indentRight / 20}pt`);
223
250
  const indentFirstLine = node.attrs.indentFirstLine as number | null;
@@ -271,7 +298,9 @@ export const editorSchema = new Schema({
271
298
  const numberingLevel = node.attrs.numberingLevel as number | null;
272
299
  const numberingSuffix = node.attrs.numberingSuffix as "tab" | "space" | "nothing" | null;
273
300
  const numberingMarkerWidth = node.attrs.numberingMarkerWidth as number | null;
301
+ const numberingMarkerStart = node.attrs.numberingMarkerStart as number | null;
274
302
  const numberingMarkerJustification = node.attrs.numberingMarkerJustification as string | null;
303
+ const numberingPicBulletSrc = node.attrs.numberingPicBulletSrc as string | null;
275
304
  const children: Array<string | number | readonly unknown[]> = [];
276
305
  if (pageBreak) {
277
306
  children.push([
@@ -285,10 +314,10 @@ export const editorSchema = new Schema({
285
314
  "Page break",
286
315
  ]);
287
316
  }
288
- if (numberingPrefix) {
317
+ if (numberingPrefix || numberingPicBulletSrc) {
289
318
  const hasResolvedMarkerWidth =
290
319
  typeof numberingMarkerWidth === "number" && numberingMarkerWidth > 0;
291
- const fallbackMinWidth = Math.min(Math.max(numberingPrefix.length + 1, 4), 14);
320
+ const fallbackMinWidth = Math.min(Math.max((numberingPrefix?.length ?? 1) + 1, 4), 14);
292
321
  const fallbackMarginRight =
293
322
  numberingSuffix === "nothing"
294
323
  ? "0.25rem"
@@ -315,7 +344,7 @@ export const editorSchema = new Schema({
315
344
 
316
345
  const prefixStyles = [
317
346
  `font-variant-numeric: tabular-nums`,
318
- `justify-content: ${resolveMarkerJustificationCss(numberingMarkerJustification)}`,
347
+ `text-align: ${resolveMarkerAlignCss(numberingMarkerJustification)}`,
319
348
  ];
320
349
 
321
350
  if (markerRunProperties) {
@@ -344,9 +373,11 @@ export const editorSchema = new Schema({
344
373
  `width: ${markerWidthPt}pt`,
345
374
  `min-width: ${markerWidthPt}pt`,
346
375
  `flex-basis: ${markerWidthPt}pt`,
376
+ `margin-left: -${markerWidthPt}pt`,
347
377
  `margin-right: 0`,
348
378
  `overflow: visible`,
349
379
  );
380
+ void numberingMarkerStart; // consumed via paragraph padding-left geometry
350
381
  } else {
351
382
  prefixStyles.push(
352
383
  `min-width: ${fallbackMinWidth}ch`,
@@ -359,14 +390,16 @@ export const editorSchema = new Schema({
359
390
  {
360
391
  class: baseClasses.join(" "),
361
392
  contenteditable: "false",
362
- "data-numbering-prefix": numberingPrefix,
393
+ "data-numbering-prefix": numberingPicBulletSrc ? "" : (numberingPrefix ?? ""),
363
394
  ...(typeof numberingLevel === "number"
364
395
  ? { "data-numbering-level": String(numberingLevel) }
365
396
  : {}),
366
397
  ...(numberingSuffix ? { "data-numbering-suffix": numberingSuffix } : {}),
367
398
  style: prefixStyles.join("; "),
368
399
  },
369
- numberingPrefix,
400
+ numberingPicBulletSrc
401
+ ? (["img", { src: numberingPicBulletSrc, alt: "", "aria-hidden": "true", style: "max-width:100%;max-height:100%;object-fit:contain;display:block;" }] as readonly unknown[])
402
+ : (numberingPrefix ?? ""),
370
403
  ]);
371
404
  }
372
405
  children.push([
@@ -465,6 +498,8 @@ export const editorSchema = new Schema({
465
498
  wrapMode: { default: null },
466
499
  distMargins: { default: null },
467
500
  positionH: { default: null },
501
+ // Lane 6d N9.b — polygon clip for tight/through wrap.
502
+ wrapPolygon: { default: null },
468
503
  // Lane 6d N11.b — CSS filter effects (soft-edge, outer shadow, glow).
469
504
  softEdgeRadius: { default: null },
470
505
  outerShadow: { default: null },
@@ -504,22 +539,46 @@ export const editorSchema = new Schema({
504
539
  const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
505
540
  const outerShadow = node.attrs.outerShadow as {
506
541
  blurRad: number; dist: number; dir: number; color: string;
542
+ colorType: "srgbClr" | "schemeClr";
543
+ } | null;
544
+ const glow = node.attrs.glow as {
545
+ radius: number; color: string;
546
+ colorType: "srgbClr" | "schemeClr";
507
547
  } | null;
508
- const glow = node.attrs.glow as { radius: number; color: string } | null;
509
548
  const filterParts: string[] = [];
510
549
  if (softEdgeRadius) {
511
550
  filterParts.push(`blur(${(softEdgeRadius / EMU_PER_PX).toFixed(2)}px)`);
512
551
  }
552
+ // Defense in depth: even though parse-picture.ts validates
553
+ // srgbClr@val against a strict hex allowlist, re-validate here at
554
+ // the CSS sink so a future parser refactor or a bypass that lands
555
+ // attacker-controlled text in node.attrs cannot escape
556
+ // `drop-shadow(#…)` into arbitrary CSS (e.g. `FF0000) url(…)/*`).
557
+ // safeFilterHexColor returns `#RRGGBB` on valid hex input and
558
+ // empty string otherwise, so schemeClr tokens (e.g. "accent1")
559
+ // naturally skip this branch until a theme resolver runs.
560
+ const safeFilterHexColor = (raw: unknown): string => {
561
+ return typeof raw === "string" &&
562
+ /^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(raw)
563
+ ? `#${raw.toUpperCase()}`
564
+ : "";
565
+ };
513
566
  if (glow) {
514
- filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px #${glow.color})`);
567
+ const glowColor = safeFilterHexColor(glow.color);
568
+ if (glowColor) {
569
+ filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px ${glowColor})`);
570
+ }
515
571
  }
516
572
  if (outerShadow) {
517
- const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
518
- const distPx = outerShadow.dist / EMU_PER_PX;
519
- const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
520
- const dx = (distPx * Math.cos(dirRad)).toFixed(2);
521
- const dy = (distPx * Math.sin(dirRad)).toFixed(2);
522
- filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px #${outerShadow.color})`);
573
+ const shadowColor = safeFilterHexColor(outerShadow.color);
574
+ if (shadowColor) {
575
+ const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
576
+ const distPx = outerShadow.dist / EMU_PER_PX;
577
+ const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
578
+ const dx = (distPx * Math.cos(dirRad)).toFixed(2);
579
+ const dy = (distPx * Math.sin(dirRad)).toFixed(2);
580
+ filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px ${shadowColor})`);
581
+ }
523
582
  }
524
583
  // N9 float-wrap → CSS float + shape-outside on the wrapper span.
525
584
  const wrapMode = node.attrs.wrapMode as string | null;
@@ -527,8 +586,16 @@ export const editorSchema = new Schema({
527
586
  const distMargins = node.attrs.distMargins as
528
587
  | { top?: number; bottom?: number; left?: number; right?: number }
529
588
  | null;
589
+ const wrapPolygon = node.attrs.wrapPolygon as Array<{ x: number; y: number }> | null;
530
590
  const wrapperStyleParts: string[] = [];
531
- if (isFloating && wrapMode === "square") {
591
+ if (isFloating && (wrapMode === "tight" || wrapMode === "through") && wrapPolygon?.length) {
592
+ // N9.b — polygon clip: OOXML wrapPolygon coords are in 21600ths-of-image units.
593
+ const floatSide = positionH?.align === "right" ? "right" : "left";
594
+ const pts = wrapPolygon
595
+ .map((p) => `${(p.x / 21600 * 100).toFixed(2)}% ${(p.y / 21600 * 100).toFixed(2)}%`)
596
+ .join(", ");
597
+ wrapperStyleParts.push(`float:${floatSide}`, `shape-outside:polygon(${pts})`);
598
+ } else if (isFloating && wrapMode === "square") {
532
599
  const floatSide = positionH?.align === "right" ? "right" : "left";
533
600
  wrapperStyleParts.push(
534
601
  `float:${floatSide}`,
@@ -898,14 +965,7 @@ export const editorSchema = new Schema({
898
965
  const geometry = node.attrs.geometry as string | null;
899
966
  const fill = node.attrs.fill as
900
967
  | ShapeFill
901
- | {
902
- kind: "gradient";
903
- stops: Array<{ pos: number; color: string; colorType: "srgbClr" | "schemeClr" }>;
904
- direction:
905
- | { kind: "linear"; angle: number; scaled?: boolean }
906
- | { kind: "path"; path: "circle" | "rect" | "shape" };
907
- rotWithShape?: boolean;
908
- }
968
+ | GradientFill
909
969
  | {
910
970
  kind: "pattern";
911
971
  preset: string;
@@ -917,11 +977,13 @@ export const editorSchema = new Schema({
917
977
  const heightEmu = node.attrs.heightEmu as number | null;
918
978
  const widthPx = widthEmu ? Math.max(8, Math.round(widthEmu / EMU_PER_PX)) : null;
919
979
  const heightPx = heightEmu ? Math.max(8, Math.round(heightEmu / EMU_PER_PX)) : null;
980
+ // N10.b — gradient fills pass through to renderShapeSvg (SVG defs path).
981
+ // Pattern fills remain unsupported → chip fallback.
920
982
  const svgFill =
921
983
  fill === undefined || fill === null
922
984
  ? undefined
923
- : fill.kind === "solid" || fill.kind === "none"
924
- ? fill
985
+ : fill.kind === "solid" || fill.kind === "none" || fill.kind === "gradient"
986
+ ? (fill as ShapeFill | GradientFill)
925
987
  : undefined;
926
988
  // N10 — try SVG render path for supported geometries with extent.
927
989
  if (
@@ -374,8 +374,14 @@ function buildParagraph(
374
374
  paragraphLayout.indentation.firstLine < 0
375
375
  ? Math.abs(paragraphLayout.indentation.firstLine)
376
376
  : null),
377
+ numberingMarkerStart: paragraphLayout.markerLane?.start ?? null,
377
378
  numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
378
379
  numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
380
+ numberingPicBulletSrc: (() => {
381
+ const mediaId = block.resolvedNumbering?.picBulletMediaId;
382
+ if (!mediaId) return null;
383
+ return mediaPreviews[mediaId]?.src ?? null;
384
+ })(),
379
385
  shadingFill: block.shading?.fill ?? cascade?.shading?.fill ?? null,
380
386
  borderTop: (block.borders as Record<string, unknown>)?.top ?? cascadeBorders?.top ?? null,
381
387
  borderBottom: (block.borders as Record<string, unknown>)?.bottom ?? cascadeBorders?.bottom ?? null,
@@ -464,6 +470,8 @@ function buildInlineContent(
464
470
  wrapMode: segment.anchor?.wrapMode ?? null,
465
471
  distMargins: segment.anchor?.distMargins ?? null,
466
472
  positionH: segment.anchor?.positionH ?? null,
473
+ // Lane 6d N9.b — polygon clip.
474
+ wrapPolygon: segment.anchor?.wrapPolygon ?? null,
467
475
  // Lane 6d N11.b — filter effects.
468
476
  softEdgeRadius: segment.pictureEffects?.softEdgeRadius ?? null,
469
477
  outerShadow: segment.pictureEffects?.outerShadow ?? null,
@@ -587,6 +595,7 @@ function buildTable(
587
595
  {
588
596
  styleId: block.styleId ?? null,
589
597
  gridColumns: block.gridColumns,
598
+ gridColumnsRelative: block.gridColumnsRelative ?? null,
590
599
  alignment: block.alignment ?? null,
591
600
  tblLookFirstRow: block.tblLook?.firstRow ?? false,
592
601
  tblLookLastRow: block.tblLook?.lastRow ?? false,
@@ -17,7 +17,7 @@
17
17
  * fill; `noLine: true` → `stroke: "none"`.
18
18
  */
19
19
 
20
- import { EMU_PER_PX } from "../../runtime/units";
20
+ import { EMU_PER_PX, ROTATION_UNITS_PER_DEGREE } from "../../runtime/units";
21
21
 
22
22
  const SUPPORTED_GEOMETRIES = new Set(["rect", "ellipse", "roundRect"]);
23
23
 
@@ -81,9 +81,18 @@ export function resolveLineCss(line: ShapeLine | undefined): ResolvedLineCss {
81
81
  return { stroke, strokeWidth };
82
82
  }
83
83
 
84
+ export type GradientFill = {
85
+ kind: "gradient";
86
+ stops: Array<{ pos: number; color: string; colorType: "srgbClr" | "schemeClr" }>;
87
+ direction:
88
+ | { kind: "linear"; angle: number; scaled?: boolean }
89
+ | { kind: "path"; path: "circle" | "rect" | "shape" };
90
+ rotWithShape?: boolean;
91
+ };
92
+
84
93
  export interface ShapeSegmentLike {
85
94
  geometry?: string;
86
- fill?: ShapeFill;
95
+ fill?: ShapeFill | GradientFill;
87
96
  line?: ShapeLine;
88
97
  }
89
98
 
@@ -94,6 +103,44 @@ export interface ShapeSegmentLike {
94
103
  */
95
104
  export type SvgSpec = readonly [string, Record<string, string>, ...SvgSpec[]];
96
105
 
106
+ /**
107
+ * Build an SVG `<defs>` element containing a `<linearGradient>` or
108
+ * `<radialGradient>` for the given gradient fill. `id` is the gradient
109
+ * element ID referenced via `fill="url(#id)"` on the geometry element.
110
+ *
111
+ * OOXML linear gradient angle: 60000ths of a degree, clockwise from north.
112
+ * Converted to SVG objectBoundingBox coordinates via:
113
+ * x1 = 0.5 − 0.5·sin(θ), y1 = 0.5 + 0.5·cos(θ)
114
+ * x2 = 0.5 + 0.5·sin(θ), y2 = 0.5 − 0.5·cos(θ)
115
+ * where θ is in radians.
116
+ *
117
+ * OOXML stop pos: 0–100000 (= 0–100%).
118
+ */
119
+ function renderGradientDefs(fill: GradientFill, id: string): SvgSpec {
120
+ const stopEls: SvgSpec[] = fill.stops.map(
121
+ (s): SvgSpec => [
122
+ "stop",
123
+ {
124
+ offset: `${(s.pos / 1000).toFixed(2)}%`,
125
+ "stop-color": s.colorType === "srgbClr" ? `#${s.color}` : "currentColor",
126
+ },
127
+ ],
128
+ );
129
+
130
+ let gradEl: SvgSpec;
131
+ if (fill.direction.kind === "linear") {
132
+ const rad = (fill.direction.angle / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
133
+ const x1 = (0.5 - 0.5 * Math.sin(rad)).toFixed(4);
134
+ const y1 = (0.5 + 0.5 * Math.cos(rad)).toFixed(4);
135
+ const x2 = (0.5 + 0.5 * Math.sin(rad)).toFixed(4);
136
+ const y2 = (0.5 - 0.5 * Math.cos(rad)).toFixed(4);
137
+ gradEl = ["linearGradient", { id, x1, y1, x2, y2, gradientUnits: "objectBoundingBox" }, ...stopEls];
138
+ } else {
139
+ gradEl = ["radialGradient", { id, cx: "50%", cy: "50%", r: "50%", gradientUnits: "objectBoundingBox" }, ...stopEls];
140
+ }
141
+ return ["defs", {}, gradEl];
142
+ }
143
+
97
144
  /**
98
145
  * Render a supported geometry into a PM-compatible DOMOutputSpec tree
99
146
  * for an inline `<svg>`. Returns `null` when the geometry is unsupported
@@ -101,6 +148,9 @@ export type SvgSpec = readonly [string, Record<string, string>, ...SvgSpec[]];
101
148
  *
102
149
  * The SVG is sized 1:1 to its container; the wrapper span owns the
103
150
  * outer `width:Xpx; height:Ypx`.
151
+ *
152
+ * Gradient fills: emits `<defs><linearGradient>` / `<radialGradient>` and
153
+ * references it via `fill="url(#g0)"` on the geometry element.
104
154
  */
105
155
  export function renderShapeSvg(
106
156
  segment: ShapeSegmentLike,
@@ -111,7 +161,19 @@ export function renderShapeSvg(
111
161
  if (!segment.geometry || !SUPPORTED_GEOMETRIES.has(segment.geometry)) {
112
162
  return null;
113
163
  }
114
- const fillCss = resolveFillCss(segment.fill);
164
+
165
+ let fillAttr: string;
166
+ let gradDefs: SvgSpec | null = null;
167
+ if (segment.fill && segment.fill.kind === "gradient") {
168
+ gradDefs = renderGradientDefs(segment.fill, "g0");
169
+ fillAttr = "url(#g0)";
170
+ } else {
171
+ fillAttr = resolveFillCss(segment.fill as ShapeFill | undefined).fill;
172
+ }
173
+
174
+ // Keep resolveFillCss call for non-gradient so the isSchemePlaceholder
175
+ // warning path (future) still fires when needed.
176
+ const fillCss = { fill: fillAttr, isSchemePlaceholder: false };
115
177
  const lineCss = resolveLineCss(segment.line);
116
178
  const sw = lineCss.strokeWidth;
117
179
  // Inset the geometry by half the stroke so the stroke paints inside
@@ -184,17 +246,17 @@ export function renderShapeSvg(
184
246
  // `xmlns` *attribute* after createElement() is meaningless — the
185
247
  // resulting node is HTMLUnknownElement and won't paint as SVG.
186
248
  // Children inherit the namespace, so the geometry tag stays bare.
187
- return [
188
- "http://www.w3.org/2000/svg svg",
189
- {
190
- viewBox: `0 0 ${widthPx} ${heightPx}`,
191
- width: String(widthPx),
192
- height: String(heightPx),
193
- preserveAspectRatio: "none",
194
- "aria-hidden": "true",
195
- },
196
- geometryEl,
197
- ];
249
+ const svgAttrs = {
250
+ viewBox: `0 0 ${widthPx} ${heightPx}`,
251
+ width: String(widthPx),
252
+ height: String(heightPx),
253
+ preserveAspectRatio: "none",
254
+ "aria-hidden": "true",
255
+ };
256
+ if (gradDefs) {
257
+ return ["http://www.w3.org/2000/svg svg", svgAttrs, gradDefs, geometryEl];
258
+ }
259
+ return ["http://www.w3.org/2000/svg svg", svgAttrs, geometryEl];
198
260
  }
199
261
 
200
262
  /**