From f52950b36b350fa05b36b661bdcf2b54684dd9e5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 16:48:57 -0300 Subject: [PATCH 1/9] refactor(painters/dom): unify paragraph rendering across body and table cells Extract the paragraph-rendering pipeline (block styles, decoration layers, list markers, indentation, line walking, drop caps) into a shared paragraph/renderParagraphContent module consumed by both the body-fragment path in renderer.ts and the table-cell path in renderTableCell.ts. The two sites had drifted into parallel ~500-line implementations; collapsing them removes ~1k lines net and gives both paths the same marker/indent/border behavior. Borders and shading now live on dedicated .superdoc-paragraph-border / .superdoc-paragraph-shading layers (already used by the body path) instead of being stamped onto the cell-paragraph wrapper, so renderTableCell tests were updated to assert against those layers. --- .../painters/dom/src/paragraph/indentation.ts | 102 ++++ .../painters/dom/src/paragraph/list-marker.ts | 216 +++++++ .../src/paragraph/renderParagraphContent.ts | 525 ++++++++++++++++ .../painters/dom/src/paragraph/styles.ts | 37 ++ .../painters/dom/src/renderer.ts | 530 ++-------------- .../dom/src/table/renderTableCell.test.ts | 111 ++-- .../painters/dom/src/table/renderTableCell.ts | 577 +----------------- 7 files changed, 1025 insertions(+), 1073 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/paragraph/indentation.ts create mode 100644 packages/layout-engine/painters/dom/src/paragraph/list-marker.ts create mode 100644 packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts create mode 100644 packages/layout-engine/painters/dom/src/paragraph/styles.ts diff --git a/packages/layout-engine/painters/dom/src/paragraph/indentation.ts b/packages/layout-engine/painters/dom/src/paragraph/indentation.ts new file mode 100644 index 0000000000..72b9368c11 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/indentation.ts @@ -0,0 +1,102 @@ +import type { Line, ParagraphIndent } from '@superdoc/contracts'; +import { adjustAvailableWidthForTextIndent } from '@superdoc/contracts'; + +export type ParagraphLineIndentationParams = { + lineEl: HTMLElement; + line: Line; + indent?: ParagraphIndent; + indentLeftPx: number; + hasListMarkerLayout: boolean; + lineIndex: number; + localStartLine: number; + continuesFromPrev?: boolean; + suppressFirstLineIndent: boolean; + resetContinuationTextIndent?: boolean; +}; + +export const hasExplicitSegmentPositioning = (line: Line): boolean => + line.segments?.some((segment) => segment.x !== undefined) === true; + +export const applyParagraphLineIndentation = (params: ParagraphLineIndentationParams): void => { + const { + lineEl, + line, + indent, + indentLeftPx, + hasListMarkerLayout, + lineIndex, + localStartLine, + continuesFromPrev, + suppressFirstLineIndent, + resetContinuationTextIndent, + } = params; + const paraIndentLeft = indent?.left ?? 0; + const paraIndentRight = indent?.right ?? 0; + const firstLineOffset = suppressFirstLineIndent ? 0 : (indent?.firstLine ?? 0) - (indent?.hanging ?? 0); + const isFirstLine = lineIndex === 0 && localStartLine === 0 && !continuesFromPrev; + const explicitSegmentPositioning = hasExplicitSegmentPositioning(line); + + if (hasListMarkerLayout && indentLeftPx) { + if (!explicitSegmentPositioning) { + lineEl.style.paddingLeft = `${indentLeftPx}px`; + } + } else if (explicitSegmentPositioning) { + if (isFirstLine && firstLineOffset !== 0) { + const effectiveLeftIndent = paraIndentLeft < 0 ? 0 : paraIndentLeft; + const adjustedPadding = effectiveLeftIndent + firstLineOffset; + if (adjustedPadding > 0) { + lineEl.style.paddingLeft = `${adjustedPadding}px`; + } + } + } else if (paraIndentLeft && paraIndentLeft > 0) { + lineEl.style.paddingLeft = `${paraIndentLeft}px`; + } else if (!isFirstLine && indent?.hanging && indent.hanging > 0 && (paraIndentLeft == null || paraIndentLeft >= 0)) { + lineEl.style.paddingLeft = `${indent.hanging}px`; + } + + if (paraIndentRight && paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } + if (isFirstLine && firstLineOffset && !explicitSegmentPositioning) { + lineEl.style.textIndent = `${firstLineOffset}px`; + } else if (firstLineOffset && explicitSegmentPositioning) { + lineEl.style.textIndent = '0px'; + } else if (firstLineOffset && !hasListMarkerLayout && resetContinuationTextIndent) { + lineEl.style.textIndent = '0px'; + } +}; + +export const resolveAvailableWidthForLine = (params: { + containerWidth: number; + line: Line; + indentLeftPx: number; + indentRightPx: number; + firstLineOffset: number; + isFirstLine: boolean; + isListFirstLine: boolean; + resolvedListTextStartPx?: number; +}): number => { + const { + containerWidth, + line, + indentLeftPx, + indentRightPx, + firstLineOffset, + isFirstLine, + isListFirstLine, + resolvedListTextStartPx, + } = params; + const positiveIndentReduction = Math.max(0, indentLeftPx) + Math.max(0, indentRightPx); + const fallbackAvailableWidth = Math.max(0, containerWidth - positiveIndentReduction); + let availableWidth = line.maxWidth != null ? Math.min(line.maxWidth, fallbackAvailableWidth) : fallbackAvailableWidth; + + if (resolvedListTextStartPx != null) { + availableWidth = containerWidth - resolvedListTextStartPx - Math.max(0, indentRightPx); + } + + if (isFirstLine && !isListFirstLine && line.hasExplicitTabStops !== true) { + availableWidth = adjustAvailableWidthForTextIndent(availableWidth, firstLineOffset, line.maxWidth); + } + + return availableWidth; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts new file mode 100644 index 0000000000..337e727410 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -0,0 +1,216 @@ +import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts'; +import { createListMarkerElement, computeTabWidth, resolvePainterListMarkerGeometry } from '../utils/marker-helpers.js'; + +export type WordLayoutMarker = { + markerText?: string; + justification?: 'left' | 'right' | 'center'; + gutterWidthPx?: number; + markerBoxWidthPx?: number; + suffix?: 'tab' | 'space' | 'nothing'; + markerX?: number; + textStartX?: number; + run: { + fontFamily?: string; + fontSize?: number; + bold?: boolean; + italic?: boolean; + color?: string; + letterSpacing?: number; + vanish?: boolean; + }; +}; + +export type MinimalWordLayout = { + marker?: WordLayoutMarker; + indentLeftPx?: number; + firstLineIndentMode?: boolean; + textStartPx?: number; + tabsPx?: number[]; +}; + +export const renderLegacyListMarker = (params: { + doc: Document; + lineEl: HTMLElement; + wordLayout?: MinimalWordLayout; + markerLayout: WordLayoutMarker; + markerMeasure: ParagraphMeasure['marker']; + markerTextWidthPx?: number; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + isRtl?: boolean; + sourceAnchor?: SourceAnchor; +}): void => { + const { + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + markerTextWidthPx, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + isRtl, + sourceAnchor, + } = params; + const markerTextWidth = markerTextWidthPx ?? markerMeasure?.markerTextWidth ?? 0; + const shouldUseSharedInlinePrefixGeometry = + markerLayout?.justification === 'left' && + wordLayout?.firstLineIndentMode !== true && + typeof markerTextWidth === 'number' && + Number.isFinite(markerTextWidth) && + markerTextWidth >= 0; + const markerGeometry = shouldUseSharedInlinePrefixGeometry + ? resolvePainterListMarkerGeometry({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx: markerTextWidth, + }) + : undefined; + + const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; + const markerJustification = markerLayout?.justification ?? 'left'; + let markerStartPos: number; + let currentPos: number; + if (markerJustification === 'left') { + markerStartPos = anchorPoint; + currentPos = markerStartPos + markerTextWidth; + } else if (markerJustification === 'right') { + markerStartPos = anchorPoint - markerTextWidth; + currentPos = anchorPoint; + } else { + markerStartPos = anchorPoint - markerTextWidth / 2; + currentPos = markerStartPos + markerTextWidth; + } + + const suffix = markerLayout?.suffix ?? 'tab'; + let suffixWidthPx = 0; + if (markerGeometry && (suffix === 'tab' || suffix === 'space')) { + suffixWidthPx = markerGeometry.suffixWidthPx; + } else if (suffix === 'tab') { + suffixWidthPx = computeTabWidth( + currentPos, + markerJustification, + wordLayout?.tabsPx, + hangingIndentPx, + firstLineIndentPx, + indentLeftPx, + ); + } else if (suffix === 'space') { + suffixWidthPx = 4; + } + + if (isRtl) { + lineEl.style.paddingRight = `${anchorPoint}px`; + } else { + lineEl.style.paddingLeft = `${anchorPoint}px`; + } + + if (markerLayout?.run?.vanish) { + return; + } + + const markerContainer = createListMarkerElement( + doc, + markerLayout?.markerText ?? '', + markerLayout?.run ?? {}, + sourceAnchor, + ); + markerContainer.style.position = 'relative'; + if (markerJustification === 'right') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${markerStartPos}px`; + } else { + markerContainer.style.left = `${markerStartPos}px`; + } + } else if (markerJustification === 'center') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${markerStartPos - markerTextWidth / 2}px`; + lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + markerTextWidth / 2}px`; + } else { + markerContainer.style.left = `${markerStartPos - markerTextWidth / 2}px`; + lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + markerTextWidth / 2}px`; + } + } + + prependMarkerSuffix(doc, lineEl, suffix, suffixWidthPx, markerLayout?.run?.fontSize); + lineEl.prepend(markerContainer); +}; + +export const renderResolvedListMarker = (params: { + doc: Document; + lineEl: HTMLElement; + marker: ResolvedListMarkerItem; + isRtl?: boolean; + sourceAnchor?: SourceAnchor; +}): void => { + const { doc, lineEl, marker, isRtl, sourceAnchor } = params; + if (isRtl) { + lineEl.style.paddingRight = `${marker.firstLinePaddingLeftPx}px`; + } else { + lineEl.style.paddingLeft = `${marker.firstLinePaddingLeftPx}px`; + } + + if (marker.vanish) { + return; + } + + const markerContainer = createListMarkerElement(doc, marker.text, marker.run, marker.sourceAnchor ?? sourceAnchor); + markerContainer.style.position = 'relative'; + if (marker.justification === 'right') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${marker.markerStartPx}px`; + } else { + markerContainer.style.left = `${marker.markerStartPx}px`; + } + } else if (marker.justification === 'center') { + markerContainer.style.position = 'absolute'; + const paddingAdjust = marker.centerPaddingAdjustPx ?? 0; + if (isRtl) { + markerContainer.style.right = `${marker.markerStartPx - paddingAdjust}px`; + lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + paddingAdjust}px`; + } else { + markerContainer.style.left = `${marker.markerStartPx - paddingAdjust}px`; + lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + paddingAdjust}px`; + } + } + + prependMarkerSuffix(doc, lineEl, marker.suffix, marker.suffixWidthPx, marker.run.fontSize); + lineEl.prepend(markerContainer); +}; + +const prependMarkerSuffix = ( + doc: Document, + lineEl: HTMLElement, + suffix: 'tab' | 'space' | 'nothing' | undefined, + suffixWidthPx: number, + fontSize?: number, +): void => { + if (suffix === 'tab') { + const tabEl = doc.createElement('span'); + tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); + tabEl.innerHTML = ' '; + tabEl.style.display = 'inline-block'; + if (fontSize != null) { + tabEl.style.fontSize = `${fontSize}px`; + } + tabEl.style.wordSpacing = '0px'; + tabEl.style.width = `${suffixWidthPx}px`; + lineEl.prepend(tabEl); + } else if (suffix === 'space') { + const spaceEl = doc.createElement('span'); + spaceEl.classList.add('superdoc-marker-suffix-space'); + if (fontSize != null) { + spaceEl.style.fontSize = `${fontSize}px`; + } + spaceEl.style.wordSpacing = '0px'; + spaceEl.textContent = '\u00A0'; + lineEl.prepend(spaceEl); + } +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts new file mode 100644 index 0000000000..028e4966a9 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -0,0 +1,525 @@ +import type { + DropCapDescriptor, + Line, + ParagraphBlock, + ParagraphMeasure, + ResolvedParagraphContent, + Run, + SdtMetadata, + SourceAnchor, +} from '@superdoc/contracts'; +import { + effectiveTableCellSpacing, + expandRunsForInlineNewlines, + getParagraphInlineDirection, +} from '@superdoc/contracts'; +import { resolveMarkerIndent } from '@superdoc/common/list-marker-utils'; +import { resolvePainterListTextStartPx } from '../utils/marker-helpers.js'; +import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { + createParagraphDecorationLayers, + stampBetweenBorderDataset, + type BetweenBorderInfo, +} from '../features/paragraph-borders/index.js'; +import { + applyParagraphLineIndentation, + hasExplicitSegmentPositioning, + resolveAvailableWidthForLine, +} from './indentation.js'; +import { renderLegacyListMarker, renderResolvedListMarker, type MinimalWordLayout } from './list-marker.js'; +import { applyParagraphBlockStyles, clearParagraphFrameIndentStyles } from './styles.js'; + +export type RenderedParagraphLineInfo = { + el: HTMLElement; + top: number; + height: number; +}; + +export type ParagraphRenderLineInput = { + block: ParagraphBlock; + line: Line; + lineIndex: number; + isLastLine: boolean; + availableWidth?: number; + skipJustify?: boolean; + preExpandedRuns?: Run[]; + resolvedListTextStartPx?: number; + indentOffsetOverride?: number; + paragraphMarkLeftOffsetOverride?: number; +}; + +export type ParagraphRenderLine = (input: ParagraphRenderLineInput) => HTMLElement; + +export type ParagraphRenderDropCap = ( + descriptor: DropCapDescriptor, + measure?: { width: number; height: number; lines: number; mode: 'drop' | 'margin' }, +) => HTMLElement; + +export type ParagraphContainerKind = 'body-fragment' | 'table-cell'; + +type ParagraphSpacingPolicy = { + isFirstBlock: boolean; + isLastBlock: boolean; + paddingTop: number; +}; + +export type RenderParagraphContentParams = { + doc: Document; + frameEl: HTMLElement; + block: ParagraphBlock; + measure: ParagraphMeasure; + containerKind: ParagraphContainerKind; + width: number; + localStartLine: number; + localEndLine: number; + contextSection: 'body' | 'header' | 'footer' | string; + linesOverride?: Line[]; + lineIndexOffset?: number; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; + markerWidth?: number; + markerTextWidth?: number; + wordLayout?: MinimalWordLayout; + resolvedContent?: ResolvedParagraphContent; + betweenInfo?: BetweenBorderInfo; + sdtBoundary?: SdtBoundaryOptions; + spacingPolicy?: ParagraphSpacingPolicy; + shouldApplySdtContainerStyling?: (sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null) => boolean; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + renderLine: ParagraphRenderLine; + renderDropCap?: ParagraphRenderDropCap; + captureLineSnapshot?: ( + lineEl: HTMLElement, + options?: { inTableParagraph?: boolean; wrapperEl?: HTMLElement; sourceAnchor?: SourceAnchor }, + ) => void; + convertFinalParagraphMark?: boolean; + lineTopOffset?: number; + sourceAnchor?: SourceAnchor; +}; + +export type RenderParagraphContentResult = { + renderedHeight: number; + totalHeight: number; + renderedLines: RenderedParagraphLineInfo[]; +}; + +export const renderParagraphContent = (params: RenderParagraphContentParams): RenderParagraphContentResult => { + const { + doc, + frameEl, + block, + measure, + linesOverride, + containerKind, + width, + localStartLine, + localEndLine, + continuesFromPrev, + continuesOnNext, + markerWidth, + markerTextWidth, + wordLayout, + resolvedContent, + betweenInfo, + sdtBoundary, + spacingPolicy, + shouldApplySdtContainerStyling, + applySdtDataset, + applyContainerSdtDataset, + renderLine, + renderDropCap, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + + applyParagraphBlockStyles(frameEl, block.attrs); + const { shadingLayer, borderLayer } = createParagraphDecorationLayers(doc, width, block.attrs, betweenInfo); + if (shadingLayer) frameEl.appendChild(shadingLayer); + if (borderLayer) frameEl.appendChild(borderLayer); + stampBetweenBorderDataset(frameEl, betweenInfo); + + if (block.attrs?.styleId) { + frameEl.dataset.styleId = block.attrs.styleId; + frameEl.setAttribute('styleid', block.attrs.styleId); + } + applySdtDataset(frameEl, block.attrs?.sdt); + applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); + + const applySdtChrome = shouldApplySdtContainerStyling?.(block.attrs?.sdt, block.attrs?.containerSdt) ?? true; + if (applySdtChrome) { + applySdtContainerStyling(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + } + + renderParagraphDropCap({ + frameEl, + block, + measure, + resolvedContent, + continuesFromPrev, + renderDropCap, + }); + + clearParagraphFrameIndentStyles(frameEl); + + const spacingBefore = block.attrs?.spacing?.before; + let beforeHeight = 0; + if (spacingPolicy && localStartLine === 0) { + beforeHeight = effectiveTableCellSpacing(spacingBefore, spacingPolicy.isFirstBlock, spacingPolicy.paddingTop); + if (beforeHeight > 0) { + frameEl.style.marginTop = `${beforeHeight}px`; + } + } + + const renderResult = + resolvedContent != null + ? renderResolvedLines({ + ...params, + resolvedContent, + lineTopOffset: lineTopOffset + beforeHeight, + }) + : renderMeasuredLines({ + ...params, + lineTopOffset: lineTopOffset + beforeHeight, + }); + + let renderedHeight = renderResult.renderedHeight; + const renderedEntireBlock = + localStartLine === 0 && localEndLine >= (linesOverride?.length ?? measure.lines?.length ?? 0); + if (renderedEntireBlock && measure.totalHeight && measure.totalHeight > renderedHeight) { + renderedHeight = measure.totalHeight; + } + + let afterHeight = 0; + if (spacingPolicy && renderedEntireBlock && !spacingPolicy.isLastBlock) { + const spacingAfter = block.attrs?.spacing?.after; + if (typeof spacingAfter === 'number' && spacingAfter > 0) { + frameEl.style.marginBottom = `${spacingAfter}px`; + afterHeight = spacingAfter; + } + } + + if (renderedHeight > 0) { + frameEl.style.height = `${renderedHeight}px`; + } + + return { + renderedHeight, + totalHeight: beforeHeight + renderedHeight + afterHeight, + renderedLines: renderResult.renderedLines, + }; +}; + +const renderResolvedLines = ( + params: RenderParagraphContentParams & { resolvedContent: ResolvedParagraphContent }, +): { renderedHeight: number; renderedLines: RenderedParagraphLineInfo[] } => { + const { + frameEl, + block, + resolvedContent: content, + markerTextWidth, + renderLine, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + const renderedLines: RenderedParagraphLineInfo[] = []; + const resolvedMarker = content.marker; + const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; + let renderedHeight = 0; + + content.lines.forEach((resolvedLine, index) => { + const paragraphMarkLeftOffset = resolveResolvedListParagraphMarkOffset( + resolvedLine.isListFirstLine ? resolvedMarker : undefined, + markerTextWidth, + resolvedLine.indentOffset, + ); + const lineEl = renderLine({ + block, + line: resolvedLine.line, + lineIndex: resolvedLine.lineIndex, + isLastLine: index === content.lines.length - 1 && !content.continuesOnNext, + availableWidth: resolvedLine.availableWidth, + skipJustify: resolvedLine.skipJustify, + preExpandedRuns: expandedRunsForBlock, + resolvedListTextStartPx: resolvedLine.resolvedListTextStartPx, + indentOffsetOverride: resolvedLine.indentOffset, + paragraphMarkLeftOffsetOverride: paragraphMarkLeftOffset, + }); + + if (!resolvedLine.isListFirstLine) { + applyResolvedLineIndentation(lineEl, block, content, resolvedLine); + } + if (resolvedLine.paddingRightPx > 0) { + lineEl.style.paddingRight = `${resolvedLine.paddingRightPx}px`; + } + if (resolvedLine.isListFirstLine && resolvedMarker) { + renderResolvedListMarker({ doc: params.doc, lineEl, marker: resolvedMarker, isRtl, sourceAnchor }); + } + if (convertFinalParagraphMark && index === content.lines.length - 1 && !content.continuesOnNext) { + convertParagraphMarkToCellMark(lineEl); + } + captureLineSnapshot?.(lineEl, { + inTableParagraph: params.containerKind === 'table-cell', + wrapperEl: frameEl, + sourceAnchor, + }); + frameEl.appendChild(lineEl); + const height = resolvedLine.line.lineHeight; + renderedLines.push({ el: lineEl, top: lineTopOffset + renderedHeight, height }); + renderedHeight += height; + }); + + return { renderedHeight, renderedLines }; +}; + +const renderMeasuredLines = ( + params: RenderParagraphContentParams, +): { renderedHeight: number; renderedLines: RenderedParagraphLineInfo[] } => { + const { + doc, + frameEl, + block, + measure, + containerKind, + width, + localStartLine, + localEndLine, + linesOverride, + lineIndexOffset = 0, + continuesFromPrev, + continuesOnNext, + markerWidth, + markerTextWidth, + wordLayout, + renderLine, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + const lines = linesOverride ?? measure.lines ?? []; + const paraIndent = block.attrs?.indent; + const paraIndentLeft = paraIndent?.left ?? 0; + const paraIndentRight = paraIndent?.right ?? 0; + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; + const { + anchorIndentPx: paraMarkerAnchorIndent, + firstLinePx: markerFirstLine, + hangingPx: markerHanging, + } = resolveMarkerIndent(paraIndent, isRtl); + const tableMarkerIndentLeft = + measure.marker?.indentLeft ?? + wordLayout?.indentLeftPx ?? + (typeof paraIndent?.left === 'number' ? paraIndent.left : 0); + const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + const expandedRunsForBlock = containerKind === 'body-fragment' ? expandRunsForInlineNewlines(block.runs) : undefined; + const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; + const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; + const markerLayout = wordLayout?.marker; + const markerMeasure = measure.marker; + + const legacyMarkerWidth = markerWidth ?? markerMeasure?.markerWidth; + const legacyMarkerTextWidth = markerTextWidth ?? markerMeasure?.markerTextWidth; + const listFirstLineTextStartPx = + !continuesFromPrev && legacyMarkerWidth && markerLayout && markerMeasure + ? resolvePainterListTextStartPx({ + wordLayout, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, + markerTextWidthPx: legacyMarkerTextWidth, + }) + : undefined; + + let renderedHeight = 0; + const renderedLines: RenderedParagraphLineInfo[] = []; + + for (let lineIdx = localStartLine; lineIdx < localEndLine && lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + const explicitSegmentPositioning = hasExplicitSegmentPositioning(line); + const isFirstLine = lineIdx === 0 && !continuesFromPrev; + const isListFirstLine = Boolean(lineIdx === 0 && !continuesFromPrev && legacyMarkerWidth && markerLayout); + const shouldUseResolvedListTextStart = + isListFirstLine && explicitSegmentPositioning && listFirstLineTextStartPx != null; + const globalLineIndex = lineIndexOffset + lineIdx; + const isLastLineOfParagraph = globalLineIndex === (measure.lines?.length ?? lines.length) - 1 && !continuesOnNext; + const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; + const availableWidth = + containerKind === 'body-fragment' + ? resolveAvailableWidthForLine({ + containerWidth: width, + line, + indentLeftPx: paraIndentLeft, + indentRightPx: paraIndentRight, + firstLineOffset, + isFirstLine, + isListFirstLine, + resolvedListTextStartPx: shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, + }) + : undefined; + const lineEl = renderLine({ + block, + line, + lineIndex: globalLineIndex, + isLastLine: isLastLineOfParagraph, + availableWidth, + skipJustify: shouldSkipJustifyForLastLine, + preExpandedRuns: expandedRunsForBlock, + resolvedListTextStartPx: shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, + }); + lineEl.style.paddingLeft = ''; + lineEl.style.paddingRight = ''; + lineEl.style.textIndent = ''; + + if (convertFinalParagraphMark && isLastLineOfParagraph) { + convertParagraphMarkToCellMark(lineEl); + } + + if (isListFirstLine && markerLayout && markerMeasure) { + renderLegacyListMarker({ + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + markerTextWidthPx: legacyMarkerTextWidth, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, + isRtl, + sourceAnchor, + }); + } else { + applyParagraphLineIndentation({ + lineEl, + line, + indent: paraIndent, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hasListMarkerLayout: Boolean(markerLayout), + lineIndex: lineIdx, + localStartLine, + continuesFromPrev, + suppressFirstLineIndent, + resetContinuationTextIndent: containerKind === 'body-fragment', + }); + } + + captureLineSnapshot?.(lineEl, { + inTableParagraph: containerKind === 'table-cell', + wrapperEl: frameEl, + sourceAnchor, + }); + frameEl.appendChild(lineEl); + const height = line.lineHeight; + renderedLines.push({ el: lineEl, top: lineTopOffset + renderedHeight, height }); + renderedHeight += height; + } + + return { renderedHeight, renderedLines }; +}; + +const renderParagraphDropCap = (params: { + frameEl: HTMLElement; + block: ParagraphBlock; + measure: ParagraphMeasure; + resolvedContent?: ResolvedParagraphContent; + continuesFromPrev?: boolean; + renderDropCap?: ParagraphRenderDropCap; +}): void => { + const { frameEl, block, measure, resolvedContent, continuesFromPrev, renderDropCap } = params; + if (!renderDropCap) return; + if (resolvedContent?.dropCap) { + const dc = resolvedContent.dropCap; + const dropCapEl = renderDropCap( + { + mode: dc.mode, + run: { + text: dc.text, + fontFamily: dc.fontFamily, + fontSize: dc.fontSize, + bold: dc.bold, + italic: dc.italic, + color: dc.color, + position: dc.position, + }, + lines: 0, + }, + dc.width != null && dc.height != null + ? { width: dc.width, height: dc.height, lines: 0, mode: dc.mode } + : undefined, + ); + frameEl.appendChild(dropCapEl); + return; + } + const dropCapDescriptor = block.attrs?.dropCapDescriptor; + const dropCapMeasure = measure.dropCap; + if (dropCapDescriptor && dropCapMeasure && !continuesFromPrev) { + frameEl.appendChild(renderDropCap(dropCapDescriptor, dropCapMeasure)); + } +}; + +const applyResolvedLineIndentation = ( + lineEl: HTMLElement, + block: ParagraphBlock, + content: ResolvedParagraphContent, + resolvedLine: ResolvedParagraphContent['lines'][number], +): void => { + if (resolvedLine.paddingLeftPx > 0) { + lineEl.style.paddingLeft = `${resolvedLine.paddingLeftPx}px`; + } + if (resolvedLine.textIndentPx !== 0) { + lineEl.style.textIndent = `${resolvedLine.textIndentPx}px`; + } else if (resolvedLine.lineIndex > 0 || content.continuesFromPrev) { + const paraIndent = block.attrs?.indent; + const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + if (firstLineOffset && !resolvedLine.isListFirstLine) { + lineEl.style.textIndent = '0px'; + } + } +}; + +const resolveResolvedListParagraphMarkOffset = ( + marker: ResolvedParagraphContent['marker'] | undefined, + markerTextWidth: number | undefined, + indentOffset: number, +): number | undefined => { + if (!marker) return undefined; + if (typeof indentOffset === 'number' && Number.isFinite(indentOffset) && indentOffset > 0) { + return indentOffset; + } + if (marker.vanish) { + return indentOffset; + } + + const paddingLeft = Number.isFinite(marker.firstLinePaddingLeftPx) ? marker.firstLinePaddingLeftPx : 0; + const suffixWidth = marker.suffix !== 'nothing' && Number.isFinite(marker.suffixWidthPx) ? marker.suffixWidthPx : 0; + + if (marker.justification === 'left') { + const markerWidth = + typeof markerTextWidth === 'number' && Number.isFinite(markerTextWidth) && markerTextWidth > 0 + ? markerTextWidth + : 0; + return paddingLeft + markerWidth + suffixWidth; + } + + const centerPadding = + marker.justification === 'center' && Number.isFinite(marker.centerPaddingAdjustPx) + ? (marker.centerPaddingAdjustPx ?? 0) + : 0; + return paddingLeft + centerPadding + suffixWidth; +}; + +const convertParagraphMarkToCellMark = (lineEl: HTMLElement): void => { + const mark = lineEl.querySelector('.superdoc-formatting-paragraph-mark'); + if (!mark) return; + + mark.classList.add('superdoc-formatting-cell-mark'); + mark.textContent = '¤'; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/styles.ts b/packages/layout-engine/painters/dom/src/paragraph/styles.ts new file mode 100644 index 0000000000..0548474826 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/styles.ts @@ -0,0 +1,37 @@ +import type { ParagraphAttrs } from '@superdoc/contracts'; +import { applyRtlStyles } from '../features/rtl-paragraph/index.js'; + +export const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { + if (!attrs) return; + if (attrs.styleId) { + element.setAttribute('styleid', attrs.styleId); + } + applyRtlStyles(element, attrs); + if ((attrs as Record).dropCap) { + element.classList.add('sd-editor-dropcap'); + } + const indent = attrs.indent; + if (indent) { + if (indent.left && indent.left > 0) { + element.style.paddingLeft = `${indent.left}px`; + } + if (indent.right && indent.right > 0) { + element.style.paddingRight = `${indent.right}px`; + } + const hasNegativeLeftIndent = indent.left != null && indent.left < 0; + if (!hasNegativeLeftIndent) { + const textIndent = (indent.firstLine ?? 0) - (indent.hanging ?? 0); + if (textIndent) { + element.style.textIndent = `${textIndent}px`; + } + } + } +}; + +export const clearParagraphFrameIndentStyles = (element: HTMLElement): void => { + if (element.style.paddingLeft) element.style.removeProperty('padding-left'); + if (element.style.paddingRight) element.style.removeProperty('padding-right'); + if (element.style.marginLeft) element.style.removeProperty('margin-left'); + if (element.style.marginRight) element.style.removeProperty('margin-right'); + if (element.style.textIndent) element.style.removeProperty('text-indent'); +}; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 4ce4d07110..0431b05d95 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -50,10 +50,8 @@ import type { ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, - ResolvedListMarkerItem, } from '@superdoc/contracts'; import { - adjustAvailableWidthForTextIndent, calculateJustifySpacing, computeLinePmRange, expandRunsForInlineNewlines, @@ -105,25 +103,12 @@ import { import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { applyImageClipPath } from './utils/image-clip-path.js'; -import { - isMinimalWordLayout as isMinimalWordLayoutShared, - resolveMarkerIndent, -} from '@superdoc/common/list-marker-utils'; -import { - computeTabWidth, - createListMarkerElement, - resolvePainterListMarkerGeometry, - resolvePainterListTextStartPx, -} from './utils/marker-helpers.js'; +import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; -import { - computeBetweenBorderFlags, - createParagraphDecorationLayers, - stampBetweenBorderDataset, - type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; +import { computeBetweenBorderFlags, type BetweenBorderInfo } from './features/paragraph-borders/index.js'; import { applyRtlStyles, shouldUseSegmentPositioning } from './features/rtl-paragraph/index.js'; import { convertOmmlToMathml } from './features/math/index.js'; +import { renderParagraphContent } from './paragraph/renderParagraphContent.js'; /** * Minimal type for WordParagraphLayoutOutput marker data used in rendering. @@ -3089,411 +3074,62 @@ export class DomPainter { fragmentEl.dataset.continuesOnNext = 'true'; } - // Use fragment.lines if available (set when paragraph was remeasured for narrower column). - // Otherwise, fall back to slicing from the original measure. const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); - applyParagraphBlockStyles(fragmentEl, block.attrs); - const { shadingLayer, borderLayer } = createParagraphDecorationLayers( - this.doc, - fragment.width, - block.attrs, + renderParagraphContent({ + doc: this.doc, + frameEl: fragmentEl, + block, + measure, + containerKind: 'body-fragment', + width: fragment.width, + localStartLine: 0, + localEndLine: lines.length, + lineIndexOffset: fragment.fromLine, + linesOverride: lines, + contextSection: context.section, + continuesFromPrev: paraContinuesFromPrev, + continuesOnNext: paraContinuesOnNext, + markerWidth: paraMarkerWidth, + markerTextWidth: fragment.markerTextWidth, + wordLayout, + resolvedContent: content, betweenInfo, - ); - if (shadingLayer) { - fragmentEl.appendChild(shadingLayer); - } - if (borderLayer) { - fragmentEl.appendChild(borderLayer); - } - stampBetweenBorderDataset(fragmentEl, betweenInfo); - if (block.attrs?.styleId) { - fragmentEl.dataset.styleId = block.attrs.styleId; - fragmentEl.setAttribute('styleid', block.attrs.styleId); - } - this.applySdtDataset(fragmentEl, block.attrs?.sdt); - this.applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); - - // Apply SDT container styling (document sections, structured content blocks) - applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - - // Render drop cap if present (only on the first fragment, not continuation) - if (content?.dropCap) { - const dc = content.dropCap; - const dropCapEl = this.renderDropCap( - { - mode: dc.mode, - run: { - text: dc.text, - fontFamily: dc.fontFamily, - fontSize: dc.fontSize, - bold: dc.bold, - italic: dc.italic, - color: dc.color, - position: dc.position, - }, - lines: 0, - }, - dc.width != null && dc.height != null - ? { width: dc.width, height: dc.height, lines: 0, mode: dc.mode } - : undefined, - ); - fragmentEl.appendChild(dropCapEl); - } else { - const dropCapDescriptor = block.attrs?.dropCapDescriptor; - const dropCapMeasure = measure.dropCap; - if (dropCapDescriptor && dropCapMeasure && !paraContinuesFromPrev) { - const dropCapEl = this.renderDropCap(dropCapDescriptor, dropCapMeasure); - fragmentEl.appendChild(dropCapEl); - } - } - - // Remove fragment-level indent so line-level indent handling doesn't double-apply. - // Include margin properties for negative indents (which use margin instead of padding). - if (fragmentEl.style.paddingLeft) fragmentEl.style.removeProperty('padding-left'); - if (fragmentEl.style.paddingRight) fragmentEl.style.removeProperty('padding-right'); - if (fragmentEl.style.marginLeft) fragmentEl.style.removeProperty('margin-left'); - if (fragmentEl.style.marginRight) fragmentEl.style.removeProperty('margin-right'); - if (fragmentEl.style.textIndent) fragmentEl.style.removeProperty('text-indent'); - - if (content) { - // ── Resolved path: read pre-computed values from ResolvedParagraphContent ── - const resolvedMarker = content.marker; - const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); - - content.lines.forEach((resolvedLine) => { - const paragraphMarkLeftOffset = this.resolveResolvedListParagraphMarkOffset( - resolvedLine.isListFirstLine ? resolvedMarker : undefined, - fragment.markerTextWidth, - resolvedLine.indentOffset, - ); - const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; - const lineEl = this.renderLine( - block, - resolvedLine.line, - context, - resolvedLine.availableWidth, - resolvedLine.lineIndex, - resolvedLine.skipJustify, - expandedRunsForBlock, - resolvedLine.resolvedListTextStartPx, - resolvedLine.indentOffset, - paragraphMarkLeftOffset, - ); - - // Apply pre-computed indent values - if (!resolvedLine.isListFirstLine) { - if (resolvedLine.paddingLeftPx > 0) { - lineEl.style.paddingLeft = `${resolvedLine.paddingLeftPx}px`; - } - if (resolvedLine.textIndentPx !== 0) { - lineEl.style.textIndent = `${resolvedLine.textIndentPx}px`; - } else if (resolvedLine.lineIndex > 0 || content.continuesFromPrev) { - // Body lines: reset textIndent to 0 if firstLineOffset would have been set - // (mirrors the legacy `else if (firstLineOffset && !isListFirstLine)` branch) - const paraIndent = block.attrs?.indent; - const suppressFLI = (block.attrs as Record)?.suppressFirstLineIndent === true; - const flo = suppressFLI ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); - if (flo && !resolvedLine.isListFirstLine) { - lineEl.style.textIndent = '0px'; - } - } - } - if (resolvedLine.paddingRightPx > 0) { - lineEl.style.paddingRight = `${resolvedLine.paddingRightPx}px`; - } - - // Render marker on list first line - if (resolvedLine.isListFirstLine && resolvedMarker) { - if (isRtl) { - lineEl.style.paddingRight = `${resolvedMarker.firstLinePaddingLeftPx}px`; - } else { - lineEl.style.paddingLeft = `${resolvedMarker.firstLinePaddingLeftPx}px`; - } - - if (!resolvedMarker.vanish) { - const markerContainer = createListMarkerElement( - this.doc!, - resolvedMarker.text, - resolvedMarker.run, - resolvedMarker.sourceAnchor ?? resolvedItem?.sourceAnchor, - ); - - markerContainer.style.position = 'relative'; - if (resolvedMarker.justification === 'right') { - markerContainer.style.position = 'absolute'; - if (isRtl) { - markerContainer.style.right = `${resolvedMarker.markerStartPx}px`; - } else { - markerContainer.style.left = `${resolvedMarker.markerStartPx}px`; - } - } else if (resolvedMarker.justification === 'center') { - markerContainer.style.position = 'absolute'; - if (isRtl) { - markerContainer.style.right = `${resolvedMarker.markerStartPx - (resolvedMarker.centerPaddingAdjustPx ?? 0)}px`; - lineEl.style.paddingRight = - ( - parseFloat(lineEl.style.paddingRight || '0') + (resolvedMarker.centerPaddingAdjustPx ?? 0) - ).toString() + 'px'; - } else { - markerContainer.style.left = `${resolvedMarker.markerStartPx - (resolvedMarker.centerPaddingAdjustPx ?? 0)}px`; - lineEl.style.paddingLeft = - parseFloat(lineEl.style.paddingLeft) + (resolvedMarker.centerPaddingAdjustPx ?? 0) + 'px'; - } - } - - if (resolvedMarker.suffix === 'tab') { - const tabEl = this.doc!.createElement('span'); - tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); - tabEl.innerHTML = ' '; - tabEl.style.display = 'inline-block'; - tabEl.style.fontSize = `${resolvedMarker.run.fontSize}px`; - tabEl.style.wordSpacing = '0px'; - tabEl.style.width = `${resolvedMarker.suffixWidthPx}px`; - lineEl.prepend(tabEl); - } else if (resolvedMarker.suffix === 'space') { - const spaceEl = this.doc!.createElement('span'); - spaceEl.classList.add('superdoc-marker-suffix-space'); - spaceEl.style.fontSize = `${resolvedMarker.run.fontSize}px`; - spaceEl.style.wordSpacing = '0px'; - spaceEl.textContent = '\u00A0'; - lineEl.prepend(spaceEl); - } - lineEl.prepend(markerContainer); - } - } - this.capturePaintSnapshotLine(lineEl, context, { - inTableFragment: false, - inTableParagraph: false, - sourceAnchor: resolvedItem?.sourceAnchor, - }); - fragmentEl.appendChild(lineEl); - }); - } else { - // ── Legacy path: compute everything from block attrs and measure ── - const paraIndent = block.attrs?.indent; - const paraIndentLeft = paraIndent?.left ?? 0; - const paraIndentRight = paraIndent?.right ?? 0; - const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; - const { - anchorIndentPx: paraMarkerAnchorIndent, - firstLinePx: markerFirstLine, - hangingPx: markerHanging, - } = resolveMarkerIndent(paraIndent, isRtl); - const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; - const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); - - const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); - const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; - const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; - - const listFirstLineTextStartPx = - !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker - ? resolvePainterListTextStartPx({ - wordLayout, - indentLeftPx: paraMarkerAnchorIndent, - hangingIndentPx: markerHanging, - firstLineIndentPx: markerFirstLine, - markerTextWidthPx: fragment.markerTextWidth, - }) - : undefined; - - const shouldUseSharedInlinePrefixGeometry = - !paraContinuesFromPrev && - paraMarkerWidth && - wordLayout?.marker?.justification === 'left' && - wordLayout.firstLineIndentMode !== true && - typeof fragment.markerTextWidth === 'number' && - Number.isFinite(fragment.markerTextWidth) && - fragment.markerTextWidth >= 0; - const listFirstLineMarkerGeometry = shouldUseSharedInlinePrefixGeometry - ? resolvePainterListMarkerGeometry({ - wordLayout, - indentLeftPx: paraMarkerAnchorIndent, - hangingIndentPx: markerHanging, - firstLineIndentPx: markerFirstLine, - markerTextWidthPx: fragment.markerTextWidth, - }) - : undefined; - - let listTabWidth = 0; - let markerStartPos = 0; - if (!paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker) { - const markerTextWidth = fragment.markerTextWidth!; - const anchorPoint = paraMarkerAnchorIndent - markerHanging + markerFirstLine; - const markerJustification = wordLayout.marker.justification ?? 'left'; - let currentPos: number; - if (markerJustification === 'left') { - markerStartPos = anchorPoint; - currentPos = markerStartPos + markerTextWidth; - } else if (markerJustification === 'right') { - markerStartPos = anchorPoint - markerTextWidth; - currentPos = anchorPoint; - } else { - markerStartPos = anchorPoint - markerTextWidth / 2; - currentPos = markerStartPos + markerTextWidth; - } - - const suffix = wordLayout.marker.suffix ?? 'tab'; - if (listFirstLineMarkerGeometry && (suffix === 'tab' || suffix === 'space')) { - listTabWidth = listFirstLineMarkerGeometry.suffixWidthPx; - } else if (suffix === 'tab') { - listTabWidth = computeTabWidth( - currentPos, - markerJustification, - wordLayout.tabsPx, - markerHanging, - markerFirstLine, - paraMarkerAnchorIndent, - ); - } else if (suffix === 'space') { - listTabWidth = 4; - } - } - - lines.forEach((line, index) => { - const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true; - const hasListFirstLineMarker = index === 0 && !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; - const shouldUseResolvedListTextStart = - hasListFirstLineMarker && hasExplicitSegmentPositioning && listFirstLineTextStartPx != null; - - const positiveIndentReduction = Math.max(0, paraIndentLeft) + Math.max(0, paraIndentRight); - const fallbackAvailableWidth = Math.max(0, fragment.width - positiveIndentReduction); - let availableWidthOverride = - line.maxWidth != null ? Math.min(line.maxWidth, fallbackAvailableWidth) : fallbackAvailableWidth; - - if (shouldUseResolvedListTextStart) { - availableWidthOverride = fragment.width - listFirstLineTextStartPx - Math.max(0, paraIndentRight); - } - - // Adjust availableWidth for first-line text indent (hanging indent). - const isFirstLine = index === 0 && !paraContinuesFromPrev; - const isListFirstLine = Boolean(hasListFirstLineMarker && fragment.markerTextWidth); - if (isFirstLine && !isListFirstLine && line.hasExplicitTabStops !== true) { - availableWidthOverride = adjustAvailableWidthForTextIndent( - availableWidthOverride, - firstLineOffset, - line.maxWidth, - ); - } - - const isLastLineOfFragment = index === lines.length - 1; - const isLastLineOfParagraph = isLastLineOfFragment && !paraContinuesOnNext; - const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; - - const lineEl = this.renderLine( + sdtBoundary, + applySdtDataset: this.applySdtDataset.bind(this), + applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + renderDropCap: this.renderDropCap.bind(this), + renderLine: ({ + block, + line, + availableWidth, + lineIndex, + skipJustify, + preExpandedRuns, + resolvedListTextStartPx, + indentOffsetOverride, + paragraphMarkLeftOffsetOverride, + }) => + this.renderLine( block, line, context, - availableWidthOverride, - fragment.fromLine + index, - shouldSkipJustifyForLastLine, - expandedRunsForBlock, - shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, - ); - - if (!isListFirstLine) { - if (hasExplicitSegmentPositioning) { - if (isFirstLine && firstLineOffset !== 0) { - const effectiveLeftIndent = paraIndentLeft < 0 ? 0 : paraIndentLeft; - const adjustedPadding = effectiveLeftIndent + firstLineOffset; - if (adjustedPadding > 0) { - lineEl.style.paddingLeft = `${adjustedPadding}px`; - } - } - } else if (paraIndentLeft && paraIndentLeft > 0) { - lineEl.style.paddingLeft = `${paraIndentLeft}px`; - } else if ( - !isFirstLine && - paraIndent?.hanging && - paraIndent.hanging > 0 && - !(paraIndentLeft != null && paraIndentLeft < 0) - ) { - lineEl.style.paddingLeft = `${paraIndent.hanging}px`; - } - } - if (paraIndentRight && paraIndentRight > 0) { - lineEl.style.paddingRight = `${paraIndentRight}px`; - } - if (!paraContinuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) { - if (!hasExplicitSegmentPositioning) { - lineEl.style.textIndent = `${firstLineOffset}px`; - } - } else if (firstLineOffset && !isListFirstLine) { - lineEl.style.textIndent = '0px'; - } - - if (isListFirstLine) { - const marker = wordLayout?.marker; - if (!marker) { - return; - } - const firstLineIndent = paraMarkerAnchorIndent - markerHanging + markerFirstLine; - if (isRtl) { - lineEl.style.paddingRight = `${firstLineIndent}px`; - } else { - lineEl.style.paddingLeft = `${firstLineIndent}px`; - } - - if (!marker.run.vanish) { - const markerContainer = createListMarkerElement( - this.doc!, - marker.markerText ?? '', - marker.run, - block.sourceAnchor ?? resolvedItem?.sourceAnchor, - ); - - const markerJustification = marker.justification ?? 'left'; - - markerContainer.style.position = 'relative'; - if (markerJustification === 'right') { - markerContainer.style.position = 'absolute'; - if (isRtl) { - markerContainer.style.right = `${markerStartPos}px`; - } else { - markerContainer.style.left = `${markerStartPos}px`; - } - } else if (markerJustification === 'center') { - markerContainer.style.position = 'absolute'; - if (isRtl) { - markerContainer.style.right = `${markerStartPos - fragment.markerTextWidth! / 2}px`; - lineEl.style.paddingRight = - (parseFloat(lineEl.style.paddingRight || '0') + fragment.markerTextWidth! / 2).toString() + 'px'; - } else { - markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; - lineEl.style.paddingLeft = - parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; - } - } - - const suffix = marker.suffix ?? 'tab'; - if (suffix === 'tab') { - const tabEl = this.doc!.createElement('span'); - tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); - tabEl.innerHTML = ' '; - tabEl.style.display = 'inline-block'; - tabEl.style.fontSize = `${marker.run.fontSize}px`; - tabEl.style.wordSpacing = '0px'; - tabEl.style.width = `${listTabWidth}px`; - lineEl.prepend(tabEl); - } else if (suffix === 'space') { - const spaceEl = this.doc!.createElement('span'); - spaceEl.classList.add('superdoc-marker-suffix-space'); - spaceEl.style.fontSize = `${marker.run.fontSize}px`; - spaceEl.style.wordSpacing = '0px'; - spaceEl.textContent = '\u00A0'; - lineEl.prepend(spaceEl); - } - lineEl.prepend(markerContainer); - } - } + availableWidth, + lineIndex, + skipJustify, + preExpandedRuns, + resolvedListTextStartPx, + indentOffsetOverride, + paragraphMarkLeftOffsetOverride, + ), + captureLineSnapshot: (lineEl, options) => { this.capturePaintSnapshotLine(lineEl, context, { inTableFragment: false, inTableParagraph: false, - sourceAnchor: resolvedItem?.sourceAnchor, + sourceAnchor: options?.sourceAnchor, }); - fragmentEl.appendChild(lineEl); - }); - } + }, + sourceAnchor: resolvedItem?.sourceAnchor, + }); return fragmentEl; } catch (error) { @@ -5325,36 +4961,6 @@ export class DomPainter { lineEl.appendChild(mark); } - private resolveResolvedListParagraphMarkOffset( - marker: ResolvedListMarkerItem | undefined, - markerTextWidth: number | undefined, - fallbackOffset: number | undefined, - ): number | undefined { - if (typeof fallbackOffset === 'number' && Number.isFinite(fallbackOffset) && fallbackOffset > 0) { - return fallbackOffset; - } - if (!marker || marker.vanish) { - return fallbackOffset; - } - - const paddingLeft = Number.isFinite(marker.firstLinePaddingLeftPx) ? marker.firstLinePaddingLeftPx : 0; - const suffixWidth = marker.suffix !== 'nothing' && Number.isFinite(marker.suffixWidthPx) ? marker.suffixWidthPx : 0; - - if (marker.justification === 'left') { - const markerWidth = - typeof markerTextWidth === 'number' && Number.isFinite(markerTextWidth) && markerTextWidth > 0 - ? markerTextWidth - : 0; - return paddingLeft + markerWidth + suffixWidth; - } - - const centerPadding = - marker.justification === 'center' && Number.isFinite(marker.centerPaddingAdjustPx) - ? (marker.centerPaddingAdjustPx ?? 0) - : 0; - return paddingLeft + centerPadding + suffixWidth; - } - private renderRun( run: Run, context: FragmentRenderContext, @@ -7984,42 +7590,6 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record< }); }; -const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { - if (!attrs) return; - if (attrs.styleId) { - element.setAttribute('styleid', attrs.styleId); - } - applyRtlStyles(element, attrs); - if ((attrs as Record).dropCap) { - element.classList.add('sd-editor-dropcap'); - } - const indent = attrs.indent; - if (indent) { - // Only apply positive indents as padding. - // Negative indents are handled by fragment positioning in the layout engine. - if (indent.left && indent.left > 0) { - element.style.paddingLeft = `${indent.left}px`; - } - if (indent.right && indent.right > 0) { - element.style.paddingRight = `${indent.right}px`; - } - // Skip textIndent when left indent is negative - fragment positioning handles the indent, - // and per-line paddingLeft handles the hanging indent for body lines. - const hasNegativeLeftIndent = indent.left != null && indent.left < 0; - if (!hasNegativeLeftIndent) { - const textIndent = (indent.firstLine ?? 0) - (indent.hanging ?? 0); - if (textIndent) { - element.style.textIndent = `${textIndent}px`; - } - } - } -}; - -// getParagraphBorderBox, createParagraphDecorationLayers, applyParagraphBorderStyles, -// setBorderSideStyle, applyParagraphShadingStyles — moved to features/paragraph-borders/ - -// applyParagraphShadingStyles — moved to features/paragraph-borders/border-layer.ts - const applyStyles = (el: HTMLElement, styles: Partial): void => { Object.entries(styles).forEach(([key, value]) => { if (value != null && value !== '' && key in el.style) { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index bbb9650371..9430547ba5 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -35,6 +35,18 @@ describe('renderTableCell', () => { expect([normalizedHex, rgb]).toContain(normalizedActual); }; + const getParagraphBorderLayer = (paraWrapper: HTMLElement): HTMLElement => { + const layer = paraWrapper.querySelector('.superdoc-paragraph-border'); + expect(layer).toBeTruthy(); + return layer!; + }; + + const getParagraphShadingLayer = (paraWrapper: HTMLElement): HTMLElement => { + const layer = paraWrapper.querySelector('.superdoc-paragraph-shading'); + expect(layer).toBeTruthy(); + return layer!; + }; + const paragraphBlock: ParagraphBlock = { kind: 'paragraph', id: 'para-1', @@ -1840,21 +1852,21 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; - - // Verify borders are applied - expect(paraWrapper.style.boxSizing).toBe('border-box'); - expect(paraWrapper.style.borderTopWidth).toBe('2px'); - expect(paraWrapper.style.borderTopStyle).toBe('solid'); - expectCssColor(paraWrapper.style.borderTopColor, '#ff0000'); - expect(paraWrapper.style.borderBottomWidth).toBe('1px'); - expect(paraWrapper.style.borderBottomStyle).toBe('dashed'); - expectCssColor(paraWrapper.style.borderBottomColor, '#0000ff'); - expect(paraWrapper.style.borderLeftWidth).toBe('3px'); - expect(paraWrapper.style.borderLeftStyle).toBe('dotted'); - expectCssColor(paraWrapper.style.borderLeftColor, '#00ff00'); - expect(paraWrapper.style.borderRightWidth).toBe('1px'); - expect(paraWrapper.style.borderRightStyle).toBe('solid'); - expectCssColor(paraWrapper.style.borderRightColor, '#000000'); + const borderLayer = getParagraphBorderLayer(paraWrapper); + + expect(borderLayer.style.boxSizing).toBe('border-box'); + expect(borderLayer.style.borderTopWidth).toBe('2px'); + expect(borderLayer.style.borderTopStyle).toBe('solid'); + expectCssColor(borderLayer.style.borderTopColor, '#ff0000'); + expect(borderLayer.style.borderBottomWidth).toBe('1px'); + expect(borderLayer.style.borderBottomStyle).toBe('dashed'); + expectCssColor(borderLayer.style.borderBottomColor, '#0000ff'); + expect(borderLayer.style.borderLeftWidth).toBe('3px'); + expect(borderLayer.style.borderLeftStyle).toBe('dotted'); + expectCssColor(borderLayer.style.borderLeftColor, '#00ff00'); + expect(borderLayer.style.borderRightWidth).toBe('1px'); + expect(borderLayer.style.borderRightStyle).toBe('solid'); + expectCssColor(borderLayer.style.borderRightColor, '#000000'); }); it('should apply paragraph shading (background) to paraWrapper', () => { @@ -1909,9 +1921,9 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const shadingLayer = getParagraphShadingLayer(paraWrapper); - // Verify shading is applied - expectCssColor(paraWrapper.style.backgroundColor, '#ffff00'); + expectCssColor(shadingLayer.style.backgroundColor, '#ffff00'); }); it('should apply both borders and shading to the same paragraph', () => { @@ -1970,11 +1982,12 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const borderLayer = getParagraphBorderLayer(paraWrapper); + const shadingLayer = getParagraphShadingLayer(paraWrapper); - // Verify both borders and shading are applied - expect(paraWrapper.style.borderTopWidth).toBe('1px'); - expect(paraWrapper.style.borderBottomWidth).toBe('1px'); - expectCssColor(paraWrapper.style.backgroundColor, '#e0e0e0'); + expect(borderLayer.style.borderTopWidth).toBe('1px'); + expect(borderLayer.style.borderBottomWidth).toBe('1px'); + expectCssColor(shadingLayer.style.backgroundColor, '#e0e0e0'); }); it('should handle multiple paragraphs with different borders in same cell', () => { @@ -2043,14 +2056,16 @@ describe('renderTableCell', () => { // First paragraph has bottom border const wrapper1 = paraWrappers[0] as HTMLElement; - expect(wrapper1.style.borderBottomWidth).toBe('2px'); - expectCssColor(wrapper1.style.borderBottomColor, '#ff0000'); + const borderLayer1 = getParagraphBorderLayer(wrapper1); + expect(borderLayer1.style.borderBottomWidth).toBe('2px'); + expectCssColor(borderLayer1.style.borderBottomColor, '#ff0000'); // Second paragraph has top border const wrapper2 = paraWrappers[1] as HTMLElement; - expect(wrapper2.style.borderTopWidth).toBe('1px'); - expect(wrapper2.style.borderTopStyle).toBe('dashed'); - expectCssColor(wrapper2.style.borderTopColor, '#0000ff'); + const borderLayer2 = getParagraphBorderLayer(wrapper2); + expect(borderLayer2.style.borderTopWidth).toBe('1px'); + expect(borderLayer2.style.borderTopStyle).toBe('dashed'); + expectCssColor(borderLayer2.style.borderTopColor, '#0000ff'); }); it('should not apply borders when paragraph has no borders attribute', () => { @@ -2161,10 +2176,10 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const borderLayer = getParagraphBorderLayer(paraWrapper); - // Border style 'none' should result in no visible border - expect(paraWrapper.style.borderTopStyle).toBe('none'); - expect(paraWrapper.style.borderTopWidth).toBe('0px'); + expect(borderLayer.style.borderTopStyle).toBe('none'); + expect(borderLayer.style.borderTopWidth).toBe('0px'); }); it('should handle zero width borders (width: 0)', () => { @@ -2219,11 +2234,11 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const borderLayer = getParagraphBorderLayer(paraWrapper); - // Zero width should render as '0px' - expect(paraWrapper.style.borderTopWidth).toBe('0px'); - expect(paraWrapper.style.borderTopStyle).toBe('solid'); - expectCssColor(paraWrapper.style.borderTopColor, '#ff0000'); + expect(borderLayer.style.borderTopWidth).toBe('0px'); + expect(borderLayer.style.borderTopStyle).toBe('solid'); + expectCssColor(borderLayer.style.borderTopColor, '#ff0000'); }); it('should clamp negative width borders to 0px', () => { @@ -2278,11 +2293,11 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const borderLayer = getParagraphBorderLayer(paraWrapper); - // Negative width should be clamped to '0px' - expect(paraWrapper.style.borderLeftWidth).toBe('0px'); - expect(paraWrapper.style.borderLeftStyle).toBe('solid'); - expectCssColor(paraWrapper.style.borderLeftColor, '#0000ff'); + expect(borderLayer.style.borderLeftWidth).toBe('0px'); + expect(borderLayer.style.borderLeftStyle).toBe('solid'); + expectCssColor(borderLayer.style.borderLeftColor, '#0000ff'); }); it('should default to 1px when width is undefined', () => { @@ -2337,11 +2352,11 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const borderLayer = getParagraphBorderLayer(paraWrapper); - // Undefined width should default to '1px' - expect(paraWrapper.style.borderBottomWidth).toBe('1px'); - expect(paraWrapper.style.borderBottomStyle).toBe('dashed'); - expectCssColor(paraWrapper.style.borderBottomColor, '#00ff00'); + expect(borderLayer.style.borderBottomWidth).toBe('1px'); + expect(borderLayer.style.borderBottomStyle).toBe('dashed'); + expectCssColor(borderLayer.style.borderBottomColor, '#00ff00'); }); it('should only apply border to specified sides (e.g., only top)', () => { @@ -2396,16 +2411,16 @@ describe('renderTableCell', () => { const contentElement = cellElement.firstElementChild as HTMLElement; const paraWrapper = contentElement.firstElementChild as HTMLElement; + const borderLayer = getParagraphBorderLayer(paraWrapper); - // Only top border should be set - expect(paraWrapper.style.borderTopWidth).toBe('3px'); - expect(paraWrapper.style.borderTopStyle).toBe('solid'); - expectCssColor(paraWrapper.style.borderTopColor, '#ff00ff'); + expect(borderLayer.style.borderTopWidth).toBe('3px'); + expect(borderLayer.style.borderTopStyle).toBe('solid'); + expectCssColor(borderLayer.style.borderTopColor, '#ff00ff'); // Left, right, and bottom borders should remain unset - expect(paraWrapper.style.borderLeftWidth).toBe(''); - expect(paraWrapper.style.borderRightWidth).toBe(''); - expect(paraWrapper.style.borderBottomWidth).toBe(''); + expect(borderLayer.style.borderLeftWidth).toBe(''); + expect(borderLayer.style.borderRightWidth).toBe(''); + expect(borderLayer.style.borderBottomWidth).toBe(''); }); it('should handle empty shading object (shading: {})', () => { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 65077310b1..bc51892d8f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -7,7 +7,6 @@ import type { ImageMeasure, Line, ParagraphBlock, - ParagraphIndent, ParagraphMeasure, PartialRowInfo, SdtMetadata, @@ -17,77 +16,15 @@ import type { WrapExclusion, WrapTextMode, } from '@superdoc/contracts'; -import { effectiveTableCellSpacing, rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; -import { - createListMarkerElement, - computeTabWidth, - resolvePainterListMarkerGeometry, - resolvePainterListTextStartPx, -} from '../utils/marker-helpers.js'; +import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; -import { applyParagraphBorderStyles, applyParagraphShadingStyles } from '../features/paragraph-borders/index.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { applyImageClipPath } from '../utils/image-clip-path.js'; -import { - applySdtContainerStyling, - getSdtContainerConfig, - getSdtContainerKey, - type SdtBoundaryOptions, -} from '../utils/sdt-helpers.js'; +import { getSdtContainerConfig, getSdtContainerKey, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; - -/** - * Word layout information for paragraph list markers. - * Contains positioning, styling, and rendering details for list markers (bullets/numbers). - */ -type WordLayoutMarker = { - /** Text content of the marker (e.g., "1.", "a)", "•") */ - markerText?: string; - /** Width of the marker box in pixels */ - markerBoxWidthPx?: number; - /** Width of the gutter (space between marker and text) in pixels */ - gutterWidthPx?: number; - /** Horizontal justification of marker within its box */ - justification?: 'left' | 'center' | 'right'; - /** Absolute x position of the marker start */ - markerX?: number; - /** Run properties for marker styling */ - run: { - /** Font family for the marker */ - fontFamily?: string; - /** Font size in pixels */ - fontSize?: number; - /** Whether marker is bold */ - bold?: boolean; - /** Whether marker is italic */ - italic?: boolean; - /** Text color as hex string */ - color?: string; - /** Letter spacing in pixels */ - letterSpacing?: number; - /** Hidden text flag */ - vanish?: boolean; - }; - /** Separator between marker and text: tab (default), space, or nothing */ - suffix?: 'tab' | 'space' | 'nothing'; -}; - -/** - * Word layout information for a paragraph. - * Computed by the word-layout engine to provide accurate list marker positioning - * and indent calculations matching Microsoft Word's behavior. - */ -type WordLayoutInfo = { - /** Marker layout information if this is a list paragraph */ - marker?: WordLayoutMarker; - /** Left indent in pixels */ - indentLeftPx?: number; - /** Whether first-line indent mode is enabled */ - firstLineIndentMode?: boolean; - /** Array of explicit tab stop positions in pixels */ - tabsPx?: number[]; -}; +import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; +import type { MinimalWordLayout } from '../paragraph/list-marker.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableCellMeasure = TableRowMeasure['cells'][number]; @@ -224,265 +161,6 @@ function computeCellVisibleHeight(cell: TableCellMeasure, cellFrom: number, cell return cellVisHeight; } -/** - * Parameters for rendering a list marker element. - */ -type MarkerRenderParams = { - /** Document object for creating DOM elements */ - doc: Document; - /** Line element to which the marker will be attached */ - lineEl: HTMLElement; - /** Full word-layout information for this paragraph */ - wordLayout?: WordLayoutInfo; - /** Marker layout information from word-layout engine */ - markerLayout: WordLayoutMarker; - /** Marker measurement data from measurement stage */ - markerMeasure: ParagraphMeasure['marker']; - /** Left indent in pixels */ - indentLeftPx: number; - /** Hanging indent in pixels */ - hangingIndentPx: number; - /** First line indent in pixels */ - firstLineIndentPx: number; - /** Array of explicit tab stop positions in pixels. */ - tabsPx?: number[]; -}; - -/** - * Parameters for applying paragraph indentation within a table cell line. - */ -type TableCellIndentParams = { - /** Line element to apply indentation styles to */ - lineEl: HTMLElement; - /** Line measurement data */ - line: Line; - /** Paragraph indentation values */ - indent?: ParagraphIndent; - /** List text indent in pixels (when list marker layout is present) */ - indentLeftPx: number; - /** Whether this paragraph has list marker layout */ - hasListMarkerLayout: boolean; - /** Zero-based index of the line within the paragraph */ - lineIndex: number; - /** Local start line for partial rendering */ - localStartLine: number; - /** Whether first-line indent should be suppressed */ - suppressFirstLineIndent: boolean; -}; - -/** - * Renders a list marker (bullet or number) for a paragraph line inside a table cell. - * - * Mirrors the top-level renderer approach: the marker and suffix separator are prepended - * inside `lineEl`, and `lineEl.paddingLeft` controls the text start position. This keeps - * table cell list markers aligned with the top-level paragraph renderer. - * - * @param params - Marker rendering parameters - */ -function renderListMarker(params: MarkerRenderParams): void { - const { - doc, - lineEl, - wordLayout, - markerLayout, - markerMeasure, - indentLeftPx, - hangingIndentPx, - firstLineIndentPx, - tabsPx, - } = params; - - const shouldUseSharedInlinePrefixGeometry = - markerLayout?.justification === 'left' && - wordLayout?.firstLineIndentMode !== true && - typeof markerMeasure?.markerTextWidth === 'number' && - Number.isFinite(markerMeasure.markerTextWidth) && - markerMeasure.markerTextWidth >= 0; - const markerGeometry = shouldUseSharedInlinePrefixGeometry - ? resolvePainterListMarkerGeometry({ - wordLayout, - indentLeftPx, - hangingIndentPx, - firstLineIndentPx, - markerTextWidthPx: markerMeasure?.markerTextWidth, - }) - : undefined; - - const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; - - const markerJustification = markerLayout?.justification ?? 'left'; - const markerTextWidth = markerMeasure?.markerTextWidth ?? 0; - - let markerStartPos: number, currentPos: number; - if (markerJustification === 'left') { - markerStartPos = anchorPoint; - currentPos = markerStartPos + markerTextWidth; - } else if (markerJustification === 'right') { - markerStartPos = anchorPoint - markerTextWidth; - currentPos = anchorPoint; - } else { - markerStartPos = anchorPoint - markerTextWidth / 2; - currentPos = markerStartPos + markerTextWidth; - } - - const suffix = markerLayout?.suffix ?? 'tab'; - let listTabWidth = 0; - if (markerGeometry && (suffix === 'tab' || suffix === 'space')) { - listTabWidth = markerGeometry.suffixWidthPx; - } else if (suffix === 'tab') { - listTabWidth = computeTabWidth( - currentPos, - markerJustification, - tabsPx, - hangingIndentPx, - firstLineIndentPx, - indentLeftPx, - ); - } else if (suffix === 'space') { - listTabWidth = 4; - } - - // Set line padding to the anchor point — this is where the inline marker flow starts. - // Matches renderer.ts: lineEl.style.paddingLeft = anchorPoint - lineEl.style.paddingLeft = `${anchorPoint}px`; - - if (markerLayout?.run?.vanish) { - // Hidden marker — preserve list indentation but don't render marker text - return; - } - - const markerContainer = createListMarkerElement(doc, markerLayout?.markerText ?? '', markerLayout?.run ?? {}); - - // Left-justified markers stay inline (position: relative) within the text flow. - // Right/center-justified markers are absolutely positioned. - markerContainer.style.position = 'relative'; - if (markerJustification === 'right') { - markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos}px`; - } else if (markerJustification === 'center') { - markerContainer.style.position = 'absolute'; - // Match renderer.ts center positioning - markerContainer.style.left = `${markerStartPos - markerTextWidth / 2}px`; - lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + markerTextWidth / 2 + 'px'; - } - - // Add suffix separator after marker, before text content - const suffixType = markerLayout?.suffix ?? 'tab'; - if (suffixType === 'tab') { - const tabEl = doc.createElement('span'); - tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); - tabEl.innerHTML = ' '; - tabEl.style.display = 'inline-block'; - if (markerLayout?.run?.fontSize != null) { - tabEl.style.fontSize = `${markerLayout.run.fontSize}px`; - } - tabEl.style.wordSpacing = '0px'; - tabEl.style.width = `${listTabWidth}px`; - lineEl.prepend(tabEl); - } else if (suffixType === 'space') { - const spaceEl = doc.createElement('span'); - spaceEl.classList.add('superdoc-marker-suffix-space'); - if (markerLayout?.run?.fontSize != null) { - spaceEl.style.fontSize = `${markerLayout.run.fontSize}px`; - } - spaceEl.style.wordSpacing = '0px'; - spaceEl.textContent = '\u00A0'; - lineEl.prepend(spaceEl); - } - - lineEl.prepend(markerContainer); -} - -/** - * Applies paragraph indentation to a rendered line inside a table cell. - * - * **SD-1472 Fix:** When segments have explicit x positions (from tab stops), the content - * is already absolutely positioned. Applying padding/textIndent would double-shift the text, - * causing the first character to be lost. This function detects explicit positioning via - * `segment.x !== undefined` and adjusts the indent strategy accordingly. - * - * **Mathematical Model (SD-1295):** - * The hanging indent effect is achieved through a combination of paddingLeft and textIndent: - * - `firstLineOffset = firstLine - hanging` - * - This offset can be positive (indent first line further right) or negative (outdent to left) - * - * **CSS Application Pattern:** - * - **First line (no explicit positioning):** - * - `paddingLeft = left` (base left indent) - * - `textIndent = firstLineOffset` (additional first-line adjustment) - * - Combined effect: text starts at `left + firstLineOffset` pixels from cell edge - * - * - **First line (with explicit positioning):** - * - `paddingLeft = max(0, left) + firstLineOffset` (only if positive) - * - `textIndent = 0` (reset to prevent double-shift) - * - * - **Body lines (continuation lines):** - * - `paddingLeft = hanging` (when hanging > 0 and no explicit positioning) - * - Creates the "hanging" visual effect where body lines are indented further right - * - * **Edge Cases:** - * - Negative hanging: Ignored for body lines (no effect, body uses left indent only) - * - Negative left indent: Clamped to 0 (browsers don't support negative padding) - * - suppressFirstLineIndent: When true, firstLineOffset is forced to 0 - * - Explicit segment positioning: Skips padding to avoid double-application - * - * @param params - Configuration for indent application within a table cell line. - */ -function applyTableCellLineIndentation(params: TableCellIndentParams): void { - const { - lineEl, - line, - indent, - indentLeftPx, - hasListMarkerLayout, - lineIndex, - localStartLine, - suppressFirstLineIndent, - } = params; - const paraIndentLeft = indent?.left ?? 0; - const paraIndentRight = indent?.right ?? 0; - const firstLineOffset = suppressFirstLineIndent ? 0 : (indent?.firstLine ?? 0) - (indent?.hanging ?? 0); - const isFirstLine = lineIndex === 0 && localStartLine === 0; - const hasExplicitSegmentPositioning = line.segments?.some((seg) => seg.x !== undefined) ?? false; - - if (hasListMarkerLayout && indentLeftPx) { - // List continuation lines should use the list text indent unless tabs handle explicit positioning. - if (!hasExplicitSegmentPositioning) { - lineEl.style.paddingLeft = `${indentLeftPx}px`; - } - } else { - // Preserve non-list paragraph indentation that was cleared above. - if (hasExplicitSegmentPositioning) { - if (isFirstLine && firstLineOffset !== 0) { - const effectiveLeftIndent = paraIndentLeft < 0 ? 0 : paraIndentLeft; - const adjustedPadding = effectiveLeftIndent + firstLineOffset; - if (adjustedPadding > 0) { - lineEl.style.paddingLeft = `${adjustedPadding}px`; - } - } - } else if (paraIndentLeft && paraIndentLeft > 0) { - lineEl.style.paddingLeft = `${paraIndentLeft}px`; - } else if ( - !isFirstLine && - indent?.hanging && - indent.hanging > 0 && - (paraIndentLeft == null || paraIndentLeft >= 0) - ) { - lineEl.style.paddingLeft = `${indent.hanging}px`; - } - } - - if (paraIndentRight && paraIndentRight > 0) { - lineEl.style.paddingRight = `${paraIndentRight}px`; - } - if (isFirstLine && firstLineOffset && !hasExplicitSegmentPositioning) { - lineEl.style.textIndent = `${firstLineOffset}px`; - } else if (firstLineOffset && hasExplicitSegmentPositioning) { - // Reset textIndent when segments have explicit positioning to prevent double-shift - lineEl.style.textIndent = '0px'; - } -} - /** * Applies inline CSS styles to an element, filtering out null/undefined/empty values. * @@ -501,14 +179,6 @@ const applyInlineStyles = (el: HTMLElement, styles: Partial }); }; -const convertParagraphMarkToCellMark = (lineEl: HTMLElement): void => { - const mark = lineEl.querySelector('.superdoc-formatting-paragraph-mark'); - if (!mark) return; - - mark.classList.add('superdoc-formatting-cell-mark'); - mark.textContent = '¤'; -}; - /** * Parameters for rendering a nested table inside a table cell. * @@ -784,50 +454,6 @@ function renderPartialEmbeddedTable(params: { return { element: tableWrapper, height: visibleHeight, nextCumulativeLineCount }; } -/** - * Apply paragraph-level visual styling such as borders and shading. - * Borders are set per side with sensible defaults and clamping. - */ -function applyParagraphBordersAndShading(paraWrapper: HTMLElement, block: ParagraphBlock): void { - const borders = block.attrs?.borders; - - if (borders) { - paraWrapper.style.boxSizing = 'border-box'; - - const sideStyles: Record<'top' | 'bottom' | 'left' | 'right', { width: string; style: string; color: string }> = { - top: { width: 'border-top-width', style: 'border-top-style', color: 'border-top-color' }, - bottom: { width: 'border-bottom-width', style: 'border-bottom-style', color: 'border-bottom-color' }, - left: { width: 'border-left-width', style: 'border-left-style', color: 'border-left-color' }, - right: { width: 'border-right-width', style: 'border-right-style', color: 'border-right-color' }, - }; - - (['top', 'bottom', 'left', 'right'] as const).forEach((side) => { - const border = borders[side]; - if (!border) return; - - const styleValue = border.style ?? 'solid'; - let widthValue = typeof border.width === 'number' ? Math.max(0, border.width) : 1; // default width when undefined - - // Border style none should render as zero width - if (styleValue === 'none') { - widthValue = 0; - } - - const cssKeys = sideStyles[side]; - paraWrapper.style.setProperty(cssKeys.style, styleValue); - paraWrapper.style.setProperty(cssKeys.width, `${widthValue}px`); - if (border.color) { - paraWrapper.style.setProperty(cssKeys.color, border.color); - } - }); - } - - const shadingFill = block.attrs?.shading?.fill; - if (shadingFill) { - paraWrapper.style.backgroundColor = shadingFill; - } -} - /** * Dependencies required for rendering a table cell. * @@ -1289,45 +915,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const lines = paragraphMeasure.lines; const blockLineCount = lines?.length || 0; const isLastBlockInCell = i === Math.min(blockMeasures.length, cellBlocks.length) - 1; - - /** - * Extract Word layout information from paragraph attributes. - * This contains computed marker positioning and indent details from the word-layout engine. - * The wordLayout is pre-computed during paragraph attribute processing and provides - * accurate positioning for list markers matching Microsoft Word's behavior. - */ - const wordLayout = (block.attrs?.wordLayout ?? null) as WordLayoutInfo | null; - - /** - * Marker layout contains the rendering details for list markers (bullets/numbers). - * This includes the marker text, positioning, justification, and styling. - */ - const markerLayout = wordLayout?.marker; - - /** - * Marker measurement data from the measurement stage. - * Contains computed dimensions (width, gutter) for the marker. - */ - const markerMeasure = paragraphMeasure.marker; - const indentLeftPx = - markerMeasure?.indentLeft ?? - wordLayout?.indentLeftPx ?? - (block.attrs?.indent && typeof block.attrs.indent.left === 'number' ? block.attrs.indent.left : 0); - const hangingIndentPx = - block.attrs?.indent && typeof block.attrs.indent.hanging === 'number' ? block.attrs.indent.hanging : 0; - const firstLineIndentPx = - block.attrs?.indent && typeof block.attrs.indent.firstLine === 'number' ? block.attrs.indent.firstLine : 0; - const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; - const listFirstLineTextStartPx = - markerLayout && markerMeasure - ? resolvePainterListTextStartPx({ - wordLayout: wordLayout ?? undefined, - indentLeftPx, - hangingIndentPx, - firstLineIndentPx, - markerTextWidthPx: markerMeasure.markerTextWidth, - }) - : undefined; + const wordLayout = (block.attrs?.wordLayout ?? null) as MinimalWordLayout | null; // Calculate the global line indices for this block const blockStartGlobal = cumulativeLineCount; @@ -1353,138 +941,37 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paraWrapper.style.position = 'relative'; paraWrapper.style.left = '0'; paraWrapper.style.width = '100%'; - applySdtDataset(paraWrapper, block.attrs?.sdt); const sdtBoundary = sdtBoundaries[i]; const blockKey = sdtContainerKeys[i] ?? null; - if (shouldApplySdtContainerStyling(block.attrs?.sdt, block.attrs?.containerSdt, blockKey)) { - applySdtContainerStyling(doc, paraWrapper, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - } - applyParagraphBordersAndShading(paraWrapper, block as ParagraphBlock); - - // Apply paragraph-level border and shading styles (SD-1296) - // These were previously missing, causing paragraph borders to not render in table cells - applyParagraphBorderStyles(paraWrapper, block.attrs?.borders); - applyParagraphShadingStyles(paraWrapper, block.attrs?.shading); - - // Apply paragraph spacing.before when rendering from the top of the paragraph. - // Word absorbs first paragraph's spacing.before into cell paddingTop (effectiveTableCellSpacing). - const spacingBefore = (block as ParagraphBlock).attrs?.spacing?.before; - if (localStartLine === 0) { - const effectiveBefore = effectiveTableCellSpacing(spacingBefore, i === 0, paddingTop); - if (effectiveBefore > 0) { - paraWrapper.style.marginTop = `${effectiveBefore}px`; - flowCursorY += effectiveBefore; - } - } - - // Calculate height of rendered content for proper block accumulation - let renderedHeight = 0; - - /** - * Render lines for this paragraph block. - * Lines are rendered within the local range (localStartLine to localEndLine). - * List markers are only rendered on the first line if we're rendering from the start. - */ - for (let lineIdx = localStartLine; lineIdx < localEndLine && lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const isLastLine = lineIdx === lines.length - 1; - const lineTop = flowCursorY + renderedHeight; - - /** - * Render line without extra paragraph padding to enable explicit marker/text offset control. - * This mirrors the main renderer behavior where list markers clear padding/textIndent. - */ - const lineEl = renderLine( - block as ParagraphBlock, - line, - { ...context, section: 'body' }, - lineIdx, - isLastLine, - lineIdx === 0 && localStartLine === 0 ? listFirstLineTextStartPx : undefined, - ); - if (isLastBlockInCell && isLastLine) { - convertParagraphMarkToCellMark(lineEl); - } - lineEl.style.paddingLeft = ''; - lineEl.style.paddingRight = ''; - lineEl.style.textIndent = ''; - - /** - * Determine if we should render a list marker for this line. - * Markers are only rendered on the first line of a paragraph, and only if: - * - We have marker layout information from word-layout engine - * - We have marker measurement data - * - This is the first line (lineIdx === 0) - * - We're rendering from the start of the paragraph (localStartLine === 0) - * - The marker has a non-zero width - * Note: vanish markers are handled inside renderListMarker (sets correct - * indentation but skips marker text rendering). - */ - const shouldRenderMarker = - markerLayout && markerMeasure && lineIdx === 0 && localStartLine === 0 && markerMeasure.markerWidth > 0; - - if (shouldRenderMarker) { - // Prepend marker + suffix inside lineEl (mirrors renderer.ts approach) - renderListMarker({ - doc, - lineEl, - wordLayout: wordLayout ?? undefined, - markerLayout, - markerMeasure, - indentLeftPx, - hangingIndentPx, - firstLineIndentPx, - tabsPx: wordLayout?.tabsPx, - }); - renderedLines.push({ el: lineEl, top: lineTop, height: line.lineHeight }); - paraWrapper.appendChild(lineEl); - } else { - /** - * For lines without markers, apply appropriate indentation: - * - For list paragraphs: apply indent padding for continuation lines - * - For non-list paragraphs: preserve the paragraph's own indent styling - */ - applyTableCellLineIndentation({ - lineEl, - line, - indent: block.attrs?.indent, - indentLeftPx, - hasListMarkerLayout: Boolean(markerLayout), - lineIndex: lineIdx, - localStartLine, - suppressFirstLineIndent, - }); - renderedLines.push({ el: lineEl, top: lineTop, height: line.lineHeight }); - paraWrapper.appendChild(lineEl); - } - - renderedHeight += line.lineHeight; - } - - // If we rendered the entire paragraph, use measured totalHeight to keep layout aligned with measurement - const renderedEntireBlock = localStartLine === 0 && localEndLine >= blockLineCount; - if (renderedEntireBlock && blockMeasure.totalHeight && blockMeasure.totalHeight > renderedHeight) { - renderedHeight = blockMeasure.totalHeight; - } content.appendChild(paraWrapper); - - if (renderedHeight > 0) { - paraWrapper.style.height = `${renderedHeight}px`; - } - - flowCursorY += renderedHeight; - - // Apply paragraph spacing.after as margin-bottom for non-last paragraphs. - // In Word, the last paragraph's spacing.after is absorbed by the cell's bottom padding. - const isLastBlock = i === Math.min(blockMeasures.length, cellBlocks.length) - 1; - if (renderedEntireBlock && !isLastBlock) { - const spacingAfter = (block as ParagraphBlock).attrs?.spacing?.after; - if (typeof spacingAfter === 'number' && spacingAfter > 0) { - paraWrapper.style.marginBottom = `${spacingAfter}px`; - flowCursorY += spacingAfter; - } - } + const result = renderParagraphContent({ + doc, + frameEl: paraWrapper, + block: block as ParagraphBlock, + measure: paragraphMeasure, + containerKind: 'table-cell', + width: contentWidthPx, + localStartLine, + localEndLine, + contextSection: 'body', + wordLayout: wordLayout ?? undefined, + spacingPolicy: { + isFirstBlock: i === 0, + isLastBlock: isLastBlockInCell, + paddingTop, + }, + sdtBoundary, + shouldApplySdtContainerStyling: (sdt, containerSdt) => + shouldApplySdtContainerStyling(sdt, containerSdt, blockKey), + applySdtDataset, + renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => + renderLine(block, line, { ...context, section: 'body' }, lineIndex, isLastLine, resolvedListTextStartPx), + convertFinalParagraphMark: isLastBlockInCell, + lineTopOffset: flowCursorY, + }); + renderedLines.push(...result.renderedLines); + flowCursorY += result.totalHeight; cumulativeLineCount += blockLineCount; } From fe8281519079aebaef8eead26130ecc7a1a6a368 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 16:58:36 -0300 Subject: [PATCH 2/9] fix(painters/dom): keep split paragraph fragment height --- .../paragraph/renderParagraphContent.test.ts | 52 +++++++++++++++++++ .../src/paragraph/renderParagraphContent.ts | 15 ++---- .../painters/dom/src/renderer.ts | 1 - .../painters/dom/src/table/renderTableCell.ts | 1 - 4 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts new file mode 100644 index 0000000000..0ba6159f29 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { renderParagraphContent } from './renderParagraphContent.js'; +import type { Line, ParagraphBlock, ParagraphMeasure } from '@superdoc/contracts'; + +describe('renderParagraphContent', () => { + const line = (index: number): Line => ({ + fromRun: 0, + fromChar: index, + toRun: 0, + toChar: index + 1, + width: 10, + ascent: 12, + descent: 4, + lineHeight: 20, + }); + + it('keeps partial body fragments at their rendered line height', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'split-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0), line(1), line(2)], + totalHeight: 60, + }; + + const result = renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + lineIndexOffset: 0, + linesOverride: measure.lines.slice(0, 1), + contextSection: 'body', + continuesOnNext: true, + applySdtDataset: () => {}, + renderLine: () => doc.createElement('div'), + }); + + expect(result.renderedHeight).toBe(20); + expect(result.totalHeight).toBe(20); + expect(frameEl.style.height).toBe('20px'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 028e4966a9..b98145ee9c 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -72,7 +72,6 @@ export type RenderParagraphContentParams = { width: number; localStartLine: number; localEndLine: number; - contextSection: 'body' | 'header' | 'footer' | string; linesOverride?: Line[]; lineIndexOffset?: number; continuesFromPrev?: boolean; @@ -111,15 +110,12 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re block, measure, linesOverride, - containerKind, width, localStartLine, localEndLine, + lineIndexOffset = 0, continuesFromPrev, continuesOnNext, - markerWidth, - markerTextWidth, - wordLayout, resolvedContent, betweenInfo, sdtBoundary, @@ -127,12 +123,8 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re shouldApplySdtContainerStyling, applySdtDataset, applyContainerSdtDataset, - renderLine, renderDropCap, - captureLineSnapshot, - convertFinalParagraphMark, lineTopOffset = 0, - sourceAnchor, } = params; applyParagraphBlockStyles(frameEl, block.attrs); @@ -186,8 +178,11 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re }); let renderedHeight = renderResult.renderedHeight; + const originalLineCount = measure.lines?.length ?? linesOverride?.length ?? 0; + const renderedStartLine = lineIndexOffset + localStartLine; + const renderedEndLine = lineIndexOffset + localEndLine; const renderedEntireBlock = - localStartLine === 0 && localEndLine >= (linesOverride?.length ?? measure.lines?.length ?? 0); + !continuesFromPrev && !continuesOnNext && renderedStartLine === 0 && renderedEndLine >= originalLineCount; if (renderedEntireBlock && measure.totalHeight && measure.totalHeight > renderedHeight) { renderedHeight = measure.totalHeight; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0431b05d95..51971ff465 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3086,7 +3086,6 @@ export class DomPainter { localEndLine: lines.length, lineIndexOffset: fragment.fromLine, linesOverride: lines, - contextSection: context.section, continuesFromPrev: paraContinuesFromPrev, continuesOnNext: paraContinuesOnNext, markerWidth: paraMarkerWidth, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index bc51892d8f..407e92c456 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -954,7 +954,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen width: contentWidthPx, localStartLine, localEndLine, - contextSection: 'body', wordLayout: wordLayout ?? undefined, spacingPolicy: { isFirstBlock: i === 0, From 74192a1c1ba536eed3cb056187a8e8569c9efcfb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 17:35:43 -0300 Subject: [PATCH 3/9] fix(painters/dom): mark remeasured paragraph final lines --- .../paragraph/renderParagraphContent.test.ts | 40 +++++++++++++++++++ .../src/paragraph/renderParagraphContent.ts | 6 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts index 0ba6159f29..2380f13a99 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -49,4 +49,44 @@ describe('renderParagraphContent', () => { expect(result.totalHeight).toBe(20); expect(frameEl.style.height).toBe('20px'); }); + + it('marks the final remeasured override line as the paragraph final line', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'remeasured-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0)], + totalHeight: 20, + }; + const renderedLines: Array<{ lineIndex: number; isLastLine: boolean; skipJustify?: boolean }> = []; + + renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 2, + lineIndexOffset: 0, + linesOverride: [line(0), line(1)], + contextSection: 'body', + applySdtDataset: () => {}, + renderLine: ({ lineIndex, isLastLine, skipJustify }) => { + renderedLines.push({ lineIndex, isLastLine, skipJustify }); + return doc.createElement('div'); + }, + }); + + expect(renderedLines).toEqual([ + { lineIndex: 0, isLastLine: false, skipJustify: false }, + { lineIndex: 1, isLastLine: true, skipJustify: true }, + ]); + }); }); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index b98145ee9c..6c0c50582d 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -334,6 +334,7 @@ const renderMeasuredLines = ( let renderedHeight = 0; const renderedLines: RenderedParagraphLineInfo[] = []; + const renderedLocalEndLine = Math.min(localEndLine, lines.length); for (let lineIdx = localStartLine; lineIdx < localEndLine && lineIdx < lines.length; lineIdx++) { const line = lines[lineIdx]; @@ -343,7 +344,10 @@ const renderMeasuredLines = ( const shouldUseResolvedListTextStart = isListFirstLine && explicitSegmentPositioning && listFirstLineTextStartPx != null; const globalLineIndex = lineIndexOffset + lineIdx; - const isLastLineOfParagraph = globalLineIndex === (measure.lines?.length ?? lines.length) - 1 && !continuesOnNext; + const isLastLineOfParagraph = + (linesOverride + ? lineIdx === renderedLocalEndLine - 1 + : globalLineIndex === (measure.lines?.length ?? lines.length) - 1) && !continuesOnNext; const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; const availableWidth = containerKind === 'body-fragment' From 5872a5b21fb252b14f3d3ffc4e6deeb143b55d6e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 17:38:11 -0300 Subject: [PATCH 4/9] fix(painters/dom): keep list marker right indent --- .../paragraph/renderParagraphContent.test.ts | 51 +++++++++++++++++++ .../src/paragraph/renderParagraphContent.ts | 3 ++ 2 files changed, 54 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts index 2380f13a99..d9690d5566 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -89,4 +89,55 @@ describe('renderParagraphContent', () => { { lineIndex: 1, isLastLine: true, skipJustify: true }, ]); }); + + it('preserves paragraph right indent on list marker lines', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'list-paragraph', + attrs: { + indent: { left: 24, hanging: 12, right: 18 }, + wordLayout: { + marker: { + markerText: '1.', + suffix: 'space', + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }, + }, + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0)], + marker: { + markerWidth: 10, + markerTextWidth: 8, + }, + totalHeight: 20, + }; + let lineEl: HTMLElement | undefined; + + renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + contextSection: 'body', + markerWidth: 10, + markerTextWidth: 8, + applySdtDataset: () => {}, + renderLine: () => { + lineEl = doc.createElement('div'); + return lineEl; + }, + }); + + expect(lineEl?.style.cssText).toContain('padding-right: 18px'); + }); }); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 6c0c50582d..c0a0269df6 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -381,6 +381,9 @@ const renderMeasuredLines = ( } if (isListFirstLine && markerLayout && markerMeasure) { + if (paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } renderLegacyListMarker({ doc, lineEl, From 854c097d493fecdae7e80f1d4974b73ea85edcb6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 17:41:21 -0300 Subject: [PATCH 5/9] refactor(painters/dom): reuse shared list marker types --- .../painters/dom/src/paragraph/list-marker.ts | 30 +-------- .../src/paragraph/renderParagraphContent.ts | 4 +- .../painters/dom/src/renderer.ts | 62 ++----------------- .../painters/dom/src/table/renderTableCell.ts | 2 +- shared/common/list-marker-utils.ts | 9 ++- 5 files changed, 17 insertions(+), 90 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts index 337e727410..7ed08a4220 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -1,38 +1,12 @@ import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts'; +import type { MinimalMarker, MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import { createListMarkerElement, computeTabWidth, resolvePainterListMarkerGeometry } from '../utils/marker-helpers.js'; -export type WordLayoutMarker = { - markerText?: string; - justification?: 'left' | 'right' | 'center'; - gutterWidthPx?: number; - markerBoxWidthPx?: number; - suffix?: 'tab' | 'space' | 'nothing'; - markerX?: number; - textStartX?: number; - run: { - fontFamily?: string; - fontSize?: number; - bold?: boolean; - italic?: boolean; - color?: string; - letterSpacing?: number; - vanish?: boolean; - }; -}; - -export type MinimalWordLayout = { - marker?: WordLayoutMarker; - indentLeftPx?: number; - firstLineIndentMode?: boolean; - textStartPx?: number; - tabsPx?: number[]; -}; - export const renderLegacyListMarker = (params: { doc: Document; lineEl: HTMLElement; wordLayout?: MinimalWordLayout; - markerLayout: WordLayoutMarker; + markerLayout: MinimalMarker; markerMeasure: ParagraphMeasure['marker']; markerTextWidthPx?: number; indentLeftPx: number; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index c0a0269df6..c8381668f2 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -13,7 +13,7 @@ import { expandRunsForInlineNewlines, getParagraphInlineDirection, } from '@superdoc/contracts'; -import { resolveMarkerIndent } from '@superdoc/common/list-marker-utils'; +import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import { resolvePainterListTextStartPx } from '../utils/marker-helpers.js'; import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; import { @@ -26,7 +26,7 @@ import { hasExplicitSegmentPositioning, resolveAvailableWidthForLine, } from './indentation.js'; -import { renderLegacyListMarker, renderResolvedListMarker, type MinimalWordLayout } from './list-marker.js'; +import { renderLegacyListMarker, renderResolvedListMarker } from './list-marker.js'; import { applyParagraphBlockStyles, clearParagraphFrameIndentStyles } from './styles.js'; export type RenderedParagraphLineInfo = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 51971ff465..9b03a887a8 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -103,68 +103,16 @@ import { import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { applyImageClipPath } from './utils/image-clip-path.js'; -import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; +import { + isMinimalWordLayout as isMinimalWordLayoutShared, + type MinimalWordLayout, +} from '@superdoc/common/list-marker-utils'; import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; import { computeBetweenBorderFlags, type BetweenBorderInfo } from './features/paragraph-borders/index.js'; import { applyRtlStyles, shouldUseSegmentPositioning } from './features/rtl-paragraph/index.js'; import { convertOmmlToMathml } from './features/math/index.js'; import { renderParagraphContent } from './paragraph/renderParagraphContent.js'; -/** - * Minimal type for WordParagraphLayoutOutput marker data used in rendering. - * Extracted to avoid dependency on @superdoc/word-layout package. - */ -type WordLayoutMarker = { - markerText?: string; - justification?: 'left' | 'right' | 'center'; - gutterWidthPx?: number; - markerBoxWidthPx?: number; - suffix?: 'tab' | 'space' | 'nothing'; - /** Pre-calculated X position where the marker should be placed (used in firstLineIndentMode). */ - markerX?: number; - /** Pre-calculated X position where paragraph text should begin after the marker (used in firstLineIndentMode). */ - textStartX?: number; - run: { - fontFamily: string; - fontSize: number; - bold?: boolean; - italic?: boolean; - color?: string; - letterSpacing?: number; - vanish?: boolean; - }; -}; - -/** - * Minimal type for wordLayout property used in this renderer. - * - * This is a subset of the full WordParagraphLayoutOutput type from @superdoc/word-layout. - * We extract only the fields needed for rendering to avoid a direct dependency on the - * word-layout package from the renderer. This allows the renderer to work with any object - * that provides these properties, maintaining loose coupling between packages. - * - * The wordLayout property is attached to ParagraphBlock.attrs during block processing - * and contains layout metadata needed for proper list marker and indent rendering. - * - * @property marker - Optional list marker layout containing text, styling, and positioning info - * @property indentLeftPx - Left indent in pixels (used for marker positioning calculations) - * @property firstLineIndentMode - When true, indicates the paragraph uses firstLine indent - * pattern (marker at left+firstLine) instead of standard hanging indent (marker at left-hanging). - * This flag changes how markers are positioned and how tab spacing is calculated. - * @property textStartPx - X position where paragraph text should begin (used for tab width calculation) - * @property tabsPx - Array of explicit tab stop positions in pixels - */ -type MinimalWordLayout = { - marker?: WordLayoutMarker; - indentLeftPx?: number; - /** True for firstLine indent pattern (marker at left+firstLine vs left-hanging). */ - firstLineIndentMode?: boolean; - /** X position where paragraph text should begin. */ - textStartPx?: number; - /** Array of explicit tab stop positions in pixels. */ - tabsPx?: number[]; -}; - type LineEnd = { type?: string; width?: string; @@ -189,7 +137,7 @@ type VectorShapeDrawingWithEffects = VectorShapeDrawing & { }; /** - * Type guard narrowing to the renderer-local MinimalWordLayout type. + * Type guard narrowing to the shared word layout contract type. * Delegates structural validation to the shared isMinimalWordLayout guard. */ function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 407e92c456..c8453ea764 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -17,6 +17,7 @@ import type { WrapTextMode, } from '@superdoc/contracts'; import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; +import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { applyImageClipPath } from '../utils/image-clip-path.js'; @@ -24,7 +25,6 @@ import { getSdtContainerConfig, getSdtContainerKey, type SdtBoundaryOptions } fr import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; -import type { MinimalWordLayout } from '../paragraph/list-marker.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableCellMeasure = TableRowMeasure['cells'][number]; diff --git a/shared/common/list-marker-utils.ts b/shared/common/list-marker-utils.ts index aff373c8af..03bdb1ffc8 100644 --- a/shared/common/list-marker-utils.ts +++ b/shared/common/list-marker-utils.ts @@ -22,6 +22,9 @@ export type MinimalMarkerRun = { fontSize?: number; bold?: boolean; italic?: boolean; + color?: string; + letterSpacing?: number; + vanish?: boolean; }; /** @@ -43,9 +46,9 @@ export type MinimalMarker = { /** Width of the gutter between marker and text (used for center/right justification) */ gutterWidthPx?: number; /** Marker justification: 'left', 'center', or 'right' */ - justification?: string; + justification?: 'left' | 'center' | 'right'; /** What follows the marker: 'tab', 'space', or 'nothing' */ - suffix?: string; + suffix?: 'tab' | 'space' | 'nothing'; /** The text content of the marker (for measurement if glyphWidthPx not available) */ markerText?: string; /** Formatting information for the marker (for measurement if needed) */ @@ -62,6 +65,8 @@ export type MinimalWordLayout = { firstLineIndentMode?: boolean; /** Pre-calculated horizontal position where text should start */ textStartPx?: number; + /** Left indent in pixels from word-layout marker positioning */ + indentLeftPx?: number; /** Array of tab stop positions in pixels (for firstLineIndentMode) */ tabsPx?: number[]; /** Marker information */ From 5b1e63b3bd47814098e004bfa4d8ad832d574dcf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 13 May 2026 17:42:07 -0300 Subject: [PATCH 6/9] test(painters/dom): cover resolved paragraph rendering --- .../paragraph/renderParagraphContent.test.ts | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts index d9690d5566..f13f33f338 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { renderParagraphContent } from './renderParagraphContent.js'; -import type { Line, ParagraphBlock, ParagraphMeasure } from '@superdoc/contracts'; +import type { Line, ParagraphBlock, ParagraphMeasure, ResolvedParagraphContent } from '@superdoc/contracts'; describe('renderParagraphContent', () => { const line = (index: number): Line => ({ @@ -140,4 +140,113 @@ describe('renderParagraphContent', () => { expect(lineEl?.style.cssText).toContain('padding-right: 18px'); }); + + it('renders resolved RTL list markers on the right side', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'resolved-list-paragraph', + attrs: { direction: 'rtl' }, + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const resolvedContent: ResolvedParagraphContent = { + lines: [ + { + line: line(0), + lineIndex: 0, + availableWidth: 160, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: true, + hasExplicitSegmentPositioning: false, + indentOffset: 30, + }, + ], + marker: { + text: '1.', + justification: 'right', + suffix: 'space', + markerStartPx: 6, + suffixWidthPx: 4, + firstLinePaddingLeftPx: 30, + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }; + + renderParagraphContent({ + doc, + frameEl, + block, + measure: { kind: 'paragraph', lines: [line(0)], totalHeight: 20 }, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + contextSection: 'body', + resolvedContent, + applySdtDataset: () => {}, + renderLine: () => doc.createElement('div'), + }); + + const lineEl = frameEl.lastElementChild as HTMLElement; + const markerEl = lineEl.querySelector('.superdoc-list-marker'); + expect(lineEl.style.paddingRight).toBe('30px'); + expect(markerEl?.style.right).toBe('6px'); + }); + + it('converts the final paragraph mark for resolved content', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'resolved-cell-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const resolvedContent: ResolvedParagraphContent = { + lines: [ + { + line: line(0), + lineIndex: 0, + availableWidth: 160, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: false, + hasExplicitSegmentPositioning: false, + indentOffset: 0, + }, + ], + }; + + renderParagraphContent({ + doc, + frameEl, + block, + measure: { kind: 'paragraph', lines: [line(0)], totalHeight: 20 }, + containerKind: 'table-cell', + width: 200, + localStartLine: 0, + localEndLine: 1, + contextSection: 'body', + resolvedContent, + convertFinalParagraphMark: true, + applySdtDataset: () => {}, + renderLine: () => { + const lineEl = doc.createElement('div'); + const mark = doc.createElement('span'); + mark.classList.add('superdoc-formatting-paragraph-mark'); + mark.textContent = '¶'; + lineEl.appendChild(mark); + return lineEl; + }, + }); + + const mark = frameEl.querySelector('.superdoc-formatting-paragraph-mark'); + expect(mark?.classList.contains('superdoc-formatting-cell-mark')).toBe(true); + expect(mark?.textContent).toBe('¤'); + }); }); From 7b3b62c42aa0b46291ccc991c3d9461adf7a9e02 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 14:39:52 -0300 Subject: [PATCH 7/9] refactor(painters/dom): colocate paragraph borders and marker helpers Move `features/paragraph-borders/` to `paragraph/borders/` and fold `utils/marker-helpers.ts` into `paragraph/list-marker.ts` so all paragraph rendering pieces live under `paragraph/`. Update the feature registry and imports to match. --- .../painters/dom/src/between-borders.test.ts | 2 +- .../dom/src/features/feature-registry.ts | 8 +- .../borders}/border-layer.ts | 0 .../borders}/group-analysis.ts | 0 .../borders}/index.ts | 0 .../painters/dom/src/paragraph/list-marker.ts | 113 ++++++++++++++- .../src/paragraph/renderParagraphContent.ts | 9 +- .../painters/dom/src/renderer.ts | 4 +- .../painters/dom/src/utils/marker-helpers.ts | 131 ------------------ 9 files changed, 119 insertions(+), 148 deletions(-) rename packages/layout-engine/painters/dom/src/{features/paragraph-borders => paragraph/borders}/border-layer.ts (100%) rename packages/layout-engine/painters/dom/src/{features/paragraph-borders => paragraph/borders}/group-analysis.ts (100%) rename packages/layout-engine/painters/dom/src/{features/paragraph-borders => paragraph/borders}/index.ts (100%) delete mode 100644 packages/layout-engine/painters/dom/src/utils/marker-helpers.ts diff --git a/packages/layout-engine/painters/dom/src/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts index 52627ac04e..12f33cf124 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -6,7 +6,7 @@ import { getParagraphBorderBox, computeBorderSpaceExpansion, type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; +} from './paragraph/borders/index.js'; import { hashParagraphBorders } from './paragraph-hash-utils.js'; /** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */ diff --git a/packages/layout-engine/painters/dom/src/features/feature-registry.ts b/packages/layout-engine/painters/dom/src/features/feature-registry.ts index 05cab44146..5d8aabe5a0 100644 --- a/packages/layout-engine/painters/dom/src/features/feature-registry.ts +++ b/packages/layout-engine/painters/dom/src/features/feature-registry.ts @@ -17,8 +17,8 @@ export const RENDERING_FEATURES = { // ─── Paragraph Borders ─────────────────────────────────────────── // @spec ECMA-376 §17.3.1.24 (pBdr) 'w:pBdr': { - feature: 'paragraph-borders', - module: './paragraph-borders', + feature: 'paragraph/borders', + module: '../paragraph/borders', handles: ['w:pBdr/w:top', 'w:pBdr/w:bottom', 'w:pBdr/w:left', 'w:pBdr/w:right', 'w:pBdr/w:between', 'w:pBdr/w:bar'], spec: '§17.3.1.24', }, @@ -26,8 +26,8 @@ export const RENDERING_FEATURES = { // ─── Paragraph Shading ─────────────────────────────────────────── // @spec ECMA-376 §17.3.1.31 (shd) 'w:shd': { - feature: 'paragraph-borders', // shading shares the border layer module - module: './paragraph-borders', + feature: 'paragraph/borders', // shading shares the border layer module + module: '../paragraph/borders', handles: ['w:shd/@w:fill', 'w:shd/@w:val', 'w:shd/@w:color'], spec: '§17.3.1.31', }, diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/border-layer.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/border-layer.ts diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/index.ts diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts index 7ed08a4220..2746a8b24c 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -1,6 +1,115 @@ +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { toCssFontFamily } from '@superdoc/font-utils'; import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts'; -import type { MinimalMarker, MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import { createListMarkerElement, computeTabWidth, resolvePainterListMarkerGeometry } from '../utils/marker-helpers.js'; +import { + computeTabWidth, + resolveListMarkerGeometry, + resolveListTextStartPx, + type MinimalMarker, + type MinimalWordLayout, + type ResolvedListMarkerGeometry, +} from '@superdoc/common/list-marker-utils'; +import { applySourceAnchorDataset } from '../renderer.js'; + +type PainterListTextStartParams = { + wordLayout: MinimalWordLayout | undefined; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + markerTextWidthPx?: number; +}; + +const getFiniteNonNegativeNumber = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return undefined; + } + return value; +}; + +const resolvePainterMarkerTextWidth = ( + markerTextWidthPx: number | undefined, + marker: { glyphWidthPx?: number; markerBoxWidthPx?: number }, +): number => + getFiniteNonNegativeNumber(markerTextWidthPx) ?? + getFiniteNonNegativeNumber(marker.glyphWidthPx) ?? + getFiniteNonNegativeNumber(marker.markerBoxWidthPx) ?? + 0; + +export const resolvePainterListMarkerGeometry = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): ResolvedListMarkerGeometry | undefined => + resolveListMarkerGeometry( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +export const resolvePainterListTextStartPx = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): number | undefined => + resolveListTextStartPx( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +type MarkerRunStyle = { + fontFamily?: string | null; + fontSize?: number | null; + bold?: boolean | null; + italic?: boolean | null; + color?: string | null; + letterSpacing?: number | null; +}; + +export const createListMarkerElement = ( + doc: Document, + markerText: string, + run: MarkerRunStyle, + sourceAnchor?: SourceAnchor, +): HTMLElement => { + const markerContainer = doc.createElement('span'); + markerContainer.classList.add(DOM_CLASS_NAMES.LIST_MARKER); + markerContainer.style.display = 'inline-block'; + markerContainer.style.wordSpacing = '0px'; + + const markerEl = doc.createElement('span'); + markerEl.classList.add('superdoc-paragraph-marker'); + markerEl.textContent = markerText; + markerEl.style.pointerEvents = 'none'; + markerEl.style.fontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily ?? ''; + + if (run.fontSize != null) { + markerEl.style.fontSize = `${run.fontSize}px`; + } + markerEl.style.fontWeight = run.bold ? 'bold' : ''; + markerEl.style.fontStyle = run.italic ? 'italic' : ''; + + if (run.color) { + markerEl.style.color = run.color; + } + if (run.letterSpacing != null) { + markerEl.style.letterSpacing = `${run.letterSpacing}px`; + } + + markerContainer.appendChild(markerEl); + if (sourceAnchor) { + applySourceAnchorDataset(markerEl, sourceAnchor); + } + return markerContainer; +}; export const renderLegacyListMarker = (params: { doc: Document; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index c8381668f2..fe032d0001 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -14,19 +14,14 @@ import { getParagraphInlineDirection, } from '@superdoc/contracts'; import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import { resolvePainterListTextStartPx } from '../utils/marker-helpers.js'; import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; -import { - createParagraphDecorationLayers, - stampBetweenBorderDataset, - type BetweenBorderInfo, -} from '../features/paragraph-borders/index.js'; +import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; import { applyParagraphLineIndentation, hasExplicitSegmentPositioning, resolveAvailableWidthForLine, } from './indentation.js'; -import { renderLegacyListMarker, renderResolvedListMarker } from './list-marker.js'; +import { renderLegacyListMarker, renderResolvedListMarker, resolvePainterListTextStartPx } from './list-marker.js'; import { applyParagraphBlockStyles, clearParagraphFrameIndentStyles } from './styles.js'; export type RenderedParagraphLineInfo = { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 9b03a887a8..80286e6c27 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -108,7 +108,7 @@ import { type MinimalWordLayout, } from '@superdoc/common/list-marker-utils'; import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; -import { computeBetweenBorderFlags, type BetweenBorderInfo } from './features/paragraph-borders/index.js'; +import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; import { applyRtlStyles, shouldUseSegmentPositioning } from './features/rtl-paragraph/index.js'; import { convertOmmlToMathml } from './features/math/index.js'; import { renderParagraphContent } from './paragraph/renderParagraphContent.js'; @@ -6862,8 +6862,6 @@ const computeSdtBoundaries = ( return boundaries; }; -// computeBetweenBorderFlags — moved to features/paragraph-borders/ - const fragmentKey = (fragment: Fragment): string => { switch (fragment.kind) { case 'para': diff --git a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts deleted file mode 100644 index 577cb2ac9c..0000000000 --- a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; -import { toCssFontFamily } from '@superdoc/font-utils'; -import { - resolveListMarkerGeometry, - resolveListTextStartPx, - computeTabWidth, - type MinimalMarker, - type MinimalWordLayout, - type ResolvedListMarkerGeometry, -} from '@superdoc/common/list-marker-utils'; -import { applySourceAnchorDataset } from '../renderer'; -import { SourceAnchor } from '@superdoc/contracts'; - -type PainterListTextStartParams = { - wordLayout: MinimalWordLayout | undefined; - indentLeftPx: number; - hangingIndentPx: number; - firstLineIndentPx: number; - markerTextWidthPx?: number; -}; - -const getFiniteNonNegativeNumber = (value: unknown): number | undefined => { - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { - return undefined; - } - return value; -}; - -/** - * Resolves marker width using the already-measured glyph width from layout whenever possible. - */ -const resolvePainterMarkerTextWidth = ( - markerTextWidthPx: number | undefined, - marker: { glyphWidthPx?: number; markerBoxWidthPx?: number }, -): number => - getFiniteNonNegativeNumber(markerTextWidthPx) ?? - getFiniteNonNegativeNumber(marker.glyphWidthPx) ?? - getFiniteNonNegativeNumber(marker.markerBoxWidthPx) ?? - 0; - -/** - * Resolves the canonical marker geometry for a list first line while letting the - * painter reuse the measured marker glyph width instead of remeasuring text. - */ -export const resolvePainterListMarkerGeometry = ({ - wordLayout, - indentLeftPx, - hangingIndentPx, - firstLineIndentPx, - markerTextWidthPx, -}: PainterListTextStartParams): ResolvedListMarkerGeometry | undefined => - resolveListMarkerGeometry( - wordLayout, - indentLeftPx, - firstLineIndentPx, - hangingIndentPx, - (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), - ); - -/** - * Resolves the canonical text-start position for a list first line while letting - * the painter reuse the measured marker glyph width instead of remeasuring text. - */ -export const resolvePainterListTextStartPx = ({ - wordLayout, - indentLeftPx, - hangingIndentPx, - firstLineIndentPx, - markerTextWidthPx, -}: PainterListTextStartParams): number | undefined => - resolveListTextStartPx( - wordLayout, - indentLeftPx, - firstLineIndentPx, - hangingIndentPx, - (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), - ); - -// Re-export computeTabWidth from shared module -export { computeTabWidth }; - -type MarkerRunStyle = { - fontFamily?: string | null; - fontSize?: number | null; - bold?: boolean | null; - italic?: boolean | null; - color?: string | null; - letterSpacing?: number | null; -}; - -/** - * Build the marker container `` with the inner - * `` already appended and styled from the - * given run. Callers handle positioning, suffix separators, and the final prepend. - */ -export const createListMarkerElement = ( - doc: Document, - markerText: string, - run: MarkerRunStyle, - sourceAnchor?: SourceAnchor, -): HTMLElement => { - const markerContainer = doc.createElement('span'); - markerContainer.classList.add(DOM_CLASS_NAMES.LIST_MARKER); - markerContainer.style.display = 'inline-block'; - markerContainer.style.wordSpacing = '0px'; - - const markerEl = doc.createElement('span'); - markerEl.classList.add('superdoc-paragraph-marker'); - markerEl.textContent = markerText; - markerEl.style.pointerEvents = 'none'; - markerEl.style.fontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily ?? ''; - - if (run.fontSize != null) { - markerEl.style.fontSize = `${run.fontSize}px`; - } - markerEl.style.fontWeight = run.bold ? 'bold' : ''; - markerEl.style.fontStyle = run.italic ? 'italic' : ''; - - if (run.color) { - markerEl.style.color = run.color; - } - if (run.letterSpacing != null) { - markerEl.style.letterSpacing = `${run.letterSpacing}px`; - } - - markerContainer.appendChild(markerEl); - if (sourceAnchor) { - applySourceAnchorDataset(markerEl, sourceAnchor); - } - return markerContainer; -}; From 8369ccd968c5f66cabcb33578e369fca564c8df6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 15:26:39 -0300 Subject: [PATCH 8/9] refactor(painters/dom): extract run rendering into runs/ module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull line- and run-level rendering out of renderer.ts (~2500 lines) into a dedicated runs/ directory split by concern: text, image, math, tab, field annotation, links, SDT, tracked changes, formatting marks, and the top-level render-line/render-run orchestrators. Run hash helpers move from paragraph-hash-utils.ts to runs/hash.ts. Public re-exports (RenderedLineInfo, sanitizeUrl, linkMetrics, applyRunDataAttributes) now resolve through runs/index.ts. No behavior change — pure colocation of the run pipeline ahead of unifying body and table-cell paragraph rendering. --- .../layout-engine/painters/dom/src/index.ts | 5 +- .../painters/dom/src/paragraph-hash-utils.ts | 116 +- .../painters/dom/src/renderer.ts | 2605 +---------------- .../dom/src/runs/field-annotation-run.ts | 238 ++ .../painters/dom/src/runs/formatting-marks.ts | 111 + .../painters/dom/src/runs/hash.ts | 170 ++ .../painters/dom/src/runs/image-run.ts | 385 +++ .../painters/dom/src/runs/index.ts | 18 + .../painters/dom/src/runs/links.ts | 430 +++ .../painters/dom/src/runs/math-run.ts | 38 + .../painters/dom/src/runs/render-line.ts | 737 +++++ .../painters/dom/src/runs/render-run.ts | 57 + .../painters/dom/src/runs/sdt.ts | 51 + .../painters/dom/src/runs/tab-run.ts | 95 + .../painters/dom/src/runs/text-run.ts | 268 ++ .../painters/dom/src/runs/tracked-changes.ts | 86 + .../painters/dom/src/runs/types.ts | 56 + 17 files changed, 2845 insertions(+), 2621 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/runs/field-annotation-run.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/formatting-marks.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/hash.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/image-run.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/index.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/links.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/math-run.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/render-line.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/render-run.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/sdt.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/tab-run.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/text-run.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/tracked-changes.ts create mode 100644 packages/layout-engine/painters/dom/src/runs/types.ts diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 5d91414acb..3207a7e360 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -40,10 +40,11 @@ export type { PaintSnapshotImageEntity, PaintSnapshotEntities, } from './renderer.js'; -export type { DomPainterInput, PositionMapping, RenderedLineInfo } from './renderer.js'; +export type { DomPainterInput, PositionMapping } from './renderer.js'; +export type { RenderedLineInfo } from './runs/index.js'; // Re-export utility functions for testing -export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './renderer.js'; +export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './runs/index.js'; export { applySquareWrapExclusionsToLines } from './utils/anchor-helpers'; export { buildImagePmSelector, buildInlineImagePmSelector } from './utils/image-selectors.js'; diff --git a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts index 55870ed7e9..57883ea6da 100644 --- a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts +++ b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts @@ -1,5 +1,4 @@ import type { - Run, ParagraphBorders, ParagraphBorder, TableBorders, @@ -82,108 +81,13 @@ export const hashCellBorders = (borders: CellBorders | undefined): string => { return parts.join(';'); }; -/** - * Type guard to check if a run has a string property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a string - */ -export const hasStringProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'string'; -}; - -/** - * Type guard to check if a run has a number property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a number - */ -export const hasNumberProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'number'; -}; - -/** - * Type guard to check if a run has a boolean property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a boolean - */ -export const hasBooleanProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'boolean'; -}; - -/** - * Safely gets a string property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The string value or empty string if not present - */ -export const getRunStringProp = (run: Run, prop: string): string => { - if (hasStringProp(run, prop)) { - return run[prop]; - } - return ''; -}; - -/** - * Safely gets a number property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The number value or 0 if not present - */ -export const getRunNumberProp = (run: Run, prop: string): number => { - if (hasNumberProp(run, prop)) { - return run[prop]; - } - return 0; -}; - -/** - * Safely gets a boolean property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The boolean value or false if not present - */ -export const getRunBooleanProp = (run: Run, prop: string): boolean => { - if (hasBooleanProp(run, prop)) { - return run[prop]; - } - return false; -}; - -/** - * Safely gets the underline style from a run. - * Handles the object-shaped underline property { style?, color? }. - * - * @param run - The run to get the underline style from - * @returns The underline style or empty string if not present - */ -export const getRunUnderlineStyle = (run: Run): string => { - if ('underline' in run && typeof run.underline === 'boolean') { - return run.underline ? 'single' : ''; - } - if ('underline' in run && run.underline && typeof run.underline === 'object') { - return (run.underline as { style?: string }).style ?? ''; - } - return ''; -}; - -/** - * Safely gets the underline color from a run. - * Handles the object-shaped underline property { style?, color? }. - * - * @param run - The run to get the underline color from - * @returns The underline color or empty string if not present - */ -export const getRunUnderlineColor = (run: Run): string => { - if ('underline' in run && run.underline && typeof run.underline === 'object') { - return (run.underline as { color?: string }).color ?? ''; - } - return ''; -}; +export { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, + hasBooleanProp, + hasNumberProp, + hasStringProp, +} from './runs/hash.js'; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 80286e6c27..0af259fcb4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -6,10 +6,8 @@ import type { DrawingFragment, DrawingGeometry, DropCapDescriptor, - FieldAnnotationRun, FlowBlock, FlowMode, - FlowRunLink, Fragment, GradientFill, ImageBlock, @@ -37,10 +35,7 @@ import type { TableCellAttrs, TableFragment, TableMeasure, - MathRun, TextRun, - TrackedChangeKind, - TrackedChangesMode, VectorShapeDrawing, VectorShapeStyle, ResolvedLayout, @@ -52,34 +47,17 @@ import type { ResolvedDrawingItem, } from '@superdoc/contracts'; import { - calculateJustifySpacing, - computeLinePmRange, expandRunsForInlineNewlines, getCellSpacingPx, getParagraphInlineDirection, normalizeColumnLayout, - normalizeBaselineShift, - resolveBaseFontSizeForVerticalText, - shouldApplyJustify, - sliceRunsForLine, - SPACE_CHARS, } from '@superdoc/contracts'; -import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; -import { - getRunBooleanProp, - getRunNumberProp, - getRunStringProp, - getRunUnderlineColor, - getRunUnderlineStyle, - hashCellBorders, - hashParagraphBorders, - hashTableBorders, -} from './paragraph-hash-utils.js'; -import { assertFragmentPmPositions, assertPmPositions } from './pm-position-validation.js'; +import { hashCellBorders, hashParagraphBorders, hashTableBorders } from './paragraph-hash-utils.js'; +import { assertFragmentPmPositions } from './pm-position-validation.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { BROWSER_DEFAULT_FONT_SIZE, @@ -95,7 +73,6 @@ import { ensureSdtContainerStyles, ensureTrackChangeStyles, fragmentStyles, - lineStyles, pageStyles, spreadStyles, type PageStyles, @@ -109,9 +86,18 @@ import { } from '@superdoc/common/list-marker-utils'; import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; -import { applyRtlStyles, shouldUseSegmentPositioning } from './features/rtl-paragraph/index.js'; -import { convertOmmlToMathml } from './features/math/index.js'; import { renderParagraphContent } from './paragraph/renderParagraphContent.js'; +import { renderLine as renderRunLine } from './runs/render-line.js'; +import type { RunRenderContext } from './runs/types.js'; +import { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, +} from './runs/hash.js'; +import { buildImageFilters } from './runs/image-run.js'; +import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; type LineEnd = { type?: string; @@ -736,449 +722,9 @@ function collectLineTabsForSnapshot(lineEl: HTMLElement): PaintSnapshotTabStyle[ const DEFAULT_PAGE_HEIGHT_PX = 1056; /** Default gap used when virtualization is enabled (kept in sync with PresentationEditor layout defaults). */ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; -// Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). - -type LinkRenderData = { - href?: string; - target?: string; - rel?: string; - tooltip?: string | null; - dataset?: Record; - blocked: boolean; -}; - -const LINK_DATASET_KEYS = { - blocked: 'linkBlocked', - docLocation: 'linkDocLocation', - history: 'linkHistory', - rId: 'linkRid', - truncated: 'linkTooltipTruncated', -} as const; - -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; - -/** - * Maximum resize multiplier for image metadata. - * Images can be resized up to 3x their original dimensions. - */ -const MAX_RESIZE_MULTIPLIER = 3; - -/** - * Fallback maximum dimension for image resizing when original size is small. - * Ensures images can be resized to at least 1000px even if original is smaller. - */ -const FALLBACK_MAX_DIMENSION = 1000; - -/** - * Minimum image dimension in pixels. - * Ensures images remain visible and interactive during resizing. - */ -const MIN_IMAGE_DIMENSION = 20; - -/** - * Pattern to detect ambiguous link text that doesn't convey destination (WCAG 2.4.4). - * Matches common generic phrases like "click here", "read more", etc. - */ -const AMBIGUOUS_LINK_PATTERNS = /^(click here|read more|more|link|here|this|download|view)$/i; - -/** - * Hyperlink rendering metrics for observability. - * Tracks sanitization, blocking, and security-related events. - */ -const linkMetrics = { - sanitized: 0, - blocked: 0, - invalidProtocol: 0, - homographWarnings: 0, - - reset() { - this.sanitized = 0; - this.blocked = 0; - this.invalidProtocol = 0; - this.homographWarnings = 0; - }, - - getMetrics() { - return { - 'hyperlink.sanitized.count': this.sanitized, - 'hyperlink.blocked.count': this.blocked, - 'hyperlink.invalid_protocol.count': this.invalidProtocol, - 'hyperlink.homograph_warnings.count': this.homographWarnings, - }; - }, -}; - -// Export for testing/monitoring -export { linkMetrics }; - -const TRACK_CHANGE_BASE_CLASS: Record = { - insert: 'track-insert-dec', - delete: 'track-delete-dec', - format: 'track-format-dec', -}; -// TRACK_CHANGE_FOCUSED_CLASS moved to CommentHighlightDecorator (super-editor). - -const TRACK_CHANGE_MODIFIER_CLASS: Record> = { - insert: { - review: 'highlighted', - original: 'hidden', - final: 'normal', - off: undefined, - }, - delete: { - review: 'highlighted', - original: 'normal', - final: 'hidden', - off: undefined, - }, - format: { - review: 'highlighted', - original: 'before', - final: 'normal', - off: undefined, - }, -}; - -type TrackedChangesRenderConfig = { - mode: TrackedChangesMode; - enabled: boolean; -}; - -/** - * Sanitize a URL to prevent XSS attacks. - * Only allows http, https, mailto, tel, and internal anchors. - * - * @param href - The URL to sanitize - * @returns Sanitized URL or null if blocked - */ -export function sanitizeUrl(href: string): string | null { - if (typeof href !== 'string') return null; - const sanitized = sanitizeHref(href); - return sanitized?.href ?? null; -} - -const LINK_TARGET_SET = new Set(['_blank', '_self', '_parent', '_top']); - -/** - * Normalize and validate an anchor fragment identifier for use in hyperlinks. - * Strips leading '#' if present and validates against safe character pattern. - * - * @param value - Raw anchor string (with or without leading '#') - * @returns Normalized anchor with leading '#' (e.g., '#section-1'), or null if invalid - * - * @remarks - * SECURITY: Only allows safe characters (A-Z, a-z, 0-9, ., _, -) to prevent HTML attribute injection. - * Rejects characters like quotes, angle brackets, colons, and spaces that could break HTML structure - * or enable XSS attacks when used in href attributes. - * - * @example - * normalizeAnchor('section-1') // Returns: '#section-1' - * normalizeAnchor('#bookmark') // Returns: '#bookmark' - * normalizeAnchor('unsafe