@@ -245,6 +245,69 @@ function setLevelTabStop(editor, abstractNumId, ilvl, value) {
245245// Marker-Mode Normalization Helpers
246246// ──────────────────────────────────────────────────────────────────────────────
247247
248+ /**
249+ * Fonts whose glyph tables remap ASCII letters and digits to pictures. If one
250+ * of these is left on a level whose numFmt is not `bullet`, Word renders the
251+ * ordered marker (`1.`, `A.`, `I.`) through the symbol font and the user sees
252+ * icon glyphs instead of digits/letters.
253+ *
254+ * Stored lowercase so lookups via `isSymbolFont()` are case-insensitive — Word
255+ * always writes canonical casing, but third-party tools and hand-authored XML
256+ * may not.
257+ */
258+ const SYMBOL_FONT_NAMES = new Set ( [
259+ 'wingdings' ,
260+ 'wingdings 2' ,
261+ 'wingdings 3' ,
262+ 'symbol' ,
263+ 'webdings' ,
264+ 'zapfdingbats' ,
265+ 'zapf dingbats' ,
266+ ] ) ;
267+
268+ /** @param {string | undefined | null } fontName */
269+ function isSymbolFont ( fontName ) {
270+ return typeof fontName === 'string' && SYMBOL_FONT_NAMES . has ( fontName . toLowerCase ( ) ) ;
271+ }
272+
273+ /** rFonts attribute names that hold a typeface — any of them can carry a symbol font. */
274+ const RFONTS_FAMILY_ATTRS = [ 'w:ascii' , 'w:hAnsi' , 'w:eastAsia' , 'w:cs' ] ;
275+
276+ /**
277+ * Return true if any of the rFonts family attributes (ascii/hAnsi/eastAsia/cs)
278+ * names a symbol font. Word always sets all four consistently, but malformed
279+ * input may only set hAnsi — we still want to strip in that case.
280+ *
281+ * @param {Object } rFontsEl
282+ * @returns {boolean }
283+ */
284+ function rFontsHasSymbolFont ( rFontsEl ) {
285+ const attrs = rFontsEl . attributes ;
286+ if ( ! attrs ) return false ;
287+ for ( const attr of RFONTS_FAMILY_ATTRS ) {
288+ if ( isSymbolFont ( attrs [ attr ] ) ) return true ;
289+ }
290+ return false ;
291+ }
292+
293+ /**
294+ * Read the primary typeface name from a rFonts element, preferring `w:ascii`
295+ * and falling back through the other family attributes. Used to pick a donor
296+ * font value for propagation.
297+ *
298+ * @param {Object } rFontsEl
299+ * @returns {string | undefined }
300+ */
301+ function readRFontsFamily ( rFontsEl ) {
302+ const attrs = rFontsEl . attributes ;
303+ if ( ! attrs ) return undefined ;
304+ for ( const attr of RFONTS_FAMILY_ATTRS ) {
305+ const val = attrs [ attr ] ;
306+ if ( val ) return val ;
307+ }
308+ return undefined ;
309+ }
310+
248311/**
249312 * Clear the `w:lvlPicBulletId` element from a level if it exists.
250313 * Used for marker-mode normalization when switching away from picture bullets.
@@ -259,6 +322,66 @@ function clearPictureBulletId(lvlEl) {
259322 return true ;
260323}
261324
325+ /**
326+ * Check whether a level element carries a `w:rPr/w:rFonts` child.
327+ * @param {Object } lvlEl
328+ * @returns {boolean }
329+ */
330+ function levelHasRFonts ( lvlEl ) {
331+ const rPr = lvlEl . elements ?. find ( ( el ) => el . name === 'w:rPr' ) ;
332+ return ! ! rPr ?. elements ?. find ( ( el ) => el . name === 'w:rFonts' ) ;
333+ }
334+
335+ /**
336+ * Find a surviving legitimate (non-symbol) marker font within the abstract.
337+ * Used as the donor for levels whose symbol-font rFonts was stripped during
338+ * a bullet→ordered transition — so nested ordered markers match the top-level
339+ * marker font instead of falling back to the paragraph body font.
340+ *
341+ * Walks every level in the abstract (not just target levels), lowest ilvl first.
342+ *
343+ * @param {Object } abstract
344+ * @returns {string | undefined }
345+ */
346+ function findDonorMarkerFont ( abstract ) {
347+ if ( ! abstract ?. elements ) return undefined ;
348+ const lvls = abstract . elements . filter ( ( el ) => el . name === 'w:lvl' ) ;
349+ lvls . sort ( ( a , b ) => Number ( a . attributes ?. [ 'w:ilvl' ] ?? 0 ) - Number ( b . attributes ?. [ 'w:ilvl' ] ?? 0 ) ) ;
350+ for ( const lvl of lvls ) {
351+ const rPr = lvl . elements ?. find ( ( el ) => el . name === 'w:rPr' ) ;
352+ const rFonts = rPr ?. elements ?. find ( ( el ) => el . name === 'w:rFonts' ) ;
353+ if ( ! rFonts ) continue ;
354+ if ( rFontsHasSymbolFont ( rFonts ) ) continue ;
355+ const family = readRFontsFamily ( rFonts ) ;
356+ if ( family ) return family ;
357+ }
358+ return undefined ;
359+ }
360+
361+ /**
362+ * When a level transitions to a non-bullet numFmt, a symbol-font `w:rFonts`
363+ * left over from the prior bullet configuration would make Word render the
364+ * ordered marker through that symbol font. Drop it; preserve legitimate text
365+ * fonts (Courier New, Arial, …). Removes the enclosing `w:rPr` if now empty.
366+ *
367+ * @param {Object } lvlEl
368+ * @param {string } newNumFmt
369+ * @returns {boolean } True if a rFonts element was removed.
370+ */
371+ function normalizeLevelFontForNumFmt ( lvlEl , newNumFmt ) {
372+ if ( newNumFmt === 'bullet' || ! lvlEl . elements ) return false ;
373+ const rPrIdx = lvlEl . elements . findIndex ( ( el ) => el . name === 'w:rPr' ) ;
374+ if ( rPrIdx === - 1 ) return false ;
375+ const rPr = lvlEl . elements [ rPrIdx ] ;
376+ if ( ! rPr . elements ) return false ;
377+ const rFontsIdx = rPr . elements . findIndex ( ( el ) => el . name === 'w:rFonts' ) ;
378+ if ( rFontsIdx === - 1 ) return false ;
379+ if ( ! rFontsHasSymbolFont ( rPr . elements [ rFontsIdx ] ) ) return false ;
380+ rPr . elements . splice ( rFontsIdx , 1 ) ;
381+ if ( rPr . elements . length === 0 ) lvlEl . elements . splice ( rPrIdx , 1 ) ;
382+ return true ;
383+ }
384+
262385/**
263386 * Set numFmt only (for setLevelNumberStyle). Rejects 'bullet'.
264387 * Clears lvlPicBulletId if present (marker-mode normalization).
@@ -267,7 +390,8 @@ function clearPictureBulletId(lvlEl) {
267390 * @returns {boolean }
268391 */
269392function mutateLevelNumberStyle ( lvlEl , numFmt ) {
270- let changed = setChildAttr ( lvlEl , 'w:numFmt' , numFmt ) ;
393+ let changed = normalizeLevelFontForNumFmt ( lvlEl , numFmt ) ;
394+ changed = setChildAttr ( lvlEl , 'w:numFmt' , numFmt ) || changed ;
271395 changed = clearPictureBulletId ( lvlEl ) || changed ;
272396 return changed ;
273397}
@@ -334,7 +458,10 @@ function applyLevelPropertiesToElement(lvlEl, entry) {
334458 if ( fmtParams . numFmt != null && fmtParams . lvlText != null ) {
335459 changed = mutateLevelNumberingFormat ( lvlEl , fmtParams ) || changed ;
336460 } else {
337- if ( fmtParams . numFmt != null ) changed = setChildAttr ( lvlEl , 'w:numFmt' , fmtParams . numFmt ) || changed ;
461+ if ( fmtParams . numFmt != null ) {
462+ changed = normalizeLevelFontForNumFmt ( lvlEl , fmtParams . numFmt ) || changed ;
463+ changed = setChildAttr ( lvlEl , 'w:numFmt' , fmtParams . numFmt ) || changed ;
464+ }
338465 if ( fmtParams . lvlText != null ) changed = setChildAttr ( lvlEl , 'w:lvlText' , fmtParams . lvlText ) || changed ;
339466 if ( fmtParams . start != null ) changed = setChildAttr ( lvlEl , 'w:start' , String ( fmtParams . start ) ) || changed ;
340467 }
@@ -363,7 +490,7 @@ function applyLevelPropertiesToElement(lvlEl, entry) {
363490 * @returns {boolean }
364491 */
365492function mutateLevelNumberingFormat ( lvlEl , { numFmt, lvlText, start } ) {
366- let changed = false ;
493+ let changed = normalizeLevelFontForNumFmt ( lvlEl , numFmt ) ;
367494 changed = setChildAttr ( lvlEl , 'w:numFmt' , numFmt ) || changed ;
368495 changed = setChildAttr ( lvlEl , 'w:lvlText' , lvlText ) || changed ;
369496 if ( start != null ) {
@@ -733,11 +860,33 @@ function applyTemplateToAbstract(editor, abstractNumId, template, levels) {
733860 }
734861
735862 let anyChanged = false ;
863+ // Track the levels whose rFonts the normalizer strips during this call.
864+ // Only these are eligible for donor propagation — we must not inject a font
865+ // onto levels that were already bare (partial-update intent) or that kept a
866+ // legitimate text-font rFonts through the transition.
867+ const strippedLevels = [ ] ;
736868
737869 for ( const ilvl of targetLevels ) {
738870 const entry = templateByLevel . get ( ilvl ) ;
739871 const lvlEl = findLevelElement ( abstract , ilvl ) ;
872+ const hadRFontsBefore = levelHasRFonts ( lvlEl ) ;
740873 anyChanged = applyLevelPropertiesToElement ( lvlEl , entry ) || anyChanged ;
874+ if ( hadRFontsBefore && ! levelHasRFonts ( lvlEl ) ) {
875+ strippedLevels . push ( { ilvl, lvlEl } ) ;
876+ }
877+ }
878+
879+ // Propagate a surviving legitimate marker font onto levels whose symbol-font
880+ // rFonts the normalizer just stripped. Without this, nested ordered markers
881+ // fall back to the paragraph body font and end up mismatched with the top-level
882+ // marker (e.g., "1." in Courier New, nested "2." in Arial).
883+ if ( strippedLevels . length > 0 ) {
884+ const donorFont = findDonorMarkerFont ( abstract ) ;
885+ if ( donorFont ) {
886+ for ( const { lvlEl } of strippedLevels ) {
887+ anyChanged = mutateLevelMarkerFont ( lvlEl , donorFont ) || anyChanged ;
888+ }
889+ }
741890 }
742891
743892 return { changed : anyChanged } ;
0 commit comments