Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0a463ac
refactor(painters/dom): consolidate SDT container helpers into sdt/ m…
luccas-harbour May 14, 2026
3284962
fix(painters/dom): continue nested table SDT chrome
luccas-harbour May 14, 2026
85e77a2
fix(painters/dom): suppress idless table SDT chrome
luccas-harbour May 14, 2026
c1d92bd
fix(painters/dom): inherit table container SDT chrome
luccas-harbour May 14, 2026
78f8f04
fix(painters/dom): keep nested SDT chrome active
luccas-harbour May 14, 2026
b1ace96
fix(painters/dom): suppress nested table SDT chrome
luccas-harbour May 14, 2026
ad6d6cc
fix(painters/dom): continue partial nested SDT chrome
luccas-harbour May 14, 2026
2bd9f2a
fix(painters/dom): preserve nested table SDT ancestor
luccas-harbour May 14, 2026
ae03e40
fix(painters/dom): allow nested SDT label overflow
luccas-harbour May 14, 2026
685ceb4
fix(painters/dom): scope nested SDT overflow to rendered content
luccas-harbour May 14, 2026
e44f2ec
fix(painters/dom): use rendered SDT lock mode
luccas-harbour May 15, 2026
944f988
fix(painters/dom): preserve ancestor SDT key
luccas-harbour May 15, 2026
28c5950
fix(painters/dom): merge idless SDT siblings
luccas-harbour May 15, 2026
308a3ed
test(painters/dom): cover SDT chrome gaps
luccas-harbour May 15, 2026
8e1627a
refactor(painters/dom): share SDT container keys
luccas-harbour May 15, 2026
913f86f
fix(painters/dom): drop unused table block local
luccas-harbour May 15, 2026
160108b
fix(painters/dom): skip media for SDT boundaries
luccas-harbour May 15, 2026
5c6753a
fix(painters/dom): preserve SDT ancestor chain
luccas-harbour May 15, 2026
cdad4c1
fix(painters/dom): defer SDT overflow to rendered chrome
luccas-harbour May 15, 2026
8f2b80c
fix(painters/dom): continue split SDT paragraph chrome
luccas-harbour May 15, 2026
738fcd3
refactor(painters/dom): extract SDT helpers from renderer
luccas-harbour May 15, 2026
22ce0ff
refactor(painters/dom): unify image block rendering
luccas-harbour May 15, 2026
8a2059c
fix(painters/dom): invalidate table image visual edits
luccas-harbour May 15, 2026
01c2e2f
docs(layout-engine): flag SdtMetadata identity invariant for pm-adapter
luccas-harbour May 21, 2026
112234f
refactor(painters/dom): drop unused block version derivation
luccas-harbour May 15, 2026
6871e02
fix(layout-resolved): invalidate inline image visual edits
luccas-harbour May 15, 2026
b0be9c8
fix(layout-resolved): hash raw inline image clip paths
luccas-harbour May 15, 2026
b512cb9
refactor(painters/dom): extract image rendering helpers into modules
luccas-harbour May 15, 2026
815af26
fix(layout-resolved): avoid image hyperlink hash collisions
luccas-harbour May 15, 2026
f823bb2
fix(painters/dom): keep table images block display
luccas-harbour May 15, 2026
e586394
test(painters/dom): cover unified drawing image rendering
luccas-harbour May 15, 2026
9491a08
refactor(painters/dom): share image clip path reader
luccas-harbour May 15, 2026
9d2871f
refactor(painters/dom): share inline style applier
luccas-harbour May 15, 2026
6e71691
docs(painters/dom): preserve image transform rationale
luccas-harbour May 15, 2026
6100449
refactor(painters/dom): share image hyperlink anchor type
luccas-harbour May 15, 2026
77c811e
refactor(painters/dom): co-locate image utils under images/
luccas-harbour May 15, 2026
dabfe32
docs(layout-resolved): clarify deriveBlockVersion no longer duplicated
luccas-harbour May 21, 2026
f2e4dcb
Merge pull request #3328 from superdoc-dev/luccas/sd-2838-unify-image…
harbournick May 26, 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
2 changes: 2 additions & 0 deletions packages/layout-engine/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ reads.
| Change style resolution | `style-engine/` |
| Change text measurement | `measuring-dom/` |

AIDEV-NOTE: `pm-adapter` must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports.

## Style Engine (`style-engine/`)

Single source of truth for OOXML style cascade resolution. All property resolution flows through here.
Expand Down
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 @@ -101,6 +101,13 @@ export type {
import type { LayoutSourceIdentity } from './layout-identity.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.
Comment thread
luccas-harbour marked this conversation as resolved.
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 @@ -2793,7 +2793,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 @@ -2815,10 +2815,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 @@ -2840,7 +2840,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 @@ -22,12 +22,12 @@ import type {
ParagraphMeasure,
LayoutStoryLocator,
} 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,
Expand Down Expand Up @@ -162,17 +162,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.

Loading
Loading