Skip to content

Commit fde078c

Browse files
committed
fix: google docs and ms word checkbox list formats
1 parent a6275dd commit fde078c

5 files changed

Lines changed: 172 additions & 10 deletions

File tree

apps/example/ios/Podfile.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,7 +2026,7 @@ EXTERNAL SOURCES:
20262026

20272027
SPEC CHECKSUMS:
20282028
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
2029-
hermes-engine: b4dad6ba67535bb03c8ff1006b337cba14db16cb
2029+
hermes-engine: d80056af9dcb5ba3db80f37943f96751037c7381
20302030
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
20312031
RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64
20322032
RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a
@@ -2035,7 +2035,7 @@ SPEC CHECKSUMS:
20352035
React: 1b1536b9099195944034e65b1830f463caaa8390
20362036
React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5
20372037
React-Core: 39ee05b5798296f433dd3c3624c57a187c1510e3
2038-
React-Core-prebuilt: 69556f895326f23c007f3a6869340045d7dca106
2038+
React-Core-prebuilt: ff86a9cfffdf1ac6deca527352be5e6d0290354a
20392039
React-CoreModules: e78bfd2617075bc0e50c689df4a29232bd72ad82
20402040
React-cxxreact: 3fe21801d46097cf74c3dff6953677bebc4a3c2a
20412041
React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74
@@ -2097,7 +2097,7 @@ SPEC CHECKSUMS:
20972097
ReactAppDependencyProvider: 706b65371b90b5cc797b6639e8979f2e5cecd6da
20982098
ReactCodegen: ab01ebfffac5cda9140204eb872ed97c15df225f
20992099
ReactCommon: 47ef95b0920948a0b54d7439f7452501eeeac071
2100-
ReactNativeDependencies: 8a208df374583424130645685d86306befc275cf
2100+
ReactNativeDependencies: 6e31d9f8b60229e795cddfbb4d99db3858730bbf
21012101
ReactNativeEnrichedHtml: 7d90df4aced7f533c7bd15ac296879b214413361
21022102
Yoga: e83c3121d079541e69f3c5c623faaaf933fb5812
21032103

