diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 39d229d2b1..f86b624070 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -290,6 +290,10 @@ export type FlowRunLink = { history?: boolean; }; +export const EMPTY_SDT_PLACEHOLDER_TEXT = 'Click or tap here to enter text'; + +export type SdtVisualPlaceholder = 'emptyInlineSdt' | 'emptyBlockSdt'; + /** * Common formatting marks that can be applied to any run type. * Used by TextRun, TabRun, and other run types that support inline formatting. @@ -343,7 +347,7 @@ export type TextRun = RunMarks & { dataAttrs?: Record; sdt?: SdtMetadata; /** Layout-only placeholder for visual affordances that do not represent document text. */ - visualPlaceholder?: 'emptyInlineSdt'; + visualPlaceholder?: SdtVisualPlaceholder; link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; @@ -458,10 +462,10 @@ export type ImageRun = { /** * Vertical alignment of image relative to text baseline. - * Currently only 'bottom' is supported (image sits on baseline). - * Future: 'top', 'middle', 'baseline', 'text-top', 'text-bottom'. + * 'top' keeps the image box inside the measured line height; 'bottom' + * preserves legacy baseline alignment for existing callers. */ - verticalAlign?: 'bottom'; + verticalAlign?: 'top' | 'bottom'; /** Absolute ProseMirror position (inclusive) of this image run. */ pmStart?: number; @@ -2199,6 +2203,11 @@ export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from // Pure transformations on inline-run shapes (used by pm-adapter, layout-bridge, // and painter-dom). Located in contracts to avoid reverse stage dependencies. -export { expandRunsForInlineNewlines, isEmptyInlineSdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; +export { + expandRunsForInlineNewlines, + isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, + sliceRunsForLine, +} from './run-helpers.js'; export * as Engines from './engines/index.js'; diff --git a/packages/layout-engine/contracts/src/pm-range.ts b/packages/layout-engine/contracts/src/pm-range.ts index 35d77343d2..cbdb70af52 100644 --- a/packages/layout-engine/contracts/src/pm-range.ts +++ b/packages/layout-engine/contracts/src/pm-range.ts @@ -1,4 +1,5 @@ import type { FlowBlock, Line, ParagraphBlock, ParagraphMeasure } from './index.js'; +import { isEmptySdtPlaceholderRun } from './run-helpers.js'; /** * Represents a ProseMirror position range for a line or fragment. @@ -93,6 +94,15 @@ export function computeLinePmRange(block: FlowBlock, line: Line): LinePmRange { const runPmStart = coercePmStart(run); if (runPmStart == null) continue; + if (isEmptySdtPlaceholderRun(run)) { + const runPmEnd = coercePmEnd(run) ?? runPmStart; + if (pmStart == null) { + pmStart = runPmStart; + } + pmEnd = runPmEnd; + continue; + } + if (isAtomicRunKind((run as { kind?: unknown }).kind) || isImageLikeRun(run)) { const runPmEnd = coercePmEnd(run) ?? runPmStart + 1; if (pmStart == null) { diff --git a/packages/layout-engine/contracts/src/run-helpers.test.ts b/packages/layout-engine/contracts/src/run-helpers.test.ts index afefce64c6..3922924bc0 100644 --- a/packages/layout-engine/contracts/src/run-helpers.test.ts +++ b/packages/layout-engine/contracts/src/run-helpers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { FlowBlock, Line, ParagraphBlock, Run, TabRun, TextRun, TrackedChangeMeta } from './index.js'; -import { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js'; +import { expandRunsForInlineNewlines, isEmptySdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; describe('expandRunsForInlineNewlines', () => { const makeRun = (text: string, pmStart = 0): TextRun => ({ @@ -153,4 +153,17 @@ describe('sliceRunsForLine', () => { expect(sliceRunsForLine(block, line)).toEqual([run]); }); + + it('recognizes block SDT visual placeholders', () => { + const run: TextRun = { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 12, + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-block-1' }, + }; + + expect(isEmptySdtPlaceholderRun(run)).toBe(true); + }); }); diff --git a/packages/layout-engine/contracts/src/run-helpers.ts b/packages/layout-engine/contracts/src/run-helpers.ts index 8b04fa638a..516a53756f 100644 --- a/packages/layout-engine/contracts/src/run-helpers.ts +++ b/packages/layout-engine/contracts/src/run-helpers.ts @@ -9,14 +9,20 @@ import type { FlowBlock, Line, Run, TextRun } from './index.js'; -export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { +export function isEmptySdtPlaceholderRun( + run: Run, +): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' | 'emptyBlockSdt' } { return ( (run.kind === 'text' || run.kind === undefined) && 'text' in run && - (run as TextRun).visualPlaceholder === 'emptyInlineSdt' + ((run as TextRun).visualPlaceholder === 'emptyInlineSdt' || (run as TextRun).visualPlaceholder === 'emptyBlockSdt') ); } +export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { + return isEmptySdtPlaceholderRun(run) && run.visualPlaceholder === 'emptyInlineSdt'; +} + /** * Expands text runs that contain inline newlines into multiple runs. * @@ -90,7 +96,7 @@ export function sliceRunsForLine(block: FlowBlock, line: Line): Run[] { } const text = run.text ?? ''; - if (isEmptyInlineSdtPlaceholderRun(run)) { + if (isEmptySdtPlaceholderRun(run)) { result.push(run); continue; } diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 82ef81fead..6e61b0b80c 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -3,6 +3,7 @@ import type { ImageBlock, DrawingBlock, ImageDrawing, + ImageRun, BoxSpacing, ImageAnchor, ImageWrap, @@ -419,6 +420,12 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock for (let i = 0; i < a.runs.length; i += 1) { const runA = a.runs[i]; const runB = b.runs[i]; + if (runA.kind === 'image' || runB.kind === 'image') { + if (runA.kind !== 'image' || runB.kind !== 'image') return false; + if (!imageRunsEqual(runA, runB)) return false; + continue; + } + // MathRun: compare textContent (derived from OMML) to detect equation changes if (runA.kind === 'math' || runB.kind === 'math') { if (runA.kind !== runB.kind) return false; @@ -449,6 +456,32 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock return true; }; +const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => { + return ( + a.src === b.src && + a.width === b.width && + a.height === b.height && + a.alt === b.alt && + a.title === b.title && + a.clipPath === b.clipPath && + a.distTop === b.distTop && + a.distBottom === b.distBottom && + a.distLeft === b.distLeft && + a.distRight === b.distRight && + a.verticalAlign === b.verticalAlign && + a.rotation === b.rotation && + a.flipH === b.flipH && + a.flipV === b.flipV && + a.gain === b.gain && + a.blacklevel === b.blacklevel && + a.grayscale === b.grayscale && + jsonEqual(a.lum, b.lum) && + jsonEqual(a.hyperlink, b.hyperlink) && + jsonEqual(a.sdt, b.sdt) && + shallowRecordEqual(a.dataAttrs, b.dataAttrs) + ); +}; + const imageBlocksEqual = (a: ImageBlock | ImageDrawing, b: ImageBlock | ImageDrawing): boolean => { return ( a.src === b.src && diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index b58704acb6..22134eb907 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -10,7 +10,7 @@ import type { ParagraphIndent, LeaderDecoration, } from '@superdoc/contracts'; -import { Engines } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT, Engines, isEmptySdtPlaceholderRun } from '@superdoc/contracts'; import type { WordParagraphLayoutOutput } from '@superdoc/word-layout'; import { LIST_MARKER_GAP as _LIST_MARKER_GAP, @@ -126,6 +126,10 @@ function fontString(run: Run): string { * @returns Text content of the run, or empty string for non-text runs */ function runText(run: Run): string { + if (isEmptySdtPlaceholderRun(run)) { + return run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? '' : EMPTY_SDT_PLACEHOLDER_TEXT; + } + return 'src' in run || run.kind === 'lineBreak' || run.kind === 'break' || @@ -1380,6 +1384,17 @@ export function remeasureParagraph( if (text.length > 0 && isTextRun(run)) { lineMaxTextFontSize = Math.max(lineMaxTextFontSize, run.fontSize ?? 16); } + if (isEmptySdtPlaceholderRun(run)) { + const placeholderWidth = text.length > 0 ? measureRunSliceWidth(run, 0, text.length) : 0; + if (width > 0 && width + placeholderWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) { + didBreakInThisLine = true; + break; + } + width += placeholderWidth; + endRun = r; + endChar = text.length > 0 ? text.length : start + 1; + continue; + } for (let c = start; c < text.length; c += 1) { const ch = text[c]; if (ch === '\t') { diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 51c195f114..b06b3dde46 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { VectorShapeDrawing } from '@superdoc/contracts'; +import type { ImageRun, ParagraphBlock, VectorShapeDrawing } from '@superdoc/contracts'; import { computeDirtyRegions } from '../src/diff'; const block = (id: string, text: string) => ({ @@ -8,6 +8,19 @@ const block = (id: string, text: string) => ({ runs: [{ text, fontFamily: 'Arial', fontSize: 16 }], }); +const imageRun = (src: string, width: number, height: number): ImageRun => ({ + kind: 'image', + src, + width, + height, +}); + +const paragraphWithRuns = (id: string, runs: ParagraphBlock['runs']) => ({ + kind: 'paragraph' as const, + id, + runs, +}); + const drawing = (overrides?: Partial): VectorShapeDrawing => ({ kind: 'drawing', id: 'drawing-0', @@ -181,6 +194,112 @@ describe('computeDirtyRegions', () => { expect(result.firstDirtyIndex).toBe(0); }); + it('detects inline image height changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 60)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects inline image width changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 120, 50)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('treats identical inline image dimensions as stable', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(next.length); + expect(result.stableBlockIds.has('0-paragraph')).toBe(true); + }); + + it.each([ + ['src', { src: 'other.png' }], + ['alt', { alt: 'Diagram' }], + ['title', { title: 'Title' }], + ['clipPath', { clipPath: 'inset(1px)' }], + ['distTop', { distTop: 1 }], + ['distBottom', { distBottom: 2 }], + ['distLeft', { distLeft: 3 }], + ['distRight', { distRight: 4 }], + ['verticalAlign', { verticalAlign: 'top' as const }], + ['rotation', { rotation: 90 }], + ['flipH', { flipH: true }], + ['flipV', { flipV: true }], + ['gain', { gain: '50000' }], + ['blacklevel', { blacklevel: '20000' }], + ['grayscale', { grayscale: true }], + ['lum', { lum: { bright: 10000, contrast: -10000 } }], + ['hyperlink', { hyperlink: { url: 'https://example.com', tooltip: 'Example' } }], + ['dataAttrs', { dataAttrs: { 'data-example': '1' } }], + ] satisfies Array<[string, Partial]>)( + 'detects inline image %s changes inside paragraphs', + (_field, overrides) => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [{ ...imageRun('img.png', 100, 50), ...overrides }])]; + + const result = computeDirtyRegions(prev, next); + + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }, + ); + + it('detects inline image SDT metadata changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [ + paragraphWithRuns('0-paragraph', [ + { + ...imageRun('img.png', 100, 50), + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'image-sdt', + lockMode: 'contentLocked', + }, + }, + ]), + ]; + + const result = computeDirtyRegions(prev, next); + + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects inline image resize in mixed text and image paragraphs', () => { + const prev = [ + paragraphWithRuns('0-paragraph', [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16 }, + imageRun('img.png', 100, 50), + { text: ' after', fontFamily: 'Arial', fontSize: 16 }, + ]), + ]; + const next = [ + paragraphWithRuns('0-paragraph', [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16 }, + imageRun('img.png', 100, 60), + { text: ' after', fontFamily: 'Arial', fontSize: 16 }, + ]), + ]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects changes to later inline image runs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 40)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 60)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + it('treats unchanged drawing blocks as stable', () => { const prev = [drawing()]; const next = [drawing()]; diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts index 84ec9f746a..859fc773c1 100644 --- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts +++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts @@ -11,7 +11,13 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import type { ParagraphBlock, Run, TabStop } from '@superdoc/contracts'; +import { + EMPTY_SDT_PLACEHOLDER_TEXT, + computeLinePmRange, + type ParagraphBlock, + type Run, + type TabStop, +} from '@superdoc/contracts'; import { remeasureParagraph } from '../src/remeasure.ts'; /** @@ -216,6 +222,53 @@ describe('remeasureParagraph', () => { expect(measure.totalHeight).toBe(0); }); + it('measures visible empty SDT placeholders using the placeholder prompt width', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'empty-block-sdt' }, + pmStart: 12, + pmEnd: 12, + }), + ]); + const measure = remeasureParagraph(block, 500); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + expect(computeLinePmRange(block, measure.lines[0])).toEqual({ pmStart: 12, pmEnd: 12 }); + }); + + it('keeps a visible empty SDT placeholder atomic when it is wider than the line', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'narrow-empty-block-sdt' }, + }), + ]); + const measure = remeasureParagraph(block, 60); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].fromRun).toBe(0); + expect(measure.lines[0].toRun).toBe(0); + expect(measure.lines[0].width).toBe(EMPTY_SDT_PLACEHOLDER_TEXT.length * CHAR_WIDTH); + }); + + it('keeps hidden empty SDT placeholders zero-width during remeasurement', () => { + const block = createBlock([ + textRun('', { + kind: 'text', + visualPlaceholder: 'emptyBlockSdt', + sdt: { type: 'structuredContent', scope: 'block', id: 'hidden-block-sdt', appearance: 'hidden' }, + }), + ]); + const measure = remeasureParagraph(block, 500); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBe(0); + }); + it('handles single character per line when maxWidth is very narrow', () => { // With maxWidth=11 (barely fits 1 char at 10px + fudge), each char should be on its own line const block = createBlock([textRun('ABC')]); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 4385b9453d..7a8eb51dd8 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageRun, SourceAnchor, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -66,3 +66,60 @@ describe('deriveBlockVersion - bidi', () => { expect(a).toBe(b); }); }); + +describe('deriveBlockVersion - inline image runs', () => { + const makeParagraphWithImage = (overrides: Partial = {}): FlowBlock => ({ + kind: 'paragraph', + id: 'p-image', + runs: [ + { + kind: 'image', + src: 'img.png', + width: 100, + height: 50, + ...overrides, + }, + ], + }); + + it('produces a different version when inline image SDT metadata changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const locked = deriveBlockVersion( + makeParagraphWithImage({ + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'image-sdt', + lockMode: 'contentLocked', + }, + }), + ); + + expect(locked).not.toBe(plain); + }); + + it('produces a different version when inline image data attributes change', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const withDataAttrs = deriveBlockVersion(makeParagraphWithImage({ dataAttrs: { 'data-example': '1' } })); + + expect(withDataAttrs).not.toBe(plain); + }); + + it('produces a different version when inline image paint metadata changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const withPaintMetadata = deriveBlockVersion( + makeParagraphWithImage({ + verticalAlign: 'top', + rotation: 90, + flipH: true, + gain: '50000', + blacklevel: '20000', + grayscale: true, + lum: { bright: 10000, contrast: -10000 }, + hyperlink: { url: 'https://example.com', tooltip: 'Example' }, + }), + ); + + expect(withPaintMetadata).not.toBe(plain); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 7d1f223147..724931317b 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -230,6 +230,17 @@ export const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distLeft ?? '', imgRun.distRight ?? '', readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + imgRun.verticalAlign ?? '', + imgRun.rotation ?? '', + imgRun.flipH ? 1 : 0, + imgRun.flipV ? 1 : 0, + imgRun.gain ?? '', + imgRun.blacklevel ?? '', + imgRun.grayscale ? 1 : 0, + stableSerializeEvidenceValue(imgRun.lum), + stableSerializeEvidenceValue(imgRun.hyperlink), + stableSerializeEvidenceValue(imgRun.sdt), + stableSerializeEvidenceValue(imgRun.dataAttrs), ].join(','); } diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index b8e993e1a4..a5a278a18a 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -9,6 +9,7 @@ import type { DrawingBlock, TableMeasure, } from '@superdoc/contracts'; +import { EMPTY_SDT_PLACEHOLDER_TEXT } from '@superdoc/contracts'; const expectParagraphMeasure = (measure: Measure): ParagraphMeasure => { expect(measure.kind).toBe('paragraph'); @@ -685,7 +686,7 @@ describe('measureBlock', () => { }); }); - it('measures empty inline SDT placeholders as a small inline box', async () => { + it('measures empty inline SDT placeholders using the visible placeholder text width', async () => { const block: FlowBlock = { kind: 'paragraph', id: 'empty-inline-sdt', @@ -707,14 +708,44 @@ describe('measureBlock', () => { const measure = expectParagraphMeasure(await measureBlock(block, 1000)); expect(measure.lines).toHaveLength(1); - expect(measure.lines[0]).toMatchObject({ - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 0, - width: 8, - segments: [{ runIndex: 0, fromChar: 0, toChar: 0, width: 8 }], - }); + expect(measure.lines[0]).toMatchObject({ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0 }); + expect(measure.lines[0].width).toBeGreaterThan(8); + expect(measure.lines[0].segments).toHaveLength(1); + expect(measure.lines[0].segments[0]).toMatchObject({ runIndex: 0, fromChar: 0, toChar: 0 }); + expect(measure.lines[0].segments[0].width).toBe(measure.lines[0].width); + }); + + it('applies textTransform when measuring empty SDT placeholder text', async () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + expect(ctx).not.toBeNull(); + ctx!.font = '16px Arial'; + const transformedPlaceholderText = EMPTY_SDT_PLACEHOLDER_TEXT.toUpperCase(); + const transformedWidth = ctx!.measureText(transformedPlaceholderText).width; + const untransformedWidth = ctx!.measureText(EMPTY_SDT_PLACEHOLDER_TEXT).width; + expect(transformedWidth).not.toBeCloseTo(untransformedWidth, 2); + + const block: FlowBlock = { + kind: 'paragraph', + id: 'empty-inline-sdt-uppercase', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + textTransform: 'uppercase', + visualPlaceholder: 'emptyInlineSdt', + sdt: { type: 'structuredContent', scope: 'inline', id: 'sdt-empty-uppercase' }, + }, + ], + attrs: {}, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0].width).toBeCloseTo(transformedWidth, 2); }); }); diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 1ae0f9395e..6e6aa87412 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -61,8 +61,9 @@ import { type CellSpacing, type TableBorders, type TableBorderValue, + EMPTY_SDT_PLACEHOLDER_TEXT, effectiveTableCellSpacing, - isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, LeaderDecoration, resolveBaseFontSizeForVerticalText, } from '@superdoc/contracts'; @@ -209,8 +210,6 @@ const FIELD_ANNOTATION_VERTICAL_PADDING = 6; // Vertical padding/border for pill const DEFAULT_FIELD_ANNOTATION_FONT_SIZE = 16; // Default font size for field annotations const DEFAULT_PARAGRAPH_FONT_SIZE = 12; const DEFAULT_PARAGRAPH_FONT_FAMILY = 'Arial'; -const EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH = 8; - const isValidFontSize = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0; @@ -1036,7 +1035,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const emptyParagraphRun = normalizedRuns.length === 1 && isEmptyTextRun(normalizedRuns[0] as Run) && - !isEmptyInlineSdtPlaceholderRun(normalizedRuns[0] as Run) + !isEmptySdtPlaceholderRun(normalizedRuns[0] as Run) ? (normalizedRuns[0] as TextRun) : null; if (emptyParagraphRun) { @@ -2018,11 +2017,22 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P continue; } - if (isEmptyInlineSdtPlaceholderRun(run)) { + if (isEmptySdtPlaceholderRun(run)) { + const placeholderFont = buildFontString(run).font; + const placeholderText = applyTextTransform(EMPTY_SDT_PLACEHOLDER_TEXT, run); + const measuredPlaceholderWidth = getMeasuredTextWidth( + placeholderText, + placeholderFont, + run.letterSpacing ?? 0, + ctx, + ); + const fallbackPlaceholderWidth = placeholderText.length * run.fontSize * 0.45; const placeholderWidth = run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? 0 - : EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH; + : measuredPlaceholderWidth > 0 + ? measuredPlaceholderWidth + : fallbackPlaceholderWidth; if (!currentLine) { currentLine = { diff --git a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts index 2492bf20f2..f08cbb3559 100644 --- a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts +++ b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts @@ -19,7 +19,7 @@ * @spec ECMA-376 §17.3.1.1 (bidi), §17.3.2.30 (rtl) */ -export { applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js'; +export { applyRtlStyles, resolveTextAlign, shouldUseSegmentPositioning } from './rtl-styles.js'; export { resolveRunDirectionAttribute, normalizeRtlDateTokenForWordParity, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6d733a18f5..72e5ffd1c0 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2269,6 +2269,49 @@ describe('DomPainter', () => { expect(annotation?.style.fontSize).toBe('14pt'); }); + it('renders field annotation images with non-base64 SVG data URLs', () => { + const svg = ''; + const imageSrc = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + const block: FlowBlock = { + kind: 'paragraph', + id: 'fa-svg-image', + runs: [ + { + kind: 'fieldAnnotation', + variant: 'signature', + displayLabel: 'Signature', + fieldId: 'F1', + fieldType: 'signer', + fieldColor: '#980043', + imageSrc, + pmStart: 0, + pmEnd: 1, + }, + ], + }; + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + const testLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'fa-svg-image', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(testLayout, mount); + + const img = mount.querySelector('.annotation img') as HTMLImageElement | null; + expect(img).toBeTruthy(); + expect(img?.src).toBe(imageSrc); + expect(img?.alt).toBe('Signature'); + }); + it('sets explicit fontSize on math run wrapper', () => { const block: FlowBlock = { kind: 'paragraph', @@ -2837,8 +2880,151 @@ describe('DomPainter', () => { expect(wrapper?.dataset.empty).toBe('true'); expect(wrapper?.dataset.pmStart).toBe('8'); expect(wrapper?.dataset.pmEnd).toBe('8'); - expect(wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder')).toBeTruthy(); + const placeholder = wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder') as HTMLElement | null; + expect(placeholder).toBeTruthy(); + expect(placeholder?.classList.contains('superdoc-empty-sdt-placeholder')).toBe(true); + expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); expect(wrapper?.textContent).not.toContain('old content'); + expect(wrapper?.textContent).not.toContain('Click or tap here to enter text'); + }); + + it('renders placeholder chrome for an empty block SDT without adding document text', () => { + const sdt = { + type: 'structuredContent', + scope: 'block', + id: 'sc-block-empty-1', + alias: 'Empty block', + } as const; + const block: FlowBlock = { + kind: 'paragraph', + id: 'block-sc-empty', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 4, + visualPlaceholder: 'emptyBlockSdt', + sdt, + }, + ], + attrs: { sdt }, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 220, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sc-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 3, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const fragment = mount.querySelector( + '.superdoc-structured-content-block[data-sdt-id="sc-block-empty-1"]', + ) as HTMLElement | null; + const placeholder = fragment?.querySelector('.superdoc-empty-block-sdt-placeholder') as HTMLElement | null; + + expect(fragment).toBeTruthy(); + expect(placeholder).toBeTruthy(); + expect(placeholder?.classList.contains('superdoc-empty-sdt-placeholder')).toBe(true); + expect(placeholder?.dataset.placeholderText).toBe('Click or tap here to enter text'); + expect(placeholder?.dataset.pmStart).toBe('4'); + expect(placeholder?.dataset.pmEnd).toBe('4'); + expect(placeholder?.style.fontFamily).toBe('Arial'); + expect(placeholder?.style.fontSize).toBe('16px'); + expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment?.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('220px'); + expect(fragment?.textContent).not.toContain('Click or tap here to enter text'); + }); + + it('marks hidden empty block SDT wrappers so placeholder chrome can be suppressed', () => { + const sdt = { + type: 'structuredContent', + scope: 'block', + id: 'sc-block-hidden-empty-1', + alias: 'Hidden empty block', + appearance: 'hidden', + } as const; + const block: FlowBlock = { + kind: 'paragraph', + id: 'block-sc-hidden-empty', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 4, + visualPlaceholder: 'emptyBlockSdt', + sdt, + }, + ], + attrs: { sdt }, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sc-hidden-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 3, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const fragment = mount.querySelector('[data-sdt-id="sc-block-hidden-empty-1"]') as HTMLElement | null; + + expect(fragment).toBeTruthy(); + expect(fragment?.dataset.appearance).toBe('hidden'); + expect(fragment?.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(fragment?.querySelector('.superdoc-structured-content__label')).toBeNull(); }); it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { @@ -7325,6 +7511,9 @@ describe('DomPainter', () => { }); describe('renderImageRun (inline image runs)', () => { + const inlineImageSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const renderInlineImageRun = ( run: Extract['runs'][number], lineWidth = 100, @@ -7377,6 +7566,94 @@ describe('DomPainter', () => { painter.paint(imageLayout, mount); }; + const renderInlineImageTextLine = (runs: Extract['runs']) => { + const imageBlock: FlowBlock = { + kind: 'paragraph', + id: 'img-text-block', + runs, + }; + + const imageMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: runs.length - 1, + toChar: 'text' in runs[runs.length - 1]! ? runs[runs.length - 1]!.text.length : 0, + width: 140, + ascent: 40, + descent: 0, + lineHeight: 40, + }, + ], + totalHeight: 40, + }; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-text-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 140, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] }); + painter.paint(imageLayout, mount); + }; + + it('bottom-aligns normal text runs on lines containing inline images', () => { + renderInlineImageTextLine([ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 }, + { kind: 'image', src: inlineImageSrc, width: 40, height: 40, pmStart: 7, pmEnd: 8 }, + { text: ' after', fontFamily: 'Arial', fontSize: 16, pmStart: 8, pmEnd: 14 }, + ]); + + const textSpans = Array.from(mount.querySelectorAll('.superdoc-line > span')) as HTMLElement[]; + expect(textSpans.map((span) => span.textContent)).toEqual(['Before ', ' after']); + expect(textSpans[0]?.style.lineHeight).toBe('normal'); + expect(textSpans[0]?.style.verticalAlign).toBe('bottom'); + expect(textSpans[1]?.style.lineHeight).toBe('normal'); + expect(textSpans[1]?.style.verticalAlign).toBe('bottom'); + + const img = mount.querySelector('img') as HTMLImageElement | null; + expect(img?.style.verticalAlign).toBe('top'); + }); + + it('preserves explicit vertical positioning on text runs beside inline images', () => { + renderInlineImageTextLine([ + { text: 'Base ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }, + { kind: 'image', src: inlineImageSrc, width: 40, height: 40, pmStart: 5, pmEnd: 6 }, + { + text: '2', + fontFamily: 'Arial', + fontSize: 10.4, + vertAlign: 'superscript', + pmStart: 6, + pmEnd: 7, + }, + ]); + + const textSpans = Array.from(mount.querySelectorAll('.superdoc-line > span')) as HTMLElement[]; + expect(textSpans.map((span) => span.textContent)).toEqual(['Base ', '2']); + expect(textSpans[0]?.style.lineHeight).toBe('normal'); + expect(textSpans[0]?.style.verticalAlign).toBe('bottom'); + expect(textSpans[1]?.style.lineHeight).toBe('1'); + expect(textSpans[1]?.style.verticalAlign).toBe('5.28px'); + }); + it('renders img element with valid data URL', () => { const imageBlock: FlowBlock = { kind: 'paragraph', @@ -7438,6 +7715,49 @@ describe('DomPainter', () => { expect(img?.height).toBe(100); }); + it('renders img element with non-base64 SVG data URL', () => { + const svg = + 'Signature'; + const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + + renderInlineImageRun({ + kind: 'image', + src: svgDataUrl, + width: 100, + height: 50, + }); + + const img = mount.querySelector('img'); + expect(img).toBeTruthy(); + expect(img?.src).toBe(svgDataUrl); + expect(img?.width).toBe(100); + expect(img?.height).toBe(50); + }); + + it('rejects non-base64 raster data URLs', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:image/png,not-base64', + width: 100, + height: 100, + }); + + const img = mount.querySelector('img'); + expect(img).toBeNull(); + }); + + it('rejects non-image data URLs without requiring base64', () => { + renderInlineImageRun({ + kind: 'image', + src: 'data:text/html;charset=utf-8,%3Cscript%3Ealert(1)%3C%2Fscript%3E', + width: 100, + height: 100, + }); + + const img = mount.querySelector('img'); + expect(img).toBeNull(); + }); + it('renders DrawingML luminance using percentage units', () => { const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; @@ -12939,6 +13259,580 @@ describe('applyRunDataAttributes', () => { expect(fragment.dataset.sdtContainerEnd).toBe('true'); }); + it('limits block SDT chrome to paragraph content width', () => { + const textSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-text', + runs: [{ text: 'Short content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 13 }], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-text', + alias: 'Text Control', + }, + }, + }; + + const textSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 13, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const textSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-text', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 13, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [textSdtBlock], measures: [textSdtMeasure] }); + painter.paint(textSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + + it('expands block SDT chrome to justified line width', () => { + const justifiedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-justified', + runs: [{ text: 'Alpha beta gamma', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 16 }], + attrs: { + alignment: 'justify', + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-justified', + alias: 'Justified Control', + }, + }, + }; + + const justifiedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 11, + toRun: 0, + toChar: 16, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const justifiedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-justified', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 10, + continuesOnNext: true, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [justifiedSdtBlock], measures: [justifiedSdtMeasure] }); + painter.paint(justifiedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe(''); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe(''); + }); + + it('offsets block SDT chrome for indented paragraph content', () => { + const indentedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-indented', + runs: [{ text: 'Indented', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + indent: { left: 40 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-indented', + alias: 'Indented Control', + }, + }, + }; + + const indentedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const indentedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-indented', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [indentedSdtBlock], measures: [indentedSdtMeasure] }); + painter.paint(indentedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('40px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + + it('uses paragraph-global line index for block SDT chrome on continuation fragments', () => { + const continuedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-continued', + runs: [{ text: 'First line second line', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 22 }], + attrs: { + indent: { left: 40, firstLine: 30 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-continued', + alias: 'Continued Control', + }, + }, + }; + + const continuedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 11, + toRun: 0, + toChar: 22, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const fragment: Fragment = { + kind: 'para', + blockId: 'block-sdt-continued', + fromLine: 1, + toLine: 2, + x: 20, + y: 30, + width: 320, + pmStart: 11, + pmEnd: 22, + continuesFromPrev: true, + }; + + const continuedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + + const painter = createTestPainter({ blocks: [continuedSdtBlock], measures: [continuedSdtMeasure] }); + painter.setResolvedLayout({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + { + id: 'page-0', + index: 0, + number: 1, + width: 400, + height: 500, + items: [ + { + kind: 'fragment', + id: 'block-sdt-continued:1:2', + pageIndex: 0, + x: fragment.x, + y: fragment.y, + width: fragment.width, + height: 20, + fragmentKind: 'para', + fragment, + blockId: 'block-sdt-continued', + fragmentIndex: 0, + pmStart: fragment.pmStart, + pmEnd: fragment.pmEnd, + continuesFromPrev: true, + block: continuedSdtBlock, + measure: continuedSdtMeasure, + }, + ], + }, + ], + }); + painter.paint(continuedSdtLayout, mount); + + const paintedFragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(paintedFragment.style.width).toBe('320px'); + expect(paintedFragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('40px'); + expect(paintedFragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + + it('limits block SDT chrome to inline image content width', () => { + const imageOnlySdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-image-only', + runs: [ + { + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 210, + height: 118, + pmStart: 0, + pmEnd: 1, + }, + ], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-image-only', + alias: 'Image Control', + }, + }, + }; + + const imageOnlySdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 210, + ascent: 118, + descent: 0, + lineHeight: 118, + }, + ], + totalHeight: 118, + }; + + const imageOnlySdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-image-only', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 1, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [imageOnlySdtBlock], measures: [imageOnlySdtMeasure] }); + painter.paint(imageOnlySdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('210px'); + }); + + it('positions block SDT chrome around centered paragraph content', () => { + const centeredSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-centered', + runs: [{ text: 'Centered', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + alignment: 'center', + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-centered', + alias: 'Centered Control', + }, + }, + }; + + const centeredSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const centeredSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-centered', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [centeredSdtBlock], measures: [centeredSdtMeasure] }); + painter.paint(centeredSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('110px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); + }); + + it('positions block SDT chrome around default RTL paragraph content', () => { + const rtlSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-rtl', + runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }], + attrs: { + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-rtl', + alias: 'RTL Control', + }, + }, + }; + + const rtlSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 80, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const rtlSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-rtl', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [rtlSdtBlock], measures: [rtlSdtMeasure] }); + painter.paint(rtlSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('240px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('80px'); + }); + + it('positions centered block SDT chrome within paragraph indents', () => { + const centeredIndentedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-centered-indented', + runs: [{ text: 'Centered', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + alignment: 'center', + indent: { left: 40, right: 60 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-centered-indented', + alias: 'Centered Indented Control', + }, + }, + }; + + const centeredIndentedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const centeredIndentedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-centered-indented', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [centeredIndentedSdtBlock], + measures: [centeredIndentedSdtMeasure], + }); + painter.paint(centeredIndentedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('100px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); + }); + it('updates block SDT boundaries when appending a new fragment during patch rendering', () => { const sdtMetadata = { type: 'structuredContent' as const, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 1488747529..385ccbec99 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -54,11 +54,13 @@ import type { ResolvedImageItem, ResolvedDrawingItem, ResolvedListMarkerItem, + ResolvedParagraphContent, LayoutSourceIdentity, LayoutStoryLocator, } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, + EMPTY_SDT_PLACEHOLDER_TEXT, adjustAvailableWidthForTextIndent, buildLayoutSourceIdentityForFragment, calculateJustifySpacing, @@ -67,6 +69,7 @@ import { getCellSpacingPx, getParagraphInlineDirection, isEmptyInlineSdtPlaceholderRun, + isEmptySdtPlaceholderRun, normalizeColumnLayout, normalizeBaselineShift, resolveBaseFontSizeForVerticalText, @@ -77,7 +80,7 @@ import { import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; +import { encodeTooltip, isValidImageDataUrl, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { @@ -133,6 +136,7 @@ import { } from './features/paragraph-borders/index.js'; import { applyRtlStyles, + resolveTextAlign, shouldUseSegmentPositioning, resolveRunDirectionAttribute, normalizeRtlDateTokenForWordParity, @@ -922,18 +926,6 @@ const MAX_HREF_LENGTH = 2048; const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; -/** - * Maximum allowed length for data URLs (10MB). - * Prevents denial of service attacks from extremely large embedded images. - */ -const MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; // 10MB - -/** - * Regular expression to validate data URL format for images. - * Only allows common, safe image MIME types with base64 encoding. - * Prevents XSS and malformed data URL attacks. - */ -const VALID_IMAGE_DATA_URL = /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp|bmp|ico|tiff?);base64,/i; const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; @@ -3231,6 +3223,15 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + this.applyBlockSdtChromeBounds( + fragmentEl, + block, + lines, + fragment.width, + fragment.fromLine, + paraContinuesOnNext, + content, + ); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5502,6 +5503,187 @@ export class DomPainter { return wrapper; } + private alignNormalTextBesideInlineImage(element: HTMLElement, run: Run, lineContainsInlineImage: boolean): void { + if (!lineContainsInlineImage) return; + if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return; + + const textRun = run as TextRun; + if (normalizeBaselineShift(textRun.baselineShift) != null || textRun.vertAlign != null) return; + + element.style.lineHeight = 'normal'; + element.style.verticalAlign = 'bottom'; + } + + private applyBlockSdtChromeBounds( + element: HTMLElement, + block: ParagraphBlock, + lines: Line[], + fragmentWidth: number, + fragmentFromLine: number, + fragmentContinuesOnNext: boolean | undefined, + content?: ResolvedParagraphContent, + ): void { + const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; + if (sdt?.type !== 'structuredContent' || sdt.scope !== 'block') return; + + const expandedBlock = { ...block, runs: expandRunsForInlineNewlines(block.runs) }; + let contentLeft = Number.POSITIVE_INFINITY; + let contentRight = Number.NEGATIVE_INFINITY; + + for (const [index, line] of lines.entries()) { + const runsForLine = sliceRunsForLine(expandedBlock, line); + if (runsForLine.length === 0) continue; + + let hasVisibleContent = false; + for (const run of runsForLine) { + if (run.kind === 'lineBreak' || run.kind === 'break') continue; + if (isEmptySdtPlaceholderRun(run)) { + hasVisibleContent = true; + break; + } + if ((run.kind === 'text' || run.kind === undefined) && 'text' in run) { + if ((run.text ?? '').trim().length === 0) continue; + } + hasVisibleContent = true; + break; + } + + if (!hasVisibleContent) continue; + + const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); + if (lineWidth <= 0) continue; + + const resolvedLine = content?.lines[index]; + const lineIndex = resolvedLine?.lineIndex ?? fragmentFromLine + index; + const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, resolvedLine, lineIndex); + const availableWidth = this.resolveBlockSdtChromeAvailableWidth( + block, + line, + fragmentWidth, + lineOffset, + resolvedLine, + ); + const paintedLineWidth = this.resolveBlockSdtChromePaintedLineWidth( + block, + line, + lineWidth, + availableWidth, + index, + lines.length, + fragmentContinuesOnNext, + resolvedLine, + content, + ); + const alignmentSlack = Math.max(0, availableWidth - paintedLineWidth); + const alignment = resolveTextAlign(block.attrs?.alignment, getParagraphInlineDirection(block.attrs) === 'rtl'); + const lineLeft = + lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); + contentLeft = Math.min(contentLeft, lineLeft); + contentRight = Math.max(contentRight, lineLeft + paintedLineWidth); + } + + if (!Number.isFinite(contentLeft) || !Number.isFinite(contentRight)) return; + + const chromeLeft = Math.max(0, contentLeft); + const chromeWidth = Math.max(0, Math.min(fragmentWidth, contentRight) - chromeLeft); + if (chromeWidth <= 0 || chromeWidth >= fragmentWidth) return; + + element.style.setProperty('--sd-sdt-chrome-left', `${chromeLeft}px`); + element.style.setProperty('--sd-sdt-chrome-width', `${chromeWidth}px`); + } + + private resolveBlockSdtChromeLineOffset( + block: ParagraphBlock, + line: Line, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + lineIndex: number, + ): number { + if (resolvedLine) { + if (resolvedLine.isListFirstLine) { + return resolvedLine.resolvedListTextStartPx ?? resolvedLine.indentOffset; + } + if (resolvedLine.hasExplicitSegmentPositioning) { + return resolvedLine.indentOffset; + } + return Math.max(0, resolvedLine.paddingLeftPx + resolvedLine.textIndentPx); + } + + const paraIndent = block.attrs?.indent; + const indentLeft = paraIndent?.left ?? 0; + const firstLine = paraIndent?.firstLine ?? 0; + const hanging = paraIndent?.hanging ?? 0; + const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : firstLine - hanging; + const isFirstLine = lineIndex === 0; + const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true; + + if (hasExplicitSegmentPositioning) { + const effectiveLeftIndent = indentLeft < 0 ? 0 : indentLeft; + return Math.max(0, effectiveLeftIndent + (isFirstLine ? firstLineOffset : 0)); + } + + if (isFirstLine) { + return Math.max(0, indentLeft + firstLineOffset); + } + if (indentLeft > 0) { + return indentLeft; + } + if (hanging > 0 && indentLeft >= 0) { + return hanging; + } + return 0; + } + + private resolveBlockSdtChromeAvailableWidth( + block: ParagraphBlock, + line: Line, + fragmentWidth: number, + lineOffset: number, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + ): number { + if (resolvedLine) { + return Math.max(0, resolvedLine.availableWidth); + } + + const rightIndent = Math.max(0, block.attrs?.indent?.right ?? 0); + const fallbackAvailableWidth = Math.max(0, fragmentWidth - lineOffset - rightIndent); + if (line.maxWidth != null) { + return Math.min(line.maxWidth, fallbackAvailableWidth); + } + return fallbackAvailableWidth; + } + + private resolveBlockSdtChromePaintedLineWidth( + block: ParagraphBlock, + line: Line, + lineWidth: number, + availableWidth: number, + fragmentLineIndex: number, + fragmentLineCount: number, + fragmentContinuesOnNext: boolean | undefined, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + content: ResolvedParagraphContent | undefined, + ): number { + const explicitPositionedSegmentCount = line.segments?.filter((segment) => segment.x !== undefined).length ?? 0; + const hasMultipleExplicitPositionedSegments = explicitPositionedSegmentCount > 1; + const paragraphEndsWithLineBreak = + content?.paragraphEndsWithLineBreak === true || block.runs[block.runs.length - 1]?.kind === 'lineBreak'; + const isLastLineOfParagraph = + resolvedLine != null + ? resolvedLine.skipJustify + : fragmentLineIndex === fragmentLineCount - 1 && !fragmentContinuesOnNext; + const justifyShouldApply = shouldApplyJustify({ + alignment: block.attrs?.alignment, + hasExplicitPositioning: line.segments?.some((segment) => segment.x !== undefined) === true, + hasExplicitTabStops: line.hasExplicitTabStops === true, + isLastLineOfParagraph, + paragraphEndsWithLineBreak, + skipJustifyOverride: (resolvedLine?.skipJustify ?? false) || hasMultipleExplicitPositionedSegments, + }); + + return justifyShouldApply ? Math.max(lineWidth, availableWidth) : lineWidth; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; @@ -5529,15 +5711,22 @@ export class DomPainter { } } - private renderEmptyInlineSdtPlaceholderRun(run: TextRun): HTMLElement | null { + private renderEmptySdtPlaceholderRun(run: TextRun): HTMLElement | null { if (!this.doc) return null; const elem = this.doc.createElement('span'); - elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + elem.classList.add('superdoc-empty-sdt-placeholder'); + if (run.visualPlaceholder === 'emptyInlineSdt') { + elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + } else if (run.visualPlaceholder === 'emptyBlockSdt') { + elem.classList.add('superdoc-empty-block-sdt-placeholder'); + } elem.setAttribute('aria-hidden', 'true'); + elem.dataset.placeholderText = EMPTY_SDT_PLACEHOLDER_TEXT; elem.dataset.layoutEpoch = String(this.layoutEpoch); if (run.pmStart != null) elem.dataset.pmStart = String(run.pmStart); if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); this.applySdtDataset(elem, run.sdt); + applyRunStyles(elem, run); return elem; } @@ -5678,8 +5867,8 @@ export class DomPainter { return null; } - if (isEmptyInlineSdtPlaceholderRun(run)) { - return this.renderEmptyInlineSdtPlaceholderRun(run); + if (isEmptySdtPlaceholderRun(run)) { + return this.renderEmptySdtPlaceholderRun(run); } // Handle TextRun @@ -5785,9 +5974,9 @@ export class DomPainter { * Renders an ImageRun as an inline element. * * SECURITY NOTES: - * - Data URLs are validated against VALID_IMAGE_DATA_URL regex to ensure proper format - * - Size limit (MAX_DATA_URL_LENGTH) prevents DoS attacks from extremely large images - * - Only allows safe image MIME types (png, jpeg, gif, etc.) with base64 encoding + * - Data URLs are validated against an allowlist of image MIME types + * - Size limit prevents DoS attacks from extremely large images + * - Only allows safe image MIME types; non-base64 data URLs are limited to SVG * - Non-data URLs are sanitized through sanitizeUrl to prevent XSS * * METADATA ATTRIBUTE: @@ -5835,13 +6024,8 @@ export class DomPainter { // but are safe for elements when properly validated const isDataUrl = typeof run.src === 'string' && run.src.startsWith('data:'); if (isDataUrl) { - // SECURITY: Validate data URL format and size - if (run.src.length > MAX_DATA_URL_LENGTH) { - // Reject data URLs that are too large (DoS prevention) - return null; - } - if (!VALID_IMAGE_DATA_URL.test(run.src)) { - // Reject data URLs with invalid MIME types or encoding + // SECURITY: Validate data URL MIME type, encoding, and size. + if (!isValidImageDataUrl(run.src)) { return null; } img.src = run.src; @@ -5907,8 +6091,7 @@ export class DomPainter { // When we don't use a wrapper (no clipPath, or clipPath with width/height 0), apply them on the img so layout is correct. const useWrapper = hasClipPath && run.width > 0 && run.height > 0; if (!useWrapper) { - // Apply vertical alignment (bottom-aligned to text baseline) - img.style.verticalAlign = run.verticalAlign ?? 'bottom'; + img.style.verticalAlign = run.verticalAlign ?? 'top'; // Apply spacing as CSS margins if (run.distTop) { @@ -5985,7 +6168,7 @@ export class DomPainter { wrapper.style.height = `${run.height}px`; wrapper.style.boxSizing = 'border-box'; wrapper.style.overflow = 'hidden'; - wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.verticalAlign = run.verticalAlign ?? 'top'; if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; if (run.distBottom) wrapper.style.marginBottom = `${run.distBottom}px`; if (run.distLeft) wrapper.style.marginLeft = `${run.distLeft}px`; @@ -6035,7 +6218,7 @@ export class DomPainter { wrapper.style.display = 'inline-block'; wrapper.style.width = `${run.width}px`; wrapper.style.height = `${run.height}px`; - wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.verticalAlign = run.verticalAlign ?? 'top'; wrapper.style.position = 'relative'; wrapper.style.zIndex = '1'; if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; @@ -6199,7 +6382,7 @@ export class DomPainter { // SECURITY: Validate data URLs const isDataUrl = run.imageSrc.startsWith('data:'); if (isDataUrl) { - if (run.imageSrc.length <= MAX_DATA_URL_LENGTH && VALID_IMAGE_DATA_URL.test(run.imageSrc)) { + if (isValidImageDataUrl(run.imageSrc)) { img.src = run.imageSrc; } else { // Invalid data URL - fall back to displayLabel @@ -6609,6 +6792,7 @@ export class DomPainter { spaceCount, shouldJustify: justifyShouldApply, }); + const lineContainsInlineImage = runsForLine.some((run) => this.isImageRun(run)); const resolveLineIndentOffset = (): number => { if (indentOffsetOverride != null) { return indentOffsetOverride; @@ -6946,6 +7130,7 @@ export class DomPainter { if (styleId) { elem.setAttribute('styleid', styleId); } + this.alignNormalTextBesideInlineImage(elem, segmentRun, lineContainsInlineImage); // Determine X position for this segment // Layout positions are relative to content area start (0). // Add indentOffset to position content at the correct paragraph indent. @@ -7045,6 +7230,7 @@ export class DomPainter { if (styleId) { elem.setAttribute('styleid', styleId); } + this.alignNormalTextBesideInlineImage(elem, run, lineContainsInlineImage); // If this run has inline SDT, add to or create wrapper if (resolved && this.doc) { @@ -7436,6 +7622,7 @@ export class DomPainter { 'sdtScope', 'sdtTag', 'sdtAlias', + 'appearance', 'lockMode', 'sdtSectionTitle', 'sdtSectionType', @@ -7566,6 +7753,7 @@ export class DomPainter { this.setDatasetString(el, 'sdtScope', metadata.scope); this.setDatasetString(el, 'sdtTag', metadata.tag); this.setDatasetString(el, 'sdtAlias', metadata.alias); + this.setDatasetString(el, 'appearance', metadata.appearance); // Always set lockMode (defaulting to 'unlocked') so CSS can target all SDTs uniformly. this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); } else if (metadata.type === 'documentSection') { @@ -7755,6 +7943,26 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); }; +const stableSerializeEvidenceValue = (value: unknown): string => { + if (value === undefined) return ''; + if (value === null) return 'null'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerializeEvidenceValue(item)).join(',')}]`; + } + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableSerializeEvidenceValue(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(String(value)); +}; + /** * Type guard to validate list marker attributes structure. * @@ -7857,6 +8065,17 @@ const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distLeft ?? '', imgRun.distRight ?? '', readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + imgRun.verticalAlign ?? '', + imgRun.rotation ?? '', + imgRun.flipH ? 1 : 0, + imgRun.flipV ? 1 : 0, + imgRun.gain ?? '', + imgRun.blacklevel ?? '', + imgRun.grayscale ? 1 : 0, + stableSerializeEvidenceValue(imgRun.lum), + stableSerializeEvidenceValue(imgRun.hyperlink), + stableSerializeEvidenceValue(imgRun.sdt), + stableSerializeEvidenceValue(imgRun.dataAttrs), // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection ].join(','); } diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index f26a0f8ffd..fec31ecc2b 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -25,6 +25,31 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain('border-color: var(--sd-content-controls-inline-hover-border, transparent);'); }); + it('keeps block SDT chrome paint-only so it does not change fragment geometry', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const blockRule = cssText.match(/\.superdoc-structured-content-block\s*\{([^}]*)\}/)?.[1] ?? ''; + const hoverRule = + cssText.match( + /\.superdoc-structured-content-block:not\(.ProseMirror-selectednode\):hover::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const backgroundRule = cssText.match(/\.superdoc-structured-content-block::before\s*\{([^}]*)\}/)?.[1] ?? ''; + const chromeRule = cssText.match(/\.superdoc-structured-content-block::after\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(blockRule).not.toContain('padding:'); + expect(blockRule).not.toContain('border:'); + expect(blockRule).toContain('--sd-sdt-chrome-left: 0px;'); + expect(blockRule).toContain('--sd-sdt-chrome-width: 100%;'); + expect(backgroundRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); + expect(hoverRule).toContain('background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2);'); + expect(chromeRule).toContain('position: absolute;'); + expect(chromeRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); + expect(chromeRule).toContain('border: 1px solid transparent;'); + expect(chromeRule).toContain('pointer-events: none;'); + }); + it('gives empty inline SDTs a default visible affordance', () => { ensureSdtContainerStyles(document); @@ -40,19 +65,119 @@ describe('ensureSdtContainerStyles', () => { expect(emptyRule).not.toContain('vertical-align'); }); - it('reserves empty inline SDT width without adding line-box height', () => { + it('uses the same label box model for block and inline SDTs', () => { ensureSdtContainerStyles(document); const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; - const placeholderRule = cssText.match(/\.superdoc-empty-inline-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + const sharedLabelRule = + cssText.match( + /\.superdoc-structured-content__label,\s*\.superdoc-structured-content-inline__label\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const inlineSelectedRule = + cssText.match( + /\.superdoc-structured-content-inline\.ProseMirror-selectednode \.superdoc-structured-content-inline__label\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const sharedLabelDragHandleRule = + cssText.match( + /\.superdoc-structured-content__label::before,\s*\.superdoc-structured-content-inline__label::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const inlineLabelRule = + [...cssText.matchAll(/\.superdoc-structured-content-inline__label\s*\{([^}]*)\}/g)] + .map((match) => match[1] ?? '') + .find((rule) => rule.includes('bottom: calc(100% + 1px);')) ?? ''; + const blockLabelRule = cssText.match(/\.superdoc-structured-content__label\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(sharedLabelRule).toContain('height: 18px;'); + expect(sharedLabelRule).toContain('padding: 0 4px;'); + expect(sharedLabelRule).toContain('border: 1px solid var(--sd-content-controls-label-border, #629be7);'); + expect(sharedLabelRule).toContain('box-sizing: border-box;'); + expect(sharedLabelRule).toContain('align-items: center;'); + expect(sharedLabelRule).toContain('justify-content: center;'); + expect(sharedLabelDragHandleRule).toContain("content: '';"); + expect(sharedLabelDragHandleRule).toContain('height: 8px;'); + expect(sharedLabelDragHandleRule).toContain( + 'radial-gradient(circle, currentColor 1px, transparent 1px) center 0 / 2px 2px no-repeat,', + ); + expect(sharedLabelDragHandleRule).toContain('center 3px / 2px 2px no-repeat,'); + expect(sharedLabelDragHandleRule).toContain('center 6px / 2px 2px no-repeat;'); + expect(inlineSelectedRule).toContain('display: inline-flex;'); + expect(inlineLabelRule).toContain('border-radius: 4px 4px 0 0;'); + expect(blockLabelRule).toContain('white-space: nowrap;'); + expect(blockLabelRule).toContain('top: -18px;'); + expect(blockLabelRule).toContain('width: max-content;'); + expect(blockLabelRule).toContain('max-width: 130px;'); + expect(blockLabelRule).toContain('min-width: 0;'); + expect(blockLabelRule).not.toContain('width: calc(var(--sd-sdt-chrome-width, 100%) - 4px);'); + expect(cssText).toContain('.superdoc-structured-content__label span'); + expect(cssText).toContain('flex: 1 1 auto;'); + expect(cssText).toContain('bottom: calc(100% + 1px);'); + }); + + it('renders empty SDT placeholder text and active selection styling', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const placeholderRule = cssText.match(/\.superdoc-empty-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + const placeholderBeforeRule = cssText.match(/\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + const selectedRule = + cssText.match( + /\.superdoc-structured-content-inline\.ProseMirror-selectednode \.superdoc-empty-sdt-placeholder::before,\s*\.superdoc-structured-content-block\.ProseMirror-selectednode \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; expect(placeholderRule).toContain('display: inline-block;'); - expect(placeholderRule).toContain('width: 8px;'); - expect(placeholderRule).toContain('height: 0;'); - expect(placeholderRule).toContain('line-height: 0;'); + expect(placeholderRule).toContain('line-height: normal;'); expect(placeholderRule).toContain('vertical-align: baseline;'); - expect(placeholderRule).not.toContain('height: 1em;'); + expect(placeholderRule).toContain('white-space: nowrap;'); + expect(placeholderBeforeRule).toContain('content: attr(data-placeholder-text);'); + expect(placeholderBeforeRule).toContain('color: var(--sd-content-controls-placeholder-text, #a6a6a6);'); + expect(selectedRule).toContain('background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight);'); + expect(selectedRule).not.toMatch(/(^|\n)\s*color\s*:/); + }); + + it('suppresses empty block SDT placeholder text when the SDT appearance is hidden', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const hiddenPlaceholderRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder,\s*\.superdoc-empty-sdt-placeholder\[data-appearance='hidden'\]\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const hiddenPlaceholderBeforeRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-appearance='hidden'\] \.superdoc-empty-inline-sdt-placeholder::before,\s*\.superdoc-structured-content-block\[data-appearance='hidden'\] \.superdoc-empty-block-sdt-placeholder::before,\s*\.superdoc-empty-sdt-placeholder\[data-appearance='hidden'\]::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + + expect(hiddenPlaceholderRule).toContain('width: 0;'); + expect(hiddenPlaceholderRule).toContain('min-width: 0;'); + expect(hiddenPlaceholderRule).toContain('overflow: hidden;'); + expect(hiddenPlaceholderBeforeRule).toContain("content: '';"); + }); + + it('keeps empty SDT placeholder text visible in viewing mode', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const viewingPlaceholderRule = + cssText.match(/\.presentation-editor--viewing \.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(viewingPlaceholderRule).toBe(''); + expect(viewingPlaceholderRule).not.toContain('visibility: hidden;'); + }); + + it('keeps empty SDT placeholder text visible in print mode', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const printPlaceholderRule = + cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-empty-sdt-placeholder::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(printPlaceholderRule).toBe(''); + expect(printPlaceholderRule).not.toContain('visibility: hidden;'); }); it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { @@ -60,6 +185,10 @@ describe('ensureSdtContainerStyles', () => { const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; + const beforeRule = + cssText.match( + /\.presentation-editor--viewing \.superdoc-structured-content-block(?:\:hover|\.sdt-group-hover|\[data-lock-mode\]\.sdt-group-hover)::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; expect(cssText).toContain('.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover'); expect(cssText).toContain( @@ -69,6 +198,7 @@ describe('ensureSdtContainerStyles', () => { '.presentation-editor--viewing .superdoc-structured-content-inline[data-lock-mode]:hover', ); expect(cssText).toContain('background: none;'); + expect(beforeRule).toContain('background: none;'); }); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 28a62683e6..e8a8f61402 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -497,56 +497,112 @@ const SDT_CONTAINER_STYLES = ` /* Structured Content Block - Blue border container */ .superdoc-structured-content-block { - padding: 1px; box-sizing: border-box; border-radius: 4px; - border: 1px solid transparent; position: relative; + --sd-sdt-chrome-left: 0px; + --sd-sdt-chrome-width: 100%; +} + +.superdoc-structured-content-block::before { + content: ''; + position: absolute; + left: var(--sd-sdt-chrome-left, 0px); + top: 0; + bottom: 0; + width: var(--sd-sdt-chrome-width, 100%); + border-radius: inherit; + box-sizing: border-box; + pointer-events: none; +} + +.superdoc-structured-content-block::after { + content: ''; + position: absolute; + left: var(--sd-sdt-chrome-left, 0px); + top: 0; + bottom: 0; + width: var(--sd-sdt-chrome-width, 100%); + border: 1px solid transparent; + border-radius: inherit; + box-sizing: border-box; + pointer-events: none; } -.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::after { border-color: var(--sd-content-controls-block-hover-border, transparent); } /* Group hover (JavaScript-coordinated via PresentationEditor) */ -.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) { +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode)::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode)::after { border-color: var(--sd-content-controls-block-hover-border, transparent); } .superdoc-structured-content-block.ProseMirror-selectednode { - border-color: var(--sd-content-controls-block-border, #629be7); outline: none; } -/* Structured content drag handle/label - positioned above */ -.superdoc-structured-content__label { +.superdoc-structured-content-block.ProseMirror-selectednode::after { + border-color: var(--sd-content-controls-block-border, #629be7); +} + +/* Structured content labels - shared box model; positioning differs by scope. */ +.superdoc-structured-content__label, +.superdoc-structured-content-inline__label { font-size: 11px; align-items: center; justify-content: center; - position: absolute; - left: 2px; - top: -19px; - width: calc(100% - 4px); - max-width: 130px; - min-width: 0; height: 18px; padding: 0 4px; border: 1px solid var(--sd-content-controls-label-border, #629be7); - border-bottom: none; - border-radius: 6px 6px 0 0; background-color: var(--sd-content-controls-label-bg, #629be7ee); color: var(--sd-content-controls-label-text, #ffffff); box-sizing: border-box; - z-index: 10; display: none; pointer-events: auto; cursor: pointer; user-select: none; } +.superdoc-structured-content__label::before, +.superdoc-structured-content-inline__label::before { + content: ''; + width: 2px; + height: 8px; + margin-right: 4px; + background: + radial-gradient(circle, currentColor 1px, transparent 1px) center 0 / 2px 2px no-repeat, + radial-gradient(circle, currentColor 1px, transparent 1px) center 3px / 2px 2px no-repeat, + radial-gradient(circle, currentColor 1px, transparent 1px) center 6px / 2px 2px no-repeat; + flex: 0 0 auto; +} + +/* Structured content drag handle/label - positioned above */ +.superdoc-structured-content__label { + position: absolute; + left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); + top: -18px; + width: max-content; + max-width: 130px; + min-width: 0; + border-bottom: none; + border-radius: 6px 6px 0 0; + white-space: nowrap; + z-index: 10; +} + .superdoc-structured-content__label span { + display: block; + flex: 1 1 auto; + min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap; @@ -566,29 +622,41 @@ const SDT_CONTAINER_STYLES = ` /* First fragment of a multi-fragment SDT: top corners, no bottom border */ .superdoc-structured-content-block[data-sdt-container-start="true"]:not([data-sdt-container-end="true"]) { border-radius: 4px 4px 0 0; +} + +.superdoc-structured-content-block[data-sdt-container-start="true"]:not([data-sdt-container-end="true"])::after { border-bottom: none; } /* Last fragment of a multi-fragment SDT: bottom corners, no top border */ .superdoc-structured-content-block[data-sdt-container-end="true"]:not([data-sdt-container-start="true"]) { border-radius: 0 0 4px 4px; +} + +.superdoc-structured-content-block[data-sdt-container-end="true"]:not([data-sdt-container-start="true"])::after { border-top: none; } /* Middle fragment (neither start nor end): no corners, no top/bottom borders */ .superdoc-structured-content-block:not([data-sdt-container-start="true"]):not([data-sdt-container-end="true"]) { border-radius: 0; +} + +.superdoc-structured-content-block:not([data-sdt-container-start="true"]):not([data-sdt-container-end="true"])::after { border-top: none; border-bottom: none; } /* Collapse double borders between adjacent SDT blocks */ .superdoc-structured-content-block + .superdoc-structured-content-block { - border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; } +.superdoc-structured-content-block + .superdoc-structured-content-block::after { + border-top: none; +} + /* Structured Content Inline - Inline wrapper with blue border */ .superdoc-structured-content-inline { padding: 1px; @@ -618,42 +686,50 @@ const SDT_CONTAINER_STYLES = ` border-color: var(--sd-content-controls-inline-border, #629be7); } -.superdoc-empty-inline-sdt-placeholder { +.superdoc-empty-sdt-placeholder { display: inline-block; - width: 8px; - height: 0; - line-height: 0; + line-height: normal; vertical-align: baseline; - overflow: hidden; + white-space: nowrap; +} + +.superdoc-empty-sdt-placeholder::before { + content: attr(data-placeholder-text); + color: var(--sd-content-controls-placeholder-text, #a6a6a6); } -.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder { +.superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-empty-sdt-placeholder::before, +.superdoc-structured-content-block.ProseMirror-selectednode .superdoc-empty-sdt-placeholder::before { + background-color: var(--sd-content-controls-placeholder-selected-bg, Highlight); +} + +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder, +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder, +.superdoc-empty-sdt-placeholder[data-appearance='hidden'] { width: 0; min-width: 0; + overflow: hidden; +} + +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder::before, +.superdoc-structured-content-block[data-appearance='hidden'] .superdoc-empty-block-sdt-placeholder::before, +.superdoc-empty-sdt-placeholder[data-appearance='hidden']::before { + content: ''; } /* Inline structured content label - shown when active */ .superdoc-structured-content-inline__label { position: absolute; - bottom: calc(100% + 2px); + bottom: calc(100% + 1px); left: 50%; transform: translateX(-50%); - font-size: 11px; - padding: 0 4px; - border: 1px solid var(--sd-content-controls-label-border, #629be7); - background-color: var(--sd-content-controls-label-bg, #629be7ee); - color: var(--sd-content-controls-label-text, #ffffff); - border-radius: 4px; + border-radius: 4px 4px 0 0; white-space: nowrap; z-index: 100; - display: none; - pointer-events: auto; - cursor: pointer; - user-select: none; } .superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-structured-content-inline__label { - display: block; + display: inline-flex; } .superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover .superdoc-structured-content-inline__label { @@ -696,6 +772,14 @@ const SDT_CONTAINER_STYLES = ` z-index: 9999999; } +.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) { + background-color: transparent; +} + +.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode)::before { + background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { @@ -709,6 +793,19 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-structured-content-block::after, +.presentation-editor--viewing .superdoc-structured-content-block:hover::after, +.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::after, +.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::after { + border: none; +} + +.presentation-editor--viewing .superdoc-structured-content-block:hover::before, +.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::before, +.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::before { + background: none; +} + .presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover, .presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover { background: none; @@ -740,6 +837,10 @@ const SDT_CONTAINER_STYLES = ` padding: 0; } + .superdoc-structured-content-block::after { + border: none; + } + .superdoc-document-section__tooltip, .superdoc-structured-content__label, .superdoc-structured-content-inline__label { diff --git a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts index a913bd1333..37ec02a69a 100644 --- a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts +++ b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts @@ -431,6 +431,7 @@ describe('DomPainter text style CSS rendering', () => { const span = container.querySelector('span'); expect(span).toBeTruthy(); + expect(span?.style.lineHeight).toBe(''); expect(span?.style.verticalAlign).toBe(''); }); diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts index 2ebad541b4..156224968a 100644 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts @@ -224,6 +224,12 @@ export function applySdtContainerStyling( config = getSdtContainerConfig(containerSdt); } if (!config) return; + if ( + (isStructuredContentMetadata(sdt) && sdt.appearance === 'hidden') || + (isStructuredContentMetadata(containerSdt) && containerSdt.appearance === 'hidden') + ) { + return; + } const isStart = boundaryOptions?.isStart ?? config.isStart; const isEnd = boundaryOptions?.isEnd ?? config.isEnd; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts index 9f0e1ddf68..c5195c9655 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/field-annotation.ts @@ -1,4 +1,4 @@ -import type { FieldAnnotationRun, FieldAnnotationMetadata } from '@superdoc/contracts'; +import type { FieldAnnotationRun } from '@superdoc/contracts'; import type { PMNode } from '../../types.js'; import { type InlineConverterParams } from './common'; import { resolveNodeSdtMetadata } from '../../sdt/index.js'; @@ -16,7 +16,7 @@ import { resolveNodeSdtMetadata } from '../../sdt/index.js'; * @returns FieldAnnotationRun object with all extracted properties */ export function fieldAnnotationNodeToRun({ node, positions }: InlineConverterParams): FieldAnnotationRun { - const fieldMetadata = resolveNodeSdtMetadata(node, 'fieldAnnotation') as FieldAnnotationMetadata | null; + const fieldMetadata = resolveNodeSdtMetadata(node, 'fieldAnnotation'); // If there's inner content, extract text to use as displayLabel override let contentText: string | undefined; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index 2c9a4b5346..0c63581391 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -45,7 +45,7 @@ const DEFAULT_IMAGE_DIMENSION_PX = 100; * }, * positionMap * ) - * // Returns: { kind: 'image', src: 'data:...', width: 200, height: 150, alt: 'Company logo', distTop: 10, distBottom: 10, verticalAlign: 'bottom' } + * // Returns: { kind: 'image', src: 'data:...', width: 200, height: 150, alt: 'Company logo', distTop: 10, distBottom: 10, verticalAlign: 'top' } * * // Missing src - returns null * imageNodeToRun({ type: 'image', attrs: {} }, positionMap) @@ -56,7 +56,7 @@ const DEFAULT_IMAGE_DIMENSION_PX = 100; * { type: 'image', attrs: { src: 'image.png', size: { width: NaN, height: -10 } } }, * positionMap * ) - * // Returns: { kind: 'image', src: 'image.png', width: 100, height: 100, verticalAlign: 'bottom' } + * // Returns: { kind: 'image', src: 'image.png', width: 100, height: 100, verticalAlign: 'top' } * ``` */ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverterParams): ImageRun | null { @@ -116,8 +116,8 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter const distRight = pickNumber(wrapAttrs.distRight ?? wrapAttrs.distR); if (distRight != null) run.distRight = distRight; - // Default vertical alignment to bottom (text baseline alignment) - run.verticalAlign = 'bottom'; + // Keep the image box inside the measured line height. + run.verticalAlign = 'top'; // Position tracking const pos = positions.get(node); diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index b4ed57350e..a6bc766124 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -3962,7 +3962,7 @@ describe('paragraph converters', () => { distBottom: 20, distLeft: 5, distRight: 15, - verticalAlign: 'bottom', + verticalAlign: 'top', pmStart: 10, pmEnd: 11, }); @@ -4219,14 +4219,14 @@ describe('paragraph converters', () => { expect(result?.pmEnd).toBeUndefined(); }); - it('sets verticalAlign to bottom by default', () => { + it('sets verticalAlign to top by default', () => { const node: PMNode = { type: 'image', attrs: { src: 'image.png', inline: true }, }; const result = imageNodeToRun(buildImageParams(node, positions)); - expect(result?.verticalAlign).toBe('bottom'); + expect(result?.verticalAlign).toBe('top'); }); it('omits alt and title when not present', () => { diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts b/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts index 5205f1c172..8d5514a1d8 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/metadata.test.ts @@ -2,7 +2,7 @@ * Tests for SDT Metadata Module */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, expectTypeOf } from 'vitest'; import { hasInstruction, getNodeInstruction, @@ -14,7 +14,16 @@ import { applySdtMetadataToListBlock, } from './metadata.js'; import type { PMNode } from '../types.js'; -import type { ParagraphBlock, TableBlock, ListBlock, SdtMetadata } from '@superdoc/contracts'; +import type { + ParagraphBlock, + TableBlock, + ListBlock, + SdtMetadata, + FieldAnnotationMetadata, + StructuredContentMetadata, + DocumentSectionMetadata, + DocPartMetadata, +} from '@superdoc/contracts'; describe('metadata', () => { describe('hasInstruction', () => { @@ -176,6 +185,25 @@ describe('metadata', () => { // Both calls should return the same cached object expect(result1).toBe(result2); }); + + it('narrows the return type when a literal override is provided', () => { + const node = { type: 'fieldAnnotation', attrs: { fieldId: 'field-1' } } as PMNode; + + expectTypeOf(resolveNodeSdtMetadata(node)).toEqualTypeOf(); + expectTypeOf(resolveNodeSdtMetadata(node, 'fieldAnnotation')).toEqualTypeOf< + FieldAnnotationMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'structuredContent')).toEqualTypeOf< + StructuredContentMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'structuredContentBlock')).toEqualTypeOf< + StructuredContentMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'documentSection')).toEqualTypeOf< + DocumentSectionMetadata | undefined + >(); + expectTypeOf(resolveNodeSdtMetadata(node, 'docPartObject')).toEqualTypeOf(); + }); }); describe('applySdtMetadataToParagraphBlocks', () => { diff --git a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts b/packages/layout-engine/pm-adapter/src/sdt/metadata.ts index 3953190518..322bad72e5 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/metadata.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/metadata.ts @@ -6,10 +6,29 @@ * document sections, TOC entries, structured content blocks, etc. */ -import type { FlowBlock, TableBlock, ListBlock, SdtMetadata } from '@superdoc/contracts'; +import type { + FlowBlock, + TableBlock, + ListBlock, + SdtMetadata, + FieldAnnotationMetadata, + StructuredContentMetadata, + DocumentSectionMetadata, + DocPartMetadata, +} from '@superdoc/contracts'; import type { PMNode } from '../types.js'; import { resolveSdtMetadata } from '@superdoc/style-engine'; +type SdtMetadataForOverride = TOverride extends 'fieldAnnotation' + ? FieldAnnotationMetadata + : TOverride extends 'structuredContent' | 'structuredContentBlock' + ? StructuredContentMetadata + : TOverride extends 'documentSection' + ? DocumentSectionMetadata + : TOverride extends 'docPartObject' + ? DocPartMetadata + : SdtMetadata; + /** * Type guard to check if a node has instruction attribute. */ @@ -57,7 +76,10 @@ export function getDocPartObjectId(node: PMNode): string | undefined { * @param overrideType - Optional type override (e.g., 'documentSection', 'docPartObject') * @returns Resolved SDT metadata, or undefined if none */ -export function resolveNodeSdtMetadata(node: PMNode, overrideType?: string): SdtMetadata | undefined { +export function resolveNodeSdtMetadata( + node: PMNode, + overrideType?: TOverride, +): SdtMetadataForOverride | undefined { const attrs = node.attrs; if (!attrs) return undefined; const nodeType = overrideType ?? node.type; @@ -74,7 +96,7 @@ export function resolveNodeSdtMetadata(node: PMNode, overrideType?: string): Sdt nodeType, attrs, cacheKey, - }); + }) as SdtMetadataForOverride | undefined; } /** diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index f9587ff8e1..d56965acc1 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -31,17 +31,23 @@ describe('structured-content-block', () => { const mockConverterContext = { docx: {} } as never; const scbMetadata: SdtMetadata = { - type: 'structuredContentBlock', + type: 'structuredContent', + scope: 'block', id: 'scb-1', }; + const nonEmptyParagraph = (text = 'Text'): PMNode => ({ + type: 'paragraph', + content: [{ type: 'text', text }], + }); beforeEach(() => { vi.clearAllMocks(); + mockPositionMap.clear(); }); // ==================== Basic Functionality Tests ==================== describe('Basic functionality', () => { - it('should return early if node.content is not an array', () => { + it('should emit a placeholder paragraph if node.content is not an array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -50,6 +56,8 @@ describe('structured-content-block', () => { const blocks: FlowBlock[] = []; const recordBlockKind = vi.fn(); + mockPositionMap.set(node, { start: 10, end: 12 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); const context: NodeHandlerContext = { blocks, @@ -70,15 +78,29 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); - expect(recordBlockKind).not.toHaveBeenCalled(); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + id: 'paragraph-test-id', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 11, + pmEnd: 11, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); }); it('should throw if paragraphToFlowBlocks is not provided', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -102,7 +124,7 @@ describe('structured-content-block', () => { expect(() => handleStructuredContentBlockNode(node, context)).toThrow(); }); - it('should handle empty children array', () => { + it('should emit a placeholder paragraph for empty children array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -133,6 +155,458 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph for a single empty paragraph child', () => { + const emptyParagraph: PMNode = { type: 'paragraph', content: [] }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 14 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 13 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-empty-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + color: '#123456', + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + fontFamily: 'Aptos', + fontSize: 14, + color: '#123456', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(paragraphToFlowBlocks).toHaveBeenCalledWith( + expect.objectContaining({ + para: emptyParagraph, + positions: mockPositionMap, + }), + ); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph when the empty paragraph only has bookmark markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'bookmarkStart', attrs: { id: '1', name: 'EmptySdtBookmark' } }, + { type: 'bookmarkEnd', attrs: { id: '1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-bookmark-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph when the empty paragraph only has comment range markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'commentRangeStart', attrs: { 'w:id': 'comment-1' } }, + { type: 'commentRangeEnd', attrs: { 'w:id': 'comment-1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-comment-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should emit a placeholder paragraph when the empty paragraph only has permission range markers', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + content: [ + { type: 'permStart', attrs: { id: 'perm-1', edGrp: 'everyone' } }, + { type: 'permEnd', attrs: { id: 'perm-1' } }, + ], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + mockPositionMap.set(node, { start: 10, end: 16 }); + mockPositionMap.set(emptyParagraph, { start: 11, end: 15 }); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([ + { + kind: 'paragraph', + id: 'converted-permission-only-paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + fontFamily: 'Aptos', + fontSize: 14, + pmStart: 12, + pmEnd: 12, + }, + ], + }, + ] satisfies ParagraphBlock[]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [ + { + text: '', + sdt: scbMetadata, + visualPlaceholder: 'emptyBlockSdt', + pmStart: 12, + pmEnd: 12, + }, + ], + }); + expect(recordBlockKind).toHaveBeenCalledWith('paragraph'); + }); + + it('should not emit a placeholder for a vanished empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: { + vanish: true, + }, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toHaveLength(0); + expect(recordBlockKind).not.toHaveBeenCalled(); + }); + + it('should preserve non-paragraph converter output for a vanished empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + pageBreakBefore: true, + paragraphProperties: { + runProperties: { + vanish: true, + }, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const pageBreakBlock: FlowBlock = { + kind: 'pageBreak', + id: 'page-break-before-hidden-paragraph', + attrs: { source: 'pageBreakBefore' }, + }; + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([pageBreakBlock]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: mockTrackedChangesConfig, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + + expect(blocks).toEqual([pageBreakBlock]); + expect(recordBlockKind).toHaveBeenCalledWith('pageBreak'); + }); + + it('should not synthesize a placeholder when tracked-change filtering removes an empty paragraph child', () => { + const emptyParagraph: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: {}, + }, + }, + content: [], + }; + const node: PMNode = { + type: 'structuredContentBlock', + attrs: { id: 'scb-1' }, + content: [emptyParagraph], + }; + + const blocks: FlowBlock[] = []; + const recordBlockKind = vi.fn(); + + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); + const paragraphToFlowBlocks = vi.fn().mockReturnValue([]); + + const context: NodeHandlerContext = { + blocks, + recordBlockKind, + nextBlockId: mockBlockIdGenerator, + positions: mockPositionMap, + defaultFont: 'Arial', + defaultSize: 12, + trackedChangesConfig: { enabled: true, mode: 'final' }, + bookmarks: mockBookmarks, + hyperlinkConfig: mockHyperlinkConfig, + enableComments: mockEnableComments, + converterContext: mockConverterContext, + converters: { + paragraphToFlowBlocks, + }, + }; + + handleStructuredContentBlockNode(node, context); + expect(blocks).toHaveLength(0); expect(recordBlockKind).not.toHaveBeenCalled(); }); @@ -234,7 +708,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -273,7 +747,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -322,7 +796,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -363,7 +837,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -402,7 +876,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -452,7 +926,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -488,7 +962,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: {}, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -694,7 +1168,7 @@ describe('structured-content-block', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; @@ -735,7 +1209,7 @@ describe('structured-content-block', () => { // ==================== Edge Cases ==================== describe('Edge cases', () => { - it('should handle node with null content', () => { + it('should emit a placeholder paragraph for null content', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, @@ -744,6 +1218,7 @@ describe('structured-content-block', () => { const blocks: FlowBlock[] = []; const recordBlockKind = vi.fn(); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(scbMetadata); const context: NodeHandlerContext = { blocks, @@ -764,14 +1239,19 @@ describe('structured-content-block', () => { handleStructuredContentBlockNode(node, context); - expect(blocks).toHaveLength(0); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + kind: 'paragraph', + attrs: { sdt: scbMetadata }, + runs: [{ visualPlaceholder: 'emptyBlockSdt', sdt: scbMetadata }], + }); }); it('should handle converter returning empty array', () => { const node: PMNode = { type: 'structuredContentBlock', attrs: { id: 'scb-1' }, - content: [{ type: 'paragraph', content: [] }], + content: [nonEmptyParagraph()], }; const blocks: FlowBlock[] = []; diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index 9302e06b60..86f4efac22 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -5,10 +5,78 @@ * paragraphs and tables while preserving their content structure. */ -import type { ParagraphBlock, TableBlock } from '@superdoc/contracts'; +import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts'; import type { PMNode, NodeHandlerContext } from '../types.js'; import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js'; +const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set([ + 'bookmarkEnd', + 'commentRangeStart', + 'commentRangeEnd', + 'permStart', + 'permEnd', +]); + +function isVisuallyEmptyInlineNode(node: PMNode): boolean { + if (node.type === 'text') { + return (node.text ?? '').length === 0; + } + + if (node.type === 'run' || node.type === 'bookmarkStart') { + return !Array.isArray(node.content) || node.content.every(isVisuallyEmptyInlineNode); + } + + return NON_RENDERED_STRUCTURAL_INLINE_TYPES.has(node.type); +} + +function isEmptyParagraphNode(node: PMNode): boolean { + if (node.type !== 'paragraph') return false; + if (!Array.isArray(node.content) || node.content.length === 0) return true; + + return node.content.every(isVisuallyEmptyInlineNode); +} + +function isVanishedParagraphNode(node: PMNode): boolean { + const paragraphProperties = node.attrs?.paragraphProperties; + if (!paragraphProperties || typeof paragraphProperties !== 'object') return false; + + const runProperties = (paragraphProperties as { runProperties?: unknown }).runProperties; + if (!runProperties || typeof runProperties !== 'object') return false; + + return (runProperties as { vanish?: unknown }).vanish === true; +} + +function asEmptyTextRun(run: unknown): TextRun | undefined { + if (!run || typeof run !== 'object') return undefined; + const candidate = run as TextRun; + if (!('text' in candidate) || candidate.text !== '') return undefined; + const kind = (candidate as { kind?: unknown }).kind; + return kind == null || kind === 'text' ? candidate : undefined; +} + +function applyPlaceholderToEmptyParagraphBlocks( + paragraphBlocks: FlowBlock[], + metadata: TextRun['sdt'], + contentPos?: number, +): boolean { + let applied = false; + paragraphBlocks.forEach((block) => { + if (block.kind !== 'paragraph') return; + const run = block.runs.map(asEmptyTextRun).find(Boolean); + if (!run) return; + run.kind = 'text'; + run.text = ''; + run.sdt = metadata; + run.visualPlaceholder = 'emptyBlockSdt'; + if (contentPos != null) { + run.pmStart = contentPos; + run.pmEnd = contentPos; + } + applied = true; + }); + return applied; +} + /** * Handle structured content block nodes. * Processes child paragraphs and tables, applying SDT metadata. @@ -17,13 +85,13 @@ import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMeta * @param context - Shared handler context */ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHandlerContext): void { - if (!Array.isArray(node.content)) return; - const { blocks, recordBlockKind, nextBlockId, positions, + defaultFont, + defaultSize, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -33,7 +101,77 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand themeColors, } = context; const structuredContentMetadata = resolveNodeSdtMetadata(node, 'structuredContentBlock'); - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; + const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; + + const emitPlaceholderBlock = (contentPos?: number): void => { + if (!structuredContentMetadata) return; + const placeholderRun: TextRun = { + kind: 'text', + text: '', + fontFamily: defaultFont, + fontSize: defaultSize, + sdt: structuredContentMetadata, + visualPlaceholder: 'emptyBlockSdt', + ...(contentPos != null ? { pmStart: contentPos, pmEnd: contentPos } : {}), + }; + const placeholderBlock: ParagraphBlock = { + kind: 'paragraph', + id: nextBlockId('paragraph'), + runs: [placeholderRun], + attrs: { sdt: structuredContentMetadata }, + }; + blocks.push(placeholderBlock); + recordBlockKind?.(placeholderBlock.kind); + }; + + if (!Array.isArray(node.content) || node.content.length === 0) { + const pos = positions.get(node); + emitPlaceholderBlock(pos ? pos.start + 1 : undefined); + return; + } + + if (node.content.length === 1 && isEmptyParagraphNode(node.content[0])) { + const isVanishedParagraph = isVanishedParagraphNode(node.content[0]); + const paragraphPos = positions.get(node.content[0]); + const blockPos = positions.get(node); + const contentPos = paragraphPos ? paragraphPos.start + 1 : blockPos ? blockPos.start + 1 : undefined; + + if (paragraphToFlowBlocks) { + const convertedBlocks = paragraphToFlowBlocks({ + para: node.content[0], + nextBlockId, + positions, + trackedChangesConfig, + bookmarks, + hyperlinkConfig, + themeColors, + enableComments, + converters, + converterContext, + }); + const paragraphBlocks = Array.isArray(convertedBlocks) ? convertedBlocks : []; + applySdtMetadataToParagraphBlocks( + paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[], + structuredContentMetadata, + ); + if (applyPlaceholderToEmptyParagraphBlocks(paragraphBlocks, structuredContentMetadata, contentPos)) { + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; + } + paragraphBlocks.forEach((block) => { + blocks.push(block); + recordBlockKind?.(block.kind); + }); + return; + } + + if (isVanishedParagraph) return; + emitPlaceholderBlock(contentPos); + return; + } // SD-1333: a documentPartObject is a transparent SDT wrapper. When it sits // as a direct child of a structuredContentBlock (e.g. a Signature SDT @@ -42,6 +180,9 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand // outer SDT metadata to them. const visitChild = (child: PMNode): void => { if (child.type === 'paragraph') { + if (!paragraphToFlowBlocks) { + throw new Error('paragraphToFlowBlocks converter is required for structuredContentBlock paragraphs'); + } const paragraphBlocks = paragraphToFlowBlocks({ para: child, nextBlockId, diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js index f2f2f14dda..fb6faf50a6 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js @@ -21,6 +21,50 @@ function createMockEditor(overrides = {}) { }; } +function createResizableImageElement({ lockMode, ancestorLockMode } = {}) { + const imageEl = document.createElement('div'); + imageEl.setAttribute('data-pm-start', '0'); + imageEl.setAttribute('data-sd-block-id', 'image-block'); + imageEl.setAttribute( + 'data-image-metadata', + JSON.stringify({ + originalWidth: 100, + originalHeight: 50, + maxWidth: 500, + maxHeight: 500, + aspectRatio: 2, + minWidth: 20, + minHeight: 20, + }), + ); + if (lockMode) { + imageEl.setAttribute('data-lock-mode', lockMode); + } + imageEl.getBoundingClientRect = vi.fn(() => ({ + left: 10, + top: 20, + width: 100, + height: 50, + right: 110, + bottom: 70, + x: 10, + y: 20, + toJSON: () => {}, + })); + + if (!ancestorLockMode) { + document.body.appendChild(imageEl); + return { imageEl, remove: () => imageEl.remove() }; + } + + const sdtEl = document.createElement('div'); + sdtEl.className = 'superdoc-structured-content-block'; + sdtEl.setAttribute('data-lock-mode', ancestorLockMode); + sdtEl.appendChild(imageEl); + document.body.appendChild(sdtEl); + return { imageEl, remove: () => sdtEl.remove() }; +} + describe('ImageResizeOverlay', () => { describe('isResizeDisabled guard', () => { it('should report resize disabled when documentMode is viewing', () => { @@ -55,6 +99,123 @@ describe('ImageResizeOverlay', () => { expect(wrapper.vm.isResizeDisabled).toBe(false); }); + + it('should report resize disabled for images inside content-locked SDTs', () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: 'contentLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + props: { editor, visible: true, imageElement: imageEl }, + }); + + expect(wrapper.vm.isResizeDisabled).toBe(true); + + wrapper.unmount(); + remove(); + }); + + it('should report resize disabled when an outer SDT is content-locked even if the image has an unlocked lock mode', () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ + lockMode: 'unlocked', + ancestorLockMode: 'contentLocked', + }); + + const wrapper = mount(ImageResizeOverlay, { + props: { editor, visible: true, imageElement: imageEl }, + }); + + expect(wrapper.vm.isResizeDisabled).toBe(true); + + wrapper.unmount(); + remove(); + }); + }); + + it.each(['contentLocked', 'sdtContentLocked'])( + 'should not start image resize drag inside %s SDTs', + async (lockMode) => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: lockMode }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.dragState).toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(false); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + expect(editor.view.state.tr.setNodeMarkup).not.toHaveBeenCalled(); + + wrapper.unmount(); + remove(); + }, + ); + + it('should not start image resize drag when the image element has contentLocked mode directly', async () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ lockMode: 'contentLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.dragState).toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(false); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + + wrapper.unmount(); + remove(); + }); + + it('should still allow image resize inside sdtLocked SDTs', async () => { + const editor = createMockEditor(); + const imageNode = { + type: { name: 'image' }, + attrs: { size: { width: 100, height: 50 } }, + }; + editor.view.state.doc.nodeAt.mockReturnValue(imageNode); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: 'sdtLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + expect(wrapper.vm.dragState).not.toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(true); + + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + + expect(editor.view.state.tr.setNodeMarkup).toHaveBeenCalledWith( + 0, + null, + expect.objectContaining({ size: { width: 140, height: 70 } }), + ); + expect(editor.view.dispatch).toHaveBeenCalledWith(editor.view.state.tr); + + wrapper.unmount(); + remove(); }); it('should dispatch resize transactions through the presentation editor active editor', async () => { diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index a4293db5d8..c77d97d44f 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -22,6 +22,7 @@