Skip to content

Commit 5ca32b3

Browse files
feat(list-level-formatting): implement symbol font normalization and donor font propagation (#2931)
* feat(list-level-formatting): implement symbol font normalization and donor font propagation - Added functions to handle symbol font normalization during transitions from bullet to ordered lists, ensuring that symbol fonts do not interfere with the rendering of ordered markers. - Introduced logic to propagate a legitimate text font from higher levels to nested ordered levels after symbol fonts are stripped, maintaining consistent font styles. - Enhanced tests to cover various scenarios of font handling during level transitions, including cases with symbol fonts and ensuring legitimate fonts are preserved. This update improves the visual consistency of numbered lists in the editor by preventing unwanted symbol font rendering. * refactor(list-level-formatting): enhance donor font propagation logic
1 parent dc706f9 commit 5ca32b3

2 files changed

Lines changed: 408 additions & 3 deletions

File tree

packages/super-editor/src/editors/v1/core/helpers/list-level-formatting-helpers.js

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
269392
function 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
*/
365492
function 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

Comments
 (0)