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 →c
" "r
"); } + +TEST(GumboParserTest, InterBlockWhitespace) { + // Pretty-printed consecutive paragraphs must not gain emptys from the
+ // newlines between them (those would later serialize as extra
s).
+ EXPECT_EQ(GumboParser::normalizeHtml(
+ "
Asdasd
\nAsdasd
\nAsdasda
"), + "Asdasd
Asdasd
Asdasda
"); + EXPECT_EQ(GumboParser::normalizeHtml( + "Asdasd
\n\nAsdasd
\n\nAsdasda
"), + "Asdasd
Asdasd
Asdasda
"); + EXPECT_EQ(GumboParser::normalizeHtml( + "\nAsdasd
\nAsdasd
\nAsdasda
\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
hellob
"), + "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 emptys from the
+ // newlines between them (those would later serialize as extra
s).
+ test.each([
+ [
+ '
Asdasd
\nAsdasd
\nAsdasda
', + 'Asdasd
Asdasd
Asdasda
', + ], + [ + 'Asdasd
\n\nAsdasd
\n\nAsdasda
', + 'Asdasd
Asdasd
Asdasda
', + ], + [ + '\nAsdasd
\nAsdasd
\nAsdasda
\n', + 'Asdasd
Asdasd
Asdasda
', + ], + ['Asdasd
Asdasd
', 'Asdasd
Asdasd
'], + // Significant inline content between blocks is still wrapped in. + ['
a
hellob
', '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 →