@@ -13,39 +13,121 @@ function getMSOList(element: Element): string | undefined {
1313 . trim ( ) ;
1414}
1515
16+ function getWordListInfo ( element : Element ) {
17+ const match = getMSOList ( element ) ?. match ( / ^ l ( \d + ) \s + l e v e l ( \d + ) \s + l f o ( \d + ) $ / i) ;
18+ return match
19+ ? { level : Number ( match [ 2 ] ) , id : `${ match [ 1 ] } :${ match [ 3 ] } ` }
20+ : undefined ;
21+ }
22+
23+ function isWordListWhitespace ( node : Node ) {
24+ return (
25+ node . nodeType === 8 || ( node . nodeType === 3 && ! node . textContent ?. trim ( ) )
26+ ) ;
27+ }
28+
29+ // Word sometimes wraps a native level-two list in an otherwise empty list.
30+ // Only peel those wrappers when they contain nothing else (including text).
31+ function getWordNativeList ( outer : Element ) {
32+ let list = outer ;
33+ while ( true ) {
34+ const children = Array . from ( list . childNodes ) . filter (
35+ ( node ) => ! isWordListWhitespace ( node ) ,
36+ ) ;
37+ const child = children [ 0 ] ;
38+ if (
39+ children . length !== 1 ||
40+ ! ( child instanceof Element ) ||
41+ ! / ^ ( U L | O L ) $ / . test ( child . tagName )
42+ ) {
43+ break ;
44+ }
45+ list = child ;
46+ }
47+ const items = Array . from ( list . childNodes ) . filter (
48+ ( node ) => ! isWordListWhitespace ( node ) ,
49+ ) ;
50+ if ( ! items . every ( ( item ) => item instanceof HTMLLIElement ) ) {
51+ return undefined ;
52+ }
53+ const first = items [ 0 ] ;
54+ const info = first && getWordListInfo ( first ) ;
55+ if (
56+ ! info ||
57+ ! items . every ( ( item ) => {
58+ const itemInfo = getWordListInfo ( item ) ;
59+ return itemInfo ?. id === info . id && itemInfo . level === info . level ;
60+ } )
61+ ) {
62+ return undefined ;
63+ }
64+ return { list, items, ...info } ;
65+ }
66+
1667function normalizeWordLists ( root : HTMLElement ) {
1768 const stack : {
18- element : HTMLElement ;
69+ element : Element ;
1970 level : number ;
2071 lastItem : HTMLLIElement ;
2172 } [ ] = [ ] ;
2273 let listID : string | undefined ;
2374
24- for ( const paragraph of Array . from ( root . querySelectorAll ( "p[style]" ) ) ) {
25- const declaration = getMSOList ( paragraph ) ;
26- const match = declaration ?. match ( / ^ l ( \d + ) \s + l e v e l ( \d + ) \s + l f o ( \d + ) $ / i ) ;
27- if ( ! match ) {
75+ for ( const paragraph of root . querySelectorAll ( "p[style], ul, ol" ) ) {
76+ // Native list content already has its own structure. In particular, do not
77+ // turn a styled paragraph inside an li into a second list item.
78+ if ( paragraph . parentElement ?. closest ( "ul, ol, li" ) ) {
2879 continue ;
2980 }
30-
31- const level = Number ( match [ 2 ] ) ;
32- const id = `${ match [ 1 ] } :${ match [ 3 ] } ` ;
81+ const native = paragraph . tagName !== "P" ;
82+ const nativeList = native ? getWordNativeList ( paragraph ) : undefined ;
83+ const info = native ? nativeList : getWordListInfo ( paragraph ) ;
84+ if ( ! info ) {
85+ continue ;
86+ }
87+ const { level, id } = info ;
3388 let previous = paragraph . previousSibling ;
34- while (
35- previous &&
36- ( previous . nodeType === 8 ||
37- ( previous . nodeType === 3 && ! previous . textContent ?. trim ( ) ) )
38- ) {
89+ while ( previous && isWordListWhitespace ( previous ) ) {
3990 previous = previous . previousSibling ;
4091 }
4192 if ( id !== listID || previous !== stack [ 0 ] ?. element ) {
4293 stack . length = 0 ;
4394 }
4495 listID = id ;
4596
97+ while ( stack . length && stack [ stack . length - 1 ] . level > level ) {
98+ stack . pop ( ) ;
99+ }
100+
101+ if ( nativeList ) {
102+ const current = stack [ stack . length - 1 ] ;
103+ const lastItem = nativeList . items [ nativeList . items . length - 1 ] ;
104+ if ( current ?. level === level ) {
105+ if ( current . element . tagName !== nativeList . list . tagName ) {
106+ stack . length = 0 ;
107+ continue ;
108+ }
109+ current . element . append ( ...nativeList . list . childNodes ) ;
110+ current . lastItem = lastItem ;
111+ paragraph . remove ( ) ;
112+ } else if ( current && level === current . level + 1 ) {
113+ current . lastItem . append ( nativeList . list ) ;
114+ if ( paragraph !== nativeList . list ) {
115+ paragraph . remove ( ) ;
116+ }
117+ stack . push ( { element : nativeList . list , level, lastItem } ) ;
118+ } else if ( ! current && paragraph === nativeList . list ) {
119+ stack . push ( { element : nativeList . list , level, lastItem } ) ;
120+ } else {
121+ // A missing parent or a skipped level is not enough evidence to move
122+ // an existing list under an unrelated preceding item.
123+ stack . length = 0 ;
124+ }
125+ continue ;
126+ }
127+
46128 let marker = "" ;
47129 const markers : Element [ ] = [ ] ;
48- for ( const span of Array . from ( paragraph . querySelectorAll ( "span[style]" ) ) ) {
130+ for ( const span of paragraph . querySelectorAll ( "span[style]" ) ) {
49131 if (
50132 getMSOList ( span ) ?. toLowerCase ( ) === "ignore" &&
51133 ! markers . some ( ( parent ) => parent . contains ( span ) )
@@ -67,9 +149,6 @@ function normalizeWordLists(root: HTMLElement) {
67149 span . remove ( ) ;
68150 }
69151
70- while ( stack . length && stack [ stack . length - 1 ] . level > level ) {
71- stack . pop ( ) ;
72- }
73152 if (
74153 stack [ stack . length - 1 ] ?. level === level &&
75154 stack [ stack . length - 1 ] . element . tagName . toLowerCase ( ) !== tag
@@ -132,6 +211,15 @@ export function normalizeOfficeHTML(html: string): string {
132211 }
133212
134213 for ( const wrapper of root . querySelectorAll ( googleDocsWrapper ) ) {
214+ // Firefox can put the source ID on the selected paragraph itself. Its BRs
215+ // are hard breaks, not sibling paragraphs. Only fragment containers can
216+ // expand direct BRs into paragraphs (never textblocks or inline spans).
217+ if (
218+ ! wrapper . matches ( "b, div" ) ||
219+ wrapper . parentElement ?. closest ( "p, h1, h2, h3, h4, h5, h6, pre" )
220+ ) {
221+ continue ;
222+ }
135223 // Direct BRs represent blank paragraphs only in block-level fragments.
136224 if (
137225 Array . from ( wrapper . childNodes ) . some (
0 commit comments