From a6275ddf7c20001eb604fb7be27eb7896d9a7fbe Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 2 Jul 2026 11:13:07 +0200 Subject: [PATCH 1/6] fix(web): normalizer understands tiptap's checkbox list --- src/web/__tests__/htmlNormalizer.test.ts | 14 ++++++++++++++ src/web/normalization/htmlNormalizer.ts | 9 +++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index 3802e54f6..5b91e60a8 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -417,6 +417,20 @@ describe('htmlNormalizer', () => { }); }); + describe('TiptapCheckboxList', () => { + test("tiptap's internal checkbox list structure gets correctly parsed", () => { + expect( + normalizeHtml( + `` + ) + ).toBe( + '' + ); + }); + }); + describe('BrRemappings', () => { test('inline collapses around
stay flat', () => { expect( diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index be34e44f9..35e92bf7e 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -251,10 +251,15 @@ function emitAttributes(el: Element, name: string): string { ); case 'ul': { const val = el.getAttribute('data-type'); - return val === 'checkbox' ? ' data-type="checkbox"' : ''; + return val === 'checkbox' || val === 'checkboxList' + ? ' data-type="checkbox"' + : ''; } case 'li': - return el.hasAttribute('checked') ? ' checked' : ''; + const isChecked = + el.hasAttribute('checked') || + el.getAttribute('data-checked') === 'true'; + return isChecked ? ' checked' : ''; case 'mention': return ( emitOneAttr(el, 'id') + From fde078c1c079c2418325b50c0018f868da0ef597 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 2 Jul 2026 13:09:02 +0200 Subject: [PATCH 2/6] fix: google docs and ms word checkbox list formats --- apps/example/ios/Podfile.lock | 6 +-- cpp/parser/GumboNormalizer.c | 52 +++++++++++++++++++++++- cpp/tests/GumboParserTest.cpp | 51 +++++++++++++++++++++++ src/web/__tests__/htmlNormalizer.test.ts | 36 ++++++++++++++++ src/web/normalization/htmlNormalizer.ts | 37 ++++++++++++++--- 5 files changed, 172 insertions(+), 10 deletions(-) diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index b18f8dd6e..aa2e40113 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2026,7 +2026,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: b4dad6ba67535bb03c8ff1006b337cba14db16cb + hermes-engine: d80056af9dcb5ba3db80f37943f96751037c7381 RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2035,7 +2035,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 39ee05b5798296f433dd3c3624c57a187c1510e3 - React-Core-prebuilt: 69556f895326f23c007f3a6869340045d7dca106 + React-Core-prebuilt: ff86a9cfffdf1ac6deca527352be5e6d0290354a React-CoreModules: e78bfd2617075bc0e50c689df4a29232bd72ad82 React-cxxreact: 3fe21801d46097cf74c3dff6953677bebc4a3c2a React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2097,7 +2097,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 706b65371b90b5cc797b6639e8979f2e5cecd6da ReactCodegen: ab01ebfffac5cda9140204eb872ed97c15df225f ReactCommon: 47ef95b0920948a0b54d7439f7452501eeeac071 - ReactNativeDependencies: 8a208df374583424130645685d86306befc275cf + ReactNativeDependencies: 6e31d9f8b60229e795cddfbb4d99db3858730bbf ReactNativeEnrichedHtml: 7d90df4aced7f533c7bd15ac296879b214413361 Yoga: e83c3121d079541e69f3c5c623faaaf933fb5812 diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index c4ece832b..10e8506fc 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -421,12 +421,47 @@ static void emit_attributes(GumboElement *el, const char *tag_name, emit_one_attr(out, el, "width"); emit_one_attr(out, el, "height"); } else if (strcmp(tag_name, "ul") == 0) { + const char *val = get_attr(el, "data-type"); - if (val && strcmp(val, "checkbox") == 0) + bool is_checkbox = (val && (strcmp(val, "checkbox") == 0 || strcmp(val, "checkboxList") == 0)); + + // In Google Docs and MS Word the
  • elements define if it is a checkbox list + if (!is_checkbox) { + GumboVector *children = &el->children; + for (unsigned int i = 0; i < children->length; i++) { + GumboNode *child = children->data[i]; + if (is_element(child)) { + char child_tag[64]; + if (get_tag_name(child, child_tag, sizeof(child_tag)) && strcmp(child_tag, "li") == 0) { + GumboElement *child_el = &child->v.element; + const char *role = get_attr(child_el, "role"); + const char *cls = get_attr(child_el, "class"); + + if ((role && strcmp(role, "checkbox") == 0) || + (cls && strstr(cls, "checklist") != NULL)) { + is_checkbox = true; + } + break; // We only need to check the first
  • + } + } + } + } + + if (is_checkbox) { buffer_append_str(out, " data-type=\"checkbox\""); + } } else if (strcmp(tag_name, "li") == 0) { - if (gumbo_get_attribute(&el->attributes, "checked") != NULL) + const char *data_checked = get_attr(el, "data-checked"); + const char *aria_checked = get_attr(el, "aria-checked"); + const char *level_text = get_attr(el, "data-leveltext"); + + // "\xEF\x83\xBE" is the UTF-8 hex encoding for U+F0FE (MS Word Checked Box) + if (gumbo_get_attribute(&el->attributes, "checked") != NULL || + (data_checked && strcmp(data_checked, "true") == 0) || + (aria_checked && strcmp(aria_checked, "true") == 0) || + (level_text && strcmp(level_text, "\xEF\x83\xBE") == 0)) { buffer_append_str(out, " checked"); + } } else if (strcmp(tag_name, "mention") == 0) { emit_one_attr(out, el, "id"); emit_one_attr(out, el, "text"); @@ -551,6 +586,19 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, flatten_li_children(node, ib, out, ctx); return; } + + char buf[64]; + const char *tag = get_tag_name(node, buf, sizeof(buf)); + if (tag && strcmp(tag, "img") == 0) { + const char *role = get_attr(ctx->el, "role"); + const char *cls = get_attr(ctx->el, "class"); + // strip the that Google Docs uses for the display of a checkbox icon + if ((role && strcmp(role, "checkbox") == 0) || + (cls && strstr(cls, "checklist") != NULL)) { + return; + } + } + if (is_list_node(node)) { if (*ctx->nested_count < ctx->max_nested) { ctx->nested_lists[*ctx->nested_count] = node; diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index f45b7ec93..cf8fad3e3 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -448,6 +448,57 @@ TEST(GumboParserTest, ListFlattening) { "
    • another one hi kacper,
    • hi
    "); } +TEST(GumboParserTest, TiptapCheckboxList) { + EXPECT_EQ( + GumboParser::normalizeHtml( + "
    • first

    • second

    "), + "
    • first
    • second
    "); +} + +TEST(GumboParserTest, GoogleDocsCheckboxList) { + EXPECT_EQ(GumboParser::normalizeHtml( + "
    • Checked

    • Unchecked

    "), + "
    • Checked
    • Unchecked
    "); +} + +TEST(GumboParserTest, MSWordCheckboxList) { + // \xEF\x83\xBE is the UTF-8 hex for U+F0FE (Checked MS Word box) + // \xEF\x82\xA8 is the UTF-8 hex for U+F0A8 (Unchecked MS Word box) + EXPECT_EQ( + GumboParser::normalizeHtml( + "
    • Checked
    • Unchecked
    "), + "
    • Checked
    • Unchecked
    "); +} + +TEST(GumboParserTest, EmptyListItems) { + EXPECT_EQ(GumboParser::normalizeHtml( + "
    • first
    • second
    • " + "
    "), + "
    • first
    • second
    "); + EXPECT_EQ(GumboParser::normalizeHtml( + "
    1. first
    2. second
    3. " + "
    "), + "
    1. first
    2. second
    "); + EXPECT_EQ(GumboParser::normalizeHtml( + "
    • first
    • " + "
    • second
    "), + "
    • first
    • second
    "); +} + TEST(GumboParserTest, BrRemappings) { EXPECT_EQ(GumboParser::normalizeHtml( "

    Asdasdasd



    Sent with { }); }); + describe('EmptyListItems', () => { + test.each([ + [ + '

    ', + '
    • first
    • second
    ', + ], + [ + '
    1. first
    2. second
    ', + '
    1. first
    2. second
    ', + ], + [ + '
    • first
    • second
    ', + '
    • first
    • second
    ', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + describe('TiptapCheckboxList', () => { test("tiptap's internal checkbox list structure gets correctly parsed", () => { expect( @@ -431,6 +450,23 @@ describe('htmlNormalizer', () => { }); }); + describe('Checkbox Lists (Google Docs & MS Word)', () => { + test.each([ + // Google Docs format + [ + '
    • Checked

    • Unchecked

    ', + '
    • Checked
    • Unchecked
    ', + ], + // MS Word format + [ + '
    • Checked
    • Unchecked
    ', + '
    • Checked
    • Unchecked
    ', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); + describe('BrRemappings', () => { test('inline collapses around
    stay flat', () => { expect( diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 35e92bf7e..bd0c92896 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -250,15 +250,33 @@ function emitAttributes(el: Element, name: string): string { emitOneAttr(el, 'height') ); case 'ul': { - const val = el.getAttribute('data-type'); - return val === 'checkbox' || val === 'checkboxList' - ? ' data-type="checkbox"' - : ''; + let isCheckbox = + el.getAttribute('data-type') === 'checkbox' || + el.getAttribute('data-type') === 'checkboxList'; + + if (!isCheckbox) { + const firstLi = Array.from(el.children).find( + (c) => c.tagName.toLowerCase() === 'li' + ); + if (firstLi) { + const role = firstLi.getAttribute('role'); + const className = firstLi.getAttribute('class') || ''; + + // Matches Google Docs (role="checkbox") OR MS Word (class includes "checklist") + if (role === 'checkbox' || className.includes('checklist')) { + isCheckbox = true; + } + } + } + + return isCheckbox ? ' data-type="checkbox"' : ''; } case 'li': const isChecked = el.hasAttribute('checked') || - el.getAttribute('data-checked') === 'true'; + el.getAttribute('data-checked') === 'true' || + el.getAttribute('aria-checked') === 'true' || + el.getAttribute('data-leveltext') === ''; // MS Word checked box return isChecked ? ' checked' : ''; case 'mention': return ( @@ -383,6 +401,15 @@ function flattenLiNode( return; } if (!isElement(node)) return; + + if (tagName(node) === 'img') { + const role = ctx.el.getAttribute('role'); + // strip the that Google Docs uses for the display of a checkbox icon + if (role === 'checkbox') { + return; + } + } + if (isListNode(node)) { ctx.nestedLists.push(node); return; From 621c1e1f90ca2fc6c849c4d9d6b1bc170676c983 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 2 Jul 2026 13:38:13 +0200 Subject: [PATCH 3/6] fix: normalizer stripping empty list elements --- cpp/parser/GumboNormalizer.c | 10 ++++++++ cpp/tests/GumboParserTest.cpp | 29 +++++++++++++----------- src/web/__tests__/htmlNormalizer.test.ts | 10 ++++---- src/web/normalization/htmlNormalizer.ts | 10 +++++++- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 10e8506fc..f7afcb6d6 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -546,6 +546,7 @@ typedef struct { GumboNode **nested_lists; int *nested_count; int max_nested; + bool has_emitted; } li_ctx_t; static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, @@ -562,6 +563,7 @@ static void flush_li_buffer(buffer_t *ib, buffer_t *out, li_ctx_t *ctx) { emit_styles_close(out, ctx->styles); buffer_append_str(out, "
  • "); buffer_clear(ib); + ctx->has_emitted = true; } static void flatten_li_children(GumboNode *node, buffer_t *ib, buffer_t *out, @@ -885,6 +887,14 @@ static void walk_node(GumboNode *node, buffer_t *out) { li_ctx_t ctx = {el, es, nested_lists, &nested_count, 16}; flatten_li_children(node, &li_ib, out, &ctx); flush_li_buffer(&li_ib, out, &ctx); + + /* if nothing emitted - the
  • is empty, we add it manually */ + if (!ctx.has_emitted) { + buffer_append_str(out, "
  • "); + } + free(li_ib.data); for (int k = 0; k < nested_count; k++) walk_children(nested_lists[k], out); diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index cf8fad3e3..124e44642 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -484,19 +484,22 @@ TEST(GumboParserTest, MSWordCheckboxList) { } TEST(GumboParserTest, EmptyListItems) { - EXPECT_EQ(GumboParser::normalizeHtml( - "
    • first
    • second
    • " - "
    "), - "
    • first
    • second
    "); - EXPECT_EQ(GumboParser::normalizeHtml( - "
    1. first
    2. second
    3. " - "
    "), - "
    1. first
    2. second
    "); - EXPECT_EQ(GumboParser::normalizeHtml( - "
    • first
    • " - "
    • second
    "), - "
    • first
    • second
    "); + EXPECT_EQ(GumboParser::normalizeHtml("
    • first
    • second
    • " + "
    "), + "
    • first
    • second
    "); + EXPECT_EQ(GumboParser::normalizeHtml("
    1. first
    2. second
    3. " + "
    "), + "
    1. first
    2. second
    "); + EXPECT_EQ( + GumboParser::normalizeHtml( + "
    • first
    • " + "
    • second
    "), + "
    • first
    • second
    "); } TEST(GumboParserTest, BrRemappings) { diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index 750aa6eac..ac5dba46c 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -420,16 +420,16 @@ describe('htmlNormalizer', () => { describe('EmptyListItems', () => { test.each([ [ - '
    • first
    • second
    ', - '
    • first
    • second
    ', + '
    • first
    • second
    ', + '
    • first
    • second
    ', ], [ - '
    1. first
    2. second
    ', - '
    1. first
    2. second
    ', + '
    1. first
    2. second
    ', + '
    1. first
    2. second
    ', ], [ '
    • first
    • second
    ', - '
    • first
    • second
    ', + '
    • first
    • second
    ', ], ])('%s → %s', (input, expected) => { expect(normalizeHtml(input)).toBe(expected); diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index bd0c92896..bd1ef8851 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -363,6 +363,7 @@ type LiCtx = { el: Element; styles: CssStyles; nestedLists: Element[]; + hasEmitted: boolean; }; function flushLiBuffer( @@ -377,6 +378,7 @@ function flushLiBuffer( out.buf += emitStylesClose(ctx.styles); out.buf += ''; ib.buf = ''; + ctx.hasEmitted = true; } function flattenLiChildren( @@ -605,9 +607,15 @@ function walkNode(node: Node, out: { buf: string }): void { if (outName === 'li') { const nestedLists: Element[] = []; const liIb = { buf: '' }; - const ctx: LiCtx = { el: node, styles: es, nestedLists }; + const ctx: LiCtx = { el: node, styles: es, nestedLists, hasEmitted: false }; flattenLiChildren(node, liIb, out, ctx); flushLiBuffer(liIb, out, ctx); + + // if nothing emitted - the
  • is empty, we add it manually + if (!ctx.hasEmitted) { + out.buf += `
  • `; + } + for (const nl of nestedLists) walkChildren(nl, out); return; } From 2a5797c5943c19e104b8156c8ca18c7b51f26194 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 2 Jul 2026 14:10:07 +0200 Subject: [PATCH 4/6] refactor: cleanup --- apps/example/ios/Podfile.lock | 6 +-- cpp/parser/GumboNormalizer.c | 56 +++++++++++++------------ src/web/normalization/htmlNormalizer.ts | 51 ++++++++++++---------- 3 files changed, 61 insertions(+), 52 deletions(-) diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index aa2e40113..b18f8dd6e 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -2026,7 +2026,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: d80056af9dcb5ba3db80f37943f96751037c7381 + hermes-engine: b4dad6ba67535bb03c8ff1006b337cba14db16cb RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2035,7 +2035,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 39ee05b5798296f433dd3c3624c57a187c1510e3 - React-Core-prebuilt: ff86a9cfffdf1ac6deca527352be5e6d0290354a + React-Core-prebuilt: 69556f895326f23c007f3a6869340045d7dca106 React-CoreModules: e78bfd2617075bc0e50c689df4a29232bd72ad82 React-cxxreact: 3fe21801d46097cf74c3dff6953677bebc4a3c2a React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2097,7 +2097,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 706b65371b90b5cc797b6639e8979f2e5cecd6da ReactCodegen: ab01ebfffac5cda9140204eb872ed97c15df225f ReactCommon: 47ef95b0920948a0b54d7439f7452501eeeac071 - ReactNativeDependencies: 6e31d9f8b60229e795cddfbb4d99db3858730bbf + ReactNativeDependencies: 8a208df374583424130645685d86306befc275cf ReactNativeEnrichedHtml: 7d90df4aced7f533c7bd15ac296879b214413361 Yoga: e83c3121d079541e69f3c5c623faaaf933fb5812 diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index f7afcb6d6..61b65958b 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -411,6 +411,34 @@ static void emit_one_attr(buffer_t *out, GumboElement *el, } } +static bool is_checkbox_list(GumboElement *el) { + const char *val = get_attr(el, "data-type"); + if (val && (strcmp(val, "checkbox") == 0 || strcmp(val, "checkboxList") == 0)) { + return true; + } + + // In Google Docs and MS Word the
  • elements define if it is a checkbox + // list. We only need to check the first
  • . + GumboVector *children = &el->children; + for (unsigned int i = 0; i < children->length; i++) { + GumboNode *child = children->data[i]; + if (is_element(child)) { + char child_tag[64]; + if (get_tag_name(child, child_tag, sizeof(child_tag)) && strcmp(child_tag, "li") == 0) { + GumboElement *child_el = &child->v.element; + const char *role = get_attr(child_el, "role"); + const char *cls = get_attr(child_el, "class"); + + // Matches Google Docs (role="checkbox") OR MS Word (class includes "checklist") + return (role && strcmp(role, "checkbox") == 0) || + (cls && strstr(cls, "checklist") != NULL); + } + } + } + + return false; +} + static void emit_attributes(GumboElement *el, const char *tag_name, buffer_t *out) { if (strcmp(tag_name, "a") == 0) { @@ -421,33 +449,7 @@ static void emit_attributes(GumboElement *el, const char *tag_name, emit_one_attr(out, el, "width"); emit_one_attr(out, el, "height"); } else if (strcmp(tag_name, "ul") == 0) { - - const char *val = get_attr(el, "data-type"); - bool is_checkbox = (val && (strcmp(val, "checkbox") == 0 || strcmp(val, "checkboxList") == 0)); - - // In Google Docs and MS Word the
  • elements define if it is a checkbox list - if (!is_checkbox) { - GumboVector *children = &el->children; - for (unsigned int i = 0; i < children->length; i++) { - GumboNode *child = children->data[i]; - if (is_element(child)) { - char child_tag[64]; - if (get_tag_name(child, child_tag, sizeof(child_tag)) && strcmp(child_tag, "li") == 0) { - GumboElement *child_el = &child->v.element; - const char *role = get_attr(child_el, "role"); - const char *cls = get_attr(child_el, "class"); - - if ((role && strcmp(role, "checkbox") == 0) || - (cls && strstr(cls, "checklist") != NULL)) { - is_checkbox = true; - } - break; // We only need to check the first
  • - } - } - } - } - - if (is_checkbox) { + if (is_checkbox_list(el)) { buffer_append_str(out, " data-type=\"checkbox\""); } } else if (strcmp(tag_name, "li") == 0) { diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index bd1ef8851..038dfb0a6 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -249,29 +249,10 @@ function emitAttributes(el: Element, name: string): string { emitOneAttr(el, 'width') + emitOneAttr(el, 'height') ); - case 'ul': { - let isCheckbox = - el.getAttribute('data-type') === 'checkbox' || - el.getAttribute('data-type') === 'checkboxList'; - - if (!isCheckbox) { - const firstLi = Array.from(el.children).find( - (c) => c.tagName.toLowerCase() === 'li' - ); - if (firstLi) { - const role = firstLi.getAttribute('role'); - const className = firstLi.getAttribute('class') || ''; - - // Matches Google Docs (role="checkbox") OR MS Word (class includes "checklist") - if (role === 'checkbox' || className.includes('checklist')) { - isCheckbox = true; - } - } - } - - return isCheckbox ? ' data-type="checkbox"' : ''; - } + case 'ul': + return isCheckboxList(el) ? ' data-type="checkbox"' : ''; case 'li': + // "" is the UTF-8 hex encoding for U+F0FE (MS Word Checked Box) const isChecked = el.hasAttribute('checked') || el.getAttribute('data-checked') === 'true' || @@ -289,6 +270,32 @@ function emitAttributes(el: Element, name: string): string { } } +function isCheckboxList(el: Element): boolean { + if ( + el.getAttribute('data-type') === 'checkbox' || + el.getAttribute('data-type') === 'checkboxList' + ) { + return true; + } + + // In Google Docs and MS Word the
  • elements define if it is a checkbox + // list. We only need to check the first
  • . + const firstLi = Array.from(el.children).find( + (c) => c.tagName.toLowerCase() === 'li' + ); + if (firstLi) { + const role = firstLi.getAttribute('role'); + const className = firstLi.getAttribute('class') || ''; + + // Matches Google Docs (role="checkbox") OR MS Word (class includes "checklist") + if (role === 'checkbox' || className.includes('checklist')) { + return true; + } + } + + return false; +} + function isGoogleDocsWrapper(el: Element, tag: string): boolean { if (tag !== 'b') return false; const id = el.getAttribute('id'); From a7202d436e3a7f14e86f068b12df5e4fb36e37f8 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Thu, 2 Jul 2026 14:59:38 +0200 Subject: [PATCH 5/6] fix: solidified both platform's normalizers --- cpp/parser/GumboNormalizer.c | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 61b65958b..c9bea6b27 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -595,10 +595,8 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, const char *tag = get_tag_name(node, buf, sizeof(buf)); if (tag && strcmp(tag, "img") == 0) { const char *role = get_attr(ctx->el, "role"); - const char *cls = get_attr(ctx->el, "class"); // strip the that Google Docs uses for the display of a checkbox icon - if ((role && strcmp(role, "checkbox") == 0) || - (cls && strstr(cls, "checklist") != NULL)) { + if (role && strcmp(role, "checkbox") == 0) { return; } } From b2c73dd909d16e8251a70afe67ed2106e0509cf9 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz <146986839+hejsztynx@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:03:41 +0200 Subject: [PATCH 6/6] more precise ms word checkbox symbol comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/web/normalization/htmlNormalizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 038dfb0a6..670f40959 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -252,7 +252,7 @@ function emitAttributes(el: Element, name: string): string { case 'ul': return isCheckboxList(el) ? ' data-type="checkbox"' : ''; case 'li': - // "" is the UTF-8 hex encoding for U+F0FE (MS Word Checked Box) + // "" is U+F0FE (MS Word checked box); often encoded as "\xEF\x83\xBE" in UTF-8. const isChecked = el.hasAttribute('checked') || el.getAttribute('data-checked') === 'true' ||