cpp/parser/GumboNormalizer.c

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,12 +421,47 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
421421
emit_one_attr(out, el, "width");
422422
emit_one_attr(out, el, "height");
423423
} else if (strcmp(tag_name, "ul") == 0) {
424+
424425
const char *val = get_attr(el, "data-type");
425-
if (val && strcmp(val, "checkbox") == 0)
426+
bool is_checkbox = (val && (strcmp(val, "checkbox") == 0 || strcmp(val, "checkboxList") == 0));
427+
428+
// In Google Docs and MS Word the <li> elements define if it is a checkbox list
429+
if (!is_checkbox) {
430+
GumboVector *children = &el->children;
431+
for (unsigned int i = 0; i < children->length; i++) {
432+
GumboNode *child = children->data[i];
433+
if (is_element(child)) {
434+
char child_tag[64];
435+
if (get_tag_name(child, child_tag, sizeof(child_tag)) && strcmp(child_tag, "li") == 0) {
436+
GumboElement *child_el = &child->v.element;
437+
const char *role = get_attr(child_el, "role");
438+
const char *cls = get_attr(child_el, "class");
439+
440+
if ((role && strcmp(role, "checkbox") == 0) ||
441+
(cls && strstr(cls, "checklist") != NULL)) {
442+
is_checkbox = true;
443+
}
444+
break; // We only need to check the first <li>
445+
}
446+
}
447+
}
448+
}
449+
450+
if (is_checkbox) {
426451
buffer_append_str(out, " data-type=\"checkbox\"");
452+
}
427453
} else if (strcmp(tag_name, "li") == 0) {
428-
if (gumbo_get_attribute(&el->attributes, "checked") != NULL)
454+
const char *data_checked = get_attr(el, "data-checked");
455+
const char *aria_checked = get_attr(el, "aria-checked");
456+
const char *level_text = get_attr(el, "data-leveltext");
457+
458+
// "\xEF\x83\xBE" is the UTF-8 hex encoding for U+F0FE (MS Word Checked Box)
459+
if (gumbo_get_attribute(&el->attributes, "checked") != NULL ||
460+
(data_checked && strcmp(data_checked, "true") == 0) ||
461+
(aria_checked && strcmp(aria_checked, "true") == 0) ||
462+
(level_text && strcmp(level_text, "\xEF\x83\xBE") == 0)) {
429463
buffer_append_str(out, " checked");
464+
}
430465
} else if (strcmp(tag_name, "mention") == 0) {
431466
emit_one_attr(out, el, "id");
432467
emit_one_attr(out, el, "text");
@@ -551,6 +586,19 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out,
551586
flatten_li_children(node, ib, out, ctx);
552587
return;
553588
}
589+
590+
char buf[64];
591+
const char *tag = get_tag_name(node, buf, sizeof(buf));
592+
if (tag && strcmp(tag, "img") == 0) {
593+
const char *role = get_attr(ctx->el, "role");
594+
const char *cls = get_attr(ctx->el, "class");
595+
// strip the <img> that Google Docs uses for the display of a checkbox icon
596+
if ((role && strcmp(role, "checkbox") == 0) ||
597+
(cls && strstr(cls, "checklist") != NULL)) {
598+
return;
599+
}
600+
}
601+
554602
if (is_list_node(node)) {
555603
if (*ctx->nested_count < ctx->max_nested) {
556604
ctx->nested_lists[*ctx->nested_count] = node;

cpp/tests/GumboParserTest.cpp

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,57 @@ TEST(GumboParserTest, ListFlattening) {
448448
"<ul><li><b>another one </b>hi kacper,</li><li>hi</li></ul>");
449449
}
450450

451+
TEST(GumboParserTest, TiptapCheckboxList) {
452+
EXPECT_EQ(
453+
GumboParser::normalizeHtml(
454+
"<ul data-type=\"checkboxList\"><li data-checked=\"true\" "
455+
"data-type=\"checkboxItem\"><label><input type=\"checkbox\" "
456+
"checked=\"checked\"><span></span></label><div><p>first</p></div></"
457+
"li><li data-checked=\"false\" data-type=\"checkboxItem\"><label>"
458+
"<input type=\"checkbox\"><span></span></label><div><p>second</p></"
459+
"div></li></ul>"),
460+
"<ul data-type=\"checkbox\"><li checked>first</li><li>second</li></ul>");
461+
}
462+
463+
TEST(GumboParserTest, GoogleDocsCheckboxList) {
464+
EXPECT_EQ(GumboParser::normalizeHtml(
465+
"<ul><li role=\"checkbox\" aria-checked=\"true\"><img "
466+
"src=\"data:...\" /><p>Checked</p></li><li role=\"checkbox\" "
467+
"aria-checked=\"false\"><img src=\"data:...\" "
468+
"/><p>Unchecked</p></li></ul>"),
469+
"<ul data-type=\"checkbox\"><li "
470+
"checked>Checked</li><li>Unchecked</li></ul>");
471+
}
472+
473+
TEST(GumboParserTest, MSWordCheckboxList) {
474+
// \xEF\x83\xBE is the UTF-8 hex for U+F0FE (Checked MS Word box)
475+
// \xEF\x82\xA8 is the UTF-8 hex for U+F0A8 (Unchecked MS Word box)
476+
EXPECT_EQ(
477+
GumboParser::normalizeHtml(
478+
"<ul><li class=\"OutlineElement checklist\" "
479+
"data-leveltext=\"\xEF\x83\xBE\">Checked</li><li "
480+
"class=\"OutlineElement "
481+
"checklist\" data-leveltext=\"\xEF\x82\xA8\">Unchecked</li></ul>"),
482+
"<ul data-type=\"checkbox\"><li "
483+
"checked>Checked</li><li>Unchecked</li></ul>");
484+
}
485+
486+
TEST(GumboParserTest, EmptyListItems) {
487+
EXPECT_EQ(GumboParser::normalizeHtml(
488+
"<ul><li></li><li>first</li><li></li><li>second</li><li></li>"
489+
"</ul>"),
490+
"<ul><li>first</li><li>second</li></ul>");
491+
EXPECT_EQ(GumboParser::normalizeHtml(
492+
"<ol><li></li><li>first</li><li></li><li>second</li><li></li>"
493+
"</ol>"),
494+
"<ol><li>first</li><li>second</li></ol>");
495+
EXPECT_EQ(GumboParser::normalizeHtml(
496+
"<ul data-type=\"checkbox\"><li checked></li><li>first</li><li>"
497+
"</li><li checked>second</li><li></li><li></li></ul>"),
498+
"<ul data-type=\"checkbox\"><li>first</li><li "
499+
"checked>second</li></ul>");
500+
}
501+
451502
TEST(GumboParserTest, BrRemappings) {
452503
EXPECT_EQ(GumboParser::normalizeHtml(
453504
"<p><b>Asdasdasd</b></p><br><br><p>Sent with<span> </span><a "

src/web/__tests__/htmlNormalizer.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,25 @@ describe('htmlNormalizer', () => {
417417
});
418418
});
419419

420+
describe('EmptyListItems', () => {
421+
test.each([
422+
[
423+
'<ul><li></li><li>first</li><li></li><li>second</li><li></li></ul>',
424+
'<ul><li>first</li><li>second</li></ul>',
425+
],
426+
[
427+
'<ol><li></li><li>first</li><li></li><li>second</li><li></li></ol>',
428+
'<ol><li>first</li><li>second</li></ol>',
429+
],
430+
[
431+
'<ul data-type="checkbox"><li checked></li><li>first</li><li></li><li checked>second</li><li></li><li></li></ul>',
432+
'<ul data-type="checkbox"><li>first</li><li checked>second</li></ul>',
433+
],
434+
])('%s → %s', (input, expected) => {
435+
expect(normalizeHtml(input)).toBe(expected);
436+
});
437+
});
438+
420439
describe('TiptapCheckboxList', () => {
421440
test("tiptap's internal checkbox list structure gets correctly parsed", () => {
422441
expect(
@@ -431,6 +450,23 @@ describe('htmlNormalizer', () => {
431450
});
432451
});
433452

453+
describe('Checkbox Lists (Google Docs & MS Word)', () => {
454+
test.each([
455+
// Google Docs format
456+
[
457+
'<ul><li role="checkbox" aria-checked="true"><img src="data:image/png;base64,..." /><p>Checked</p></li><li role="checkbox" aria-checked="false"><img src="data:image/png;base64,..." /><p>Unchecked</p></li></ul>',
458+
'<ul data-type="checkbox"><li checked>Checked</li><li>Unchecked</li></ul>',
459+
],
460+
// MS Word format
461+
[
462+
'<ul><li class="OutlineElement checklist" data-leveltext="\uF0FE">Checked</li><li class="OutlineElement checklist" data-leveltext="\uF0A8">Unchecked</li></ul>',
463+
'<ul data-type="checkbox"><li checked>Checked</li><li>Unchecked</li></ul>',
464+
],
465+
])('%s → %s', (input, expected) => {
466+
expect(normalizeHtml(input)).toBe(expected);
467+
});
468+
});
469+
434470
describe('BrRemappings', () => {
435471
test('inline collapses around <br> stay flat', () => {
436472
expect(

src/web/normalization/htmlNormalizer.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,33 @@ function emitAttributes(el: Element, name: string): string {
250250
emitOneAttr(el, 'height')
251251
);
252252
case 'ul': {
253-
const val = el.getAttribute('data-type');
254-
return val === 'checkbox' || val === 'checkboxList'
255-
? ' data-type="checkbox"'
256-
: '';
253+
let isCheckbox =
254+
el.getAttribute('data-type') === 'checkbox' ||
255+
el.getAttribute('data-type') === 'checkboxList';
256+
257+
if (!isCheckbox) {
258+
const firstLi = Array.from(el.children).find(
259+
(c) => c.tagName.toLowerCase() === 'li'
260+
);
261+
if (firstLi) {
262+
const role = firstLi.getAttribute('role');
263+
const className = firstLi.getAttribute('class') || '';
264+
265+
// Matches Google Docs (role="checkbox") OR MS Word (class includes "checklist")
266+
if (role === 'checkbox' || className.includes('checklist')) {
267+
isCheckbox = true;
268+
}
269+
}
270+
}
271+
272+
return isCheckbox ? ' data-type="checkbox"' : '';
257273
}
258274
case 'li':
259275
const isChecked =
260276
el.hasAttribute('checked') ||
261-
el.getAttribute('data-checked') === 'true';
277+
el.getAttribute('data-checked') === 'true' ||
278+
el.getAttribute('aria-checked') === 'true' ||
279+
el.getAttribute('data-leveltext') === ''; // MS Word checked box
262280
return isChecked ? ' checked' : '';
263281
case 'mention':
264282
return (
@@ -383,6 +401,15 @@ function flattenLiNode(
383401
return;
384402
}
385403
if (!isElement(node)) return;
404+
405+
if (tagName(node) === 'img') {
406+
const role = ctx.el.getAttribute('role');
407+
// strip the <img> that Google Docs uses for the display of a checkbox icon
408+
if (role === 'checkbox') {
409+
return;
410+
}
411+
}
412+
386413
if (isListNode(node)) {
387414
ctx.nestedLists.push(node);
388415
return;

0 commit comments

Comments
 (0)