Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d8d1880
refactor(painters/dom): consolidate SDT container helpers into sdt/ m…
luccas-harbour May 14, 2026
ce05c10
fix(painters/dom): continue nested table SDT chrome
luccas-harbour May 14, 2026
06871d5
fix(painters/dom): suppress idless table SDT chrome
luccas-harbour May 14, 2026
6a49fd8
fix(painters/dom): inherit table container SDT chrome
luccas-harbour May 14, 2026
227b810
fix(painters/dom): keep nested SDT chrome active
luccas-harbour May 14, 2026
9fe6701
fix(painters/dom): suppress nested table SDT chrome
luccas-harbour May 14, 2026
487972c
fix(painters/dom): continue partial nested SDT chrome
luccas-harbour May 14, 2026
9cf932e
fix(painters/dom): preserve nested table SDT ancestor
luccas-harbour May 14, 2026
b8ee607
fix(painters/dom): allow nested SDT label overflow
luccas-harbour May 14, 2026
658eed9
fix(painters/dom): scope nested SDT overflow to rendered content
luccas-harbour May 14, 2026
a4521bc
fix(painters/dom): use rendered SDT lock mode
luccas-harbour May 15, 2026
be7b62f
fix(painters/dom): preserve ancestor SDT key
luccas-harbour May 15, 2026
2df6de1
fix(painters/dom): merge idless SDT siblings
luccas-harbour May 15, 2026
459c207
test(painters/dom): cover SDT chrome gaps
luccas-harbour May 15, 2026
54a12c4
refactor(painters/dom): share SDT container keys
luccas-harbour May 15, 2026
0e256b7
fix(painters/dom): drop unused table block local
luccas-harbour May 15, 2026
c16746a
fix(painters/dom): skip media for SDT boundaries
luccas-harbour May 15, 2026
4cbf3a7
fix(painters/dom): preserve SDT ancestor chain
luccas-harbour May 15, 2026
20b0694
fix(painters/dom): defer SDT overflow to rendered chrome
luccas-harbour May 15, 2026
18f3766
fix(painters/dom): continue split SDT paragraph chrome
luccas-harbour May 15, 2026
1ce01ee
refactor(painters/dom): extract SDT helpers from renderer
luccas-harbour May 15, 2026
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
7 changes: 7 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ export {
export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';
export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js';
export type { NormalizedColumnLayout } from './column-layout.js';
export {
getSdtContainerKey,
getSdtContainerKeyForBlock,
getSdtContainerMetadata,
hasExplicitSdtContainerKey,
isSdtContainerMetadata,
} from './sdt-container.js';
/** Inline field annotation metadata extracted from w:sdt nodes. */
export type FieldAnnotationMetadata = {
type: 'fieldAnnotation';
Expand Down
42 changes: 42 additions & 0 deletions packages/layout-engine/contracts/src/sdt-container.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import type { SdtMetadata } from './index.js';
import {
getSdtContainerKey,
getSdtContainerKeyForBlock,
getSdtContainerMetadata,
hasExplicitSdtContainerKey,
} from './sdt-container.js';

describe('SDT container key helpers', () => {
it('uses the first renderable container metadata', () => {
const containerSdt: SdtMetadata = { type: 'documentSection', id: 'section-1' };

expect(getSdtContainerMetadata({ type: 'structuredContent', scope: 'inline', id: 'inline-1' }, containerSdt)).toBe(
containerSdt,
);
});

it('derives explicit keys for block content controls and document sections', () => {
expect(getSdtContainerKey({ type: 'structuredContent', scope: 'block', id: 'sdt-1' })).toBe(
'structuredContent:sdt-1',
);
expect(getSdtContainerKey({ type: 'documentSection', sdBlockId: 'section-block-1' })).toBe(
'documentSection:section-block-1',
);
});

it('derives stable object keys for id-less containers', () => {
const sharedSdt: SdtMetadata = { type: 'structuredContent', scope: 'block', alias: 'Shared' };
const firstKey = getSdtContainerKey(sharedSdt);

expect(firstKey).toMatch(/^idlessSdt:/);
expect(getSdtContainerKey(sharedSdt)).toBe(firstKey);
expect(hasExplicitSdtContainerKey(sharedSdt)).toBe(false);
});

it('derives keys from any block-like object with SDT attrs', () => {
const sdt: SdtMetadata = { type: 'structuredContent', scope: 'block', id: 'media-sdt' };

expect(getSdtContainerKeyForBlock({ attrs: { sdt } })).toBe('structuredContent:media-sdt');
});
});
78 changes: 78 additions & 0 deletions packages/layout-engine/contracts/src/sdt-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { SdtMetadata } from './index.js';

type SdtBlockCandidate = {
attrs?: {
sdt?: SdtMetadata | null;
containerSdt?: SdtMetadata | null;
} | null;
};

const idlessSdtContainerKeys = new WeakMap<SdtMetadata, string>();
let nextIdlessSdtContainerKey = 0;

function getIdlessSdtContainerKey(metadata: SdtMetadata): string {
const existingKey = idlessSdtContainerKeys.get(metadata);
if (existingKey) return existingKey;

// AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same
// SdtMetadata object across sibling blocks in one container. Do not replace
// this with alias/title matching; separate controls can share display text.
const key = `idlessSdt:${++nextIdlessSdtContainerKey}`;
idlessSdtContainerKeys.set(metadata, key);
return key;
}

export function isSdtContainerMetadata(sdt: SdtMetadata | null | undefined): boolean {
if (!sdt) return false;
if (sdt.type === 'documentSection') return true;
if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true;
return false;
}

export function getSdtContainerMetadata(
sdt?: SdtMetadata | null,
containerSdt?: SdtMetadata | null,
): SdtMetadata | null {
if (isSdtContainerMetadata(sdt)) return sdt ?? null;
if (isSdtContainerMetadata(containerSdt)) return containerSdt ?? null;
return null;
}

export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null {
const metadata = getSdtContainerMetadata(sdt, containerSdt);
if (!metadata) return null;

if (metadata.type === 'structuredContent') {
if (metadata.scope !== 'block') return null;
if (metadata.id) return `structuredContent:${metadata.id}`;
return getIdlessSdtContainerKey(metadata);
}

if (metadata.type === 'documentSection') {
const sectionId = metadata.id ?? metadata.sdBlockId;
if (sectionId) return `documentSection:${sectionId}`;
return getIdlessSdtContainerKey(metadata);
}

return null;
}

export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean {
const metadata = getSdtContainerMetadata(sdt, containerSdt);
if (!metadata) return false;

if (metadata.type === 'structuredContent') {
return metadata.scope === 'block' && Boolean(metadata.id);
}

if (metadata.type === 'documentSection') {
return Boolean(metadata.id ?? metadata.sdBlockId);
}

return false;
}

export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null {
if (!block) return null;
return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2763,7 +2763,7 @@ describe('resolveLayout', () => {
expect(drItem.sdtContainerKey).toBeUndefined();
});

it('returns null (omits key) for structuredContent block scope with no id', () => {
it('sets an object-stable key for structuredContent block scope with no id', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
Expand All @@ -2785,10 +2785,10 @@ describe('resolveLayout', () => {

const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
expect(item.sdtContainerKey).toBeUndefined();
expect(item.sdtContainerKey).toMatch(/^idlessSdt:/);
});

it('returns null (omits key) for documentSection with no id or sdBlockId', () => {
it('sets an object-stable key for documentSection with no id or sdBlockId', () => {
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
Expand All @@ -2810,7 +2810,7 @@ describe('resolveLayout', () => {

const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures });
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
expect(item.sdtContainerKey).toBeUndefined();
expect(item.sdtContainerKey).toMatch(/^idlessSdt:/);
});
});

Expand Down
8 changes: 4 additions & 4 deletions packages/layout-engine/layout-resolved/src/resolveLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import type {
ParagraphBlock,
ParagraphMeasure,
} from '@superdoc/contracts';
import { getSdtContainerKey } from '@superdoc/contracts';
import { resolveParagraphContent } from './resolveParagraph.js';
import { resolveTableItem } from './resolveTable.js';
import { resolveImageItem } from './resolveImage.js';
import { resolveDrawingItem } from './resolveDrawing.js';
import type { BlockMapEntry } from './resolvedBlockLookup.js';
import { computeSdtContainerKey } from './sdtContainerKey.js';
import { hashParagraphBorders } from './paragraphBorderHash.js';
import { deriveBlockVersion, fragmentSignature, sourceAnchorSignature } from './versionSignature.js';

Expand Down Expand Up @@ -156,17 +156,17 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map<string
const block = entry.block;

if (fragment.kind === 'para' && block.kind === 'paragraph') {
return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
}

if (fragment.kind === 'list-item' && block.kind === 'list') {
const listBlock = block as ListBlock;
const item = listBlock.items.find((listItem) => listItem.id === fragment.itemId);
return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt);
return getSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt);
}

if (fragment.kind === 'table' && block.kind === 'table') {
return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
}

// image, drawing — no SDT container keys
Expand Down
40 changes: 0 additions & 40 deletions packages/layout-engine/layout-resolved/src/sdtContainerKey.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
getParagraphInlineDirection,
} from '@superdoc/contracts';
import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils';
import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js';
import {
applySdtContainerChrome,
shouldRenderSdtContainerChrome,
type SdtAncestorOptions,
type SdtBoundaryOptions,
} from '../sdt/container.js';
import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js';
import {
applyParagraphLineIndentation,
Expand Down Expand Up @@ -78,7 +83,11 @@ export type RenderParagraphContentParams = {
betweenInfo?: BetweenBorderInfo;
sdtBoundary?: SdtBoundaryOptions;
spacingPolicy?: ParagraphSpacingPolicy;
shouldApplySdtContainerStyling?: (sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null) => boolean;
ancestorContainerKey?: string | null;
ancestorContainerSdt?: SdtMetadata | null;
ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys'];
ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts'];
onSdtContainerChrome?: () => void;
applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void;
applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void;
renderLine: ParagraphRenderLine;
Expand Down Expand Up @@ -115,7 +124,11 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re
betweenInfo,
sdtBoundary,
spacingPolicy,
shouldApplySdtContainerStyling,
ancestorContainerKey,
ancestorContainerSdt,
ancestorContainerKeys,
ancestorContainerSdts,
onSdtContainerChrome,
applySdtDataset,
applyContainerSdtDataset,
renderDropCap,
Expand All @@ -135,9 +148,16 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re
applySdtDataset(frameEl, block.attrs?.sdt);
applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt);

const applySdtChrome = shouldApplySdtContainerStyling?.(block.attrs?.sdt, block.attrs?.containerSdt) ?? true;
const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, {
ancestorContainerKey,
ancestorContainerSdt,
ancestorContainerKeys,
ancestorContainerSdts,
});
if (applySdtChrome) {
applySdtContainerStyling(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary);
if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) {
onSdtContainerChrome?.();
}
}

renderParagraphDropCap({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils';
import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils';
import { CLASS_NAMES, fragmentStyles } from '../styles.js';
import type { SdtBoundaryOptions } from '../utils/sdt-helpers.js';
import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js';
import type { BetweenBorderInfo } from './borders/index.js';
import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js';

Expand Down Expand Up @@ -72,11 +72,7 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams):

const isTocEntry = block.attrs?.isTocEntry;
const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker;
const hasSdtContainer =
block.attrs?.sdt?.type === 'documentSection' ||
block.attrs?.sdt?.type === 'structuredContent' ||
block.attrs?.containerSdt?.type === 'documentSection' ||
block.attrs?.containerSdt?.type === 'structuredContent';
const hasSdtContainer = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt);
const paraIndentForOverflow = block.attrs?.indent;
const hasNegativeIndent = (paraIndentForOverflow?.left ?? 0) < 0 || (paraIndentForOverflow?.right ?? 0) < 0;
const styles = isTocEntry
Expand Down
Loading
Loading