Skip to content

Commit 4bc6307

Browse files
authored
feat: normalizer preserves text alignment styles (#702)
# Summary HTML normalizer did not preserve `textAlignment` styling attributes, so `textAlignment` was lost on re-normalization. - implemented the fix - added relevant normalizer tests ## Test Plan - run added tests When you used this html in the `Set input's value` in the example app before the fix, the text alignment was lost ```html <ul style="text-align: right"><li>Something</li></ul> ``` ## Videos Before: https://github.com/user-attachments/assets/7e357934-7e0f-4984-9370-8c497c989c2f After: The text alignment is preserved https://github.com/user-attachments/assets/0ae5a723-5dd2-4813-bebc-5af48d8e8a8d ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ✅ | | Android | ✅ | | Web | ✅ | ## Checklist - [ ] E2E tests are passing - [ ] Required E2E tests have been added (if applicable)
1 parent 7db3d82 commit 4bc6307

4 files changed

Lines changed: 242 additions & 14 deletions

File tree

cpp/parser/GumboNormalizer.c

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,53 @@ static void emit_one_attr(buffer_t *out, GumboElement *el,
411411
}
412412
}
413413

414+
/* Tags that may carry a text-align style in our canonical output */
415+
static bool is_alignable_tag(const char *tag_name) {
416+
return strcmp(tag_name, "p") == 0 || strcmp(tag_name, "ul") == 0 ||
417+
strcmp(tag_name, "ol") == 0 || strcmp(tag_name, "h1") == 0 ||
418+
strcmp(tag_name, "h2") == 0 || strcmp(tag_name, "h3") == 0 ||
419+
strcmp(tag_name, "h4") == 0 || strcmp(tag_name, "h5") == 0 ||
420+
strcmp(tag_name, "h6") == 0;
421+
}
422+
423+
static const char *canonical_alignment(const char *val, size_t val_len) {
424+
static const char *aligns[] = {"left", "center", "right", "justify"};
425+
for (size_t i = 0; i < 4; i++) {
426+
size_t alen = strlen(aligns[i]);
427+
if (alen != val_len)
428+
continue;
429+
bool match = true;
430+
for (size_t j = 0; j < val_len; j++) {
431+
if (tolower((unsigned char)val[j]) != aligns[i][j]) {
432+
match = false;
433+
break;
434+
}
435+
}
436+
if (match)
437+
return aligns[i];
438+
}
439+
return NULL;
440+
}
441+
442+
static void emit_alignment(GumboElement *el, const char *tag_name,
443+
buffer_t *out) {
444+
if (!is_alignable_tag(tag_name))
445+
return;
446+
const char *style = get_attr(el, "style");
447+
if (!style)
448+
return;
449+
size_t vlen;
450+
const char *val = find_css_value(style, strlen(style), "text-align", &vlen);
451+
if (!val)
452+
return;
453+
const char *canon = canonical_alignment(val, vlen);
454+
if (!canon)
455+
return;
456+
buffer_append_str(out, " style=\"text-align: ");
457+
buffer_append_str(out, canon);
458+
buffer_append_str(out, "\"");
459+
}
460+
414461
static bool is_checkbox_list(GumboElement *el) {
415462
const char *val = get_attr(el, "data-type");
416463
if (val && (strcmp(val, "checkbox") == 0 || strcmp(val, "checkboxList") == 0)) {
@@ -452,6 +499,7 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
452499
if (is_checkbox_list(el)) {
453500
buffer_append_str(out, " data-type=\"checkbox\"");
454501
}
502+
emit_alignment(el, tag_name, out);
455503
} else if (strcmp(tag_name, "li") == 0) {
456504
const char *data_checked = get_attr(el, "data-checked");
457505
const char *aria_checked = get_attr(el, "aria-checked");
@@ -468,6 +516,9 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
468516
emit_one_attr(out, el, "id");
469517
emit_one_attr(out, el, "text");
470518
emit_one_attr(out, el, "indicator");
519+
} else {
520+
/* preserve text-align */
521+
emit_alignment(el, tag_name, out);
471522
}
472523
}
473524

@@ -497,9 +548,13 @@ static void walk_node(GumboNode *node, buffer_t *out);
497548

