1313
1414import 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 }
0 commit comments