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/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/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts new file mode 100644 index 0000000000..19ffa2f086 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -0,0 +1,230 @@ +import type { ImageRun, ParagraphAttrs, ParagraphBlock, TextRun } from '@superdoc/contracts'; +import { getParagraphInlineDirection } from '@superdoc/contracts'; +import { hashParagraphBorders } from '../paragraph-hash-utils.js'; +import { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, +} from '../runs/hash.js'; + +type ParagraphHashFns = { + hashString: (seed: number, value: string) => number; + hashNumber: (seed: number, value: number | undefined | null) => number; +}; + +const hasListMarkerProperties = ( + attrs: unknown, +): attrs is { + numberingProperties: { numId?: number | string; ilvl?: number }; + wordLayout?: { marker?: { markerText?: string } }; +} => { + if (!attrs || typeof attrs !== 'object') return false; + const obj = attrs as Record; + + if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false; + const numProps = obj.numberingProperties as Record; + + if ('numId' in numProps) { + const numId = numProps.numId; + if (typeof numId !== 'number' && typeof numId !== 'string') return false; + } + + if ('ilvl' in numProps) { + const ilvl = numProps.ilvl; + if (typeof ilvl !== 'number') return false; + } + + if ('wordLayout' in obj && obj.wordLayout !== undefined) { + if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false; + const wordLayout = obj.wordLayout as Record; + + if ('marker' in wordLayout && wordLayout.marker !== undefined) { + if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false; + const marker = wordLayout.marker as Record; + + if ('markerText' in marker && marker.markerText !== undefined) { + if (typeof marker.markerText !== 'string') return false; + } + } + } + + return true; +}; + +export const deriveParagraphBlockVersion = ( + block: ParagraphBlock, + getSdtMetadataVersion: (metadata: ParagraphAttrs['sdt']) => string, + readClipPathValue: (value: unknown) => string, +): string => { + const markerVersion = hasListMarkerProperties(block.attrs) + ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}` + : ''; + + const runsVersion = block.runs + .map((run) => { + // Paragraph-level cache keys intentionally exclude run pmStart/pmEnd; position-only edits update datasets in place. + if (run.kind === 'image') { + const imgRun = run as ImageRun; + return [ + 'img', + imgRun.src, + imgRun.width, + imgRun.height, + imgRun.alt ?? '', + imgRun.title ?? '', + imgRun.clipPath ?? '', + imgRun.distTop ?? '', + imgRun.distBottom ?? '', + imgRun.distLeft ?? '', + imgRun.distRight ?? '', + readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + ].join(','); + } + + if (run.kind === 'lineBreak') { + return 'linebreak'; + } + + if (run.kind === 'tab') { + return [run.text ?? '', 'tab'].join(','); + } + + if (run.kind === 'fieldAnnotation') { + const size = run.size ? `${run.size.width ?? ''}x${run.size.height ?? ''}` : ''; + const highlighted = run.highlighted !== false ? 1 : 0; + return [ + 'field', + run.variant ?? '', + run.displayLabel ?? '', + run.fieldColor ?? '', + run.borderColor ?? '', + highlighted, + run.hidden ? 1 : 0, + run.visibility ?? '', + run.imageSrc ?? '', + run.linkUrl ?? '', + run.rawHtml ?? '', + size, + run.fontFamily ?? '', + run.fontSize ?? '', + run.textColor ?? '', + run.textHighlight ?? '', + run.bold ? 1 : 0, + run.italic ? 1 : 0, + run.underline ? 1 : 0, + run.fieldId ?? '', + run.fieldType ?? '', + ].join(','); + } + + const textRun = run as TextRun; + const trackedChangeVersion = textRun.trackedChange + ? [ + textRun.trackedChange.kind ?? '', + textRun.trackedChange.id ?? '', + textRun.trackedChange.storyKey ?? '', + textRun.trackedChange.author ?? '', + textRun.trackedChange.authorEmail ?? '', + textRun.trackedChange.authorImage ?? '', + textRun.trackedChange.date ?? '', + textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '', + textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '', + ].join(':') + : ''; + return [ + textRun.text ?? '', + textRun.fontFamily, + textRun.fontSize, + textRun.bold ? 1 : 0, + textRun.italic ? 1 : 0, + textRun.color ?? '', + textRun.underline?.style ?? '', + textRun.underline?.color ?? '', + textRun.strike ? 1 : 0, + textRun.highlight ?? '', + textRun.letterSpacing != null ? textRun.letterSpacing : '', + textRun.vertAlign ?? '', + textRun.baselineShift != null ? textRun.baselineShift : '', + textRun.token ?? '', + trackedChangeVersion, + textRun.comments?.length ?? 0, + ].join(','); + }) + .join('|'); + + const attrs = block.attrs as ParagraphAttrs | undefined; + const paragraphAttrsVersion = attrs + ? [ + attrs.alignment ?? '', + attrs.spacing?.before ?? '', + attrs.spacing?.after ?? '', + attrs.spacing?.line ?? '', + attrs.spacing?.lineRule ?? '', + attrs.indent?.left ?? '', + attrs.indent?.right ?? '', + attrs.indent?.firstLine ?? '', + attrs.indent?.hanging ?? '', + attrs.borders ? hashParagraphBorders(attrs.borders) : '', + attrs.shading?.fill ?? '', + attrs.shading?.color ?? '', + getParagraphInlineDirection(attrs) ?? '', + attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '', + ].join(':') + : ''; + + const sdtVersion = getSdtMetadataVersion(attrs?.sdt); + const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean); + return parts.join('|'); +}; + +export const hashParagraphBlockForTableVersion = ( + seed: number, + paragraphBlock: ParagraphBlock, + hashFns: ParagraphHashFns, +): number => { + const { hashNumber, hashString } = hashFns; + const runs = paragraphBlock.runs ?? []; + let hash = hashNumber(seed, runs.length); + const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined; + + if (attrs) { + hash = hashString(hash, attrs.alignment ?? ''); + hash = hashNumber(hash, attrs.spacing?.before ?? 0); + hash = hashNumber(hash, attrs.spacing?.after ?? 0); + hash = hashNumber(hash, attrs.spacing?.line ?? 0); + hash = hashString(hash, attrs.spacing?.lineRule ?? ''); + hash = hashNumber(hash, attrs.indent?.left ?? 0); + hash = hashNumber(hash, attrs.indent?.right ?? 0); + hash = hashNumber(hash, attrs.indent?.firstLine ?? 0); + hash = hashNumber(hash, attrs.indent?.hanging ?? 0); + hash = hashString(hash, attrs.shading?.fill ?? ''); + hash = hashString(hash, attrs.shading?.color ?? ''); + hash = hashString(hash, getParagraphInlineDirection(attrs) ?? ''); + if (attrs.borders) { + hash = hashString(hash, hashParagraphBorders(attrs.borders)); + } + } + + for (const run of runs) { + if ('text' in run && typeof run.text === 'string') { + hash = hashString(hash, run.text); + } + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + hash = hashString(hash, getRunStringProp(run, 'color')); + hash = hashString(hash, getRunStringProp(run, 'highlight')); + hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : ''); + hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : ''); + hash = hashNumber(hash, getRunNumberProp(run, 'fontSize')); + hash = hashString(hash, getRunStringProp(run, 'fontFamily')); + hash = hashString(hash, getRunUnderlineStyle(run)); + hash = hashString(hash, getRunUnderlineColor(run)); + hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); + hash = hashString(hash, getRunStringProp(run, 'vertAlign')); + hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + } + + return hash; +}; 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/frame.ts b/packages/layout-engine/painters/dom/src/paragraph/frame.ts new file mode 100644 index 0000000000..9e3b940543 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/frame.ts @@ -0,0 +1,43 @@ +import type { ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts'; +import { assertFragmentPmPositions } from '../pm-position-validation.js'; + +export type FragmentFrameSection = 'body' | 'header' | 'footer'; + +export const applyParagraphFragmentPmAttributes = ( + el: HTMLElement, + fragment: ParaFragment, + section?: FragmentFrameSection, + resolvedItem?: ResolvedFragmentItem, +): void => { + if (section === 'body' || section === undefined) { + assertFragmentPmPositions(fragment, 'paragraph fragment'); + } + + const pmStart = resolvedItem ? resolvedItem.pmStart : fragment.pmStart; + if (pmStart != null) { + el.dataset.pmStart = String(pmStart); + } else { + delete el.dataset.pmStart; + } + + const pmEnd = resolvedItem ? resolvedItem.pmEnd : fragment.pmEnd; + if (pmEnd != null) { + el.dataset.pmEnd = String(pmEnd); + } else { + delete el.dataset.pmEnd; + } + + const continuesFromPrev = resolvedItem ? resolvedItem.continuesFromPrev : fragment.continuesFromPrev; + if (continuesFromPrev) { + el.dataset.continuesFromPrev = 'true'; + } else { + delete el.dataset.continuesFromPrev; + } + + const continuesOnNext = resolvedItem ? resolvedItem.continuesOnNext : fragment.continuesOnNext; + if (continuesOnNext) { + el.dataset.continuesOnNext = 'true'; + } else { + delete el.dataset.continuesOnNext; + } +}; 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..2746a8b24c --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -0,0 +1,299 @@ +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { toCssFontFamily } from '@superdoc/font-utils'; +import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts'; +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; + lineEl: HTMLElement; + wordLayout?: MinimalWordLayout; + markerLayout: MinimalMarker; + 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.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts new file mode 100644 index 0000000000..f13f33f338 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest'; +import { renderParagraphContent } from './renderParagraphContent.js'; +import type { Line, ParagraphBlock, ParagraphMeasure, ResolvedParagraphContent } 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'); + }); + + 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 }, + ]); + }); + + 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'); + }); + + 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('¤'); + }); +}); 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..fe032d0001 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -0,0 +1,522 @@ +import type { + DropCapDescriptor, + Line, + ParagraphBlock, + ParagraphMeasure, + ResolvedParagraphContent, + Run, + SdtMetadata, + SourceAnchor, +} from '@superdoc/contracts'; +import { + effectiveTableCellSpacing, + expandRunsForInlineNewlines, + getParagraphInlineDirection, +} from '@superdoc/contracts'; +import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; +import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; +import { + applyParagraphLineIndentation, + hasExplicitSegmentPositioning, + resolveAvailableWidthForLine, +} from './indentation.js'; +import { renderLegacyListMarker, renderResolvedListMarker, resolvePainterListTextStartPx } 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; + 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, + width, + localStartLine, + localEndLine, + lineIndexOffset = 0, + continuesFromPrev, + continuesOnNext, + resolvedContent, + betweenInfo, + sdtBoundary, + spacingPolicy, + shouldApplySdtContainerStyling, + applySdtDataset, + applyContainerSdtDataset, + renderDropCap, + lineTopOffset = 0, + } = 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 originalLineCount = measure.lines?.length ?? linesOverride?.length ?? 0; + const renderedStartLine = lineIndexOffset + localStartLine; + const renderedEndLine = lineIndexOffset + localEndLine; + const renderedEntireBlock = + !continuesFromPrev && !continuesOnNext && renderedStartLine === 0 && renderedEndLine >= originalLineCount; + 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[] = []; + const renderedLocalEndLine = Math.min(localEndLine, lines.length); + + 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 = + (linesOverride + ? lineIdx === renderedLocalEndLine - 1 + : 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) { + if (paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } + 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/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts new file mode 100644 index 0000000000..671bbdb2e3 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -0,0 +1,188 @@ +import type { + DropCapDescriptor, + ParaFragment, + ParagraphBlock, + ParagraphMeasure, + ResolvedFragmentItem, + SdtMetadata, +} from '@superdoc/contracts'; +import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; +import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import type { SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import type { BetweenBorderInfo } from './borders/index.js'; +import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; + +type ApplyStyles = (el: HTMLElement, styles: Partial) => void; + +type RenderParagraphFragmentParams = { + doc: Document | null; + fragment: ParaFragment; + sdtBoundary?: SdtBoundaryOptions; + betweenInfo?: BetweenBorderInfo; + resolvedItem?: ResolvedFragmentItem; + applyStyles: ApplyStyles; + applyResolvedFragmentFrame: (el: HTMLElement, item: ResolvedFragmentItem, fragment: ParaFragment) => void; + applyFragmentFrame: (el: HTMLElement, fragment: ParaFragment) => void; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + applyContainerSdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + renderLine: (input: ParagraphRenderLineInput) => HTMLElement; + captureLineSnapshot: (lineEl: HTMLElement, options?: { sourceAnchor?: ResolvedFragmentItem['sourceAnchor'] }) => void; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +const isMinimalWordLayout = (value: unknown): value is MinimalWordLayout => isMinimalWordLayoutShared(value); + +export const renderParagraphFragment = (params: RenderParagraphFragmentParams): HTMLElement => { + const { + doc, + fragment, + sdtBoundary, + betweenInfo, + resolvedItem, + applyStyles, + applyResolvedFragmentFrame, + applyFragmentFrame, + applySdtDataset, + applyContainerSdtDataset, + renderLine, + captureLineSnapshot, + createErrorPlaceholder, + } = params; + + try { + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + if (resolvedItem?.block?.kind !== 'paragraph' || resolvedItem?.measure?.kind !== 'paragraph') { + throw new Error(`DomPainter: missing resolved paragraph block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ParagraphBlock; + const measure = resolvedItem.measure as ParagraphMeasure; + const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; + const content = resolvedItem?.content; + + const paraContinuesFromPrev = resolvedItem?.continuesFromPrev; + const paraContinuesOnNext = resolvedItem?.continuesOnNext; + const paraMarkerWidth = resolvedItem?.markerWidth; + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment); + + const isTocEntry = block.attrs?.isTocEntry; + const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; + const hasSdtContainer = + block.attrs?.sdt?.type === 'documentSection' || + block.attrs?.sdt?.type === 'structuredContent' || + block.attrs?.containerSdt?.type === 'documentSection' || + block.attrs?.containerSdt?.type === 'structuredContent'; + const paraIndentForOverflow = block.attrs?.indent; + const hasNegativeIndent = (paraIndentForOverflow?.left ?? 0) < 0 || (paraIndentForOverflow?.right ?? 0) < 0; + const styles = isTocEntry + ? { ...fragmentStyles, whiteSpace: 'nowrap' } + : hasMarker || hasSdtContainer || hasNegativeIndent + ? { ...fragmentStyles, overflow: 'visible' } + : fragmentStyles; + applyStyles(fragmentEl, styles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment); + } else { + applyFragmentFrame(fragmentEl, fragment); + } + + if (isTocEntry) { + fragmentEl.classList.add('superdoc-toc-entry'); + } + + if (paraContinuesFromPrev) { + fragmentEl.dataset.continuesFromPrev = 'true'; + } + if (paraContinuesOnNext) { + fragmentEl.dataset.continuesOnNext = 'true'; + } + + const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); + renderParagraphContent({ + doc, + frameEl: fragmentEl, + block, + measure, + containerKind: 'body-fragment', + width: fragment.width, + localStartLine: 0, + localEndLine: lines.length, + lineIndexOffset: fragment.fromLine, + linesOverride: lines, + continuesFromPrev: paraContinuesFromPrev, + continuesOnNext: paraContinuesOnNext, + markerWidth: paraMarkerWidth, + markerTextWidth: fragment.markerTextWidth, + wordLayout, + resolvedContent: content, + betweenInfo, + sdtBoundary, + applySdtDataset, + applyContainerSdtDataset, + renderDropCap: (descriptor, dropCapMeasure) => renderDropCap(doc, descriptor, dropCapMeasure), + renderLine, + captureLineSnapshot: (lineEl, options) => { + captureLineSnapshot(lineEl, { + sourceAnchor: options?.sourceAnchor, + }); + }, + sourceAnchor: resolvedItem?.sourceAnchor, + }); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; + +const renderDropCap = ( + doc: Document, + descriptor: DropCapDescriptor, + measure: ParagraphMeasure['dropCap'], +): HTMLElement => { + const { run, mode } = descriptor; + + const dropCapEl = doc.createElement('span'); + dropCapEl.classList.add('superdoc-drop-cap'); + dropCapEl.textContent = run.text; + + dropCapEl.style.fontFamily = run.fontFamily; + dropCapEl.style.fontSize = `${run.fontSize}px`; + if (run.bold) { + dropCapEl.style.fontWeight = 'bold'; + } + if (run.italic) { + dropCapEl.style.fontStyle = 'italic'; + } + if (run.color) { + dropCapEl.style.color = run.color; + } + + if (mode === 'drop') { + dropCapEl.style.float = 'left'; + dropCapEl.style.marginRight = '4px'; + dropCapEl.style.lineHeight = '1'; + } else if (mode === 'margin') { + dropCapEl.style.position = 'absolute'; + dropCapEl.style.left = '0'; + dropCapEl.style.lineHeight = '1'; + } + + if (run.position && run.position !== 0) { + dropCapEl.style.position = dropCapEl.style.position || 'relative'; + dropCapEl.style.top = `${run.position}px`; + } + + if (measure) { + dropCapEl.style.width = `${measure.width}px`; + dropCapEl.style.height = `${measure.height}px`; + } + + return dropCapEl; +}; 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..890679e6da 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5,25 +5,19 @@ import type { DrawingBlock, DrawingFragment, DrawingGeometry, - DropCapDescriptor, - FieldAnnotationRun, FlowBlock, FlowMode, - FlowRunLink, Fragment, GradientFill, ImageBlock, ImageDrawing, ImageFragment, ImageHyperlink, - ImageRun, Line, LineSegment, PageMargins, ParaFragment, - ParagraphAttrs, ParagraphBlock, - ParagraphMeasure, PositionedDrawingGeometry, Run, SdtMetadata, @@ -37,10 +31,7 @@ import type { TableCellAttrs, TableFragment, TableMeasure, - MathRun, TextRun, - TrackedChangeKind, - TrackedChangesMode, VectorShapeDrawing, VectorShapeStyle, ResolvedLayout, @@ -50,38 +41,13 @@ import type { ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, - ResolvedListMarkerItem, } from '@superdoc/contracts'; -import { - adjustAvailableWidthForTextIndent, - calculateJustifySpacing, - computeLinePmRange, - expandRunsForInlineNewlines, - getCellSpacingPx, - getParagraphInlineDirection, - normalizeColumnLayout, - normalizeBaselineShift, - resolveBaseFontSizeForVerticalText, - shouldApplyJustify, - sliceRunsForLine, - SPACE_CHARS, -} from '@superdoc/contracts'; -import { toCssFontFamily } from '@superdoc/font-utils'; +import { expandRunsForInlineNewlines, getCellSpacingPx, normalizeColumnLayout } from '@superdoc/contracts'; 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, hashTableBorders } from './paragraph-hash-utils.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { BROWSER_DEFAULT_FONT_SIZE, @@ -97,7 +63,6 @@ import { ensureSdtContainerStyles, ensureTrackChangeStyles, fragmentStyles, - lineStyles, pageStyles, spreadStyles, type PageStyles, @@ -105,80 +70,15 @@ 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 { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; -import { - computeBetweenBorderFlags, - createParagraphDecorationLayers, - stampBetweenBorderDataset, - type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; -import { applyRtlStyles, shouldUseSegmentPositioning } from './features/rtl-paragraph/index.js'; -import { convertOmmlToMathml } from './features/math/index.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[]; -}; +import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; +import { deriveParagraphBlockVersion, hashParagraphBlockForTableVersion } from './paragraph/block-version.js'; +import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; +import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; +import { renderLine as renderRunLine } from './runs/render-line.js'; +import type { RunRenderContext } from './runs/types.js'; +import { buildImageFilters } from './runs/image-run.js'; +import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; type LineEnd = { type?: string; @@ -203,14 +103,6 @@ type VectorShapeDrawingWithEffects = VectorShapeDrawing & { effectExtent?: EffectExtent; }; -/** - * Type guard narrowing to the renderer-local MinimalWordLayout type. - * Delegates structural validation to the shared isMinimalWordLayout guard. - */ -function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { - return isMinimalWordLayoutShared(value); -} - /** * Layout mode for document rendering. * @typedef {('vertical'|'horizontal'|'book')} LayoutMode @@ -803,449 +695,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