diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index e2311a6f0..53c14d4c2 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -548,17 +548,34 @@ static void walk_node(GumboNode *node, buffer_t *out); static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out); -static void flush_inline_p(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 bool flush_inline_p(buffer_t *ib, buffer_t *out, GumboElement *align_el) { - if (ib->len > 0) { + bool emitted = ib->len > 0 && !is_whitespace_only(ib->data, ib->len); + if (emitted) { buffer_append_str(out, ""); buffer_append(out, ib->data, ib->len); buffer_append_str(out, "

"); - buffer_clear(ib); } + buffer_clear(ib); + return emitted; } static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out) { @@ -730,9 +747,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) - flush_inline_p(&ib, out, NULL); - else + /* Whitespace-only buffer is layout noise; treat like empty →
*/ + if (!flush_inline_p(&ib, out, NULL)) buffer_append_str(out, "
"); i++; continue; diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index 0f5586969..0a978260a 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -571,3 +571,30 @@ TEST(GumboParserTest, TextAlignment) { "

c

" "

r

"); } + +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"); +} diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index e2ebcf3ed..0d9a628a6 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -575,4 +575,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 a7ed7f1e7..3150fc964 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -350,15 +350,33 @@ 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 + 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 }, attrs = '' -): void { - if (ib.buf.length > 0) { +): boolean { + const emitted = ib.buf.length > 0 && !isWhitespaceOnly(ib.buf); + if (emitted) { out.buf += `${ib.buf}

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