Skip to content

Commit 77b8035

Browse files
committed
fix(core): refine Office HTML normalization and simplify parsing tests
1 parent 807bd88 commit 77b8035

3 files changed

Lines changed: 369 additions & 144 deletions

File tree

packages/core/src/api/clipboard/fromClipboard/__snapshots__/officeHTML.browser.test.ts.snap renamed to packages/core/src/api/clipboard/fromClipboard/__snapshots__/officeHTML.test.ts.snap

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -425,43 +425,44 @@ exports[`Office clipboard HTML > parses captured clipboard HTML: Word mixed list
425425
"type": "bulletListItem",
426426
},
427427
{
428-
"children": [],
429-
"content": [
428+
"children": [
430429
{
431-
"styles": {},
432-
"text": "Three Alskdfj alksdfj lkasj df",
433-
"type": "text",
430+
"children": [],
431+
"content": [
432+
{
433+
"styles": {},
434+
"text": "Three sub one Asldkfjalksdfj laksjdf",
435+
"type": "text",
436+
},
437+
],
438+
"props": {
439+
"backgroundColor": "default",
440+
"textAlignment": "left",
441+
"textColor": "default",
442+
},
443+
"type": "bulletListItem",
434444
},
435-
],
436-
"props": {
437-
"backgroundColor": "default",
438-
"textAlignment": "left",
439-
"textColor": "default",
440-
},
441-
"type": "bulletListItem",
442-
},
443-
{
444-
"children": [],
445-
"content": [
446445
{
447-
"styles": {},
448-
"text": "Three sub one Asldkfjalksdfj laksjdf",
449-
"type": "text",
446+
"children": [],
447+
"content": [
448+
{
449+
"styles": {},
450+
"text": "Three sub two Alskdfjlaksjfd lkasd f",
451+
"type": "text",
452+
},
453+
],
454+
"props": {
455+
"backgroundColor": "default",
456+
"textAlignment": "left",
457+
"textColor": "default",
458+
},
459+
"type": "bulletListItem",
450460
},
451461
],
452-
"props": {
453-
"backgroundColor": "default",
454-
"textAlignment": "left",
455-
"textColor": "default",
456-
},
457-
"type": "bulletListItem",
458-
},
459-
{
460-
"children": [],
461462
"content": [
462463
{
463464
"styles": {},
464-
"text": "Three sub two Alskdfjlaksjfd lkasd f",
465+
"text": "Three Alskdfj alksdfj lkasj df",
465466
"type": "text",
466467
},
467468
],

‎packages/core/src/api/clipboard/fromClipboard/normalizeOfficeHTML.ts‎

Lines changed: 105 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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+level(\d+)\s+lfo(\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+
!/^(UL|OL)$/.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+
1667
function 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+level(\d+)\s+lfo(\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

Comments
 (0)