@beyondwork/docx-react-component 1.0.71 → 1.0.72

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 (75) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +243 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/replacement.ts +8 -0
  9. package/src/api/v3/ai/review.ts +342 -0
  10. package/src/api/v3/ai/stats.ts +62 -0
  11. package/src/api/v3/runtime/viewport.ts +181 -0
  12. package/src/api/v3/runtime/workflow.ts +114 -1
  13. package/src/api/v3/ui/_types.ts +35 -0
  14. package/src/api/v3/ui/index.ts +1 -0
  15. package/src/api/v3/ui/viewport.ts +112 -0
  16. package/src/compare/diff-engine.ts +2 -0
  17. package/src/core/commands/formatting-commands.ts +1 -0
  18. package/src/core/commands/table-structure-commands.ts +1 -0
  19. package/src/io/export/serialize-headers-footers.ts +1 -0
  20. package/src/io/export/serialize-main-document.ts +13 -0
  21. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  22. package/src/io/export/split-review-boundaries.ts +1 -0
  23. package/src/io/normalize/normalize-text.ts +11 -0
  24. package/src/io/ooxml/parse-main-document.ts +21 -5
  25. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  26. package/src/model/canonical-document.ts +401 -1
  27. package/src/runtime/formatting/formatting-context.ts +2 -1
  28. package/src/runtime/geometry/overlay-rects.ts +7 -10
  29. package/src/runtime/layout/layout-engine-version.ts +257 -1
  30. package/src/runtime/layout/paginated-layout-engine.ts +134 -8
  31. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  32. package/src/runtime/markdown-sanitizer.ts +21 -4
  33. package/src/runtime/render/render-kernel.ts +21 -1
  34. package/src/runtime/scopes/audit-bundle.ts +8 -0
  35. package/src/runtime/scopes/compiler-service.ts +1 -0
  36. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  37. package/src/runtime/scopes/replacement/apply.ts +49 -3
  38. package/src/runtime/scopes/semantic-scope-types.ts +8 -0
  39. package/src/runtime/surface-projection.ts +22 -0
  40. package/src/runtime/workflow/coordinator.ts +3 -0
  41. package/src/runtime/workflow/scope-writer.ts +34 -0
  42. package/src/session/export/embedded-reconstitute.ts +37 -3
  43. package/src/session/import/embedded-offload.ts +26 -1
  44. package/src/shell/media-previews.ts +8 -6
  45. package/src/ui/WordReviewEditor.tsx +1 -0
  46. package/src/ui/editor-surface-controller.tsx +11 -0
  47. package/src/ui/headless/selection-helpers.ts +2 -2
  48. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  49. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  51. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  52. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  53. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  54. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  55. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  56. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  57. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
  58. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  59. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  60. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  62. package/src/ui-tailwind/index.ts +4 -2
  63. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  64. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
  65. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  66. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  67. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  68. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
  69. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
  70. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  71. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
  72. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  73. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  74. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  75. package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
