Skip to content

Commit c4390c7

Browse files
fix: whitespaces parsing inside normalizer (#704)
# Summary This PR fixes whitespaces parsing between tags in normaliser. ## Test Plan Insert some external html into example mobile/web app by `Set input's value` button for example: ``` <p>Asdasd</p> <p>Asdasd</p> <p>Asdasd</p> ``` And see if the output html is the same. ## Screenshots / Videos https://github.com/user-attachments/assets/b112bfb8-0cc5-4fa7-b106-8b9978f70fa1 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ✅ | | Android | ✅ | | Web | ✅ | ## Checklist - [X] E2E tests are passing - [x] Required E2E tests have been added (if applicable) ---------
1 parent 0513dd8 commit c4390c7

4 files changed

Lines changed: 100 additions & 12 deletions

File tree

cpp/parser/GumboNormalizer.c

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -548,17 +548,34 @@ static void walk_node(GumboNode *node, buffer_t *out);
548548

549549
static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out);
550550

551-
static void flush_inline_p(buffer_t *ib, buffer_t *out,
551+
/** True if buf is empty or contains only ASCII whitespace. */
552+
static bool is_whitespace_only(const char *data, size_t len) {
553+
for (size_t i = 0; i < len; i++) {
554+
unsigned char c = (unsigned char)data[i];
555+
if (c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\f')
556+
return false;
557+
}
558+
return true;
559+
}
560+
561+
/**
562+
* Flush buffered inline content as a <p>. Inter-block whitespace (newlines /
563+
* spaces between block tags in pretty-printed HTML) is discarded so it does
564+
* not become empty paragraphs that later serialize as extra <br>s.
565+
*/
566+
static bool flush_inline_p(buffer_t *ib, buffer_t *out,
552567
GumboElement *align_el) {
553-
if (ib->len > 0) {
568+
bool emitted = ib->len > 0 && !is_whitespace_only(ib->data, ib->len);
569+
if (emitted) {
554570
buffer_append_str(out, "<p");
555571
if (align_el)
556572
emit_alignment(align_el, "p", out);
557573
buffer_append_str(out, ">");
558574
buffer_append(out, ib->data, ib->len);
559575
buffer_append_str(out, "</p>");
560-
buffer_clear(ib);
561576
}
577+
buffer_clear(ib);
578+
return emitted;
562579
}
563580

564581
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) {
730747
!is_blockquote_node(children->data[i])) {
731748
child = children->data[i];
732749
if (is_br_node(child)) {
733-
if (ib.len > 0)
734-
flush_inline_p(&ib, out, NULL);
735-
else
750+
/* Whitespace-only buffer is layout noise; treat like empty → <br> */
751+
if (!flush_inline_p(&ib, out, NULL))
736752
buffer_append_str(out, "<br>");
737753
i++;
738754
continue;

cpp/tests/GumboParserTest.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,30 @@ TEST(GumboParserTest, TextAlignment) {
571571
"<p style=\"text-align: center\">c</p>"
572572
"<p style=\"text-align: right\">r</p></blockquote>");
573573
}
574+
575+
TEST(GumboParserTest, InterBlockWhitespace) {
576+
// Pretty-printed consecutive paragraphs must not gain empty <p>s from the
577+
// newlines between them (those would later serialize as extra <br>s).
578+
EXPECT_EQ(GumboParser::normalizeHtml(
579+
"<p>Asdasd</p>\n<p>Asdasd</p>\n<p>Asdasda</p>"),
580+
"<p>Asdasd</p><p>Asdasd</p><p>Asdasda</p>");
581+
EXPECT_EQ(GumboParser::normalizeHtml(
582+
"<p>Asdasd</p>\n\n<p>Asdasd</p>\n\n<p>Asdasda</p>"),
583+
"<p>Asdasd</p><p>Asdasd</p><p>Asdasda</p>");
584+
EXPECT_EQ(GumboParser::normalizeHtml(
585+
"<html>\n<p>Asdasd</p>\n<p>Asdasd</p>\n<p>Asdasda</p>\n</html>"),
586+
"<p>Asdasd</p><p>Asdasd</p><p>Asdasda</p>");
587+
EXPECT_EQ(GumboParser::normalizeHtml("<p>Asdasd</p> <p>Asdasd</p>"),
588+
"<p>Asdasd</p><p>Asdasd</p>");
589+
590+
// Significant inline content between blocks is still wrapped in <p>.
591+
EXPECT_EQ(GumboParser::normalizeHtml("<p>a</p> hello <p>b</p>"),
592+
"<p>a</p><p> hello </p><p>b</p>");
593+
594+
// Spaces inside text / between inlines must be preserved.
595+
EXPECT_EQ(GumboParser::normalizeHtml("hello world"), "hello world");
596+
EXPECT_EQ(GumboParser::normalizeHtml("<p>hello world</p>"),
597+
"<p>hello world</p>");
598+
EXPECT_EQ(GumboParser::normalizeHtml("<b>hello</b> <i>world</i>"),
599+
"<b>hello</b> <i>world</i>");
600+
}

src/web/__tests__/htmlNormalizer.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,4 +575,32 @@ describe('htmlNormalizer', () => {
575575
expect(normalizeHtml(input)).toBe(expected);
576576
});
577577
});
578+
579+
describe('InterBlockWhitespace', () => {
580+
// Pretty-printed consecutive paragraphs must not gain empty <p>s from the
581+
// newlines between them (those would later serialize as extra <br>s).
582+
test.each([
583+
[
584+
'<p>Asdasd</p>\n<p>Asdasd</p>\n<p>Asdasda</p>',
585+
'<p>Asdasd</p><p>Asdasd</p><p>Asdasda</p>',
586+
],
587+
[
588+
'<p>Asdasd</p>\n\n<p>Asdasd</p>\n\n<p>Asdasda</p>',
589+
'<p>Asdasd</p><p>Asdasd</p><p>Asdasda</p>',
590+
],
591+
[
592+
'<html>\n<p>Asdasd</p>\n<p>Asdasd</p>\n<p>Asdasda</p>\n</html>',
593+
'<p>Asdasd</p><p>Asdasd</p><p>Asdasda</p>',
594+
],
595+
['<p>Asdasd</p> <p>Asdasd</p>', '<p>Asdasd</p><p>Asdasd</p>'],
596+
// Significant inline content between blocks is still wrapped in <p>.
597+
['<p>a</p> hello <p>b</p>', '<p>a</p><p> hello </p><p>b</p>'],
598+
// Spaces inside text / between inlines must be preserved.
599+
['hello world', 'hello world'],
600+
['<p>hello world</p>', '<p>hello world</p>'],
601+
['<b>hello</b> <i>world</i>', '<b>hello</b> <i>world</i>'],
602+
])('%s → %s', (input, expected) => {
603+
expect(normalizeHtml(input)).toBe(expected);
604+
});
605+
});
578606
});

