Skip to content

Commit 72c3df5

Browse files
authored
fix: handle default(manual) tabs (#2976)
* fix: handle default(manual) tabs * fix: skip hanging indent and improve text-align: justify rendering * fix: get rid of magic numbers/fix function params
1 parent 6cc8247 commit 72c3df5

13 files changed

Lines changed: 278 additions & 78 deletions

File tree

packages/layout-engine/contracts/src/engines/tabs.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('engines-tabs computeTabStops', () => {
1414

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

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

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

150+
it('adds an implicit stop at the hanging-indent body text start', () => {
151+
const stops = computeTabStops({
152+
explicitStops: [],
153+
defaultTabInterval: 720,
154+
paragraphIndent: { left: 1000, hanging: 500 },
155+
});
156+
157+
const implicitBodyStop = stops.find((stop) => stop.pos === 1000);
158+
expect(implicitBodyStop).toMatchObject({
159+
val: 'start',
160+
leader: 'none',
161+
source: 'default',
162+
});
163+
expect(stops[0]?.pos).toBe(1000);
164+
expect(stops.find((stop) => stop.pos === 720)).toBeUndefined();
165+
expect(stops.find((stop) => stop.pos === 1440)).toBeDefined();
166+
});
167+
168+
it('does not duplicate the implicit hanging stop when it lands on the default grid', () => {
169+
const stops = computeTabStops({
170+
explicitStops: [],
171+
defaultTabInterval: 720,
172+
paragraphIndent: { left: 3600, hanging: 3600 },
173+
});
174+
175+
expect(stops.filter((stop) => stop.pos === 3600)).toHaveLength(1);
176+
});
177+
178+
it('does not synthesize the implicit hanging stop when it was explicitly cleared', () => {
179+
const stops = computeTabStops({
180+
explicitStops: [{ val: 'clear', pos: 1000 }],
181+
defaultTabInterval: 720,
182+
paragraphIndent: { left: 1000, hanging: 500 },
183+
});
184+
185+
expect(stops.find((stop) => stop.pos === 1000)).toBeUndefined();
186+
expect(stops.find((stop) => stop.pos === 1440)).toBeDefined();
187+
});
188+
148189
it('combines explicit stops in hanging range with defaults starting at leftIndent', () => {
149190
// When explicit stops exist in the hanging indent range AND there's a gap before leftIndent,
150191
// explicit stops should be preserved, but defaults should start from leftIndent.

packages/layout-engine/contracts/src/engines/tabs.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

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

16+
const TAB_POSITION_TOLERANCE_TWIPS = 20;
17+
1618
/**
1719
* OOXML-aligned tab stop definition.
1820
* Positions are in twips (1/1440 inch) to preserve exact OOXML values.
@@ -22,6 +24,7 @@ export interface TabStop {
2224
val: 'start' | 'end' | 'center' | 'decimal' | 'bar' | 'clear';
2325
pos: number; // Twips from paragraph start (after left indent)
2426
leader?: 'none' | 'dot' | 'hyphen' | 'heavy' | 'underscore' | 'middleDot';
27+
source?: 'explicit' | 'default';
2528
}
2629

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

130134
// Find the rightmost explicit stop (use original stops for this calculation)
131135
const maxExplicit = filteredExplicitStops.reduce((max, stop) => Math.max(max, stop.pos), 0);
132136
// Collect all stops: start with filtered explicit stops
133-
const stops = [...filteredExplicitStops];
137+
const stops: TabStop[] = [...filteredExplicitStops];
134138
const hasStartAlignedExplicit = filteredExplicitStops.some((stop) => stop.val === 'start');
139+
const hasExplicitStops = filteredExplicitStops.length > 0;
140+
const hasClearAtLeftIndent = clearPositions.some(
141+
(clearPos) => Math.abs(clearPos - leftIndent) < TAB_POSITION_TOLERANCE_TWIPS,
142+
);
143+
144+
// Word treats the body text start of a hanging-indent paragraph as an implicit
145+
// tab target. This is what lets manual numbering like "1.\tText" align the
146+
// first-line text with wrapped body lines even when the left indent is not on
147+
// the document's default tab grid.
148+
if (!hasExplicitStops && !hasClearAtLeftIndent && hanging > 0 && leftIndent > effectiveMinIndent) {
149+
stops.push({
150+
val: 'start',
151+
pos: leftIndent,
152+
leader: 'none',
153+
source: 'default',
154+
});
155+
}
135156

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

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

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

155-
if (!hasExplicitStop && !hasClearStop && isValidDefault) {
176+
if (!hasExistingStop && !hasClearStop && isValidDefault) {
156177
stops.push({
157178
val: 'start',
158179
pos,
159180
leader: 'none',
181+
source: 'default',
160182
});
161183
}
162184
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,8 @@ export type Line = {
15741574
naturalWidth?: number;
15751575
/** Number of spaces in the line (pre-computed for efficiency in justify calculations). */
15761576
spaceCount?: number;
1577+
/** True when this line used author-defined OOXML tab stops, not synthesized default stops. */
1578+
hasExplicitTabStops?: boolean;
15771579
segments?: LineSegment[];
15781580
leaders?: LeaderDecoration[];
15791581
bars?: BarDecoration[];

packages/layout-engine/contracts/src/justify-utils.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe('shouldApplyJustify', () => {
104104
expect(shouldApplyJustify(params)).toBe(true);
105105
});
106106

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

117+
it('returns true for default tab positioning when explicit tabs are absent', () => {
118+
const params: ShouldApplyJustifyParams = {
119+
alignment: 'justify',
120+
hasExplicitPositioning: true,
121+
hasExplicitTabStops: false,
122+
isLastLineOfParagraph: false,
123+
paragraphEndsWithLineBreak: false,
124+
};
125+
expect(shouldApplyJustify(params)).toBe(true);
126+
});
127+
128+
it('returns false for author-defined tab stops', () => {
129+
const params: ShouldApplyJustifyParams = {
130+
alignment: 'justify',
131+
hasExplicitPositioning: false,
132+
hasExplicitTabStops: true,
133+
isLastLineOfParagraph: false,
134+
paragraphEndsWithLineBreak: false,
135+
};
136+
expect(shouldApplyJustify(params)).toBe(false);
137+
});
138+
117139
it('returns false when skipJustifyOverride is true', () => {
118140
const params: ShouldApplyJustifyParams = {
119141
alignment: 'justify',

packages/layout-engine/contracts/src/justify-utils.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ export const SPACE_CHARS = new Set([' ', '\u00A0']);
1818
export type ShouldApplyJustifyParams = {
1919
/** Paragraph alignment value (must be 'justify' for justify to apply). */
2020
alignment: string | undefined;
21-
/** Whether the line has explicit tab positioning (tab stops with x values). */
22-
hasExplicitPositioning: boolean;
21+
/** Whether the line has explicit segment positioning. Used as a legacy fallback. */
22+
hasExplicitPositioning?: boolean;
23+
/** Whether the line used author-defined OOXML tab stops. */
24+
hasExplicitTabStops?: boolean;
2325
/** Whether this is the last line of the paragraph. */
2426
isLastLineOfParagraph: boolean;
2527
/** Whether the paragraph ends with a soft break (Shift+Enter / LineBreak run). */
@@ -34,20 +36,28 @@ export type ShouldApplyJustifyParams = {
3436
* Justify is applied when ALL of the following are true:
3537
* - Alignment is 'justify'
3638
* - No explicit skip override
37-
* - Line doesn't have tab stops (explicit positioning)
39+
* - Line doesn't have author-defined tab stops
3840
* - Line is NOT the last line, OR paragraph ends with a soft break
3941
*
4042
* This matches Microsoft Word's behavior:
4143
* - All lines are justified except the true last line
4244
* - Soft breaks (Shift+Enter) do NOT count as "last line"
43-
* - Tab-aligned text is never justified
45+
* - Explicit tab-aligned text is never justified
46+
* - Default/manual tab-aligned text can still be justified
4447
*
4548
* @param params - Parameters for justify decision
4649
* @returns true if justify should be applied, false otherwise
4750
*/
4851
export function shouldApplyJustify(params: ShouldApplyJustifyParams): boolean {
49-
const { alignment, hasExplicitPositioning, isLastLineOfParagraph, paragraphEndsWithLineBreak, skipJustifyOverride } =
50-
params;
52+
const {
53+
alignment,
54+
hasExplicitPositioning,
55+
hasExplicitTabStops,
56+
isLastLineOfParagraph,
57+
paragraphEndsWithLineBreak,
58+
skipJustifyOverride,
59+
} = params;
60+
const lineHasExplicitTabStops = hasExplicitTabStops ?? hasExplicitPositioning ?? false;
5161

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

63-
// Lines with tab stops use explicit positioning
64-
if (hasExplicitPositioning) {
73+
// Author-defined tab stops control horizontal positioning and should not be stretched.
74+
if (lineHasExplicitTabStops) {
6575
return false;
6676
}
6777

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ type TabStopPx = {
364364
val: TabStop['val'];
365365
/** Optional leader character style (dots, dashes, etc.) */
366366
leader?: TabStop['leader'];
367+
/** Whether this came from author-defined tabs or the default tab grid. */
368+
source?: TabStop['source'];
367369
};
368370

369371
/**
@@ -466,6 +468,7 @@ const buildTabStopsPx = (indent?: ParagraphIndent, tabs?: TabStop[], tabInterval
466468
pos: twipsToPx(stop.pos),
467469
val: stop.val,
468470
leader: stop.leader,
471+
source: stop.source,
469472
}));
470473
};
471474

@@ -845,6 +848,9 @@ const applyTabLayoutToLines = (
845848
const clampedTarget = Number.isFinite(maxAbsWidth) ? Math.min(target, maxAbsWidth) : target;
846849
const relativeTarget = clampedTarget - effectiveIndent;
847850
lineWidth = Math.max(lineWidth, relativeTarget);
851+
if (stop?.source === 'explicit') {
852+
line.hasExplicitTabStops = true;
853+
}
848854
let currentLeader: LeaderDecoration | null = null;
849855

850856
// Add leader if specified

0 commit comments

Comments
 (0)