Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
LayoutSourceIdentity,
LayoutStoryLocator,
ListBlock,
SdtMetadata,
} from '@superdoc/contracts';
import {
computeLinePmRange,
Expand Down Expand Up @@ -82,8 +83,8 @@ import {
} from './styles.js';
import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js';
import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js';
import { computeSdtBoundaries } from './sdt/boundaries.js';
import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js';
import { computeSdtBoundaries, computeSdtBoundaryLayers, type SdtBoundaryLayer } from './sdt/boundaries.js';
import { shouldRebuildForSdtBoundary, renderSdtAncestorLayers, type SdtBoundaryOptions } from './sdt/container.js';
import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js';
import {
createInlineSdtWrapper,
Expand Down Expand Up @@ -1870,14 +1871,25 @@ export class DomPainter {

const resolvedItems = page.items;
const sdtBoundaries = computeSdtBoundaries(resolvedItems, this.sdtLabelsRendered);
// Ancestor overlay layers for nested block content controls. Computed after
// computeSdtBoundaries so it shares this.sdtLabelsRendered: the nearest
// control's label is already claimed, so only ancestor labels are emitted here.
const sdtBoundaryLayers = computeSdtBoundaryLayers(resolvedItems, this.sdtLabelsRendered);
const betweenBorderFlags = computeBetweenBorderFlags(resolvedItems);

resolvedItems.forEach((resolvedItem, index) => {
if (resolvedItem.kind !== 'fragment') return;
const fragment = resolvedItem.fragment;
const sdtBoundary = sdtBoundaries.get(index);
el.appendChild(
this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem),
this.renderFragment(
fragment,
contextBase,
sdtBoundary,
betweenBorderFlags.get(index),
resolvedItem,
sdtBoundaryLayers.get(index),
),
);
});
this.renderDecorationsForPage(el, page, pageIndex);
Expand Down Expand Up @@ -2592,7 +2604,10 @@ export class DomPainter {
}
if (freshStart == null || !Number.isFinite(freshStart)) return;

const elements = [fragmentEl, ...Array.from(fragmentEl.querySelectorAll<HTMLElement>('[data-pm-start], [data-pm-end]'))];
const elements = [
fragmentEl,
...Array.from(fragmentEl.querySelectorAll<HTMLElement>('[data-pm-start], [data-pm-end]')),
];
let paintedStart = Infinity;
for (const el of elements) {
const start = Number(el.dataset.pmStart);
Expand Down Expand Up @@ -2762,6 +2777,38 @@ export class DomPainter {
sdtBoundary?: SdtBoundaryOptions,
betweenInfo?: BetweenBorderInfo,
resolvedItem?: ResolvedPaintItem,
sdtLayers?: SdtBoundaryLayer[],
): HTMLElement {
const element = this.renderFragmentByKind(fragment, context, sdtBoundary, betweenInfo, resolvedItem);
// Draw content-control chrome for nested block SDTs as overlays. Paragraph and
// table draw their own nearest control via the border path, so overlays add
// only the enclosing (ancestor) controls. Image and drawing have no border
// path, so every control in their chain is drawn as an overlay here.
if (this.doc && sdtLayers && sdtLayers.length > 0) {
const nearestDrawnByHost = fragment.kind === 'para' || fragment.kind === 'table';
const hasOverlayWork = nearestDrawnByHost ? sdtLayers.length > 1 : sdtLayers.length >= 1;
if (hasOverlayWork) {
const containerChain = (resolvedItem as { block?: { attrs?: { sdtContainers?: SdtMetadata[] } } } | undefined)
?.block?.attrs?.sdtContainers;
renderSdtAncestorLayers(
this.doc,
element,
sdtLayers,
containerChain,
this.contentControlsChrome,
nearestDrawnByHost,
);
}
}
return element;
}

private renderFragmentByKind(
fragment: Fragment,
context: FragmentRenderContext,
sdtBoundary?: SdtBoundaryOptions,
betweenInfo?: BetweenBorderInfo,
resolvedItem?: ResolvedPaintItem,
): HTMLElement {
if (fragment.kind === 'para') {
return this.renderParagraphFragment(
Expand Down Expand Up @@ -2958,7 +3005,23 @@ export class DomPainter {
this.applyFragmentWrapperZIndex(fragmentEl, fragment);
}
fragmentEl.style.position = 'absolute';
fragmentEl.style.overflow = 'hidden';
// Chrome host stays overflow:visible so an ancestor SDT label drawn above
// the box is not clipped; content clipping moves to an inner wrapper sized
// to the fragment box (clips identically to the old overflow:hidden here).
fragmentEl.style.overflow = 'visible';

// Stamp SDT dataset so a drawing inside a content control carries the
// control identity (the drawing path previously set neither dataset nor
// chrome). Ancestor chrome is layered on by renderFragment.
const drawingAttrs = block.attrs as { sdt?: SdtMetadata | null; containerSdt?: SdtMetadata | null } | undefined;
applySdtDataset(fragmentEl, drawingAttrs?.sdt);
applyContainerSdtDataset(fragmentEl, drawingAttrs?.containerSdt);

const clipWrapper = this.doc.createElement('div');
clipWrapper.classList.add('superdoc-drawing-clip');
clipWrapper.style.position = 'absolute';
clipWrapper.style.inset = '0';
clipWrapper.style.overflow = 'hidden';

const innerWrapper = this.doc.createElement('div');
innerWrapper.classList.add('superdoc-drawing-inner');
Expand All @@ -2978,7 +3041,8 @@ export class DomPainter {
innerWrapper.style.transform = transforms.join(' ');

innerWrapper.appendChild(this.renderDrawingContent(block, fragment, context));
fragmentEl.appendChild(innerWrapper);
clipWrapper.appendChild(innerWrapper);
fragmentEl.appendChild(clipWrapper);

return fragmentEl;
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it, beforeEach } from 'vitest';
import type { SdtMetadata } from '@superdoc/contracts';
import { renderSdtAncestorLayers } from './container.js';
import type { SdtBoundaryLayer } from './boundaries.js';

const outer = {
type: 'structuredContent',
scope: 'block',
id: 'outer',
alias: 'Outer Control',
} as unknown as SdtMetadata;
const inner = {
type: 'structuredContent',
scope: 'block',
id: 'inner',
alias: 'Inner Control',
} as unknown as SdtMetadata;

// A fragment inside [outer, inner]: depth 0 = outer (ancestor), depth 1 = inner (nearest).
const layers: SdtBoundaryLayer[] = [
{ key: 'structuredContent:outer', depth: 0, isStart: true, isEnd: false, showLabel: true },
{ key: 'structuredContent:inner', depth: 1, isStart: true, isEnd: true, showLabel: true },
];

describe('renderSdtAncestorLayers', () => {
let host: HTMLElement;
beforeEach(() => {
host = document.createElement('div');
});

it('renders one overlay for the ancestor and skips the nearest (deepest) layer', () => {
renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default');
const overlays = host.querySelectorAll('.superdoc-sdt-ancestor-layer');
expect(overlays.length).toBe(1);
expect((overlays[0] as HTMLElement).dataset.sdtDepth).toBe('0');
});

it('carries start/end boundary attributes from the layer', () => {
renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default');
const overlay = host.querySelector('.superdoc-sdt-ancestor-layer') as HTMLElement;
expect(overlay.dataset.sdtContainerStart).toBe('true');
expect(overlay.dataset.sdtContainerEnd).toBe('false');
});

it('renders the ancestor label from its metadata when showLabel is set', () => {
renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default');
const label = host.querySelector('.superdoc-sdt-ancestor-layer span');
expect(label?.textContent).toBe('Outer Control');
});

it('omits the label when chrome is none', () => {
renderSdtAncestorLayers(document, host, layers, [outer, inner], 'none');
expect(host.querySelector('.superdoc-sdt-ancestor-layer span')).toBeNull();
// The overlay box itself is still rendered.
expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(1);
});

it('renders nothing for a non-nested fragment (single layer)', () => {
const single: SdtBoundaryLayer[] = [
{ key: 'structuredContent:only', depth: 0, isStart: true, isEnd: true, showLabel: true },
];
renderSdtAncestorLayers(document, host, single, [outer], 'default');
expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(0);
});

it('skips an ancestor whose metadata is hidden', () => {
const hiddenOuter = { ...(outer as object), appearance: 'hidden' } as unknown as SdtMetadata;
renderSdtAncestorLayers(document, host, layers, [hiddenOuter, inner], 'default');
expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(0);
});

it('draws the nearest control as an overlay for media (nearestDrawnByHost=false)', () => {
// Image/drawing have no border path, so even a single (nearest) control must
// be drawn as an overlay or the media gets no chrome at all.
const single: SdtBoundaryLayer[] = [
{ key: 'structuredContent:only', depth: 0, isStart: true, isEnd: true, showLabel: true },
];
renderSdtAncestorLayers(document, host, single, [outer], 'default', false);
expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(1);
});

it('draws both nearest and ancestor for media inside nested controls', () => {
renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default', false);
expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(2);
});

it('applies widthOverride as the ancestor run width', () => {
const withWidth: SdtBoundaryLayer[] = [
{ key: 'structuredContent:outer', depth: 0, isStart: true, isEnd: false, showLabel: true, widthOverride: 480 },
{ key: 'structuredContent:inner', depth: 1, isStart: true, isEnd: true, showLabel: true },
];
renderSdtAncestorLayers(document, host, withWidth, [outer, inner], 'default');
const overlay = host.querySelector('.superdoc-sdt-ancestor-layer') as HTMLElement;
expect(overlay.style.getPropertyValue('--sd-sdt-ancestor-width')).toBe('480px');
});
});
115 changes: 115 additions & 0 deletions packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import type { ResolvedPaintItem } from '@superdoc/contracts';
import { computeSdtBoundaryLayers } from './boundaries.js';

const makeItem = (
y: number,
sdtContainerKeys: (string | null)[],
fragmentKind: 'para' | 'image' | 'drawing' = 'para',
): ResolvedPaintItem =>
({
kind: 'fragment',
id: `f-${y}`,
pageIndex: 0,
fragmentKind,
blockId: `b-${y}`,
fragmentIndex: y,
height: 20,
// Image/drawing resolved items are kind: 'fragment' with a fragment
// back-pointer of the matching kind (see ResolvedImageItem/ResolvedDrawingItem),
// so the boundary pass reads their geometry the same way as paragraphs.
fragment: { kind: fragmentKind, blockId: `b-${y}`, x: 0, y, width: 100 },
sdtContainerKey: sdtContainerKeys.length ? sdtContainerKeys[sdtContainerKeys.length - 1] : null,
sdtContainerKeys,
}) as unknown as ResolvedPaintItem;

const layerAtDepth = (layers: ReturnType<typeof computeSdtBoundaryLayers>, idx: number, depth: number) =>
layers.get(idx)?.find((layer) => layer.depth === depth);

describe('computeSdtBoundaryLayers', () => {
it('groups outer-inner-outer into one outer run and one inner run', () => {
const items = [
makeItem(0, ['structuredContent:outer']),
makeItem(20, ['structuredContent:outer', 'structuredContent:inner']),
makeItem(40, ['structuredContent:outer']),
];
const layers = computeSdtBoundaryLayers(items, new Set());

// depth 0: the outer control spans all three items as one run.
expect(layerAtDepth(layers, 0, 0)).toMatchObject({ key: 'structuredContent:outer', isStart: true, isEnd: false });
expect(layerAtDepth(layers, 1, 0)).toMatchObject({ key: 'structuredContent:outer', isStart: false, isEnd: false });
expect(layerAtDepth(layers, 2, 0)).toMatchObject({ key: 'structuredContent:outer', isStart: false, isEnd: true });

// depth 1: only the middle item belongs to the inner control.
expect(layerAtDepth(layers, 1, 1)).toMatchObject({ key: 'structuredContent:inner', isStart: true, isEnd: true });
expect(layerAtDepth(layers, 0, 1)).toBeUndefined();
expect(layerAtDepth(layers, 2, 1)).toBeUndefined();
});

it('keeps real image and drawing items inside the run instead of splitting it', () => {
// Image and drawing resolved items are kind: 'fragment' (fragmentKind
// 'image'/'drawing') and, via FU2, carry the same container chain. They must
// stay inside the outer run rather than break it into separate boxes.
const items = [
makeItem(0, ['structuredContent:outer'], 'para'),
makeItem(20, ['structuredContent:outer'], 'image'),
makeItem(40, ['structuredContent:outer'], 'drawing'),
makeItem(60, ['structuredContent:outer'], 'para'),
];
const layers = computeSdtBoundaryLayers(items, new Set());

// One continuous outer run across para, image, drawing, para.
expect(layerAtDepth(layers, 0, 0)).toMatchObject({ isStart: true, isEnd: false });
expect(layerAtDepth(layers, 1, 0)).toMatchObject({ isStart: false, isEnd: false });
expect(layerAtDepth(layers, 2, 0)).toMatchObject({ isStart: false, isEnd: false });
expect(layerAtDepth(layers, 3, 0)).toMatchObject({ isStart: false, isEnd: true });
// The image and drawing items are part of the run, not skipped.
expect(layerAtDepth(layers, 1, 0)?.key).toBe('structuredContent:outer');
expect(layerAtDepth(layers, 2, 0)?.key).toBe('structuredContent:outer');
});

it('dedupes labels by key and renders each container label once', () => {
const labels = new Set<string>();
const items = [
makeItem(0, ['structuredContent:outer']),
makeItem(20, ['structuredContent:outer', 'structuredContent:inner']),
];
const layers = computeSdtBoundaryLayers(items, labels);

expect(layerAtDepth(layers, 0, 0)?.showLabel).toBe(true);
expect(layerAtDepth(layers, 1, 1)?.showLabel).toBe(true);
expect(labels.has('structuredContent:outer')).toBe(true);
expect(labels.has('structuredContent:inner')).toBe(true);

// A second pass with the populated set must not re-show the outer label.
const layers2 = computeSdtBoundaryLayers([makeItem(60, ['structuredContent:outer'])], labels);
expect(layerAtDepth(layers2, 0, 0)?.showLabel).toBe(false);
});

it('falls back to the single sdtContainerKey when there is no chain', () => {
const item = {
kind: 'fragment',
id: 'f',
pageIndex: 0,
fragmentKind: 'para',
blockId: 'b',
fragmentIndex: 0,
height: 20,
fragment: { kind: 'para', blockId: 'b', x: 0, y: 0, width: 100 },
sdtContainerKey: 'documentSection:sec-1',
} as unknown as ResolvedPaintItem;

const layers = computeSdtBoundaryLayers([item], new Set());
expect(layerAtDepth(layers, 0, 0)).toMatchObject({
key: 'documentSection:sec-1',
depth: 0,
isStart: true,
isEnd: true,
});
});

it('produces no layers for items with no container key', () => {
const layers = computeSdtBoundaryLayers([makeItem(0, [])], new Set());
expect(layers.size).toBe(0);
});
});
Loading
Loading