@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -1,5 +1,6 @@
1
1
  import { NodeSelection, Plugin, PluginKey } from "prosemirror-state";
2
- import { columnResizing, goToNextCell, isInTable, tableEditing } from "prosemirror-tables";
2
+ import { columnResizing, goToNextCell, isInTable, selectedRect, tableEditing, TableMap } from "prosemirror-tables";
3
+ import type { EditorState } from "prosemirror-state";
3
4
 
4
5
  import type { SelectionSnapshot } from "../../api/public-types";
5
6
  import {
@@ -12,6 +13,7 @@ import {
12
13
  type PastePlainSegment,
13
14
  } from "./paste-plain-text";
14
15
  import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
16
+ import { parseCanonicalFragmentFromHtml } from "../../io/paste/html-clipboard";
15
17
  import type { PositionMap } from "./pm-position-map";
16
18
 
17
19
  /**
@@ -34,6 +36,71 @@ function readWordMLPayload(clipboard: DataTransfer): string | null {
34
36
  return null;
35
37
  }
36
38
 
39
+ /**
40
+ * I2 Tier B Slice 5 follow-up — MIME types for image paste. `DataTransfer.items`
41
+ * exposes binary payloads (unlike `getData` which is string-only), so we
42
+ * iterate items looking for an image-typed entry.
43
+ */
44
+ const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
45
+
46
+ /**
47
+ * Synchronous check for whether the clipboard holds at least one image item.
48
+ * Used to decide whether to `return true` from `handlePaste` (blocking PM's
49
+ * default) before the async `readImagePayload` resolves. Side-effect free.
50
+ */
51
+ function hasImageItem(clipboard: DataTransfer): boolean {
52
+ const items = clipboard.items;
53
+ if (!items || items.length === 0) return false;
54
+ for (let i = 0; i < items.length; i += 1) {
55
+ const item = items[i];
56
+ if (item.kind === "file" && IMAGE_MIMES.has(item.type)) return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ async function readImagePayload(
62
+ clipboard: DataTransfer,
63
+ ): Promise<{ data: Uint8Array; mimeType: string } | null> {
64
+ const items = clipboard.items;
65
+ if (!items || items.length === 0) return null;
66
+ for (let i = 0; i < items.length; i += 1) {
67
+ const item = items[i];
68
+ if (item.kind !== "file") continue;
69
+ if (!IMAGE_MIMES.has(item.type)) continue;
70
+ const file = item.getAsFile();
71
+ if (!file) continue;
72
+ const buffer = await file.arrayBuffer();
73
+ return { data: new Uint8Array(buffer), mimeType: item.type };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * I3 widening tail — detect whether the PM selection is inside the last cell
80
+ * of the last row of its containing table. Used to gate Tab-at-last-cell
81
+ * implicit row-insert. Returns false if not in a table, if the cell resolution
82
+ * fails, or if the selection is anywhere other than the bottom-right cell.
83
+ *
84
+ * Uses `TableMap` + `selectedRect` from prosemirror-tables — the same
85
+ * primitives the table command surface consumes — so the computation matches
86
+ * what the runtime sees when it dispatches `addRowAfter`.
87
+ */
88
+ function isAtLastCellOfTable(state: EditorState): boolean {
89
+ if (!isInTable(state)) return false;
90
+ try {
91
+ const rect = selectedRect(state);
92
+ const { map } = rect;
93
+ // `rect.bottom` / `rect.right` are one past the selection's bottom-right
94
+ // cell coordinates; a "tail cell" means the selection is in the last
95
+ // row AND the last column of the table.
96
+ return rect.bottom === map.height && rect.right === map.width;
97
+ } catch {
98
+ // selectedRect throws if there's no cell at the selection (rare edge
99
+ // case). Treat as "not at tail" — safest fallback.
100
+ return false;
101
+ }
102
+ }
103
+
37
104
  /**
38
105
  * Callback subset used by paste / drop dispatch. Exported so tests can
39
106
  * record dispatch order without constructing the full
@@ -98,6 +165,13 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
98
165
  onInsertHardBreak: () => void;
99
166
  onInsertTab: () => void;
100
167
  onOutdentTab?: () => void;
168
+ /**
169
+ * I3 widening tail — optional. Fires on Tab at the last cell of the last
170
+ * row of a table (Word-matching row-insert behavior). Host should dispatch
171
+ * `addRowAfter` via the runtime so track-changes + collab replay stay
172
+ * consistent. When omitted, Tab at table tail is a no-op.
173
+ */
174
+ onTableInsertRowBelow?: () => void;
101
175
  onListIndent?: () => void;
102
176
  onListOutdent?: () => void;
103
177
  onUndo: () => void;
@@ -124,7 +198,37 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
124
198
  */
125
199
  onPasteFragment?: (meta: {
126
200
  fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
127
- source: "wordml";
201
+ source: "wordml" | "html";
202
+ }) => void;
203
+ /**
204
+ * I2 Tier B Slice 5 follow-up — optional. Fires when the paste / drop
205
+ * payload carries an image MIME type (`image/png`, `image/jpeg`,
206
+ * `image/gif`, `image/webp`). The host wires this to
207
+ * `WordReviewEditorRef.insertImage({ data, mimeType })`. Without this
208
+ * callback, image payloads fall through to `onBlockedInput` so the host
209
+ * can show its own "image paste not supported" toast.
210
+ */
211
+ onPasteImage?: (meta: {
212
+ data: Uint8Array;
213
+ mimeType: string;
214
+ source: "paste" | "drop";
215
+ }) => void;
216
+ /**
217
+ * I2 Tier B Slice 5 — optional. Fires when a DataTransfer drop carries a
218
+ * WordprocessingML fragment payload. `effect` reflects the standard
219
+ * move-vs-copy convention: Ctrl (or Cmd on macOS) held → "copy" (source
220
+ * untouched), otherwise "move".
221
+ *
222
+ * `sourceRange` is populated (v5 follow-up) when the drag originated from
223
+ * within this editor — it gives the host enough information to delete the
224
+ * source range AFTER it has inserted the fragment at the drop point,
225
+ * completing the drag-to-move round trip. When `undefined`, the drag came
226
+ * from outside the editor (another app) and the OS owns source deletion.
227
+ */
228
+ onDropFragment?: (meta: {
229
+ fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
230
+ effect: "move" | "copy";
231
+ sourceRange?: { from: number; to: number };
128
232
  }) => void;
129
233
  /**
130
234
  * Optional. Fires on `compositionstart` (true) and `compositionend`
@@ -187,6 +291,12 @@ export function createCommandBridgePlugins(
187
291
  options?: CommandBridgePluginOptions,
188
292
  ): Plugin[] {
189
293
  let isComposing = false;
294
+ // v5 Slice 5 follow-up: same-editor drag tracking. Captured on `dragstart`
295
+ // (if the selection is non-empty at drag time), consumed on `drop` when
296
+ // `effect === "move"`, cleared on `dragend`. `null` outside of active
297
+ // drag cycles OR for external drags (payloads originating from another
298
+ // application, where the OS handles source removal).
299
+ let dragSourceRange: { from: number; to: number } | null = null;
190
300
 
191
301
  const filterPlugin = callbacks.gate ?? new Plugin({
192
302
  key: bridgeKey,
@@ -219,6 +329,40 @@ export function createCommandBridgePlugins(
219
329
  callbacks.onCompositionChange?.(false);
220
330
  return false;
221
331
  },
332
+ // v5 Slice 5 follow-up: capture the selection range at drag-start so
333
+ // drop-handler can delete the source on move-effect drop. Without
334
+ // this, same-editor drag-to-move is observationally identical to
335
+ // drag-to-copy — the fragment lands at the drop site but the source
336
+ // stays, leaving a duplicate.
337
+ dragstart(view, event) {
338
+ const dragEvent = event as DragEvent;
339
+ const sel = view.state.selection;
340
+ if (!sel || sel.empty) {
341
+ dragSourceRange = null;
342
+ return false;
343
+ }
344
+ const posMap = callbacks.getPositionMap?.();
345
+ if (!posMap) {
346
+ dragSourceRange = null;
347
+ return false;
348
+ }
349
+ const from = posMap.pmToRuntime(sel.from);
350
+ const to = posMap.pmToRuntime(sel.to);
351
+ if (from === to) {
352
+ dragSourceRange = null;
353
+ return false;
354
+ }
355
+ dragSourceRange = { from, to };
356
+ // Don't preventDefault — let PM/browser handle the visual drag.
357
+ return false;
358
+ },
359
+ dragend() {
360
+ // Clear the captured range after the drag cycle completes. For
361
+ // copy-effect drops this runs without having consumed the range;
362
+ // for move-effect drops the drop-handler already deleted it.
363
+ dragSourceRange = null;
364
+ return false;
365
+ },
222
366
  },
223
367
  handleTextInput(_view, _from, _to, text) {
224
368
  if (isComposing) {
@@ -247,7 +391,32 @@ export function createCommandBridgePlugins(
247
391
  return true;
248
392
  }
249
393
 
250
- // Tier B: WordprocessingML
394
+ // v5 close-out: Image paste — detect image MIME before WordML/HTML.
395
+ // Real-world copy-image-from-browser / copy-image-from-Word sources
396
+ // put the image as a binary clipboard item alongside an HTML fallback
397
+ // that links to the same image; the fallback chain would otherwise
398
+ // prefer the HTML (lower fidelity). Image check first wins.
399
+ if (callbacks.onPasteImage) {
400
+ // readImagePayload is async; fire-and-forget via the Promise chain.
401
+ // We return true immediately to block PM's default; if the promise
402
+ // resolves with no image, we've already blocked and the paste
403
+ // silently drops. Hosts that need strict "image or nothing"
404
+ // semantics can inspect `clipboardData.items` themselves before
405
+ // wiring the callback.
406
+ const imagePromise = readImagePayload(clipboard);
407
+ void imagePromise.then((img) => {
408
+ if (img) {
409
+ callbacks.onPasteImage!({ ...img, source: "paste" });
410
+ }
411
+ });
412
+ // Synchronous fast-path: if items tells us there's an image item
413
+ // available right now, consume the paste to avoid double-processing.
414
+ if (hasImageItem(clipboard)) {
415
+ return true;
416
+ }
417
+ }
418
+
419
+ // Tier B: WordprocessingML (highest fidelity when available)
251
420
  if (callbacks.onPasteFragment) {
252
421
  const wordml = readWordMLPayload(clipboard);
253
422
  if (wordml) {
@@ -256,8 +425,20 @@ export function createCommandBridgePlugins(
256
425
  callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
257
426
  return true;
258
427
  }
259
- // Parse failed or empty — fall through to plain-text so the paste
260
- // still does something (defensive against malformed clipboard payloads).
428
+ // Parse failed or empty — fall through to HTML / plain-text so the
429
+ // paste still does something (defensive against malformed payloads).
430
+ }
431
+ }
432
+
433
+ // Tier B: HTML (Google Docs, Word web, Outlook-lite)
434
+ if (callbacks.onPasteFragment) {
435
+ const htmlPayload = clipboard.getData("text/html");
436
+ if (htmlPayload && htmlPayload.trim().length > 0) {
437
+ const parsed = parseCanonicalFragmentFromHtml(htmlPayload);
438
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
439
+ callbacks.onPasteFragment({ fragment: parsed.fragment, source: "html" });
440
+ return true;
441
+ }
261
442
  }
262
443
  }
263
444
 
@@ -280,15 +461,51 @@ export function createCommandBridgePlugins(
280
461
  return true;
281
462
  },
282
463
 
283
- // Plain-text drop: symmetric path extract text/plain from the
284
- // DataTransfer and dispatch through the same callbacks paste uses.
464
+ // Drop handler I2 Tier B Slice 5 adds fragment-drop + Ctrl-modifier
465
+ // (move vs. copy) semantics on top of the existing Tier A plain-text path.
466
+ //
467
+ // Preference order:
468
+ // 1. WordML fragment payload → dispatch via onDropFragment (fragment
469
+ // route, mirrors handlePaste → onPasteFragment). Ctrl flips
470
+ // move→copy semantics; the runtime decides source-deletion.
471
+ // 2. Plain text → dispatch through onInsertText + friends (Tier A).
472
+ // 3. Empty / non-text payload → onBlockedInput.
285
473
  handleDrop(_view, event) {
286
474
  if (isComposing) return true;
287
- const dt = (event as DragEvent).dataTransfer;
475
+ const dragEvent = event as DragEvent;
476
+ const dt = dragEvent.dataTransfer;
288
477
  if (!dt) {
289
478
  callbacks.onBlockedInput?.("drop", "Drop data was not available.");
290
479
  return true;
291
480
  }
481
+
482
+ // Tier B drop: WordprocessingML fragment
483
+ if (callbacks.onDropFragment) {
484
+ const wordml = readWordMLPayload(dt);
485
+ if (wordml) {
486
+ const parsed = parseCanonicalFragmentFromWordML(wordml);
487
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
488
+ const ctrlModifier = Boolean(dragEvent.ctrlKey || dragEvent.metaKey);
489
+ const effect = ctrlModifier ? "copy" : "move";
490
+ // Pass the captured source range only for move-effect drops
491
+ // originating from the same editor. `dragSourceRange` was set
492
+ // on `dragstart` if we're inside a same-editor drag cycle.
493
+ const sourceRange =
494
+ effect === "move" && dragSourceRange ? dragSourceRange : undefined;
495
+ callbacks.onDropFragment({
496
+ fragment: parsed.fragment,
497
+ effect,
498
+ ...(sourceRange ? { sourceRange } : {}),
499
+ });
500
+ // Clear immediately — the drop consumed the range; `dragend`
501
+ // is a no-op safety net.
502
+ dragSourceRange = null;
503
+ return true;
504
+ }
505
+ }
506
+ }
507
+
508
+ // Tier A drop: plain text (existing path)
292
509
  const plain = dt.getData("text/plain");
293
510
  if (!plain) {
294
511
  callbacks.onBlockedInput?.(
@@ -338,7 +555,10 @@ export function createCommandBridgePlugins(
338
555
  altKey: event.altKey,
339
556
  shiftKey: event.shiftKey,
340
557
  },
341
- { inTable: isInTable(view.state) },
558
+ {
559
+ inTable: isInTable(view.state),
560
+ isAtTableTail: isAtLastCellOfTable(view.state),
561
+ },
342
562
  );
343
563
 
344
564
  switch (resolution.kind) {
@@ -364,6 +584,13 @@ export function createCommandBridgePlugins(
364
584
  return true;
365
585
  case "navigate-table-cell":
366
586
  return goToNextCell(resolution.direction)(view.state, view.dispatch, view);
587
+ case "table-insert-row-below":
588
+ // I3 widening tail — Tab at the last cell of the last row inserts
589
+ // a new row below. Host routes the dispatch through the runtime's
590
+ // `addRowAfter` ref method so track-changes + collab replay stay
591
+ // consistent with every other row-insert path.
592
+ callbacks.onTableInsertRowBelow?.();
593
+ return true;
367
594
  case "history":
368
595
  if (resolution.history === "undo") {
369
596
  callbacks.onUndo();
@@ -5,6 +5,17 @@ import {
5
5
  tableCellNodeSpec,
6
6
  tableHeaderCellNodeSpec,
7
7
  } from "../../runtime/table-schema.ts";
8
+ import {
9
+ isSupportedShapeGeometry,
10
+ renderShapeSvg,
11
+ type ShapeFill,
12
+ type ShapeLine,
13
+ } from "./shape-renderer.ts";
14
+ import {
15
+ EMU_PER_PX,
16
+ ROTATION_UNITS_PER_DEGREE,
17
+ SRCRECT_UNITS_PER_PERCENT,
18
+ } from "../../runtime/units.ts";
8
19
 
9
20
  const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
10
21
  const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
@@ -442,6 +453,18 @@ export const editorSchema = new Schema({
442
453
  src: { default: null },
443
454
  widthEmu: { default: null },
444
455
  heightEmu: { default: null },
456
+ // Lane 6d N11 — picture effects from `SurfacePictureEffects`.
457
+ // Defaults are no-effect (null/false) so legacy callers stay
458
+ // visually identical. OOXML units: rotation = 60000ths of a
459
+ // degree; srcRect = 1/1000 of a percent.
460
+ rotation: { default: null },
461
+ flipH: { default: false },
462
+ flipV: { default: false },
463
+ srcRect: { default: null },
464
+ // Lane 6d N9 — float-wrap fields surfaced from `SurfaceDrawingAnchor`.
465
+ wrapMode: { default: null },
466
+ distMargins: { default: null },
467
+ positionH: { default: null },
445
468
  },
446
469
  toDOM(node) {
447
470
  const isMissing = node.attrs.state === "missing";
@@ -450,22 +473,72 @@ export const editorSchema = new Schema({
450
473
  const widthEmu = node.attrs.widthEmu as number | null;
451
474
  const heightEmu = node.attrs.heightEmu as number | null;
452
475
  if (!isMissing && src) {
453
- const widthPx = widthEmu ? Math.max(24, Math.round(widthEmu / 9525)) : undefined;
454
- const heightPx = heightEmu ? Math.max(24, Math.round(heightEmu / 9525)) : undefined;
476
+ const widthPx = widthEmu ? Math.max(24, Math.round(widthEmu / EMU_PER_PX)) : undefined;
477
+ const heightPx = heightEmu ? Math.max(24, Math.round(heightEmu / EMU_PER_PX)) : undefined;
478
+ // N11 picture effects → CSS transform + clip-path.
479
+ const rotation = node.attrs.rotation as number | null;
480
+ const flipH = Boolean(node.attrs.flipH);
481
+ const flipV = Boolean(node.attrs.flipV);
482
+ const srcRect = node.attrs.srcRect as
483
+ | { top: number; bottom: number; left: number; right: number }
484
+ | null;
485
+ const transformParts: string[] = [];
486
+ if (rotation && rotation !== 0) {
487
+ transformParts.push(`rotate(${(rotation / ROTATION_UNITS_PER_DEGREE).toFixed(3)}deg)`);
488
+ }
489
+ if (flipH) transformParts.push("scaleX(-1)");
490
+ if (flipV) transformParts.push("scaleY(-1)");
491
+ // OOXML srcRect uses 1/1000 of a percent (e.g. 5000 = 5%).
492
+ // CSS clip-path inset() uses literal percent.
493
+ const clipParts: string[] = [];
494
+ if (srcRect) {
495
+ clipParts.push(
496
+ `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
+ );
498
+ }
499
+ // N9 float-wrap → CSS float + shape-outside on the wrapper span.
500
+ const wrapMode = node.attrs.wrapMode as string | null;
501
+ const positionH = node.attrs.positionH as { align?: string } | null;
502
+ const distMargins = node.attrs.distMargins as
503
+ | { top?: number; bottom?: number; left?: number; right?: number }
504
+ | null;
505
+ const wrapperStyleParts: string[] = [];
506
+ if (isFloating && wrapMode === "square") {
507
+ const floatSide = positionH?.align === "right" ? "right" : "left";
508
+ wrapperStyleParts.push(
509
+ `float:${floatSide}`,
510
+ "shape-outside:margin-box",
511
+ );
512
+ } else if (isFloating && wrapMode === "topAndBottom") {
513
+ wrapperStyleParts.push("clear:both", "display:block");
514
+ }
515
+ // distMargins from EMU → px on the wrapper.
516
+ if (isFloating && distMargins) {
517
+ const m = (v?: number) => (v ? Math.round(v / EMU_PER_PX) : 0);
518
+ wrapperStyleParts.push(
519
+ `margin:${m(distMargins.top)}px ${m(distMargins.right)}px ${m(distMargins.bottom)}px ${m(distMargins.left)}px`,
520
+ );
521
+ }
455
522
  const style = [
456
523
  "display:inline-block",
457
524
  "vertical-align:middle",
458
- "margin:0 4px",
525
+ wrapperStyleParts.length === 0 ? "margin:0 4px" : "",
459
526
  widthPx ? `width:${widthPx}px` : "",
460
527
  heightPx ? `height:${heightPx}px` : "",
528
+ transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
529
+ clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
461
530
  ].filter(Boolean).join(";");
531
+ const wrapperStyle = wrapperStyleParts.join(";");
532
+ const wrapperAttrs: Record<string, string> = {
533
+ class: "inline-flex items-center rounded",
534
+ "data-node-type": "image",
535
+ title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Image",
536
+ };
537
+ if (wrapMode) wrapperAttrs["data-wrap-mode"] = wrapMode;
538
+ if (wrapperStyle) wrapperAttrs.style = wrapperStyle;
462
539
  return [
463
540
  "span",
464
- {
465
- class: "inline-flex items-center rounded",
466
- "data-node-type": "image",
467
- title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Image",
468
- },
541
+ wrapperAttrs,
469
542
  [
470
543
  "img",
471
544
  {
@@ -774,19 +847,123 @@ export const editorSchema = new Schema({
774
847
  text: { default: null },
775
848
  geometry: { default: null },
776
849
  detail: { default: null },
850
+ // V2c.5 additions — populated when the segment originates from a
851
+ // canonical `DrawingFrameNode` with `content.type === "shape"`.
852
+ // Legacy debug-mode opaque-preview consumers leave these at their
853
+ // defaults so the original toDOM output is unchanged.
854
+ label: { default: null },
855
+ fill: { default: null },
856
+ line: { default: null },
857
+ isTextBox: { default: false },
858
+ txbxText: { default: null },
859
+ wrapMode: { default: "none" },
860
+ display: { default: "inline" },
861
+ widthEmu: { default: null },
862
+ heightEmu: { default: null },
777
863
  },
778
864
  toDOM(node) {
865
+ // V2c.5: when the rich attrs are present (DrawingFrame source),
866
+ // render either an SVG (N10 supported geometries) or a chip
867
+ // fallback with shape-aware data attributes. Otherwise fall back
868
+ // to the legacy opaque-preview chip so existing harness debug
869
+ // previews stay visually identical.
870
+ const isV2c5 = node.attrs.label !== null || node.attrs.isTextBox === true;
871
+ if (isV2c5) {
872
+ const geometry = node.attrs.geometry as string | null;
873
+ const fill = node.attrs.fill as
874
+ | 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
+ }
883
+ | {
884
+ kind: "pattern";
885
+ preset: string;
886
+ fg?: { color: string; colorType: "srgbClr" | "schemeClr" };
887
+ bg?: { color: string; colorType: "srgbClr" | "schemeClr" };
888
+ }
889
+ | null;
890
+ const widthEmu = node.attrs.widthEmu as number | null;
891
+ const heightEmu = node.attrs.heightEmu as number | null;
892
+ const widthPx = widthEmu ? Math.max(8, Math.round(widthEmu / EMU_PER_PX)) : null;
893
+ const heightPx = heightEmu ? Math.max(8, Math.round(heightEmu / EMU_PER_PX)) : null;
894
+ const svgFill =
895
+ fill === undefined || fill === null
896
+ ? undefined
897
+ : fill.kind === "solid" || fill.kind === "none"
898
+ ? fill
899
+ : undefined;
900
+ // N10 — try SVG render path for supported geometries with extent.
901
+ if (
902
+ geometry &&
903
+ isSupportedShapeGeometry(geometry) &&
904
+ widthPx &&
905
+ heightPx &&
906
+ svgFill !== undefined
907
+ ) {
908
+ const svgSpec = renderShapeSvg(
909
+ {
910
+ geometry,
911
+ fill: svgFill,
912
+ line: node.attrs.line as ShapeLine | undefined,
913
+ },
914
+ widthPx,
915
+ heightPx,
916
+ );
917
+ if (svgSpec) {
918
+ return [
919
+ "span",
920
+ {
921
+ class: "inline-block align-middle mx-0.5",
922
+ style: `display:inline-block;width:${widthPx}px;height:${heightPx}px;vertical-align:middle`,
923
+ "data-node-type": "shape",
924
+ "data-shape-geometry": geometry,
925
+ "data-shape-wrap": (node.attrs.wrapMode as string) ?? "none",
926
+ contenteditable: "false",
927
+ title: (node.attrs.detail as string) || (node.attrs.label as string) || "Shape",
928
+ },
929
+ svgSpec as unknown as readonly [string, Record<string, string>],
930
+ ];
931
+ }
932
+ }
933
+ // Chip fallback for unsupported geometry / missing extent.
934
+ const isTextBox = Boolean(node.attrs.isTextBox);
935
+ const txbxText = node.attrs.txbxText as string | null;
936
+ const display =
937
+ isTextBox && txbxText
938
+ ? `\u25A1 ${txbxText}`
939
+ : isTextBox
940
+ ? "\u25A1 Text box"
941
+ : "\u25C7 Shape";
942
+ return [
943
+ "span",
944
+ {
945
+ class:
946
+ "inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs border-none text-secondary bg-surface",
947
+ "data-node-type": "shape",
948
+ "data-shape-geometry": (node.attrs.geometry as string) ?? "",
949
+ "data-shape-wrap": (node.attrs.wrapMode as string) ?? "none",
950
+ contenteditable: "false",
951
+ title: (node.attrs.detail as string) || (node.attrs.label as string) || "Shape",
952
+ },
953
+ display,
954
+ ];
955
+ }
779
956
  const text = node.attrs.text as string | null;
780
- const label = text ? `Shape: ${text}` : "Shape";
957
+ const legacyLabel = text ? `Shape: ${text}` : "Shape";
781
958
  return [
782
959
  "span",
783
960
  {
784
961
  class: "inline-flex items-center gap-1 mx-0.5 rounded border border-success/25 bg-success-soft px-1.5 py-0.5 text-xs text-success",
785
962
  "data-node-type": "shape_atom",
786
963
  contenteditable: "false",
787
- title: (node.attrs.detail as string) ?? label,
964
+ title: (node.attrs.detail as string) ?? legacyLabel,
788
965
  },
789
- "\u25A1 " + label,
966
+ "\u25A1 " + legacyLabel,
790
967
  ];
791
968
  },
792
969
  },
@@ -453,8 +453,17 @@ function buildInlineContent(
453
453
  display: segment.display ?? "inline",
454
454
  detail: segment.detail ?? null,
455
455
  src: preview?.src ?? null,
456
- widthEmu: preview?.widthEmu ?? null,
457
- heightEmu: preview?.heightEmu ?? null,
456
+ widthEmu: preview?.widthEmu ?? preview?.widthEmu ?? segment.anchor?.extent.widthEmu ?? null,
457
+ heightEmu: preview?.heightEmu ?? segment.anchor?.extent.heightEmu ?? null,
458
+ // Lane 6d N11 — picture effects.
459
+ rotation: segment.pictureEffects?.rotation ?? null,
460
+ flipH: segment.pictureEffects?.flipH ?? false,
461
+ flipV: segment.pictureEffects?.flipV ?? false,
462
+ srcRect: segment.pictureEffects?.srcRect ?? null,
463
+ // Lane 6d N9 — float-wrap.
464
+ wrapMode: segment.anchor?.wrapMode ?? null,
465
+ distMargins: segment.anchor?.distMargins ?? null,
466
+ positionH: segment.anchor?.positionH ?? null,
458
467
  }),
459
468
  ];
460
469
  }
@@ -462,6 +471,23 @@ function buildInlineContent(
462
471
  case "opaque_inline":
463
472
  return [buildOpaqueInlineOrComplexAtom(segment, mediaPreviews, showUnsupportedObjectPreviews)];
464
473
 
474
+ case "shape":
475
+ return [
476
+ editorSchema.nodes.shape_atom.create({
477
+ label: segment.label,
478
+ detail: segment.detail,
479
+ geometry: segment.geometry ?? null,
480
+ fill: segment.fill ?? null,
481
+ line: segment.line ?? null,
482
+ isTextBox: segment.isTextBox ?? false,
483
+ txbxText: segment.txbxText ?? null,
484
+ wrapMode: segment.anchor?.wrapMode ?? "none",
485
+ display: segment.anchor?.display ?? "inline",
486
+ widthEmu: segment.anchor?.extent.widthEmu ?? null,
487
+ heightEmu: segment.anchor?.extent.heightEmu ?? null,
488
+ }),
489
+ ];
490
+
465
491
  case "note_ref": {
466
492
  const text = editorSchema.text(
467
493
  segment.label,