From 566900a7866ecaa420471fc6e02ba559069fb313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Thu, 16 Jul 2026 12:32:02 +0200 Subject: [PATCH 1/5] fix: whitespaces parsing inside normalizer --- cpp/parser/GumboNormalizer.c | 20 ++++++++++++++++++-- cpp/tests/GumboParserTest.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index c9bea6b27..2c76766e1 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -497,8 +497,23 @@ static void walk_node(GumboNode *node, buffer_t *out); static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out); +/** True if buf is empty or contains only ASCII whitespace. */ +static bool is_whitespace_only(const char *data, size_t len) { + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)data[i]; + if (c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\f') + return false; + } + return true; +} + +/** + * Flush buffered inline content as a

. Inter-block whitespace (newlines / + * spaces between block tags in pretty-printed HTML) is discarded so it does + * not become empty paragraphs that later serialize as extra
s. + */ static void flush_inline_p(buffer_t *ib, buffer_t *out) { - if (ib->len > 0) { + if (ib->len > 0 && !is_whitespace_only(ib->data, ib->len)) { buffer_append_str(out, "

"); buffer_append(out, ib->data, ib->len); buffer_append_str(out, "

"); @@ -674,7 +689,8 @@ static void walk_children(GumboNode *node, buffer_t *out) { !is_blockquote_node(children->data[i])) { child = children->data[i]; if (is_br_node(child)) { - if (ib.len > 0) + /* Whitespace-only buffer is layout noise; treat like empty →
*/ + if (ib.len > 0 && !is_whitespace_only(ib.data, ib.len)) flush_inline_p(&ib, out); else buffer_append_str(out, "
"); diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index 124e44642..0da50e0d7 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -509,3 +509,30 @@ TEST(GumboParserTest, BrRemappings) { "

Asdasdasd



Sent with Net

"); } + +TEST(GumboParserTest, InterBlockWhitespace) { + // Pretty-printed consecutive paragraphs must not gain empty

s from the + // newlines between them (those would later serialize as extra
s). + EXPECT_EQ(GumboParser::normalizeHtml( + "

Asdasd

\n

Asdasd

\n

Asdasda

"), + "

Asdasd

Asdasd

Asdasda

"); + EXPECT_EQ(GumboParser::normalizeHtml( + "

Asdasd

\n\n

Asdasd

\n\n

Asdasda

"), + "

Asdasd

Asdasd

Asdasda

"); + EXPECT_EQ(GumboParser::normalizeHtml( + "\n

Asdasd

\n

Asdasd

\n

Asdasda

\n"), + "

Asdasd

Asdasd

Asdasda

"); + EXPECT_EQ(GumboParser::normalizeHtml("

Asdasd

Asdasd

"), + "

Asdasd

Asdasd

"); + + // Significant inline content between blocks is still wrapped in

. + EXPECT_EQ(GumboParser::normalizeHtml("

a

hello

b

"), + "

a

hello

b

"); + + // Spaces inside text / between inlines must be preserved. + EXPECT_EQ(GumboParser::normalizeHtml("hello world"), "hello world"); + EXPECT_EQ(GumboParser::normalizeHtml("

hello world

"), + "

hello world

"); + EXPECT_EQ(GumboParser::normalizeHtml("hello world"), + "hello world"); +} From 9e965c93e6bfe4c5206a6032ac70bfde2823dcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Thu, 16 Jul 2026 12:52:51 +0200 Subject: [PATCH 2/5] fix(web): parsing whitespaces in normalizer --- src/web/__tests__/htmlNormalizer.test.ts | 28 ++++++++++++++++++++++++ src/web/normalization/htmlNormalizer.ts | 24 +++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index ac5dba46c..632d99049 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -497,4 +497,32 @@ describe('htmlNormalizer', () => { expect(normalizeHtml(input)).toBe(expected); }); }); + + describe('InterBlockWhitespace', () => { + // Pretty-printed consecutive paragraphs must not gain empty

s from the + // newlines between them (those would later serialize as extra
s). + test.each([ + [ + '

Asdasd

\n

Asdasd

\n

Asdasda

', + '

Asdasd

Asdasd

Asdasda

', + ], + [ + '

Asdasd

\n\n

Asdasd

\n\n

Asdasda

', + '

Asdasd

Asdasd

Asdasda

', + ], + [ + '\n

Asdasd

\n

Asdasd

\n

Asdasda

\n', + '

Asdasd

Asdasd

Asdasda

', + ], + ['

Asdasd

Asdasd

', '

Asdasd

Asdasd

'], + // Significant inline content between blocks is still wrapped in

. + ['

a

hello

b

', '

a

hello

b

'], + // Spaces inside text / between inlines must be preserved. + ['hello world', 'hello world'], + ['

hello world

', '

hello world

'], + ['hello world', 'hello world'], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); }); diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 670f40959..4ba287eba 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -323,11 +323,27 @@ function escapeText(s: string): string { // --- Blockquote content flattening --- +function isWhitespaceOnly(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + // space, tab, LF, CR, FF — mirrors GumboNormalizer.c + if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d && c !== 0x0c) { + return false; + } + } + return true; +} + +/** + * Flush buffered inline content as a

. Inter-block whitespace (newlines / + * spaces between block tags in pretty-printed HTML) is discarded so it does + * not become empty paragraphs that later serialize as extra
s. + */ function flushInlineP(ib: { buf: string }, out: { buf: string }): void { - if (ib.buf.length > 0) { + if (ib.buf.length > 0 && !isWhitespaceOnly(ib.buf)) { out.buf += `

${ib.buf}

`; - ib.buf = ''; } + ib.buf = ''; } function flattenBqChildren( @@ -493,9 +509,11 @@ function walkChildren(node: Element, out: { buf: string }): void { break; } if (isElement(cur) && isBrNode(cur)) { - if (ib.buf.length > 0) { + // Whitespace-only buffer is layout noise; treat like empty →
+ if (ib.buf.length > 0 && !isWhitespaceOnly(ib.buf)) { flushInlineP(ib, out); } else { + ib.buf = ''; out.buf += '
'; } i++; From d5b5f765a3be1fb0ec72c977f01fa2e4ca34e854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Thu, 16 Jul 2026 13:01:27 +0200 Subject: [PATCH 3/5] fix: add buffer cleaning --- cpp/parser/GumboNormalizer.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 2c76766e1..0ab39702b 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -517,8 +517,8 @@ static void flush_inline_p(buffer_t *ib, buffer_t *out) { buffer_append_str(out, "

"); buffer_append(out, ib->data, ib->len); buffer_append_str(out, "

"); - buffer_clear(ib); } + buffer_clear(ib); } static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out) { @@ -692,8 +692,10 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* Whitespace-only buffer is layout noise; treat like empty →
*/ if (ib.len > 0 && !is_whitespace_only(ib.data, ib.len)) flush_inline_p(&ib, out); - else + else { + buffer_clear(&ib); buffer_append_str(out, "
"); + } i++; continue; } From 59a9cc040e76ae18ce1724390db0a97120614b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= <74975508+kacperzolkiewski@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:44:36 +0200 Subject: [PATCH 4/5] Update src/web/normalization/htmlNormalizer.ts Co-authored-by: Krystian Sienkiewicz <146986839+hejsztynx@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 4ba287eba..922dc265c 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -326,7 +326,7 @@ function escapeText(s: string): string { function isWhitespaceOnly(value: string): boolean { for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); - // space, tab, LF, CR, FF — mirrors GumboNormalizer.c + // space, tab, LF, CR, FF if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d && c !== 0x0c) { return false; } From 9b32d08ec08a2e7cc7435dd6e1ab7f6e4d07d378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Thu, 16 Jul 2026 18:56:40 +0200 Subject: [PATCH 5/5] fix: code review fixes --- cpp/parser/GumboNormalizer.c | 12 +++++------- src/web/normalization/htmlNormalizer.ts | 11 +++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index d27eec9a4..53c14d4c2 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -563,9 +563,10 @@ static bool is_whitespace_only(const char *data, size_t len) { * spaces between block tags in pretty-printed HTML) is discarded so it does * not become empty paragraphs that later serialize as extra
s. */ -static void flush_inline_p(buffer_t *ib, buffer_t *out, +static bool flush_inline_p(buffer_t *ib, buffer_t *out, GumboElement *align_el) { - if (ib->len > 0 && !is_whitespace_only(ib->data, ib->len)) { + bool emitted = ib->len > 0 && !is_whitespace_only(ib->data, ib->len); + if (emitted) { buffer_append_str(out, ""); } buffer_clear(ib); + return emitted; } static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out) { @@ -746,12 +748,8 @@ static void walk_children(GumboNode *node, buffer_t *out) { child = children->data[i]; if (is_br_node(child)) { /* Whitespace-only buffer is layout noise; treat like empty →
*/ - if (ib.len > 0 && !is_whitespace_only(ib.data, ib.len)) - flush_inline_p(&ib, out, NULL); - else { - buffer_clear(&ib); + if (!flush_inline_p(&ib, out, NULL)) buffer_append_str(out, "
"); - } i++; continue; } diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index abd471235..3150fc964 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -370,11 +370,13 @@ function flushInlineP( ib: { buf: string }, out: { buf: string }, attrs = '' -): void { - if (ib.buf.length > 0 && !isWhitespaceOnly(ib.buf)) { +): boolean { + const emitted = ib.buf.length > 0 && !isWhitespaceOnly(ib.buf); + if (emitted) { out.buf += `${ib.buf}

`; } ib.buf = ''; + return emitted; } function flattenBqChildren( @@ -542,10 +544,7 @@ function walkChildren(node: Element, out: { buf: string }): void { } if (isElement(cur) && isBrNode(cur)) { // Whitespace-only buffer is layout noise; treat like empty →
- if (ib.buf.length > 0 && !isWhitespaceOnly(ib.buf)) { - flushInlineP(ib, out); - } else { - ib.buf = ''; + if (!flushInlineP(ib, out)) { out.buf += '
'; } i++;