src/web/normalization/htmlNormalizer.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -350,15 +350,33 @@ function escapeText(s: string): string {
350350

351351
// --- Blockquote content flattening ---
352352

353+
function isWhitespaceOnly(value: string): boolean {
354+
for (let i = 0; i < value.length; i++) {
355+
const c = value.charCodeAt(i);
356+
// space, tab, LF, CR, FF
357+
if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d && c !== 0x0c) {
358+
return false;
359+
}
360+
}
361+
return true;
362+
}
363+
364+
/**
365+
* Flush buffered inline content as a <p>. Inter-block whitespace (newlines /
366+
* spaces between block tags in pretty-printed HTML) is discarded so it does
367+
* not become empty paragraphs that later serialize as extra <br>s.
368+
*/
353369
function flushInlineP(
354370
ib: { buf: string },
355371
out: { buf: string },
356372
attrs = ''
357-
): void {
358-
if (ib.buf.length > 0) {
373+
): boolean {
374+
const emitted = ib.buf.length > 0 && !isWhitespaceOnly(ib.buf);
375+
if (emitted) {
359376
out.buf += `<p${attrs}>${ib.buf}</p>`;
360-
ib.buf = '';
361377
}
378+
ib.buf = '';
379+
return emitted;
362380
}
363381

364382
function flattenBqChildren(
@@ -525,9 +543,8 @@ function walkChildren(node: Element, out: { buf: string }): void {
525543
break;
526544
}
527545
if (isElement(cur) && isBrNode(cur)) {
528-
if (ib.buf.length > 0) {
529-
flushInlineP(ib, out);
530-
} else {
546+
// Whitespace-only buffer is layout noise; treat like empty → <br>
547+
if (!flushInlineP(ib, out)) {
531548
out.buf += '<br>';
532549
}
533550
i++;

0 commit comments

Comments
 (0)