@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
@@ -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,12 @@ 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 },
503
+ // Lane 6d N11.b — CSS filter effects (soft-edge, outer shadow, glow).
504
+ softEdgeRadius: { default: null },
505
+ outerShadow: { default: null },
506
+ glow: { default: null },
468
507
  },
469
508
  toDOM(node) {
470
509
  const isMissing = node.attrs.state === "missing";
@@ -496,14 +535,67 @@ export const editorSchema = new Schema({
496
535
  `inset(${(srcRect.top / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.right / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.bottom / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.left / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}%)`,
497
536
  );
498
537
  }
538
+ // N11.b filter effects → CSS filter on the img element.
539
+ const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
540
+ const outerShadow = node.attrs.outerShadow as {
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";
547
+ } | null;
548
+ const filterParts: string[] = [];
549
+ if (softEdgeRadius) {
550
+ filterParts.push(`blur(${(softEdgeRadius / EMU_PER_PX).toFixed(2)}px)`);
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
+ };
566
+ if (glow) {
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
+ }
571
+ }
572
+ if (outerShadow) {
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
+ }
582
+ }
499
583
  // N9 float-wrap → CSS float + shape-outside on the wrapper span.
500
584
  const wrapMode = node.attrs.wrapMode as string | null;
501
585
  const positionH = node.attrs.positionH as { align?: string } | null;
502
586
  const distMargins = node.attrs.distMargins as
503
587
  | { top?: number; bottom?: number; left?: number; right?: number }
504
588
  | null;
589
+ const wrapPolygon = node.attrs.wrapPolygon as Array<{ x: number; y: number }> | null;
505
590
  const wrapperStyleParts: string[] = [];
506
- 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") {
507
599
  const floatSide = positionH?.align === "right" ? "right" : "left";
