Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions packages/layout-engine/layout-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,24 @@ export function clickToPosition(
const { fragment, block, measure, pageIndex, pageY } = fragmentHit;
// Handle paragraph fragments
if (fragment.kind === 'para' && measure.kind === 'paragraph' && block.kind === 'paragraph') {
const lineIndex = findLineIndexAtY(measure, pageY, fragment.fromLine, fragment.toLine);
// Use fragment-specific lines when available (remeasured fragments in multi-column layouts).
// When a paragraph is remeasured for column width, fragment.lines contains the actual
// rendered lines which may differ from measure.lines.
let lines: Line[];
let fromLine: number;
let toLine: number;

if (fragment.lines && fragment.lines.length > 0) {
lines = fragment.lines;
fromLine = 0;
toLine = fragment.lines.length;
} else {
lines = measure.lines;
fromLine = fragment.fromLine;
toLine = fragment.toLine;
}

let lineIndex = findLineIndexAtY(lines, pageY, fromLine, toLine);
if (lineIndex == null) {
logClickStage('warn', 'no-line', {
blockId: fragment.blockId,
Expand All @@ -968,7 +985,23 @@ export function clickToPosition(
});
return null;
}
const line = measure.lines[lineIndex];

const line = lines[lineIndex];
// Convert to absolute index when using fragment-local lines
if (lines === fragment.lines) {
lineIndex = fragment.fromLine + lineIndex;
}

// Guard against undefined line (defensive check)
if (!line) {
logClickStage('warn', 'no-line', {
blockId: fragment.blockId,
pageIndex,
pageY,
reason: 'line is undefined after lookup',
});
return null;
}

const isRTL = isRtlBlock(block);
// Type guard: Validate indent structure and ensure numeric values
Expand Down Expand Up @@ -1077,7 +1110,7 @@ export function clickToPosition(
const { cellBlock, cellMeasure, localX, localY, pageIndex } = tableHit;

// Find the line at the local Y position within the cell paragraph
const lineIndex = findLineIndexAtY(cellMeasure, localY, 0, cellMeasure.lines.length);
const lineIndex = findLineIndexAtY(cellMeasure.lines, localY, 0, cellMeasure.lines.length);
if (lineIndex != null) {
const line = cellMeasure.lines[lineIndex];
const isRTL = isRtlBlock(cellBlock);
Expand Down Expand Up @@ -2078,29 +2111,29 @@ const determineColumn = (layout: Layout, fragmentX: number): number => {
*
* @throws Never throws - returns null for invalid inputs
*/
const findLineIndexAtY = (measure: Measure, offsetY: number, fromLine: number, toLine: number): number | null => {
if (measure.kind !== 'paragraph') return null;
const findLineIndexAtY = (lines: Line[], offsetY: number, fromLine: number, toLine: number): number | null => {
if (!lines || lines.length === 0) return null;

// Validate bounds to prevent out-of-bounds access
const lineCount = measure.lines.length;
const lineCount = lines.length;
if (fromLine < 0 || toLine > lineCount || fromLine >= toLine) {
return null;
}

let cursor = 0;
// Only search within the fragment's line range
// Only search within the specified line range
for (let i = fromLine; i < toLine; i += 1) {
const line = measure.lines[i];
const line = lines[i];
// Guard against undefined lines (defensive check for corrupted data)
if (!line) return null;

const next = cursor + line.lineHeight;
if (offsetY >= cursor && offsetY < next) {
return i; // Return absolute line index within measure
return i; // Return line index within the array
}
cursor = next;
}
// If beyond all lines, return the last line in the fragment
// If beyond all lines, return the last line in the range
return toLine - 1;
};

Expand Down
152 changes: 151 additions & 1 deletion packages/layout-engine/layout-bridge/test/clickToPosition.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { clickToPosition, hitTestPage } from '../src/index.ts';
import type { Layout } from '@superdoc/contracts';
import type { Layout, FlowBlock, Measure, Line, ParaFragment } from '@superdoc/contracts';
import {
simpleLayout,
blocks,
Expand Down Expand Up @@ -100,3 +100,153 @@ describe('hitTestPage with pageGap', () => {
expect(result?.pageIndex).toBe(1);
});
});

describe('clickToPosition with fragment.lines', () => {
// Tests for multi-column documents where fragments have remeasured lines
// that differ from measure.lines.
//
// Example scenario - paragraph "Hello world" in a two-column layout:
//
// Original measure (full page width): Remeasured for column width:
// ┌────────────────────────────────┐ ┌──────────────┐
// │ Hello world │ │ Hello │ ← line 0
// └────────────────────────────────┘ │ world │ ← line 1
// (1 line) └──────────────┘
// (2 lines)
//
// measure.lines = [line0] fragment.lines = [line0, line1]
//
// The bug: using measure.lines with fragment.fromLine/toLine indices
// caused out-of-bounds access when the fragment had more lines than measure.

const remeasuredLine1: Line = {
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 5,
width: 100,
ascent: 12,
descent: 4,
lineHeight: 20,
};

const remeasuredLine2: Line = {
fromRun: 0,
fromChar: 5,
toRun: 1,
toChar: 5,
width: 100,
ascent: 12,
descent: 4,
lineHeight: 20,
};

// Block that will be rendered in two columns
const twoColumnBlock: FlowBlock = {
kind: 'paragraph',
id: 'two-column-para',
runs: [
{ text: 'Hello ', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 7 },
{ text: 'world', fontFamily: 'Arial', fontSize: 16, pmStart: 7, pmEnd: 12 },
],
};

// Original measure only has 1 line (measured at full width)
const originalMeasure: Measure = {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 1,
toChar: 5,
width: 200,
ascent: 12,
descent: 4,
lineHeight: 20,
},
],
totalHeight: 20,
};

// Fragment in column 2 has remeasured lines with indices that don't exist in measure.lines
// This simulates a paragraph that wraps to multiple lines when remeasured at column width
const fragmentWithRemeasuredLines: ParaFragment = {
kind: 'para',
blockId: 'two-column-para',
fromLine: 0, // These indices are into the remeasured lines array
toLine: 2,
x: 300, // Column 2 position
y: 40,
width: 150,
pmStart: 1,
pmEnd: 12,
lines: [remeasuredLine1, remeasuredLine2], // The remeasured lines
};

const twoColumnLayout: Layout = {
pageSize: { w: 600, h: 800 },
columns: { count: 2, gap: 20 },
pages: [
{
number: 1,
fragments: [fragmentWithRemeasuredLines],
},
],
};

it('uses fragment.lines when available instead of measure.lines', () => {
// Click in the middle of the fragment (y=50 is within the fragment which starts at y=40)
// This should use fragment.lines to find the line, not measure.lines
// Without the fix, this would throw "can't access property 'fromRun', line is undefined"
// because fragment.fromLine/toLine indices don't match measure.lines
const result = clickToPosition(twoColumnLayout, [twoColumnBlock], [originalMeasure], { x: 350, y: 50 });

expect(result).not.toBeNull();
expect(result?.blockId).toBe('two-column-para');
expect(result?.pos).toBeGreaterThanOrEqual(1);
expect(result?.pos).toBeLessThanOrEqual(12);
});

it('correctly maps click position in second line of fragment with remeasured lines', () => {
// Click in the second line of the fragment (y=65 is within line 2 which starts at y=40+20=60)
const result = clickToPosition(twoColumnLayout, [twoColumnBlock], [originalMeasure], { x: 350, y: 65 });

expect(result).not.toBeNull();
expect(result?.blockId).toBe('two-column-para');
// The click should map to a position in the second line's range
expect(result?.pos).toBeGreaterThanOrEqual(1);
expect(result?.pos).toBeLessThanOrEqual(12);
});

it('handles fragment without lines array (uses measure.lines)', () => {
// Fragment without remeasured lines - should use measure.lines
const fragmentWithoutLines: ParaFragment = {
kind: 'para',
blockId: 'two-column-para',
fromLine: 0,
toLine: 1,
x: 30,
y: 40,
width: 200,
pmStart: 1,
pmEnd: 12,
// No `lines` property - should fall back to measure.lines
};

const layoutWithoutFragmentLines: Layout = {
pageSize: { w: 400, h: 500 },
pages: [
{
number: 1,
fragments: [fragmentWithoutLines],
},
],
};

const result = clickToPosition(layoutWithoutFragmentLines, [twoColumnBlock], [originalMeasure], { x: 100, y: 50 });

expect(result).not.toBeNull();
expect(result?.blockId).toBe('two-column-para');
});
});