Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 65 additions & 9 deletions cpp/parser/GumboNormalizer.c
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,53 @@ static void emit_one_attr(buffer_t *out, GumboElement *el,
}
}

/* Tags that may carry a text-align style in our canonical output */
static bool is_alignable_tag(const char *tag_name) {
return strcmp(tag_name, "p") == 0 || strcmp(tag_name, "ul") == 0 ||
strcmp(tag_name, "ol") == 0 || strcmp(tag_name, "h1") == 0 ||
strcmp(tag_name, "h2") == 0 || strcmp(tag_name, "h3") == 0 ||
strcmp(tag_name, "h4") == 0 || strcmp(tag_name, "h5") == 0 ||
strcmp(tag_name, "h6") == 0;
}

static const char *canonical_alignment(const char *val, size_t val_len) {
static const char *aligns[] = {"left", "center", "right", "justify"};
for (size_t i = 0; i < 4; i++) {
size_t alen = strlen(aligns[i]);
if (alen != val_len)
continue;
bool match = true;
for (size_t j = 0; j < val_len; j++) {
if (tolower((unsigned char)val[j]) != aligns[i][j]) {
match = false;
break;
}
}
if (match)
return aligns[i];
}
return NULL;
}

static void emit_alignment(GumboElement *el, const char *tag_name,
buffer_t *out) {
if (!is_alignable_tag(tag_name))
return;
const char *style = get_attr(el, "style");
if (!style)
return;
size_t vlen;
const char *val = find_css_value(style, strlen(style), "text-align", &vlen);
if (!val)
return;
const char *canon = canonical_alignment(val, vlen);
if (!canon)
return;
buffer_append_str(out, " style=\"text-align: ");
buffer_append_str(out, canon);
buffer_append_str(out, "\"");
}

static bool is_checkbox_list(GumboElement *el) {
const char *val = get_attr(el, "data-type");
if (val && (strcmp(val, "checkbox") == 0 || strcmp(val, "checkboxList") == 0)) {
Expand Down Expand Up @@ -452,6 +499,7 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
if (is_checkbox_list(el)) {
buffer_append_str(out, " data-type=\"checkbox\"");
}
emit_alignment(el, tag_name, out);
} else if (strcmp(tag_name, "li") == 0) {
const char *data_checked = get_attr(el, "data-checked");
const char *aria_checked = get_attr(el, "aria-checked");
Expand All @@ -468,6 +516,9 @@ static void emit_attributes(GumboElement *el, const char *tag_name,
emit_one_attr(out, el, "id");
emit_one_attr(out, el, "text");
emit_one_attr(out, el, "indicator");
} else {
/* preserve text-align */
emit_alignment(el, tag_name, out);
}
}

Expand Down Expand Up @@ -497,9 +548,13 @@ 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) {
static void flush_inline_p(buffer_t *ib, buffer_t *out,
GumboElement *align_el) {
if (ib->len > 0) {
buffer_append_str(out, "<p>");
buffer_append_str(out, "<p");
if (align_el)
emit_alignment(align_el, "p", out);
buffer_append_str(out, ">");
buffer_append(out, ib->data, ib->len);
buffer_append_str(out, "</p>");
buffer_clear(ib);
Expand All @@ -526,13 +581,14 @@ static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out) {
return;
}
if (is_br_node(node)) {
flush_inline_p(ib, out);
flush_inline_p(ib, out, NULL);
return;
}
if (is_block_producing(node) || is_blockquote_node(node)) {
flush_inline_p(ib, out);
flush_inline_p(ib, out, NULL);
flatten_bq_children(node, ib, out);
flush_inline_p(ib, out);
// The flattened block becomes a <p>; carry over its text-align (if any).
flush_inline_p(ib, out, &node->v.element);
return;
}
walk_node(node, ib);
Expand Down Expand Up @@ -660,7 +716,7 @@ static void walk_children(GumboNode *node, buffer_t *out) {
flatten_bq_children(children->data[i], &bq_ib, out);
i++;
}
flush_inline_p(&bq_ib, out);
flush_inline_p(&bq_ib, out, NULL);
free(bq_ib.data);
buffer_append_str(out, "</blockquote>");
continue;
Expand All @@ -675,23 +731,23 @@ static void walk_children(GumboNode *node, buffer_t *out) {
child = children->data[i];
if (is_br_node(child)) {
if (ib.len > 0)
flush_inline_p(&ib, out);
flush_inline_p(&ib, out, NULL);
else
buffer_append_str(out, "<br>");
i++;
continue;
}
/* Transparent inline wrapper for block/bq children */
if (is_element(child) && has_block_or_bq_child(child)) {
flush_inline_p(&ib, out);
flush_inline_p(&ib, out, NULL);
walk_children(child, out);
i++;
continue;
}
walk_node(child, &ib);
i++;
}
flush_inline_p(&ib, out);
flush_inline_p(&ib, out, NULL);
free(ib.data);
continue;
}
Expand Down
62 changes: 62 additions & 0 deletions cpp/tests/GumboParserTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,65 @@ TEST(GumboParserTest, BrRemappings) {
"<p><b>Asdasdasd</b></p><br><br><p>Sent with <a "
"href=\"https://google.com\">Net</a></p>");
}

