diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ae68f933f..850f8e2ef 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -75,6 +75,7 @@ import { type SdtBoundaryOptions, } from './utils/sdt-helpers.js'; import { SdtGroupedHover } from './utils/sdt-hover.js'; +import { computeTabWidth } from './utils/marker-helpers.js'; import { generateRulerDefinitionFromPx, createRulerElement, ensureRulerStyles } from './ruler/index.js'; import { toCssFontFamily } from '@superdoc/font-utils'; import { @@ -368,12 +369,6 @@ export type FragmentRenderContext = { }; const LIST_MARKER_GAP = 8; -/** - * Default tab interval in pixels (0.5 inch at 96 DPI). - * Used when calculating tab stops for list markers that extend past the implicit tab stop. - * This matches Microsoft Word's default tab interval behavior. - */ -const DEFAULT_TAB_INTERVAL_PX = 48; /** * Default page height in pixels (11 inches at 96 DPI). * Used as a fallback when page size information is not available for ruler rendering. @@ -6331,57 +6326,3 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { } return run.text ?? ''; }; - -const computeTabWidth = ( - currentPos: number, - justification: string, - tabs: number[] | undefined, - hangingIndent: number | undefined, - firstLineIndent: number | undefined, - leftIndent: number, -): number => { - const nextDefaultTabStop = currentPos + DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); - let tabWidth: number; - if ((justification ?? 'left') === 'left') { - // Check for explicit tab stops past current position - const explicitTabs = [...(tabs ?? [])]; - if (hangingIndent && hangingIndent > 0) { - // Account for hanging indent by adding an implicit tab stop at (left + hanging) - const implicitTabPos = leftIndent; // paraIndentLeft already accounts for hanging - explicitTabs.push(implicitTabPos); - // Sort tab stops to maintain order - explicitTabs.sort((a, b) => { - if (typeof a === 'number' && typeof b === 'number') { - return a - b; - } - return 0; - }); - } - let targetTabStop: number | undefined; - - if (Array.isArray(explicitTabs) && explicitTabs.length > 0) { - // Find the first tab stop that's past the current position - for (const tab of explicitTabs) { - if (typeof tab === 'number' && tab > currentPos) { - targetTabStop = tab; - break; - } - } - } - - if (targetTabStop === undefined) { - // advance to next default 48px tab interval, matching Word behavior. - targetTabStop = nextDefaultTabStop; - } - tabWidth = targetTabStop - currentPos; - } else if (justification === 'right') { - if (firstLineIndent != null && firstLineIndent > 0) { - tabWidth = nextDefaultTabStop - currentPos; - } else { - tabWidth = hangingIndent ?? 0; - } - } else { - tabWidth = nextDefaultTabStop - currentPos; - } - return tabWidth; -}; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c694c4d7c..185d28450 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -30,6 +30,7 @@ import { type SdtBoundaryOptions, } from '../utils/sdt-helpers.js'; import { normalizeZIndex } from '@superdoc/pm-adapter/utilities.js'; +import { computeTabWidth } from '../utils/marker-helpers.js'; /** * Default gap between list marker and text content in pixels. @@ -68,6 +69,7 @@ type WordLayoutMarker = { /** Letter spacing in pixels */ letterSpacing?: number; }; + suffix?: 'tab' | 'space' | 'nothing'; }; /** @@ -100,6 +102,12 @@ type MarkerRenderParams = { 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[]; }; /** @@ -145,30 +153,39 @@ type TableCellIndentParams = { * @returns Container element with marker and line as children */ function renderListMarker(params: MarkerRenderParams): HTMLElement { - const { doc, lineEl, markerLayout, markerMeasure, indentLeftPx } = params; + const { doc, lineEl, markerLayout, markerMeasure, indentLeftPx, hangingIndentPx, firstLineIndentPx, tabsPx } = params; - const markerJustification = markerLayout?.justification ?? 'left'; - - // Extract marker box width with fallback chain: layout -> measure -> 0 - const markerBoxWidth = - (typeof markerLayout?.markerBoxWidthPx === 'number' ? markerLayout.markerBoxWidthPx : undefined) ?? - markerMeasure?.markerWidth ?? - 0; + const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; - // Extract gutter width with fallback chain: layout -> measure -> default gap - const gutter = - (typeof markerLayout?.gutterWidthPx === 'number' ? markerLayout.gutterWidthPx : undefined) ?? - markerMeasure?.gutterWidth ?? - LIST_MARKER_GAP; - - // Calculate marker start position based on justification - const markerStartPos = - markerJustification === 'left' - ? indentLeftPx - : ((typeof markerLayout?.markerX === 'number' ? markerLayout.markerX : undefined) ?? indentLeftPx); + 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; + } - // Marker left position is marker start minus the width of the marker box - const markerLeftPos = markerStartPos - markerBoxWidth; + const suffix = markerLayout?.suffix ?? 'tab'; + let listTabWidth = 0; + if (suffix === 'tab') { + listTabWidth = computeTabWidth( + currentPos, + markerJustification, + tabsPx, + hangingIndentPx, + firstLineIndentPx, + indentLeftPx, + ); + } else if (suffix === 'space') { + listTabWidth = 4; + } // Create container to hold both marker and line const lineContainer = doc.createElement('div'); @@ -193,17 +210,39 @@ function renderListMarker(params: MarkerRenderParams): HTMLElement { markerEl.style.letterSpacing = `${markerLayout.run.letterSpacing}px`; } - // Position marker absolutely within the container - markerEl.style.position = 'absolute'; - markerEl.style.left = `${markerLeftPos}px`; - markerEl.style.width = `${markerBoxWidth}px`; - markerEl.style.textAlign = markerJustification; - markerEl.style.paddingRight = `${gutter}px`; - - // Align text start to the marker start position (gutter spacing comes from marker padding) - lineEl.style.paddingLeft = `${markerStartPos}px`; + // Position marker within the container + if (['center', 'right'].includes(markerJustification)) { + lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft || '0') + currentPos + listTabWidth + 'px'; + if (markerJustification === 'right') { + markerEl.style.position = 'absolute'; + markerEl.style.left = `${markerStartPos}px`; + } else { + markerEl.style.position = 'absolute'; + markerEl.style.left = `${markerStartPos - markerTextWidth / 2}px`; + } + } + lineEl.style.display = 'inline-block'; + + // Add separator + let separatorEl; + if (suffix === 'tab') { + separatorEl = doc.createElement('span'); + separatorEl.className = 'superdoc-tab'; + separatorEl.innerHTML = ' '; + separatorEl.style.display = 'inline-block'; + separatorEl.style.wordSpacing = '0px'; + separatorEl.style.width = `${listTabWidth}px`; + } else if (suffix === 'space') { + separatorEl = doc.createElement('span'); + separatorEl.className = 'superdoc-marker-suffix-space'; + separatorEl.style.wordSpacing = '0px'; + separatorEl.textContent = '\u00A0'; + } lineContainer.appendChild(markerEl); + if (separatorEl) { + lineContainer.appendChild(separatorEl); + } lineContainer.appendChild(lineEl); return lineContainer; @@ -887,6 +926,10 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen 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; // Calculate the global line indices for this block @@ -910,6 +953,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // Create wrapper for this paragraph's SDT metadata // Use absolute positioning within the content container to stack blocks vertically const paraWrapper = doc.createElement('div'); + paraWrapper.classList.add('superdoc-table-paragraph'); paraWrapper.style.position = 'relative'; paraWrapper.style.left = '0'; paraWrapper.style.width = '100%'; @@ -978,6 +1022,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen markerLayout, markerMeasure, indentLeftPx, + hangingIndentPx, + firstLineIndentPx, }); renderedLines.push({ el: lineContainer, top: lineTop, height: line.lineHeight }); paraWrapper.appendChild(lineContainer); diff --git a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts new file mode 100644 index 000000000..55e9b0032 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts @@ -0,0 +1,60 @@ +/** + * Default tab interval in pixels (0.5 inch at 96 DPI). + * Used when calculating tab stops for list markers that extend past the implicit tab stop. + * This matches Microsoft Word's default tab interval behavior. + */ +const DEFAULT_TAB_INTERVAL_PX = 48; + +export const computeTabWidth = ( + currentPos: number, + justification: string, + tabs: number[] | undefined, + hangingIndent: number | undefined, + firstLineIndent: number | undefined, + leftIndent: number, +): number => { + const nextDefaultTabStop = currentPos + DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); + let tabWidth: number; + if ((justification ?? 'left') === 'left') { + // Check for explicit tab stops past current position + const explicitTabs = [...(tabs ?? [])]; + if (hangingIndent && hangingIndent > 0) { + // Account for hanging indent by adding an implicit tab stop at (left + hanging) + const implicitTabPos = leftIndent; // paraIndentLeft already accounts for hanging + explicitTabs.push(implicitTabPos); + // Sort tab stops to maintain order + explicitTabs.sort((a, b) => { + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + return 0; + }); + } + let targetTabStop: number | undefined; + + if (Array.isArray(explicitTabs) && explicitTabs.length > 0) { + // Find the first tab stop that's past the current position + for (const tab of explicitTabs) { + if (typeof tab === 'number' && tab > currentPos) { + targetTabStop = tab; + break; + } + } + } + + if (targetTabStop === undefined) { + // advance to next default 48px tab interval, matching Word behavior. + targetTabStop = nextDefaultTabStop; + } + tabWidth = targetTabStop - currentPos; + } else if (justification === 'right') { + if (firstLineIndent != null && firstLineIndent > 0) { + tabWidth = nextDefaultTabStop - currentPos; + } else { + tabWidth = hangingIndent ?? 0; + } + } else { + tabWidth = nextDefaultTabStop - currentPos; + } + return tabWidth; +};