Skip to content

Commit 6cec2bb

Browse files
committed
Merge branch 'main' into nick/sd-2271-add-non-body-story-targeting-to-the-document-api
2 parents 1518a7b + e0982da commit 6cec2bb

8 files changed

Lines changed: 2214 additions & 118 deletions

File tree

packages/layout-engine/layout-bridge/src/index.ts

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ParagraphMeasure,
1515
} from '@superdoc/contracts';
1616
import { computeLinePmRange as computeLinePmRangeUnified, effectiveTableCellSpacing } from '@superdoc/contracts';
17+
import { describeCellRenderBlocks, computeCellSliceContentHeight, getEmbeddedRowLines } from '@superdoc/layout-engine';
1718
import { charOffsetToPm, findCharacterAtX, measureCharacterX } from './text-measurement.js';
1819
import { clickToPositionDom, findPageElement } from './dom-mapping.js';
1920
import {
@@ -1381,6 +1382,32 @@ const getCellMeasures = (cell: TableCellMeasure | undefined) => {
13811382
return cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []);
13821383
};
13831384

1385+
/**
1386+
* Count the number of segments a measured block contributes to getCellLines().
1387+
* Used to advance the global line counter past non-paragraph blocks so that
1388+
* paragraph line ranges stay aligned with the full global index space.
1389+
*/
1390+
const countBlockSegments = (measure: {
1391+
kind: string;
1392+
rows?: { cells: unknown[] }[];
1393+
height?: number;
1394+
lines?: unknown[];
1395+
}): number => {
1396+
if (measure.kind === 'paragraph') {
1397+
return (measure as ParagraphMeasure).lines?.length ?? 0;
1398+
}
1399+
if (measure.kind === 'table') {
1400+
let count = 0;
1401+
for (const row of (measure as TableMeasure).rows) {
1402+
count += getEmbeddedRowLines(row).length;
1403+
}
1404+
return count;
1405+
}
1406+
// Image, drawing, other: 1 segment if height > 0
1407+
const h = typeof measure.height === 'number' ? measure.height : 0;
1408+
return h > 0 ? 1 : 0;
1409+
};
1410+
13841411
const sumLineHeights = (measure: ParagraphMeasure, fromLine: number, toLine: number) => {
13851412
let height = 0;
13861413
for (let i = fromLine; i < toLine && i < measure.lines.length; i += 1) {
@@ -1657,33 +1684,52 @@ export function selectionToRects(
16571684
const cellBlocks = getCellBlocks(cell);
16581685
const cellBlockMeasures = getCellMeasures(cellMeasure);
16591686

1660-
// Map each block to its global line range within the cell
1687+
// Build block descriptors for renderer-semantic content height.
1688+
// This fixes the spacing.after bug where the old code used measurement
1689+
// semantics (effectiveTableCellSpacing) for the last block, but the
1690+
// renderer skips spacing.after entirely for the last block.
1691+
const cellRenderBlocks = describeCellRenderBlocks(cellMeasure, cell, padding);
1692+
const totalCellLines =
1693+
cellRenderBlocks.length > 0 ? cellRenderBlocks[cellRenderBlocks.length - 1].globalEndLine : 0;
1694+
const cellAllowedStart = partialRowData?.fromLineByCell?.[cellIdx] ?? 0;
1695+
const rawCellAllowedEnd = partialRowData?.toLineByCell?.[cellIdx];
1696+
const cellAllowedEnd =
1697+
rawCellAllowedEnd == null || rawCellAllowedEnd === -1 ? totalCellLines : rawCellAllowedEnd;
1698+
1699+
// Map each paragraph block to its global line range within the cell.
1700+
// cumulativeLine must advance for ALL block types (not just paragraphs)
1701+
// so that paragraph line ranges align with the global index space used
1702+
// by cellAllowedStart/cellAllowedEnd and computeCellSliceContentHeight.
16611703
const renderedBlocks: Array<{
16621704
block: ParagraphBlock;
16631705
measure: ParagraphMeasure;
16641706
startLine: number;
16651707
endLine: number;
16661708
height: number;
1709+
originalBlockIndex: number;
1710+
globalBlockStart: number;
16671711
}> = [];
16681712

16691713
let cumulativeLine = 0;
1670-
for (let i = 0; i < Math.min(cellBlocks.length, cellBlockMeasures.length); i += 1) {
1714+
const blockCount = Math.min(cellBlocks.length, cellBlockMeasures.length);
1715+
for (let i = 0; i < blockCount; i += 1) {
16711716
const paraBlock = cellBlocks[i];
16721717
const paraMeasure = cellBlockMeasures[i];
16731718
if (!paraBlock || !paraMeasure || paraBlock.kind !== 'paragraph' || paraMeasure.kind !== 'paragraph') {
1719+
// Advance cumulativeLine past non-paragraph segments to stay
1720+
// aligned with getCellLines() / describeCellRenderBlocks().
1721+
if (paraMeasure) {
1722+
cumulativeLine += countBlockSegments(paraMeasure);
1723+
}
16741724
continue;
16751725
}
16761726
const lineCount = paraMeasure.lines.length;
16771727
const blockStart = cumulativeLine;
16781728
const blockEnd = cumulativeLine + lineCount;
16791729
cumulativeLine = blockEnd;
16801730

1681-
const allowedStart = partialRowData?.fromLineByCell?.[cellIdx] ?? 0;
1682-
const rawAllowedEnd = partialRowData?.toLineByCell?.[cellIdx];
1683-
const allowedEnd = rawAllowedEnd == null || rawAllowedEnd === -1 ? cumulativeLine : rawAllowedEnd;
1684-
1685-
const renderStartGlobal = Math.max(blockStart, allowedStart);
1686-
const renderEndGlobal = Math.min(blockEnd, allowedEnd);
1731+
const renderStartGlobal = Math.max(blockStart, cellAllowedStart);
1732+
const renderEndGlobal = Math.min(blockEnd, cellAllowedEnd);
16871733
if (renderStartGlobal >= renderEndGlobal) continue;
16881734

16891735
const startLine = renderStartGlobal - blockStart;
@@ -1697,17 +1743,32 @@ export function selectionToRects(
16971743
height = totalHeight;
16981744
}
16991745
const isFirstBlock = i === 0;
1700-
const isLastBlock = i === cellBlocks.length - 1;
17011746
const spacingBefore = (paraBlock as ParagraphBlock).attrs?.spacing?.before;
17021747
height += effectiveTableCellSpacing(spacingBefore, isFirstBlock, padding.top);
1703-
const spacingAfter = (paraBlock as ParagraphBlock).attrs?.spacing?.after;
1704-
height += effectiveTableCellSpacing(spacingAfter, isLastBlock, padding.bottom);
1748+
// Match renderer: skip spacing.after for the last block
1749+
const isLastBlock = i === blockCount - 1;
1750+
if (!isLastBlock) {
1751+
const spacingAfter = (paraBlock as ParagraphBlock).attrs?.spacing?.after;
1752+
if (typeof spacingAfter === 'number' && spacingAfter > 0) {
1753+
height += spacingAfter;
1754+
}
1755+
}
17051756
}
17061757

1707-
renderedBlocks.push({ block: paraBlock, measure: paraMeasure, startLine, endLine, height });
1758+
renderedBlocks.push({
1759+
block: paraBlock,
1760+
measure: paraMeasure,
1761+
startLine,
1762+
endLine,
1763+
height,
1764+
originalBlockIndex: i,
1765+
globalBlockStart: blockStart,
1766+
});
17081767
}
17091768

1710-
const contentHeight = renderedBlocks.reduce((acc, info) => acc + info.height, 0);
1769+
// Use shared helper for aggregate content height — keeps selection
1770+
// rects aligned with pagination and the DOM painter.
1771+
const contentHeight = computeCellSliceContentHeight(cellRenderBlocks, cellAllowedStart, cellAllowedEnd);
17111772
const contentAreaHeight = Math.max(0, rowHeight - (padding.top + padding.bottom));
17121773
const freeSpace = Math.max(0, contentAreaHeight - contentHeight);
17131774

@@ -1721,7 +1782,27 @@ export function selectionToRects(
17211782

17221783
let blockTopCursor = padding.top + verticalOffset;
17231784

1724-
renderedBlocks.forEach((info, blockIndex) => {
1785+
// Track the global end line of the last processed block so we can
1786+
// advance blockTopCursor past non-paragraph blocks (images, tables)
1787+
// that sit between consecutive paragraphs.
1788+
let prevBlockGlobalEndLine = cellAllowedStart;
1789+
1790+
renderedBlocks.forEach((info) => {
1791+
// Advance past any visible non-paragraph blocks between the previous
1792+
// paragraph and this one. Without this, images/tables between
1793+
// paragraphs would be invisible to blockTopCursor and later
1794+
// paragraph rects would be positioned too high.
1795+
for (const rb of cellRenderBlocks) {
1796+
if (rb.kind === 'paragraph') continue;
1797+
if (rb.visibleHeight === 0) continue;
1798+
if (rb.globalEndLine <= prevBlockGlobalEndLine) continue;
1799+
if (rb.globalStartLine >= info.globalBlockStart) break;
1800+
const localStart = Math.max(0, cellAllowedStart - rb.globalStartLine);
1801+
const localEnd = Math.min(rb.lineHeights.length, cellAllowedEnd - rb.globalStartLine);
1802+
for (let li = localStart; li < localEnd; li++) {
1803+
blockTopCursor += rb.lineHeights[li];
1804+
}
1805+
}
17251806
const paragraphMarkerWidth = info.measure.marker?.markerWidth ?? 0;
17261807
// List items in table cells are also rendered with left alignment
17271808
const cellIsListItem = isListItem(paragraphMarkerWidth, info.block);
@@ -1735,9 +1816,13 @@ export function selectionToRects(
17351816
const intersectingLines = findLinesIntersectingRange(info.block, info.measure, from, to);
17361817

17371818
// Match renderer: spacing.before is only applied when rendering from the start of the block (startLine === 0).
1819+
// Use the original block index (not renderedBlocks index) so that isFirstBlock matches
1820+
// the renderer's i === 0 check, which includes non-paragraph blocks.
17381821
const rawSpacingBefore = (info.block as ParagraphBlock).attrs?.spacing?.before;
17391822
const effectiveSpacingBeforePx =
1740-
info.startLine === 0 ? effectiveTableCellSpacing(rawSpacingBefore, blockIndex === 0, padding.top) : 0;
1823+
info.startLine === 0
1824+
? effectiveTableCellSpacing(rawSpacingBefore, info.originalBlockIndex === 0, padding.top)
1825+
: 0;
17411826

17421827
intersectingLines.forEach(({ line, index }) => {
17431828
if (index < info.startLine || index >= info.endLine) {
@@ -1789,6 +1874,7 @@ export function selectionToRects(
17891874
});
17901875

17911876
blockTopCursor += info.height;
1877+
prevBlockGlobalEndLine = info.globalBlockStart + info.endLine;
17921878
});
17931879
}
17941880

packages/layout-engine/layout-bridge/test/mock-data.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,103 @@ export const tableSpacingAfterLayout: Layout = {
608608
],
609609
};
610610

611+
// Table cell mixed blocks — selectionToRects should advance past inline images
612+
// between paragraphs when positioning later paragraph rects.
613+
export const TABLE_INLINE_IMAGE_HEIGHT = 24;
614+
export const TABLE_MIXED_BLOCK_FRAGMENT_Y = 60;
615+
616+
export const tableMixedBlockSelectionBlock: FlowBlock = {
617+
...tableBlock,
618+
id: 'table-mixed-blocks',
619+
rows: [
620+
{
621+
...tableBlock.rows[0],
622+
cells: [
623+
{
624+
...tableBlock.rows[0].cells[0],
625+
attrs: { padding: { top: 0, bottom: 0, left: 4, right: 4 } },
626+
blocks: [
627+
{
628+
...tableParagraph,
629+
id: 'mixed-p1',
630+
runs: [{ ...tableParagraph.runs[0], text: 'Top', pmStart: 1, pmEnd: 4 }],
631+
},
632+
{
633+
kind: 'image',
634+
id: 'mixed-img',
635+
src: 'test.png',
636+
width: 24,
637+
height: TABLE_INLINE_IMAGE_HEIGHT,
638+
},
639+
{
640+
...tableParagraph,
641+
id: 'mixed-p2',
642+
runs: [{ ...tableParagraph.runs[0], text: 'Bottom', pmStart: 5, pmEnd: 11 }],
643+
},
644+
],
645+
},
646+
],
647+
},
648+
],
649+
};
650+
651+
const tableMixedBlockTotalHeight = TABLE_CELL_LINE_HEIGHT * 2 + TABLE_INLINE_IMAGE_HEIGHT;
652+
653+
export const tableMixedBlockSelectionMeasure: Measure = {
654+
kind: 'table',
655+
rows: [
656+
{
657+
height: tableMixedBlockTotalHeight,
658+
cells: [
659+
{
660+
width: 100,
661+
height: tableMixedBlockTotalHeight,
662+
gridColumnStart: 0,
663+
blocks: [
664+
{
665+
kind: 'paragraph',
666+
lines: [{ ...tableParagraphLine, toChar: 3, width: 32, ascent: 12 }],
667+
totalHeight: TABLE_CELL_LINE_HEIGHT,
668+
},
669+
{
670+
kind: 'image',
671+
width: 24,
672+
height: TABLE_INLINE_IMAGE_HEIGHT,
673+
},
674+
{
675+
kind: 'paragraph',
676+
lines: [{ ...tableParagraphLine, toChar: 6, width: 52, ascent: 12 }],
677+
totalHeight: TABLE_CELL_LINE_HEIGHT,
678+
},
679+
],
680+
},
681+
],
682+
},
683+
],
684+
columnWidths: [100],
685+
totalWidth: 100,
686+
totalHeight: tableMixedBlockTotalHeight,
687+
};
688+
689+
export const tableMixedBlockSelectionLayout: Layout = {
690+
...tableLayout,
691+
pages: [
692+
{
693+
...tableLayout.pages[0],
694+
fragments: [
695+
{
696+
...tablePageFragment,
697+
blockId: 'table-mixed-blocks',
698+
x: 20,
699+
y: TABLE_MIXED_BLOCK_FRAGMENT_Y,
700+
width: 100,
701+
height: tableMixedBlockTotalHeight,
702+
},
703+
],
704+
},
705+
],
706+
};
707+
611708
// Mock data for table with rowspan (SD-1626 / IT-22)
612709
// Table structure:
613710
// Row 0: [Cell A (rowspan=2)] [Cell B] [Cell C]

packages/layout-engine/layout-bridge/test/selectionToRects.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ import {
3737
TABLE_SPACING_AFTER,
3838
TABLE_SPACING_AFTER_PADDING_BOTTOM,
3939
TABLE_CELL_LINE_HEIGHT,
40+
tableMixedBlockSelectionBlock,
41+
tableMixedBlockSelectionMeasure,
42+
tableMixedBlockSelectionLayout,
43+
TABLE_INLINE_IMAGE_HEIGHT,
44+
TABLE_MIXED_BLOCK_FRAGMENT_Y,
4045
} from './mock-data';
4146
import { PageGeometryHelper } from '../src/page-geometry-helper';
4247

@@ -126,6 +131,21 @@ describe('selectionToRects', () => {
126131
});
127132
});
128133

134+
describe('table cell mixed blocks', () => {
135+
it('offsets later paragraph rects by visible non-paragraph blocks between paragraphs', () => {
136+
const rects = selectionToRects(
137+
tableMixedBlockSelectionLayout,
138+
[tableMixedBlockSelectionBlock],
139+
[tableMixedBlockSelectionMeasure],
140+
5,
141+
11,
142+
);
143+
144+
expect(rects).toHaveLength(1);
145+
expect(rects[0].y).toBe(TABLE_MIXED_BLOCK_FRAGMENT_Y + TABLE_CELL_LINE_HEIGHT + TABLE_INLINE_IMAGE_HEIGHT);
146+
});
147+
});
148+
129149
describe('firstLineIndentMode integration', () => {
130150
it('uses textStartPx for first line of list with firstLineIndentMode', () => {
131151
// Create a list item with firstLineIndentMode and textStartPx

packages/layout-engine/layout-engine/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2790,4 +2790,12 @@ export { resolvePageNumberTokens } from './resolvePageTokens.js';
27902790
export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js';
27912791

27922792
// Export table utilities for reuse by painter-dom
2793-
export { rescaleColumnWidths, getCellLines } from './layout-table.js';
2793+
export { rescaleColumnWidths, getCellLines, getEmbeddedRowLines } from './layout-table.js';
2794+
export {
2795+
describeCellRenderBlocks,
2796+
computeCellSliceContentHeight,
2797+
computeFullCellContentHeight,
2798+
createCellSliceCursor,
2799+
type CellRenderBlock,
2800+
type CellSliceCursor,
2801+
} from './table-cell-slice.js';

0 commit comments

Comments
 (0)