@@ -253,12 +253,26 @@ export const editorSchema = new Schema({
253
253
  else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}pt`);
254
254
  const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
255
255
  if (shadingColor) styles.push(`background-color: ${shadingColor}`);
256
+ // Paragraph borders. Reads the PublicBorderSpec shape
257
+ // (`{value, size, space, color}`) that `pm-state-from-snapshot.ts`
258
+ // forwards verbatim from `SurfaceBlockFragment.borders`. `size` is
259
+ // in eighths of a point (ECMA-376 §17.18.88 ST_Border); `value`
260
+ // follows ECMA-376 §17.18.2 ST_Border enumeration.
256
261
  for (const [side, attrName] of [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]] as const) {
257
- const border = node.attrs[attrName] as { color?: string; sz?: number; val?: string } | null;
258
- if (border && border.val && border.val !== "none") {
259
- const width = border.sz ? `${border.sz / 8}px` : "1px";
262
+ const border = node.attrs[attrName] as
263
+ | { color?: string; size?: number; space?: number; value?: string }
264
+ | null;
265
+ if (border && border.value && border.value !== "none" && border.value !== "nil") {
266
+ const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
260
267
  const color = safeHexColor(border.color ?? null) ?? "#000000";
261
- const bStyle = border.val === "dotted" ? "dotted" : border.val === "dashed" ? "dashed" : "solid";
268
+ const bStyle =
269
+ border.value === "double"
270
+ ? "double"
271
+ : border.value === "dashed" || border.value === "dashSmallGap"
272
+ ? "dashed"
273
+ : border.value === "dotted"
274
+ ? "dotted"
275
+ : "solid";
262
276
  styles.push(`border-${side}: ${width} ${bStyle} ${color}`);
263
277
  }
264
278
  }
@@ -101,10 +101,13 @@ export function findScrollAnchor(
101
101
 
102
102
  // Cold-open / pre-paint fallback — the single permitted DOM-origin
103
103
  // branch under `src/ui-tailwind/editor-surface/scroll-anchor.ts` per
104
- // the Slice-3 plan.
104
+ // the Slice-3 plan. Warm path flows through `geometryFacet.getBlock`
105
+ // above; this fallback fires only before the first render frame.
106
+ // geometry:allow-dom-fallback
105
107
  const rootRect = root.getBoundingClientRect();
106
108
  const rootTop = rootRect.top;
107
109
  for (const block of blocks) {
110
+ // geometry:allow-dom-fallback
108
111
  const rect = block.getBoundingClientRect();
109
112
  if (rect.bottom < rootTop) continue;
110
113
  const blockId = block.getAttribute("data-block-id");
@@ -153,7 +156,11 @@ export function restoreScrollAnchor(
153
156
  const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
154
157
  const block = root.querySelector<HTMLElement>(selector);
155
158
  if (!block) return;
159
+ // Cold-open / pre-paint DOM fallback — same rationale as
160
+ // findScrollAnchor's fallback above.
161
+ // geometry:allow-dom-fallback
156
162
  const rootRect = root.getBoundingClientRect();
163
+ // geometry:allow-dom-fallback
157
164
  const blockRect = block.getBoundingClientRect();
158
165
  // We want, post-restore, `blockRect.top === rootRect.top - offsetWithinBlock`
159
166
  // (i.e. the block's leading edge sits `offsetWithinBlock` px above the
@@ -15,17 +15,15 @@ import { Plugin, PluginKey } from "prosemirror-state";
15
15
  import type { EditorState, Transaction } from "prosemirror-state";
16
16
  import { Decoration, DecorationSet } from "prosemirror-view";
17
17
 
18
- import type {
19
- SearchOptions as PublicSearchOptions,
20
- } from "../../api/public-types";
21
18
  import {
19
+ type SearchOptions as PublicSearchOptions,
22
20
  buildSearchPattern,
23
21
  createSearchExcerpt,
24
22
  findSearchMatches,
25
23
  searchSecondaryStories,
26
24
  type SecondaryStorySearchResult,
27
25
  type SearchTextOptions,
28
- } from "../../core/search/search-text.ts";
26
+ } from "../../api/public-types";
29
27
 
30
28
  // ---------------------------------------------------------------------------
31
29
  // Public types
@@ -159,6 +159,43 @@ export function buildParagraphStyle(
159
159
  const shadingColor = safeHexColor(shadingFill);
160
160
  if (shadingColor) style.backgroundColor = shadingColor;
161
161
 
162
+ // Paragraph borders (`w:pBdr`). Mirror `pm-schema.ts::paragraph.toDOM`
163
+ // so the static render path (pages 2+, non-PM) paints the same
164
+ // borders as the PM-rendered page 1. Reads the PublicBorderSpec shape
165
+ // (`{value, size, space, color}`); `size` is eighths of a point.
166
+ const borders = block.borders;
167
+ if (borders) {
168
+ const sideToCss: Array<["top" | "bottom" | "left" | "right", keyof NonNullable<typeof borders>]> = [
169
+ ["top", "top"],
170
+ ["bottom", "bottom"],
171
+ ["left", "left"],
172
+ ["right", "right"],
173
+ ];
174
+ for (const [cssSide, key] of sideToCss) {
175
+ const border = borders[key];
176
+ if (!border || !border.value || border.value === "none" || border.value === "nil") continue;
177
+ const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
178
+ const color = safeHexColor(border.color ?? null) ?? "#000000";
179
+ const bStyle =
180
+ border.value === "double"
181
+ ? "double"
182
+ : border.value === "dashed" || border.value === "dashSmallGap"
183
+ ? "dashed"
184
+ : border.value === "dotted"
185
+ ? "dotted"
186
+ : "solid";
187
+ const cssKey =
188
+ cssSide === "top"
189
+ ? "borderTop"
190
+ : cssSide === "bottom"
191
+ ? "borderBottom"
192
+ : cssSide === "left"
193
+ ? "borderLeft"
194
+ : "borderRight";
195
+ (style as Record<string, string>)[cssKey] = `${width} ${bStyle} ${color}`;
196
+ }
197
+ }
198
+
162
199
  // Page break visual indicator
163
200
  if (block.pageBreakBefore) {
164
201
  style.borderTop = "2px dashed rgba(0,0,0,0.1)";
@@ -53,7 +53,7 @@ import { buildDecorations } from "./pm-decorations";
53
53
  import { buildPageBreakDecorations } from "./pm-page-break-decorations";
54
54
  import { DecorationSet } from "prosemirror-view";
55
55
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
56
- import { buildPagePreviewMaps } from "../../runtime/layout/resolve-page-previews";
56
+ import { buildPagePreviewMaps } from "../../api/public-types";
57
57
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
58
58
  import {
59
59
  finishPerfProbe,
@@ -65,7 +65,10 @@ import { buildPositionMap, type PositionMap } from "./pm-position-map";
65
65
  import { createLocalEditSessionState } from "./local-edit-session-state";
66
66
  import { createFastTextEditLane } from "./fast-text-edit-lane";
67
67
  import { createPredictedTxGate } from "./predicted-tx-gate";
68
- import { createScopeTagRegistry } from "../../runtime/workflow/scope-tag-registry";
68
+ import {
69
+ createScopeTagRegistry,
70
+ type ScopeTagRegistry,
71
+ } from "../../api/public-types";
69
72
  import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
70
73
  import {
71
74
  clearSearch as clearSearchPlugin,
@@ -265,6 +268,20 @@ export interface TwProseMirrorSurfaceProps {
265
268
  runtimeGetTableSelectionDescriptor?: (
266
269
  state: import("prosemirror-state").EditorState,
267
270
  ) => TableSelectionDescriptor | null;
271
+ /**
272
+ * Coord-10 L11-4 — host-supplied scope-tag registry factory.
273
+ * Threaded from `api.runtime.workflow.createScopeTagRegistry` at the
274
+ * WordReviewEditor mount. Mints a fresh default-seeded
275
+ * `ScopeTagRegistry` that the PM surface uses to drive annotation-
276
+ * family boundary behaviors (bail-on-cross, split-on-cross, etc.).
277
+ *
278
+ * When omitted, the surface falls back to the public-types re-export
279
+ * (`createScopeTagRegistry` from `src/api/public-types.ts`, shipped
280
+ * 2026-04-24 per refactor/11 §4.17 as a pragmatic pass-through).
281
+ * The fallback is retained for back-compat with any consumer that
282
+ * mounts the surface without threading an api handle (legacy tests).
283
+ */
284
+ scopeTagRegistryFactory?: () => ScopeTagRegistry;
268
285
  onPasteApplied?: (meta: {
269
286
  segmentCount: number;
270
287
  charCount: number;
@@ -435,9 +452,17 @@ export const TwProseMirrorSurface = forwardRef<
435
452
  const snapshotRef = useRef(snapshot);
436
453
  snapshotRef.current = snapshot;
437
454
 
455
+ // Coord-10 L11-4 — prefer the host-supplied `scopeTagRegistryFactory`
456
+ // (threaded from `api.runtime.workflow.createScopeTagRegistry` at the
457
+ // WordReviewEditor mount); fall back to the public-types re-export
458
+ // for legacy mounts that don't thread an api handle.
459
+ const scopeTagRegistryFactoryProp = props.scopeTagRegistryFactory;
438
460
  const scopeTagRegistry = useMemo(
439
- () => createScopeTagRegistry(),
440
- [],
461
+ () =>
462
+ scopeTagRegistryFactoryProp
463
+ ? scopeTagRegistryFactoryProp()
464
+ : createScopeTagRegistry(),
465
+ [scopeTagRegistryFactoryProp],
441
466
  );
442
467
 
443
468
  // Keep callbacks ref up to date (avoids stale closures in PM plugins)
@@ -112,11 +112,13 @@ export {
112
112
  type TwScopeRailLayerProps,
113
113
  } from "./chrome-overlay";
114
114
 
115
- // Session capabilities
115
+ // Session capabilities — sourced via `src/api/public-types` re-export
116
+ // so the public facade under `src/ui-tailwind/**` stays P3-clean.
117
+ // Canonical impl lives on `src/runtime/session-capabilities.ts`.
116
118
  export {
117
119
  deriveCapabilities,
118
120
  type SessionCapabilities,
119
- } from "../runtime/session-capabilities";
121
+ } from "../api/public-types";
120
122
 
121
123
  // Headless (re-export for convenience)
122
124
  export {
@@ -1,16 +1,14 @@
1
- import type {
2
- DocumentNavigationSnapshot,
3
- PageLayoutSnapshot,
4
- SurfaceBlockSnapshot,
5
- } from "../api/public-types.ts";
6
- import { findPageForOffset } from "../runtime/document-navigation.ts";
7
1
  import {
2
+ type DocumentNavigationSnapshot,
3
+ type PageLayoutSnapshot,
4
+ type SurfaceBlockSnapshot,
5
+ findPageForOffset,
8
6
  DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
9
7
  estimateBlockHeight,
10
8
  estimateParagraphLineCount,
11
9
  estimateParagraphLineHeight,
12
10
  getUsableColumnWidth,
13
- } from "../runtime/page-layout-estimation.ts";
11
+ } from "../api/public-types.ts";
14
12
 
15
13
  const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
16
14
 
@@ -7,8 +7,11 @@ import type {
7
7
  SurfaceInlineSegment,
8
8
  } from "../../api/public-types.ts";
9
9
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
10
- import { storyTargetKey } from "../../runtime/story-targeting.ts";
11
- import { EMU_PER_PX, TWIPS_PER_PX } from "../../api/public-types.ts";
10
+ import {
11
+ storyTargetKey,
12
+ EMU_PER_PX,
13
+ TWIPS_PER_PX,
14
+ } from "../../api/public-types.ts";
12
15
  import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
13
16
 
14
17
  /**
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -27,11 +28,13 @@ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
27
28
  export interface TwEndnoteAreaProps {
28
29
  blocks: readonly SurfaceBlockSnapshot[];
29
30
  "data-testid"?: string;
31
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
30
32
  }
31
33
 
32
34
  export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
33
35
  blocks,
34
36
  "data-testid": testId,
37
+ mediaPreviews,
35
38
  }) => {
36
39
  if (blocks.length === 0) return null;
37
40
  return (
@@ -49,7 +52,7 @@ export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
49
52
  marginBottom: "8pt",
50
53
  }}
51
54
  />
52
- <TwRegionBlockRenderer blocks={blocks} />
55
+ <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
53
56
  </div>
54
57
  );
55
58
  };
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -30,6 +31,7 @@ export interface TwFootnoteAreaProps {
30
31
  widthPx: number;
31
32
  heightPx: number;
32
33
  "data-testid"?: string;
34
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
33
35
  }
34
36
 
35
37
  export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
@@ -40,6 +42,7 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
40
42
  widthPx,
41
43
  heightPx,
42
44
  "data-testid": testId,
45
+ mediaPreviews,
43
46
  }) => {
44
47
  return (
45
48
  <div
@@ -63,7 +66,7 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
63
66
  marginBottom: "4pt",
64
67
  }}
65
68
  />
66
- <TwRegionBlockRenderer blocks={blocks} />
69
+ <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
67
70
  </div>
68
71
  );
69
72
  });
@@ -26,6 +26,7 @@ import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
26
26
  import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
27
27
  import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
28
28
  import { TwFootnoteArea } from "./tw-footnote-area.tsx";
29
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
29
30
 
30
31
  export interface TwPageChromeEntryProps {
31
32
  rect: PageOverlayRect;
@@ -36,6 +37,9 @@ export interface TwPageChromeEntryProps {
36
37
  onOpenStory?: (target: EditorStoryTarget) => void;
37
38
  visiblePageIndexRange?: { start: number; end: number } | null;
38
39
  renderFrameRevision: number;
40
+ /** Preview catalog threaded into header/footer/footnote region renderers
41
+ * so images (CCEP logos on 7-of-8 CCEP docs) render as real <img>s. */
42
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
39
43
  }