// Preserve text alignment
TEST(GumboParserTest, TextAlignment) {
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: left\">x</p>"),
"<p style=\"text-align: left\">x</p>");
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: center\">x</p>"),
"<p style=\"text-align: center\">x</p>");
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: right\">x</p>"),
"<p style=\"text-align: right\">x</p>");
EXPECT_EQ(
GumboParser::normalizeHtml("<p style=\"text-align: justify\">x</p>"),
"<p style=\"text-align: justify\">x</p>");

EXPECT_EQ(GumboParser::normalizeHtml(
"<ul style=\"text-align: center\"><li>x</li></ul>"),
"<ul style=\"text-align: center\"><li>x</li></ul>");
EXPECT_EQ(GumboParser::normalizeHtml(
"<ol style=\"text-align: right\"><li>x</li></ol>"),
"<ol style=\"text-align: right\"><li>x</li></ol>");
EXPECT_EQ(GumboParser::normalizeHtml(
"<ul data-type=\"checkbox\" style=\"text-align: center\">"
"<li>x</li></ul>"),
"<ul data-type=\"checkbox\" style=\"text-align: center\">"
"<li>x</li></ul>");

EXPECT_EQ(
GumboParser::normalizeHtml("<h1 style=\"text-align: center\">x</h1>"),
"<h1 style=\"text-align: center\">x</h1>");
EXPECT_EQ(
GumboParser::normalizeHtml("<h6 style=\"text-align: justify\">x</h6>"),
"<h6 style=\"text-align: justify\">x</h6>");

// Value is normalized to lowercase
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: CENTER\">x</p>"),
"<p style=\"text-align: center\">x</p>");

// Coexists with inline formatting on the same tag
EXPECT_EQ(GumboParser::normalizeHtml(
"<p style=\"font-weight: bold; text-align: center\">x</p>"),
"<p style=\"text-align: center\"><b>x</b></p>");

// Invalid value is stripped
EXPECT_EQ(GumboParser::normalizeHtml("<p style=\"text-align: bogus\">x</p>"),
"<p>x</p>");

// Not emitted on non-alignable tags
EXPECT_EQ(GumboParser::normalizeHtml(
"<ul><li style=\"text-align: center\">x</li></ul>"),
"<ul><li>x</li></ul>");
EXPECT_EQ(GumboParser::normalizeHtml(
"<blockquote style=\"text-align: center\">x</blockquote>"),
"<blockquote><p>x</p></blockquote>");

// Preserved per-paragraph when a <p> blocks are flattened
EXPECT_EQ(GumboParser::normalizeHtml(
"<blockquote><p style=\"text-align: left\">l</p>"
"<p style=\"text-align: center\">c</p>"
"<p style=\"text-align: right\">r</p></blockquote>"),
"<blockquote><p style=\"text-align: left\">l</p>"
"<p style=\"text-align: center\">c</p>"
"<p style=\"text-align: right\">r</p></blockquote>");
}
78 changes: 78 additions & 0 deletions src/web/__tests__/htmlNormalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,82 @@ describe('htmlNormalizer', () => {
expect(normalizeHtml(input)).toBe(expected);
});
});

