Skip to content
Merged
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
41 changes: 41 additions & 0 deletions packages/layout-engine/contracts/src/engines/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('engines-tabs computeTabStops', () => {

expect(stops[0].pos).toBeGreaterThanOrEqual(360);
expect(stops.find((stop) => stop.pos === 1440)?.val).toBe('end');
expect(stops.find((stop) => stop.pos === 1440)?.source).toBe('explicit');
});

it('filters out clear tabs', () => {
Expand Down Expand Up @@ -72,6 +73,7 @@ describe('engines-tabs computeTabStops', () => {
const firstDefault = stops.find((stop) => stop.pos === 720);
expect(firstDefault?.val).toBe('start');
expect(firstDefault?.leader).toBe('none');
expect(firstDefault?.source).toBe('default');
});

it('preserves tab stops between (left - hanging) and left when hanging indent exists', () => {
Expand Down Expand Up @@ -145,6 +147,45 @@ describe('engines-tabs computeTabStops', () => {
expect(stops.find((stop) => stop.pos === 4320)).toBeDefined(); // Second default at 4320
});

it('adds an implicit stop at the hanging-indent body text start', () => {
const stops = computeTabStops({
explicitStops: [],
defaultTabInterval: 720,
paragraphIndent: { left: 1000, hanging: 500 },
});

const implicitBodyStop = stops.find((stop) => stop.pos === 1000);
expect(implicitBodyStop).toMatchObject({
val: 'start',
leader: 'none',
source: 'default',
});
expect(stops[0]?.pos).toBe(1000);
expect(stops.find((stop) => stop.pos === 720)).toBeUndefined();
expect(stops.find((stop) => stop.pos === 1440)).toBeDefined();
});

it('does not duplicate the implicit hanging stop when it lands on the default grid', () => {
const stops = computeTabStops({
explicitStops: [],
defaultTabInterval: 720,
paragraphIndent: { left: 3600, hanging: 3600 },
});

expect(stops.filter((stop) => stop.pos === 3600)).toHaveLength(1);
});

it('does not synthesize the implicit hanging stop when it was explicitly cleared', () => {
const stops = computeTabStops({
explicitStops: [{ val: 'clear', pos: 1000 }],
defaultTabInterval: 720,
paragraphIndent: { left: 1000, hanging: 500 },
});

expect(stops.find((stop) => stop.pos === 1000)).toBeUndefined();
expect(stops.find((stop) => stop.pos === 1440)).toBeDefined();
});

it('combines explicit stops in hanging range with defaults starting at leftIndent', () => {
// When explicit stops exist in the hanging indent range AND there's a gap before leftIndent,
// explicit stops should be preserved, but defaults should start from leftIndent.
Expand Down
34 changes: 28 additions & 6 deletions packages/layout-engine/contracts/src/engines/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import type { ParagraphIndent } from './paragraph.js';

const TAB_POSITION_TOLERANCE_TWIPS = 20;

/**
* OOXML-aligned tab stop definition.
* Positions are in twips (1/1440 inch) to preserve exact OOXML values.
Expand All @@ -22,6 +24,7 @@ export interface TabStop {
val: 'start' | 'end' | 'center' | 'decimal' | 'bar' | 'clear';
pos: number; // Twips from paragraph start (after left indent)
leader?: 'none' | 'dot' | 'hyphen' | 'heavy' | 'underscore' | 'middleDot';
source?: 'explicit' | 'default';
}

/**
Expand Down Expand Up @@ -125,13 +128,31 @@ export function computeTabStops(context: TabContext): TabStop[] {
// Filter explicit stops: keep those >= effectiveMinIndent (supports hanging indent first lines)
const filteredExplicitStops = explicitStops
.filter((stop) => stop.val !== 'clear')
.filter((stop) => stop.pos >= effectiveMinIndent);
.filter((stop) => stop.pos >= effectiveMinIndent)
.map((stop) => ({ ...stop, source: 'explicit' as const }));

// Find the rightmost explicit stop (use original stops for this calculation)
const maxExplicit = filteredExplicitStops.reduce((max, stop) => Math.max(max, stop.pos), 0);
// Collect all stops: start with filtered explicit stops
const stops = [...filteredExplicitStops];
const stops: TabStop[] = [...filteredExplicitStops];
const hasStartAlignedExplicit = filteredExplicitStops.some((stop) => stop.val === 'start');
const hasExplicitStops = filteredExplicitStops.length > 0;
const hasClearAtLeftIndent = clearPositions.some(
(clearPos) => Math.abs(clearPos - leftIndent) < TAB_POSITION_TOLERANCE_TWIPS,
);

// Word treats the body text start of a hanging-indent paragraph as an implicit
// tab target. This is what lets manual numbering like "1.\tText" align the
// first-line text with wrapped body lines even when the left indent is not on
// the document's default tab grid.
if (!hasExplicitStops && !hasClearAtLeftIndent && hanging > 0 && leftIndent > effectiveMinIndent) {
stops.push({
val: 'start',
pos: leftIndent,
leader: 'none',
source: 'default',
});
}

// Generate default stops at regular intervals.
// - When no explicit start tabs exist (e.g., TOC paragraphs with only right-aligned tabs),
Expand All @@ -145,18 +166,19 @@ export function computeTabStops(context: TabContext): TabStop[] {
while (pos < targetLimit) {
pos += defaultTabInterval;

// Don't add if there's already an explicit stop OR a cleared position at this position
const hasExplicitStop = filteredExplicitStops.some((s) => Math.abs(s.pos - pos) < 20);
const hasClearStop = clearPositions.some((clearPos) => Math.abs(clearPos - pos) < 20);
// Don't add if there's already a stop OR a cleared position at this position
const hasExistingStop = stops.some((s) => Math.abs(s.pos - pos) < TAB_POSITION_TOLERANCE_TWIPS);
const hasClearStop = clearPositions.some((clearPos) => Math.abs(clearPos - pos) < TAB_POSITION_TOLERANCE_TWIPS);

// Default stops must be >= leftIndent (for body text alignment)
const isValidDefault = pos >= leftIndent;

if (!hasExplicitStop && !hasClearStop && isValidDefault) {
if (!hasExistingStop && !hasClearStop && isValidDefault) {
stops.push({
val: 'start',
pos,
leader: 'none',
source: 'default',
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,8 @@ export type Line = {
naturalWidth?: number;
/** Number of spaces in the line (pre-computed for efficiency in justify calculations). */
spaceCount?: number;
/** True when this line used author-defined OOXML tab stops, not synthesized default stops. */
hasExplicitTabStops?: boolean;
segments?: LineSegment[];
leaders?: LeaderDecoration[];
bars?: BarDecoration[];
Expand Down
24 changes: 23 additions & 1 deletion packages/layout-engine/contracts/src/justify-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('shouldApplyJustify', () => {
expect(shouldApplyJustify(params)).toBe(true);
});

it('returns false when hasExplicitPositioning is true (tab stops)', () => {
it('returns false when legacy hasExplicitPositioning is true', () => {
const params: ShouldApplyJustifyParams = {
alignment: 'justify',
hasExplicitPositioning: true,
Expand All @@ -114,6 +114,28 @@ describe('shouldApplyJustify', () => {
expect(shouldApplyJustify(params)).toBe(false);
});

it('returns true for default tab positioning when explicit tabs are absent', () => {
const params: ShouldApplyJustifyParams = {
alignment: 'justify',
hasExplicitPositioning: true,
hasExplicitTabStops: false,
isLastLineOfParagraph: false,
paragraphEndsWithLineBreak: false,
};
expect(shouldApplyJustify(params)).toBe(true);
});

it('returns false for author-defined tab stops', () => {
const params: ShouldApplyJustifyParams = {
alignment: 'justify',
hasExplicitPositioning: false,
hasExplicitTabStops: true,
isLastLineOfParagraph: false,
paragraphEndsWithLineBreak: false,
};
expect(shouldApplyJustify(params)).toBe(false);
});

it('returns false when skipJustifyOverride is true', () => {
const params: ShouldApplyJustifyParams = {
alignment: 'justify',
Expand Down
26 changes: 18 additions & 8 deletions packages/layout-engine/contracts/src/justify-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ export const SPACE_CHARS = new Set([' ', '\u00A0']);
export type ShouldApplyJustifyParams = {
/** Paragraph alignment value (must be 'justify' for justify to apply). */
alignment: string | undefined;
/** Whether the line has explicit tab positioning (tab stops with x values). */
hasExplicitPositioning: boolean;
/** Whether the line has explicit segment positioning. Used as a legacy fallback. */
hasExplicitPositioning?: boolean;
/** Whether the line used author-defined OOXML tab stops. */
hasExplicitTabStops?: boolean;
/** Whether this is the last line of the paragraph. */
isLastLineOfParagraph: boolean;
/** Whether the paragraph ends with a soft break (Shift+Enter / LineBreak run). */
Expand All @@ -34,20 +36,28 @@ export type ShouldApplyJustifyParams = {
* Justify is applied when ALL of the following are true:
* - Alignment is 'justify'
* - No explicit skip override
* - Line doesn't have tab stops (explicit positioning)
* - Line doesn't have author-defined tab stops
* - Line is NOT the last line, OR paragraph ends with a soft break
*
* This matches Microsoft Word's behavior:
* - All lines are justified except the true last line
* - Soft breaks (Shift+Enter) do NOT count as "last line"
* - Tab-aligned text is never justified
* - Explicit tab-aligned text is never justified
* - Default/manual tab-aligned text can still be justified
*
* @param params - Parameters for justify decision
* @returns true if justify should be applied, false otherwise
*/
export function shouldApplyJustify(params: ShouldApplyJustifyParams): boolean {
const { alignment, hasExplicitPositioning, isLastLineOfParagraph, paragraphEndsWithLineBreak, skipJustifyOverride } =
params;
const {
alignment,
hasExplicitPositioning,
hasExplicitTabStops,
isLastLineOfParagraph,
paragraphEndsWithLineBreak,
skipJustifyOverride,
} = params;
const lineHasExplicitTabStops = hasExplicitTabStops ?? hasExplicitPositioning ?? false;

// Must be justify alignment
// Accept both 'justify' (normalized) and 'both' (raw OOXML) for defensive compatibility
Expand All @@ -60,8 +70,8 @@ export function shouldApplyJustify(params: ShouldApplyJustifyParams): boolean {
return false;
}

// Lines with tab stops use explicit positioning
if (hasExplicitPositioning) {
// Author-defined tab stops control horizontal positioning and should not be stretched.
if (lineHasExplicitTabStops) {
return false;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/layout-engine/layout-bridge/src/remeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ type TabStopPx = {
val: TabStop['val'];
/** Optional leader character style (dots, dashes, etc.) */
leader?: TabStop['leader'];
/** Whether this came from author-defined tabs or the default tab grid. */
source?: TabStop['source'];
};

/**
Expand Down Expand Up @@ -466,6 +468,7 @@ const buildTabStopsPx = (indent?: ParagraphIndent, tabs?: TabStop[], tabInterval
pos: twipsToPx(stop.pos),
val: stop.val,
leader: stop.leader,
source: stop.source,
}));
};

Expand Down Expand Up @@ -845,6 +848,9 @@ const applyTabLayoutToLines = (
const clampedTarget = Number.isFinite(maxAbsWidth) ? Math.min(target, maxAbsWidth) : target;
const relativeTarget = clampedTarget - effectiveIndent;
lineWidth = Math.max(lineWidth, relativeTarget);
if (stop?.source === 'explicit') {
line.hasExplicitTabStops = true;
}
let currentLeader: LeaderDecoration | null = null;

// Add leader if specified
Expand Down
Loading
Loading