40
44
 
41
45
  function TwPageChromeEntryInner({
@@ -47,6 +51,7 @@ function TwPageChromeEntryInner({
47
51
  onOpenStory,
48
52
  visiblePageIndexRange,
49
53
  renderFrameRevision,
54
+ mediaPreviews,
50
55
  }: TwPageChromeEntryProps): React.ReactElement {
51
56
  const layout = page.layout;
52
57
  const headerStory = page.stories.header;
@@ -186,6 +191,7 @@ function TwPageChromeEntryInner({
186
191
  isActiveSlot={Boolean(headerActive)}
187
192
  sectionLabel={headerActive ? headerSectionLabel : undefined}
188
193
  onClick={handleHeaderClick}
194
+ mediaPreviews={mediaPreviews}
189
195
  />
190
196
  ) : null}
191
197
  {footerRegion && footerStory ? (
@@ -199,6 +205,7 @@ function TwPageChromeEntryInner({
199
205
  isActiveSlot={Boolean(footerActive)}
200
206
  sectionLabel={footerActive ? footerSectionLabel : undefined}
201
207
  onClick={handleFooterClick}
208
+ mediaPreviews={mediaPreviews}
202
209
  />
203
210
  ) : null}
204
211
  {footnoteRegion ? (
@@ -209,6 +216,7 @@ function TwPageChromeEntryInner({
209
216
  leftPx={bandLeftPx}
210
217
  widthPx={px(footnoteRegion.widthTwips)}
211
218
  heightPx={px(footnoteRegion.heightTwips)}
219
+ mediaPreviews={mediaPreviews}
212
220
  />
213
221
  ) : null}
214
222
  {continuationTableEntries.map(({ blockId, headerRows }) => (
@@ -240,7 +248,8 @@ function propsAreEqual(
240
248
  prev.renderFrameRevision === next.renderFrameRevision &&
241
249
  prev.rect.topPx === next.rect.topPx &&
242
250
  prev.rect.bottomPx === next.rect.bottomPx &&
243
- prev.rect.pageId === next.rect.pageId
251
+ prev.rect.pageId === next.rect.pageId &&
252
+ prev.mediaPreviews === next.mediaPreviews
244
253
  );
245
254
  }
246
255
 
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -34,6 +35,7 @@ export interface TwPageFooterBandProps {
34
35
  sectionLabel?: string;
35
36
  onClick: () => void;
36
37
  "data-testid"?: string;
38
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
37
39
  }
38
40
 
39
41
  export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
@@ -47,6 +49,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
47
49
  sectionLabel,
48
50
  onClick,
49
51
  "data-testid": testId,
52
+ mediaPreviews,
50
53
  }) => {
51
54
  return (
52
55
  <div
@@ -77,7 +80,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
77
80
  style={{ width: "100%", height: "100%" }}
78
81
  />
79
82
  ) : (
80
- <TwRegionBlockRenderer blocks={blocks} />
83
+ <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
81
84
  )}
82
85
  </div>
83
86
  );
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -35,6 +36,10 @@ export interface TwPageHeaderBandProps {
35
36
  sectionLabel?: string;
36
37
  onClick: () => void;
37
38
  "data-testid"?: string;
39
+ /** Preview catalog threaded to the region renderer so header images
40
+ * (CCEP logos on 7-of-8 CCEP docs) render as real <img>s instead of
41
+ * the 48×32 placeholder chip. */
42
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
38
43
  }
39
44
 
40
45
  export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
@@ -48,6 +53,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
48
53
  sectionLabel,
49
54
  onClick,
50
55
  "data-testid": testId,
56
+ mediaPreviews,
51
57
  }) => {
52
58
  return (
53
59
  <div
@@ -78,7 +84,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
78
84
  style={{ width: "100%", height: "100%" }}
79
85
  />
80
86
  ) : (
81
- <TwRegionBlockRenderer blocks={blocks} />
87
+ <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
82
88
  )}
83
89
  </div>
84
90
  );
@@ -145,6 +145,10 @@ export interface TwPageStackChromeLayerProps {
145
145
  visiblePageIndexRange?: { start: number; end: number } | null;
146
146
  /** Optional test id applied to the layer root. */
147
147
  "data-testid"?: string;
148
+ /** Preview catalog threaded through to per-page chrome bands so images
149
+ * in headers/footers/footnote bodies render as real <img>s. Without
150
+ * this, image segments fall back to the 48×32 gray placeholder chip. */
151
+ mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot.ts").MediaPreviewDescriptor>;
148
152
  }
149
153
 
150
154
  const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
@@ -157,6 +161,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
157
161
  pmView,
158
162
  visiblePageIndexRange,
159
163
  "data-testid": testId,
164
+ mediaPreviews,
160
165
  }) => {
161
166
  const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
162
167
  const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
@@ -401,10 +406,11 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
401
406
  onOpenStory={onOpenStory}
402
407
  visiblePageIndexRange={visiblePageIndexRange}
403
408
  renderFrameRevision={renderFrameRevision}
409
+ mediaPreviews={mediaPreviews}
404
410
  />
405
411
  );
406
412
  })}