498549
static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out);
499550

500-
static void flush_inline_p(buffer_t *ib, buffer_t *out) {
551+
static void flush_inline_p(buffer_t *ib, buffer_t *out,
552+
GumboElement *align_el) {
501553
if (ib->len > 0) {
502-
buffer_append_str(out, "<p>");
554+
buffer_append_str(out, "<p");
555+
if (align_el)
556+
emit_alignment(align_el, "p", out);
557+
buffer_append_str(out, ">");
503558
buffer_append(out, ib->data, ib->len);
504559
buffer_append_str(out, "</p>");
505560
buffer_clear(ib);
@@ -526,13 +581,14 @@ static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out) {
526581
return;
527582
}
528583
if (is_br_node(node)) {
529-
flush_inline_p(ib, out);
584+
flush_inline_p(ib, out, NULL);
530585
return;
531586
}
532587
if (is_block_producing(node) || is_blockquote_node(node)) {
533-
flush_inline_p(ib, out);
588+
flush_inline_p(ib, out, NULL);
534589
flatten_bq_children(node, ib, out);
535-
flush_inline_p(ib, out);
590+
// The flattened block becomes a <p>; carry over its text-align (if any).
591+
flush_inline_p(ib, out, &node->v.element);
536592
return;
537593
}
538594
walk_node(node, ib);
@@ -660,7 +716,7 @@ static void walk_children(GumboNode *node, buffer_t *out) {
660716
flatten_bq_children(children->data[i], &bq_ib, out);
661717
i++;
662718
}
663-
flush_inline_p(&bq_ib, out);
719+
flush_inline_p(&bq_ib, out, NULL);
664720
free(bq_ib.data);
665721
buffer_append_str(out, "</blockquote>");
666722
continue;
@@ -675,23 +731,23 @@ static void walk_children(GumboNode *node, buffer_t *out) {
675731
child = children->data[i];
676732
if (is_br_node(child)) {
677733
if (ib.len > 0)
678-
flush_inline_p(&ib, out);
734+
flush_inline_p(&ib, out, NULL);
679735
else
680736
buffer_append_str(out, "<br>");
681737
i++;
682738
continue;
683739
}
684740
/* Transparent inline wrapper for block/bq children */
685741
if (is_element(child) && has_block_or_bq_child(child)) {
686-
flush_inline_p(&ib, out);
742+
flush_inline_p(&ib, out, NULL);
687743
walk_children(child, out);
688744
i++;
689745
continue;
690746
}
691747
walk_node(child, &ib);
692748
i++;
693749
}
694-
flush_inline_p(&ib, out);
750+
flush_inline_p(&ib, out, NULL);
695751
free(ib.data);
696752
continue;
697753
}

cpp/tests/GumboParserTest.cpp

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,3 +509,65 @@ TEST(GumboParserTest, BrRemappings) {
509509
"<p><b>Asdasdasd</b></p><br><br><p>Sent with <a "
510510
"href=\"https://google.com\">Net</a></p>");
511511
}
512+
513+
// Preserve text alignment
514+
TEST(GumboParserTest, TextAlignment) {
515+
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: left\">x</p>"),
516+
"<p style=\"text-align: left\">x</p>");
517+
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: center\">x</p>"),
518+
"<p style=\"text-align: center\">x</p>");
519+
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: right\">x</p>"),
520+
"<p style=\"text-align: right\">x</p>");
521+
EXPECT_EQ(
522+
GumboParser::normalizeHtml("<p style=\"text-align: justify\">x</p>"),
523+
"<p style=\"text-align: justify\">x</p>");
524+
525+
EXPECT_EQ(GumboParser::normalizeHtml(
526+
"<ul style=\"text-align: center\"><li>x</li></ul>"),
527+
"<ul style=\"text-align: center\"><li>x</li></ul>");
528+
EXPECT_EQ(GumboParser::normalizeHtml(
529+
"<ol style=\"text-align: right\"><li>x</li></ol>"),
530+
"<ol style=\"text-align: right\"><li>x</li></ol>");
531+
EXPECT_EQ(GumboParser::normalizeHtml(
532+
"<ul data-type=\"checkbox\" style=\"text-align: center\">"
533+
"<li>x</li></ul>"),
534+
"<ul data-type=\"checkbox\" style=\"text-align: center\">"
535+
"<li>x</li></ul>");
536+
537+
EXPECT_EQ(
538+
GumboParser::normalizeHtml("<h1 style=\"text-align: center\">x</h1>"),
539+
"<h1 style=\"text-align: center\">x</h1>");
540+
EXPECT_EQ(
541+
GumboParser::normalizeHtml("<h6 style=\"text-align: justify\">x</h6>"),
542+
"<h6 style=\"text-align: justify\">x</h6>");
543+
544+
// Value is normalized to lowercase
545+
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: CENTER\">x</p>"),
546+
"<p style=\"text-align: center\">x</p>");
547+
548+
// Coexists with inline formatting on the same tag
549+
EXPECT_EQ(GumboParser::normalizeHtml(
550+
"<p style=\"font-weight: bold; text-align: center\">x</p>"),
551+
"<p style=\"text-align: center\"><b>x</b></p>");
552+
553+
// Invalid value is stripped
554+
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: bogus\">x</p>"),
555+
"<p>x</p>");
556+
557+
// Not emitted on non-alignable tags
558+
EXPECT_EQ(GumboParser::normalizeHtml(
559+
"<ul><li style=\"text-align: center\">x</li></ul>"),
560+
"<ul><li>x</li></ul>");
561+
EXPECT_EQ(GumboParser::normalizeHtml(
562+
"<blockquote style=\"text-align: center\">x</blockquote>"),
563+
"<blockquote><p>x</p></blockquote>");
564+
565+
// Preserved per-paragraph when a <p> blocks are flattened
566+
EXPECT_EQ(GumboParser::normalizeHtml(
567+
"<blockquote><p style=\"text-align: left\">l</p>"
568+
"<p style=\"text-align: center\">c</p>"
569+
"<p style=\"text-align: right\">r</p></blockquote>"),
570+
"<blockquote><p style=\"text-align: left\">l</p>"
571+
"<p style=\"text-align: center\">c</p>"
572+
"<p style=\"text-align: right\">r</p></blockquote>");
573+
}