// Preserve text alignment
describe('TextAlignment', () => {
test.each([
[
'<p style="text-align: left">x</p>',
'<p style="text-align: left">x</p>',
],
[
'<p style="text-align: center">x</p>',
'<p style="text-align: center">x</p>',
],
[
'<p style="text-align: right">x</p>',
'<p style="text-align: right">x</p>',
],
[
'<p style="text-align: justify">x</p>',
'<p style="text-align: justify">x</p>',
],

[
'<ul style="text-align: center"><li>x</li></ul>',
'<ul style="text-align: center"><li>x</li></ul>',
],
[
'<ol style="text-align: right"><li>x</li></ol>',
'<ol style="text-align: right"><li>x</li></ol>',
],
[
'<ul data-type="checkbox" style="text-align: center"><li>x</li></ul>',
'<ul data-type="checkbox" style="text-align: center"><li>x</li></ul>',
],

[
'<h1 style="text-align: center">x</h1>',
'<h1 style="text-align: center">x</h1>',
],
[
'<h6 style="text-align: justify">x</h6>',
'<h6 style="text-align: justify">x</h6>',
],

// Value is normalized to lowercase
[
'<p style="text-align: CENTER">x</p>',
'<p style="text-align: center">x</p>',
],

// Coexists with inline formatting on the same tag
[
'<p style="font-weight: bold; text-align: center">x</p>',
'<p style="text-align: center"><b>x</b></p>',
],

// Invalid value is stripped
['<p style="text-align: bogus">x</p>', '<p>x</p>'],

// Not emitted on non-alignable tags
['<ul><li style="text-align: center">x</li></ul>', '<ul><li>x</li></ul>'],
[
'<blockquote style="text-align: center">x</blockquote>',
'<blockquote><p>x</p></blockquote>',
],

// Preserved per-paragraph when a <p> blocks are flattened
[
'<blockquote><p style="text-align: left">l</p>' +
'<p style="text-align: center">c</p>' +
'<p style="text-align: right">r</p></blockquote>',
'<blockquote><p style="text-align: left">l</p>' +
'<p style="text-align: center">c</p>' +
'<p style="text-align: right">r</p></blockquote>',
],
])('%s → %s', (input, expected) => {
expect(normalizeHtml(input)).toBe(expected);
});
});
});
42 changes: 37 additions & 5 deletions src/web/normalization/htmlNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,29 @@ function emitStylesClose(s: CssStyles): string {
return out;
}

// Tags that may carry a text-align style in our canonical output
const ALIGN_TAGS = new Set([
'p',
'ul',
'ol',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
]);
const ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify']);

function emitAlignment(el: Element, name: string): string {
if (!ALIGN_TAGS.has(name)) return '';
const align = findCssValue(el.getAttribute('style') ?? '', 'text-align');
if (!align) return '';
const lower = align.toLowerCase();
if (!ALIGN_VALUES.has(lower)) return '';
return ` style="text-align: ${lower}"`;
}

function emitOneAttr(el: Element, attr: string): string {
const val = el.getAttribute(attr);
if (val == null || val === '') return '';
Expand All @@ -250,7 +273,10 @@ function emitAttributes(el: Element, name: string): string {
emitOneAttr(el, 'height')
);
case 'ul':
return isCheckboxList(el) ? ' data-type="checkbox"' : '';
return (
(isCheckboxList(el) ? ' data-type="checkbox"' : '') +
emitAlignment(el, name)
);
case 'li':
// "" is U+F0FE (MS Word checked box); often encoded as "\xEF\x83\xBE" in UTF-8.
const isChecked =
Expand All @@ -266,7 +292,8 @@ function emitAttributes(el: Element, name: string): string {
emitOneAttr(el, 'indicator')
);
default:
return '';
// preserve text-align
return emitAlignment(el, name);
}
}

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

// --- Blockquote content flattening ---

function flushInlineP(ib: { buf: string }, out: { buf: string }): void {
function flushInlineP(
ib: { buf: string },
out: { buf: string },
attrs = ''
): void {
if (ib.buf.length > 0) {
out.buf += `<p>${ib.buf}</p>`;
out.buf += `<p${attrs}>${ib.buf}</p>`;
ib.buf = '';
}
}
Expand Down Expand Up @@ -358,7 +389,8 @@ function flattenBqNode(
if (isBlockProducing(node) || isBlockquoteNode(node)) {
flushInlineP(ib, out);
flattenBqChildren(node, ib, out);
flushInlineP(ib, out);
// The flattened block becomes a <p>; carry over its text-align (if any).
flushInlineP(ib, out, emitAlignment(node, 'p'));
return;
}
walkNode(node, ib);
Expand Down
Loading