407
- <TwEndnoteArea blocks={endnoteBlocks} />
413
+ <TwEndnoteArea blocks={endnoteBlocks} mediaPreviews={mediaPreviews} />
408
414
  </div>
409
415
  );
410
416
  };
@@ -4,6 +4,7 @@ import type {
4
4
  SurfaceBlockSnapshot,
5
5
  SurfaceInlineSegment,
6
6
  } from "../../api/public-types.ts";
7
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
7
8
  import {
8
9
  buildMarkerStyle,
9
10
  buildParagraphStyle,
@@ -13,6 +14,8 @@ import {
13
14
  resolveHeadingLevel,
14
15
  } from "../editor-surface/tw-page-block-view.helpers.ts";
15
16
 
17
+ const EMU_PER_PX = 9525;
18
+
16
19
  // ---------------------------------------------------------------------------
17
20
  // TwRegionBlockRenderer (P8.4)
18
21
  //
@@ -37,7 +40,10 @@ import {
37
40
  // Inline segment renderer — mirrors `tw-page-block-view`'s `renderSegment`.
38
41
  // ---------------------------------------------------------------------------
39
42
 
40
- function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
43
+ function renderSegment(
44
+ seg: SurfaceInlineSegment,
45
+ mediaPreviews: Record<string, MediaPreviewDescriptor>,
46
+ ): React.ReactNode {
41
47
  switch (seg.kind) {
42
48
  case "text": {
43
49
  const style = buildSegmentStyle(seg.marks, seg.markAttrs);
@@ -63,11 +69,46 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
63
69
  );
64
70
  case "hard_break":
65
71
  return <br key={seg.segmentId} />;
66
- case "image":
72
+ case "image": {
73
+ // Mirror body-renderer behavior (`pm-state-from-snapshot.ts` :500+):
74
+ // look up the preview via `seg.mediaId` and render a real <img> when
75
+ // available. Without a preview (or `state === "missing"`), fall back
76
+ // to the placeholder chip — a deliberate signal that the media part
77
+ // exists canonical-side but no bytes have been resolved yet.
78
+ const preview = mediaPreviews[seg.mediaId];
79
+ const widthEmu = preview?.widthEmu ?? seg.anchor?.extent.widthEmu;
80
+ const heightEmu = preview?.heightEmu ?? seg.anchor?.extent.heightEmu;
81
+ if (preview?.src && seg.state !== "missing") {
82
+ const widthPx = widthEmu
83
+ ? Math.max(8, Math.round(widthEmu / EMU_PER_PX))
84
+ : undefined;
85
+ const heightPx = heightEmu
86
+ ? Math.max(8, Math.round(heightEmu / EMU_PER_PX))
87
+ : undefined;
88
+ return (
89
+ <img
90
+ key={seg.segmentId}
91
+ src={preview.src}
92
+ alt={seg.altText ?? ""}
93
+ data-node-type="image"
94
+ data-media-id={seg.mediaId}
95
+ style={{
96
+ display: "inline-block",
97
+ verticalAlign: "middle",
98
+ ...(widthPx ? { width: `${widthPx}px` } : {}),
99
+ ...(heightPx ? { height: `${heightPx}px` } : {}),
100
+ maxWidth: "100%",
101
+ objectFit: "contain",
102
+ }}
103
+ />
104
+ );
105
+ }
67
106
  return (
68
107
  <span
69
108
  key={seg.segmentId}
70
109
  data-node-type="image"
110
+ data-media-id={seg.mediaId}
111
+ data-state={seg.state}
71
112
  style={{
72
113
  display: "inline-block",
73
114
  width: "48px",
@@ -80,6 +121,7 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
80
121
  title={seg.altText ?? "Image"}
81
122
  />
82
123
  );
124
+ }
83
125
  case "field_ref":
84
126
  return (
85
127
  <span
@@ -121,8 +163,10 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
121
163
 
122
164
  function RegionParagraph({
123
165
  block,
166
+ mediaPreviews,
124
167
  }: {
125
168
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
169
+ mediaPreviews: Record<string, MediaPreviewDescriptor>;
126
170
  }): React.ReactElement {
127
171
  const headingLevel = resolveHeadingLevel(block.styleId, block.outlineLevel);
128
172
  const classes: string[] = ["leading-relaxed"];
@@ -195,7 +239,7 @@ function RegionParagraph({
195
239
  <div {...attrs}>
196
240
  {prefixSpan}
197
241
  <span className="pm-paragraph-content">
198
- {block.segments.map((seg) => renderSegment(seg))}
242
+ {block.segments.map((seg) => renderSegment(seg, mediaPreviews))}
199
243
  </span>
200
244
  </div>
201
245
  );
@@ -203,8 +247,10 @@ function RegionParagraph({
203
247
 
204
248
  function RegionTable({
205
249
  block,
250
+ mediaPreviews,
206
251
  }: {
207
252
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
253
+ mediaPreviews: Record<string, MediaPreviewDescriptor>;
208
254
  }): React.ReactElement {
209
255
  const tableStyle: React.CSSProperties = {
210
256
  borderCollapse: "collapse",
@@ -273,7 +319,11 @@ function RegionTable({
273
319
  style={Object.keys(cellStyle).length > 0 ? cellStyle : undefined}
274
320
  >
275
321
  {cell.content.map((childBlock) => (
276
- <RegionBlockItem key={childBlock.blockId} block={childBlock} />
322
+ <RegionBlockItem
323
+ key={childBlock.blockId}
324
+ block={childBlock}
325
+ mediaPreviews={mediaPreviews}
326
+ />
277
327
  ))}
278
328
  </td>
279
329
  );
@@ -311,14 +361,16 @@ function RegionOpaque({
311
361
 
312
362
  function RegionBlockItem({
313
363
  block,
364
+ mediaPreviews,
314
365
  }: {
315
366
  block: SurfaceBlockSnapshot;
367
+ mediaPreviews: Record<string, MediaPreviewDescriptor>;
316
368
  }): React.ReactElement | null {
317
369
  switch (block.kind) {
318
370
  case "paragraph":
319
- return <RegionParagraph block={block} />;
371
+ return <RegionParagraph block={block} mediaPreviews={mediaPreviews} />;
320
372
  case "table":
321
- return <RegionTable block={block} />;
373
+ return <RegionTable block={block} mediaPreviews={mediaPreviews} />;
322
374
  case "sdt_block":
323
375
  return (
324
376
  <section
@@ -328,7 +380,7 @@ function RegionBlockItem({
328
380
  style={{ margin: "8px 0" }}
329
381
  >
330
382
  {block.children.map((child) => (
331
- <RegionBlockItem key={child.blockId} block={child} />
383
+ <RegionBlockItem key={child.blockId} block={child} mediaPreviews={mediaPreviews} />
332
384
  ))}
333
385
  </section>
334
386
  );
@@ -348,8 +400,19 @@ export interface TwRegionBlockRendererProps {
348
400
  blocks: readonly SurfaceBlockSnapshot[];
349
401
  /** Optional class name applied to the root wrapper. */
350
402
  className?: string;
403
+ /**
404
+ * Media preview catalog. Without it, any `kind: "image"` segment falls
405
+ * back to the 48×32 gray placeholder chip — the pre-existing behavior
406
+ * for the 7-of-8 CCEP docs whose headers carry a logo. Pass the same
407
+ * catalog the body renderer uses (`props.mediaPreviews` on the
408
+ * workspace root) to light up headers + footers + footnote bodies with
409
+ * real image bytes.
410
+ */
411
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
351
412
  }
352
413
 
414
+ const EMPTY_MEDIA_PREVIEWS: Record<string, MediaPreviewDescriptor> = {};
415
+
353
416
  /**
354
417
  * TwRegionBlockRenderer — read-only React renderer for a region's
355
418
  * `SurfaceBlockSnapshot[]`. Used by the header / footer / footnote /
@@ -363,9 +426,11 @@ export interface TwRegionBlockRendererProps {
363
426
  export function TwRegionBlockRenderer({
364
427
  blocks,
365
428
  className,
429
+ mediaPreviews,
366
430
  }: TwRegionBlockRendererProps): React.ReactElement {
367
431
  const rootClasses = ["ProseMirror"];
368
432
  if (className) rootClasses.push(className);
433
+ const previews = mediaPreviews ?? EMPTY_MEDIA_PREVIEWS;
369
434
  return (
370
435
  <div
371
436
  className={rootClasses.join(" ")}
@@ -373,7 +438,7 @@ export function TwRegionBlockRenderer({
373
438
  data-region-block-renderer=""
374
439
  >
375
440
  {blocks.map((block) => (
376
- <RegionBlockItem key={block.blockId} block={block} />
441
+ <RegionBlockItem key={block.blockId} block={block} mediaPreviews={previews} />
377
442
  ))}
378
443
  </div>
379
444
  );
@@ -5,7 +5,7 @@ import type {
5
5
  CommentBody,
6
6
  CommentMention,
7
7
  } from "../../api/comment-presentation-types";
8
- import { sanitizeMarkdown } from "../../runtime/markdown-sanitizer";
8
+ import { sanitizeMarkdown } from "../../api/public-types";
9
9
 
10
10
  export interface CommentMarkdownRendererProps {
11
11
  body: CommentBody;