src/web/__tests__/htmlNormalizer.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,4 +497,82 @@ describe('htmlNormalizer', () => {
497497
expect(normalizeHtml(input)).toBe(expected);
498498
});
499499
});
500+
501+
// Preserve text alignment
502+
describe('TextAlignment', () => {
503+
test.each([
504+
[
505+
'<p style="text-align: left">x</p>',
506+
'<p style="text-align: left">x</p>',
507+
],
508+
[
509+
'<p style="text-align: center">x</p>',
510+
'<p style="text-align: center">x</p>',
511+
],
512+
[
513+
'<p style="text-align: right">x</p>',
514+
'<p style="text-align: right">x</p>',
515+
],
516+
[
517+
'<p style="text-align: justify">x</p>',
518+
'<p style="text-align: justify">x</p>',
519+
],
520+
521+
[
522+
'<ul style="text-align: center"><li>x</li></ul>',
523+
'<ul style="text-align: center"><li>x</li></ul>',
524+
],
525+
[
526+
'<ol style="text-align: right"><li>x</li></ol>',
527+
'<ol style="text-align: right"><li>x</li></ol>',
528+
],
529+
[
530+
'<ul data-type="checkbox" style="text-align: center"><li>x</li></ul>',
531+
'<ul data-type="checkbox" style="text-align: center"><li>x</li></ul>',
532+
],
533+
534+
[
535+
'<h1 style="text-align: center">x</h1>',
536+
'<h1 style="text-align: center">x</h1>',
537+
],
538+
[
539+
'<h6 style="text-align: justify">x</h6>',
540+
'<h6 style="text-align: justify">x</h6>',
541+
],
542+
543+
// Value is normalized to lowercase
544+
[
545+
'<p style="text-align: CENTER">x</p>',
546+
'<p style="text-align: center">x</p>',
547+
],
548+
549+
// Coexists with inline formatting on the same tag
550+
[
551+
'<p style="font-weight: bold; text-align: center">x</p>',
552+
'<p style="text-align: center"><b>x</b></p>',
553+
],
554+
555+
// Invalid value is stripped
556+
['<p style="text-align: bogus">x</p>', '<p>x</p>'],
557+
558+
// Not emitted on non-alignable tags
559+
['<ul><li style="text-align: center">x</li></ul>', '<ul><li>x</li></ul>'],
560+
[
561+
'<blockquote style="text-align: center">x</blockquote>',
562+
'<blockquote><p>x</p></blockquote>',
563+
],
564+
565+
// Preserved per-paragraph when a <p> blocks are flattened
566+
[
567+
'<blockquote><p style="text-align: left">l</p>' +
568+
'<p style="text-align: center">c</p>' +
569+
'<p style="text-align: right">r</p></blockquote>',
570+
'<blockquote><p style="text-align: left">l</p>' +
571+
'<p style="text-align: center">c</p>' +
572+
'<p style="text-align: right">r</p></blockquote>',
573+
],
574+
])('%s → %s', (input, expected) => {
575+
expect(normalizeHtml(input)).toBe(expected);
576+
});
577+
});
500578
});