508
600
  wrapperStyleParts.push(
509
601
  `float:${floatSide}`,
@@ -527,6 +619,7 @@ export const editorSchema = new Schema({
527
619
  heightPx ? `height:${heightPx}px` : "",
528
620
  transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
529
621
  clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
622
+ filterParts.length > 0 ? `filter:${filterParts.join(" ")}` : "",
530
623
  ].filter(Boolean).join(";");
531
624
  const wrapperStyle = wrapperStyleParts.join(";");
532
625
  const wrapperAttrs: Record<string, string> = {
@@ -872,14 +965,7 @@ export const editorSchema = new Schema({
872
965
  const geometry = node.attrs.geometry as string | null;
873
966
  const fill = node.attrs.fill as
874
967
  | ShapeFill
875
- | {
876
- kind: "gradient";
877
- stops: Array<{ pos: number; color: string; colorType: "srgbClr" | "schemeClr" }>;
878
- direction:
879
- | { kind: "linear"; angle: number; scaled?: boolean }
880
- | { kind: "path"; path: "circle" | "rect" | "shape" };
881
- rotWithShape?: boolean;
882
- }
968
+ | GradientFill
883
969
  | {
884
970
  kind: "pattern";
885
971
  preset: string;
@@ -891,11 +977,13 @@ export const editorSchema = new Schema({
891
977
  const heightEmu = node.attrs.heightEmu as number | null;
892
978
  const widthPx = widthEmu ? Math.max(8, Math.round(widthEmu / EMU_PER_PX)) : null;
893
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.
894
982
  const svgFill =
895
983
  fill === undefined || fill === null
896
984
  ? undefined
897
- : fill.kind === "solid" || fill.kind === "none"
898
- ? fill
985
+ : fill.kind === "solid" || fill.kind === "none" || fill.kind === "gradient"
986
+ ? (fill as ShapeFill | GradientFill)
899
987
  : undefined;
900
988
  // N10 — try SVG render path for supported geometries with extent.
901
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,12 @@ 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,
475
+ // Lane 6d N11.b — filter effects.
476
+ softEdgeRadius: segment.pictureEffects?.softEdgeRadius ?? null,
477
+ outerShadow: segment.pictureEffects?.outerShadow ?? null,
478
+ glow: segment.pictureEffects?.glow ?? null,
467
479
  }),
468
480
  ];
469
481
  }
@@ -583,6 +595,7 @@ function buildTable(
583
595
  {
584
596
  styleId: block.styleId ?? null,
585
597
  gridColumns: block.gridColumns,
598
+ gridColumnsRelative: block.gridColumnsRelative ?? null,
586
599
  alignment: block.alignment ?? null,
587
600
  tblLookFirstRow: block.tblLook?.firstRow ?? false,
588
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
  /**
@@ -71,6 +71,20 @@ export function resolveMarkerJustificationCss(raw: string | undefined): string {
71
71
  }
72
72
  }
73
73
 
74
+ export function resolveMarkerAlignCss(raw: string | undefined): React.CSSProperties["textAlign"] {
75
+ switch (raw) {
76
+ case "left":
77
+ return "left";
78
+ case "center":
79
+ return "center";
80
+ case "right":
81
+ case "both":
82
+ case "distribute":
83
+ default:
84
+ return "right";
85
+ }
86
+ }
87
+
74
88
  /** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
75
89
  export function buildParagraphStyle(
76
90
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
@@ -144,11 +158,12 @@ export function buildMarkerStyle(
144
158
  suffix: "tab" | "space" | "nothing" | undefined,
145
159
  markerRunProperties: CanonicalRunFormatting | undefined,
146
160
  markerWidth: number | undefined,
161
+ markerStart: number | undefined,
147
162
  markerJustification: string | undefined,
148
163
  ): React.CSSProperties {
149
164
  const style: React.CSSProperties = {
150
165
  fontVariantNumeric: "tabular-nums",
151
- justifyContent: resolveMarkerJustificationCss(markerJustification),
166
+ textAlign: resolveMarkerAlignCss(markerJustification),
152
167
  };
153
168
 
154
169
  if (markerRunProperties) {
@@ -180,8 +195,10 @@ export function buildMarkerStyle(
180
195
  style.width = `${markerWidthPt}pt`;
181
196
  style.minWidth = `${markerWidthPt}pt`;
182
197
  style.flexBasis = `${markerWidthPt}pt`;
198
+ style.marginLeft = `-${markerWidthPt}pt`;
183
199
  style.marginRight = 0;
184
200
  style.overflow = "visible";
201
+ void markerStart; // consumed via paragraph padding-left geometry
185
202
  } else {
186
203
  const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
187
204
  const fallbackMarginRight =
@@ -142,6 +142,7 @@ function ParagraphBlock({
142
142
  const resolvedNumbering = block.resolvedNumbering;
143
143
  const markerRunProperties = resolvedNumbering?.markerRunProperties;
144
144
  const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
145
+ const markerStart = resolvedNumbering?.geometry?.markerLane?.start;
145
146
  const markerJustification = resolvedNumbering?.geometry?.markerJustification;
146
147
 
147
148
  const prefixSpan =
@@ -164,6 +165,7 @@ function ParagraphBlock({
164
165
  numberingSuffix,
165
166
  markerRunProperties,
166
167
  markerWidth,
168
+ markerStart,
167
169
  markerJustification,
168
170
  )}
169
171
  >
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Node as PMNode } from "prosemirror-model";
12
12
  import type { NodeViewConstructor, ViewMutationRecord } from "prosemirror-view";
13
+ import { PERCENTAGE_PARTS } from "../../runtime/units.ts";
13
14
 
14
15
  // R2c: band class styles live in ./tw-table-bands.module.css. Consumers import
15
16
  // that stylesheet through their build pipeline (same pattern as editor-theme.css).
@@ -325,7 +326,7 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
325
326
  const tableWidthType = node.attrs.tableWidthType as string | null | undefined;
326
327
  let baseClasses = "border-collapse w-full my-2 text-sm";
327
328
  if (tableWidthType === "pct" && typeof tableWidth === "number") {
328
- // OOXML pct widths are fiftieths of a percent (5000 = 100%).
329
+ // OOXML pct widths are fiftieths of a percent (PERCENTAGE_PARTS = 100%).
329
330
  table.style.width = `${tableWidth / 50}%`;
330
331
  baseClasses = "border-collapse my-2 text-sm";
331
332
  } else if (tableWidthType === "dxa" && typeof tableWidth === "number") {
@@ -406,6 +407,14 @@ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
406
407
  const gridColumns = Array.isArray(node.attrs.gridColumns)
407
408
  ? (node.attrs.gridColumns as number[])
408
409
  : [];
410
+ // SOW gap G1 — percent widths win when the table itself is sized in
411
+ // percent. The relative array sums to 100 and comes from
412
+ // `computeRelativeGridColumns` in surface-projection so the column
413
+ // proportions track the container instead of the absolute `pt` widths
414
+ // sliding against it. `null` (the default) keeps the legacy pt path.
415
+ const gridColumnsRelative = Array.isArray(node.attrs.gridColumnsRelative)
416
+ ? (node.attrs.gridColumnsRelative as number[])
417
+ : null;
409
418
  const existing = Array.from(table.children).find(
410
419
  (child): child is HTMLTableColElement =>
411
420
  child instanceof (table.ownerDocument?.defaultView?.HTMLTableColElement ??
@@ -429,12 +438,19 @@ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
429
438
  while (colgroup.childElementCount > desired) {
430
439
  colgroup.lastElementChild?.remove();
431
440
  }
441
+ const usePct =
442
+ gridColumnsRelative !== null && gridColumnsRelative.length === desired;
432
443
  for (let i = 0; i < desired; i += 1) {
433
444
  const col = colgroup.children[i] as HTMLTableColElement;
434
445
  const twips = gridColumns[i] ?? 0;
435
446
  col.setAttribute("data-col-index", String(i));
436
447
  col.setAttribute("data-col-twips", String(twips));
437
- col.style.width = twips > 0 ? `${twips / 20}pt` : "";
448
+ if (usePct) {
449
+ const pct = gridColumnsRelative[i] ?? 0;
450
+ col.style.width = pct > 0 ? `${pct.toFixed(4)}%` : "";
451
+ } else {
452
+ col.style.width = twips > 0 ? `${twips / 20}pt` : "";
453
+ }
438
454
  }
439
455
 
440
456
  if (!existing) {
@@ -67,6 +67,15 @@ export {
67
67
  type CommandPaletteItem,
68
68
  type TwCommandPaletteProps,
69
69
  } from "./chrome/tw-command-palette";
70
+ export {
71
+ TwCommandPaletteMount,
72
+ type TwCommandPaletteMountProps,
73
+ } from "./chrome/tw-command-palette-mount";
74
+ export {
75
+ useContainerBreakpoint,
76
+ resolveBreakpoint,
77
+ type BreakpointMap,
78
+ } from "./chrome/use-container-breakpoint";
70
79
 
71
80
  // Collab chrome (P9) — mount when chromePreset === "collab"; each
72
81
  // component is pure presentational and takes snapshots + callbacks.
@@ -3,6 +3,16 @@ import type {
3
3
  PageLayoutSnapshot,
4
4
  SurfaceBlockSnapshot,
5
5
  } from "../api/public-types.ts";
6
+ import { findPageForOffset } from "../runtime/document-navigation.ts";
7
+ import {
8
+ DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
9
+ estimateBlockHeight,
10
+ estimateParagraphLineCount,
11
+ estimateParagraphLineHeight,
12
+ getUsableColumnWidth,
13
+ } from "../runtime/page-layout-estimation.ts";
14
+
15
+ const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
6
16
 
7
17
  export interface LineMarker {
8
18
  id: string;
@@ -14,14 +24,76 @@ export function computeLineMarkersIfEnabled(input: {
14
24
  pageLayout: PageLayoutSnapshot | undefined;
15
25
  surfaceBlocks: readonly SurfaceBlockSnapshot[];
16
26
  pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>;
17
- buildLineNumberMarkers: (
18
- blocks: readonly SurfaceBlockSnapshot[],
19
- pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
20
- ) => LineMarker[];
21
27
  }): LineMarker[] {
22
28
  if (!input.pageLayout?.lineNumbering) {
23
29
  return [];
24
30
  }
25
31
 
26
- return input.buildLineNumberMarkers(input.surfaceBlocks, input.pages);
32
+ return buildLineNumberMarkers(input.surfaceBlocks, input.pages);
33
+ }
34
+
35
+ function buildLineNumberMarkers(
36
+ blocks: readonly SurfaceBlockSnapshot[],
37
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
38
+ ): LineMarker[] {
39
+ const markers: LineMarker[] = [];
40
+ if (pages.length === 0) {
41
+ return markers;
42
+ }
43
+
44
+ let currentTopTwips = 0;
45
+ let lineNumber = 1;
46
+ let lastPageIndex = -1;
47
+ let lastSectionIndex = -1;
48
+
49
+ for (const block of blocks) {
50
+ const pageIndex = findPageForOffset(pages, block.from);
51
+ const page = pages[pageIndex];
52
+ if (!page) {
53
+ continue;
54
+ }
55
+
56
+ const lineNumbering = page.layout.lineNumbering;
57
+ const restartMode = lineNumbering?.restart ?? "newPage";
58
+ const restartStart = lineNumbering?.start ?? 1;
59
+ const countBy = Math.max(1, lineNumbering?.countBy ?? 1);
60
+ const columnWidth = getUsableColumnWidth(page.layout);
61
+
62
+ if (pageIndex !== lastPageIndex) {
63
+ if (restartMode === "newPage" || lastPageIndex === -1) {
64
+ lineNumber = restartStart;
65
+ }
66
+ lastPageIndex = pageIndex;
67
+ }
68
+ if (page.sectionIndex !== lastSectionIndex) {
69
+ if (restartMode === "newSection" || lastSectionIndex === -1) {
70
+ lineNumber = restartStart;
71
+ }
72
+ lastSectionIndex = page.sectionIndex;
73
+ }
74
+
75
+ if (block.kind === "paragraph" && lineNumbering) {
76
+ const lineCount = estimateParagraphLineCount(block, columnWidth);
77
+ const lineHeight = estimateParagraphLineHeight(block);
78
+ const suppress = block.suppressLineNumbers === true;
79
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
80
+ if (!suppress && (lineNumber - restartStart) % countBy === 0) {
81
+ markers.push({
82
+ id: `${block.blockId}-${lineIndex}`,
83
+ label: String(lineNumber),
84
+ topPx:
85
+ DOCUMENT_CONTENT_TOP_PADDING_PX +
86
+ (currentTopTwips + lineIndex * lineHeight) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
87
+ });
88
+ }
89
+ if (!suppress) {
90
+ lineNumber += 1;
91
+ }
92
+ }
93
+ }
94
+
95
+ currentTopTwips += estimateBlockHeight(block, columnWidth);
96
+ }
97
+
98
+ return markers;
27
99
  }