@@ -65,6 +65,42 @@ const ORDERED_LIST_STYLES: Record<string, { fmt: string; text: string }> = {
6565 'lower-alpha-paren' : { fmt : 'lowerLetter' , text : '%1)' } ,
6666} ;
6767
68+ /**
69+ * Default `w:lvlJc` per ordered numFmt, matching Word's own multilevel-list
70+ * defaults (sampled from a real-world numbering.xml):
71+ * decimal / *Letter → left — single-character or narrow markers stay flush left
72+ * *Roman → right — markers grow with count ("I." → "VIII."), so right-
73+ * justification keeps content aligned at one X.
74+ */
75+ const DEFAULT_LVL_JC_BY_FMT : Record < string , 'left' | 'right' > = {
76+ decimal : 'left' ,
77+ upperRoman : 'right' ,
78+ lowerRoman : 'right' ,
79+ upperLetter : 'left' ,
80+ lowerLetter : 'left' ,
81+ } ;
82+
83+ /**
84+ * Default `w:ind w:hanging` per ordered numFmt, paired with the lvlJc above.
85+ * Word ships left-justified levels with a wider hanging (so the marker fits
86+ * inside it without overflow) and right-justified levels with a narrower one
87+ * (because the marker right-anchors at indent.left and extends leftward
88+ * regardless of the hanging value). Sourced from the same reference doc.
89+ *
90+ * Values are in twips. Refreshing this together with `lvlJc` is essential —
91+ * leaving e.g. `hanging=180` (the right-just default) on a level we just
92+ * switched to a left-just numFmt causes content drift, because narrow markers
93+ * land within the hanging zone but wider ones overflow it (sending the
94+ * overflow path's per-marker fallback into action).
95+ */
96+ const DEFAULT_HANGING_BY_FMT : Record < string , number > = {
97+ decimal : 360 ,
98+ upperRoman : 180 ,
99+ lowerRoman : 180 ,
100+ upperLetter : 360 ,
101+ lowerLetter : 360 ,
102+ } ;
103+
68104interface GenerateResult {
69105 numId : number ;
70106 abstractId : number ;
@@ -221,16 +257,35 @@ export function generateNewListDefinition(numbering: NumberingModel, options: Ge
221257 lvlText . attributes [ 'w:val' ] = `%${ targetLevel + 1 } ${ styleConfig . text . replace ( / ^ % \d + / , '' ) } ` ;
222258 }
223259
224- // Default ordered list markers to right-justification so siblings
225- // with varying widths (e.g. "I." vs "III.", "1." vs "10.") share a
226- // single content-start X. The base ordered template ships with
227- // lvlJc="left", which would otherwise leave wider markers pushing
228- // their own line right.
229- const lvlJc = lvl . elements . find ( ( el : any ) => el . name === 'w:lvlJc' ) ;
230- if ( lvlJc ) {
231- lvlJc . attributes [ 'w:val' ] = 'right' ;
232- } else {
233- lvl . elements . push ( { type : 'element' , name : 'w:lvlJc' , attributes : { 'w:val' : 'right' } } ) ;
260+ // Refresh lvlJc + hanging in lockstep with the new numFmt (Word's
261+ // multilevel defaults: decimal/letter → left/360, roman → right/180).
262+ // Setting only one of them leaves a level that can drift (e.g. left-
263+ // just numFmt with hanging=180 overflows for wider markers).
264+ const defaultLvlJc = DEFAULT_LVL_JC_BY_FMT [ styleConfig . fmt ] ;
265+ if ( defaultLvlJc ) {
266+ const lvlJc = lvl . elements . find ( ( el : any ) => el . name === 'w:lvlJc' ) ;
267+ if ( lvlJc ) {
268+ lvlJc . attributes [ 'w:val' ] = defaultLvlJc ;
269+ } else {
270+ lvl . elements . push ( { type : 'element' , name : 'w:lvlJc' , attributes : { 'w:val' : defaultLvlJc } } ) ;
271+ }
272+ }
273+
274+ const defaultHanging = DEFAULT_HANGING_BY_FMT [ styleConfig . fmt ] ;
275+ if ( defaultHanging != null ) {
276+ let pPr = lvl . elements . find ( ( el : any ) => el . name === 'w:pPr' ) ;
277+ if ( ! pPr ) {
278+ pPr = { type : 'element' , name : 'w:pPr' , elements : [ ] } ;
279+ lvl . elements . push ( pPr ) ;
280+ }
281+ if ( ! pPr . elements ) pPr . elements = [ ] ;
282+ let ind = pPr . elements . find ( ( el : any ) => el . name === 'w:ind' ) ;
283+ if ( ! ind ) {
284+ ind = { type : 'element' , name : 'w:ind' , attributes : { 'w:hanging' : String ( defaultHanging ) } } ;
285+ pPr . elements . push ( ind ) ;
286+ } else {
287+ ind . attributes = { ...( ind . attributes || { } ) , 'w:hanging' : String ( defaultHanging ) } ;
288+ }
234289 }
235290 }
236291 }
@@ -524,36 +579,58 @@ export function setLvlStyleOnAbstract(
524579 let numFmtValue : string | null = null ;
525580 let lvlTextValue : string | null = null ;
526581 let lvlJcValue : string | null = null ;
582+ let hangingValue : number | null = null ;
527583
528584 if ( options . bulletStyle ) {
529585 const char = BULLET_STYLE_CHARS [ options . bulletStyle ] ;
530586 if ( ! char ) return false ;
531587 numFmtValue = 'bullet' ;
532588 lvlTextValue = char ;
533- // Bullet markers are single-character; the source's lvlJc carries no
534- // meaningful drift. Leave it untouched to avoid clobbering imported docs .
589+ // Bullet markers are single-character; the source's lvlJc/hanging carry
590+ // no meaningful drift. Leave them untouched to avoid clobbering imports .
535591 } else if ( options . orderedStyle ) {
536592 const config = ORDERED_LIST_STYLES [ options . orderedStyle ] ;
537593 if ( ! config ) return false ;
538594 // OOXML `%N` references counter level N-1 (1-indexed from the top), so at ilvl=N we
539595 // need `%(N+1)`. Preserve the style's suffix (e.g. ".", ")") so paren styles stay paren.
540596 numFmtValue = config . fmt ;
541597 lvlTextValue = `%${ ilvl + 1 } ${ config . text . replace ( / ^ % \d + / , '' ) } ` ;
542- // Default ordered styles to right-justified markers: when widths vary
543- // across siblings (e.g. "I." vs "III." in a roman list, or "1." vs
544- // "10." in a long decimal list), right-justification keeps content
545- // aligned at one X. The source's lvlJc was tied to the previous numFmt
546- // (which may have been single-width like a bullet) and would otherwise
547- // cause drift on the new style.
548- lvlJcValue = 'right' ;
598+ // Match Word's per-numFmt defaults (decimal/letter → left, roman → right).
599+ // The source's lvlJc was tied to the PREVIOUS numFmt and is often wrong
600+ // for the new one. Refresh hanging in lockstep — leaving e.g. hanging=180
601+ // (the right-just default) on a level switched to a left-just numFmt
602+ // means narrow markers fit but wider ones overflow → drift.
603+ lvlJcValue = DEFAULT_LVL_JC_BY_FMT [ config . fmt ] ?? null ;
604+ hangingValue = DEFAULT_HANGING_BY_FMT [ config . fmt ] ?? null ;
549605 } else {
550606 return false ;
551607 }
552608
609+ // Refresh `w:ind w:hanging` on the level's pPr without touching `w:left`
610+ // (that's the user's chosen indentation, not part of the marker geometry).
611+ const setHangingOnLevel = ( hanging : number ) : boolean => {
612+ let pPr = lvlEl . elements . find ( ( el : any ) => el . name === 'w:pPr' ) ;
613+ if ( ! pPr ) {
614+ pPr = { type : 'element' , name : 'w:pPr' , elements : [ ] } ;
615+ lvlEl . elements . push ( pPr ) ;
616+ }
617+ if ( ! pPr . elements ) pPr . elements = [ ] ;
618+ let ind = pPr . elements . find ( ( el : any ) => el . name === 'w:ind' ) ;
619+ if ( ! ind ) {
620+ ind = { type : 'element' , name : 'w:ind' , attributes : { 'w:hanging' : String ( hanging ) } } ;
621+ pPr . elements . push ( ind ) ;
622+ return true ;
623+ }
624+ if ( ind . attributes ?. [ 'w:hanging' ] === String ( hanging ) ) return false ;
625+ ind . attributes = { ...( ind . attributes || { } ) , 'w:hanging' : String ( hanging ) } ;
626+ return true ;
627+ } ;
628+
553629 let changed = false ;
554630 if ( setOrAddChild ( 'w:numFmt' , numFmtValue ) ) changed = true ;
555631 if ( setOrAddChild ( 'w:lvlText' , lvlTextValue ) ) changed = true ;
556632 if ( lvlJcValue != null && setOrAddChild ( 'w:lvlJc' , lvlJcValue ) ) changed = true ;
633+ if ( hangingValue != null && setHangingOnLevel ( hangingValue ) ) changed = true ;
557634 if ( stripMarkerFont ( ) ) changed = true ;
558635 return changed ;
559636}
0 commit comments