src/web/normalization/htmlNormalizer.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,29 @@ function emitStylesClose(s: CssStyles): string {
231231
return out;
232232
}
233233

234+
// Tags that may carry a text-align style in our canonical output
235+
const ALIGN_TAGS = new Set([
236+
'p',
237+
'ul',
238+
'ol',
239+
'h1',
240+
'h2',
241+
'h3',
242+
'h4',
243+
'h5',
244+
'h6',
245+
]);
246+
const ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify']);
247+
248+
function emitAlignment(el: Element, name: string): string {
249+
if (!ALIGN_TAGS.has(name)) return '';
250+
const align = findCssValue(el.getAttribute('style') ?? '', 'text-align');
251+
if (!align) return '';
252+
const lower = align.toLowerCase();
253+
if (!ALIGN_VALUES.has(lower)) return '';
254+
return ` style="text-align: ${lower}"`;
255+
}
256+
234257
function emitOneAttr(el: Element, attr: string): string {
235258
const val = el.getAttribute(attr);
236259
if (val == null || val === '') return '';
@@ -250,7 +273,10 @@ function emitAttributes(el: Element, name: string): string {
250273
emitOneAttr(el, 'height')
251274
);
252275
case 'ul':
253-
return isCheckboxList(el) ? ' data-type="checkbox"' : '';
276+
return (
277+
(isCheckboxList(el) ? ' data-type="checkbox"' : '') +
278+
emitAlignment(el, name)
279+
);
254280
case 'li':
255281
// "" is U+F0FE (MS Word checked box); often encoded as "\xEF\x83\xBE" in UTF-8.
256282
const isChecked =
@@ -266,7 +292,8 @@ function emitAttributes(el: Element, name: string): string {
266292
emitOneAttr(el, 'indicator')
267293
);
268294
default:
269-
return '';
295+
// preserve text-align
296+
return emitAlignment(el, name);
270297
}
271298
}
272299

@@ -323,9 +350,13 @@ function escapeText(s: string): string {
323350

324351
// --- Blockquote content flattening ---
325352

326-
function flushInlineP(ib: { buf: string }, out: { buf: string }): void {
353+
function flushInlineP(
354+
ib: { buf: string },
355+
out: { buf: string },
356+
attrs = ''
357+
): void {
327358
if (ib.buf.length > 0) {
328-
out.buf += `<p>${ib.buf}</p>`;
359+
out.buf += `<p${attrs}>${ib.buf}</p>`;
329360
ib.buf = '';
330361
}
331362
}
@@ -358,7 +389,8 @@ function flattenBqNode(
358389
if (isBlockProducing(node) || isBlockquoteNode(node)) {
359390
flushInlineP(ib, out);
360391
flattenBqChildren(node, ib, out);
361-
flushInlineP(ib, out);
392+
// The flattened block becomes a <p>; carry over its text-align (if any).
393+
flushInlineP(ib, out, emitAlignment(node, 'p'));
362394
return;
363395
}
364396
walkNode(node, ib);

0 commit comments

Comments
 (0)