diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java index 4188480a5..c951aae05 100644 --- a/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java +++ b/android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java @@ -374,13 +374,21 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, } if (fontFamily != null && !fontFamily.isEmpty()) { if (cssProps.length() > 0) cssProps.append(" "); - if (fontFamily.indexOf(' ') >= 0) { + if (fontFamily.indexOf(' ') >= 0 || fontFamily.indexOf(',') >= 0) { cssProps.append("font-family: '").append(fontFamily).append("';"); } else { cssProps.append("font-family: ").append(fontFamily).append(";"); } } - out.append(""); + out.append("", ">") + .replace("\"", """)) + .append("\">"); } else { out.append(""); } @@ -1032,14 +1040,30 @@ private static void startSpan(Editable text, Attributes attributes) { private static String parseCssFontFamily(String raw) { if (raw == null) return null; - String value = raw.trim(); - if ((value.startsWith("'") && value.endsWith("'")) - || (value.startsWith("\"") && value.endsWith("\""))) { + String value = firstInFontFamilyString(raw).trim(); + if (value.length() >= 2 + && ((value.startsWith("'") && value.endsWith("'")) + || (value.startsWith("\"") && value.endsWith("\"")))) { value = value.substring(1, value.length() - 1); } return value.isEmpty() ? null : value; } + private static String firstInFontFamilyString(String value) { + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == quote) quote = 0; + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == ',') { + return value.substring(0, i); + } + } + return value; + } + private static void endSpan(Editable text, T style, EnrichedSpanFactory spanFactory) { CustomStyleMark mark = getLast(text, CustomStyleMark.class); if (mark == null) return; diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt index a41cfe0a0..9f6f4927b 100644 --- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedCustomStyleSpan.kt @@ -50,8 +50,6 @@ open class EnrichedCustomStyleSpan( protected fun getAssets(): AssetManager = assets - protected fun getAllowFontScaling(): Boolean = allowFontScaling - override fun updateMeasureState(textPaint: TextPaint) { applyFontState(textPaint) } diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt index 4f82aca95..05281984a 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt @@ -59,6 +59,7 @@ data class EnrichedTextStyle( override val inlineCodeBackgroundColor: Int, // Mentions override val mentionsStyle: Map, + val allowFontScaling: Boolean, ) : EnrichedStyle { companion object { fun fromReadableMap( @@ -120,6 +121,7 @@ data class EnrichedTextStyle( inlineCodeColor = parseColor(context, inlineCode, "color"), inlineCodeBackgroundColor = parseColorWithOpacity(context, inlineCode, "backgroundColor"), mentionsStyle = parseMentionsStyle(context, mentions), + allowFontScaling = allowFontScaling, ) } diff --git a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt index fdb6566b6..1aad8094a 100644 --- a/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/text/spans/EnrichedTextCustomStyleSpan.kt @@ -30,6 +30,6 @@ class EnrichedTextCustomStyleSpan( getFontSize(), getFontFamily(), getAssets(), - getAllowFontScaling(), + style.allowFontScaling, ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index b0f403e5b..7e7342731 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -1051,6 +1051,7 @@ class EnrichedTextInputView : runAsATransaction { customStyles?.setStyle(styleJSON) } + layoutManager.invalidateLayout() } fun requestHTML(requestId: Int) { diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt index da78595cb..34ded64ef 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedInputCustomStyleSpan.kt @@ -21,7 +21,9 @@ class EnrichedInputCustomStyleSpan( allowFontScaling, ), EnrichedInputSpan { - override val dependsOnHtmlStyle: Boolean = false + // font size depends on the htmlStyle's allowFontScaling + override val dependsOnHtmlStyle: Boolean + get() = getFontSize() != null override fun rebuildWithStyle(htmlStyle: HtmlStyle): EnrichedInputCustomStyleSpan = EnrichedInputCustomStyleSpan( @@ -30,6 +32,6 @@ class EnrichedInputCustomStyleSpan( getFontSize(), getFontFamily(), getAssets(), - getAllowFontScaling(), + htmlStyle.allowFontScaling, ) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt index ee71a6234..5a67113a5 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt @@ -74,6 +74,9 @@ class HtmlStyle : EnrichedStyle { override var mentionsStyle: MutableMap = mutableMapOf() + val allowFontScaling: Boolean + get() = view?.allowFontScaling ?: EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT + constructor(view: EnrichedTextInputView?, style: ReadableMap?) { this.view = view this.style = style @@ -157,7 +160,7 @@ class HtmlStyle : EnrichedStyle { ): Float { val safeMap = ensureValueIsSet(map, key) val value = safeMap.getDouble(key) - return ceil(pixelFromSpOrDp(value, view?.allowFontScaling ?: EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT)) + return ceil(pixelFromSpOrDp(value, allowFontScaling)) } private fun parseColorWithOpacity( diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 2ccbcd885..25a20af49 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -28,6 +28,7 @@ #include #include #include +#include /* ------------------------------------------------------------------ */ /* Dynamic string buffer */ @@ -219,6 +220,20 @@ static bool is_purely_inline(GumboNode *node) { return true; } +static bool is_table_tag(const char *name) { + return strcmp(name, "table") == 0 || strcmp(name, "thead") == 0 || + strcmp(name, "tbody") == 0 || strcmp(name, "tfoot") == 0 || + strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || + strcmp(name, "th") == 0 || strcmp(name, "caption") == 0 || + strcmp(name, "colgroup") == 0 || strcmp(name, "col") == 0; +} + +/** Table tags that hold inline text rather than other table tags. */ +static bool is_table_text_tag(const char *name) { + return strcmp(name, "td") == 0 || strcmp(name, "th") == 0 || + strcmp(name, "caption") == 0; +} + /** True if any direct child is block-producing or a blockquote. */ static bool has_block_or_bq_child(GumboNode *node) { if (!is_element(node)) @@ -236,11 +251,24 @@ static bool has_block_or_bq_child(GumboNode *node) { /* CSS style → canonical tag mapping (simple string matching) */ /* ------------------------------------------------------------------ */ +typedef struct { + const char *val; + size_t len; +} css_slice_t; + +typedef struct { + css_slice_t fg; + css_slice_t bg; + css_slice_t font_size; + css_slice_t font_family; +} custom_style_t; + typedef struct { bool bold; bool italic; bool underline; bool strikethrough; + custom_style_t custom; } css_styles_t; static const char *find_css_value(const char *style, size_t style_len, @@ -281,6 +309,26 @@ static const char *find_css_value(const char *style, size_t style_len, return NULL; } +static void append_style_prop(buffer_t *out, const char *prefix, + const char *val, size_t val_len, bool *first) { + if (!*first) + buffer_append_str(out, "; "); + *first = false; + buffer_append_str(out, prefix); + for (size_t i = 0; i < val_len; i++) { + if (val[i] == '&') + buffer_append_str(out, "&"); + else if (val[i] == '"') + buffer_append_str(out, """); + else if (val[i] == '<') + buffer_append_str(out, "<"); + else if (val[i] == '>') + buffer_append_str(out, ">"); + else + buffer_append(out, &val[i], 1); + } +} + static bool css_val_contains(const char *val, size_t val_len, const char *needle) { size_t nlen = strlen(needle); @@ -301,8 +349,24 @@ static bool css_val_contains(const char *val, size_t val_len, return false; } +static bool css_value_is(css_slice_t value, const char *keyword) { + return value.len == strlen(keyword) && + strncasecmp(value.val, keyword, value.len) == 0; +} + +static css_slice_t find_custom_css_value(const char *style, size_t style_len, + const char *prop_name) { + css_slice_t slice = {NULL, 0}; + slice.val = find_css_value(style, style_len, prop_name, &slice.len); + // `inherit` must not overwrite the parent span's value + if (!slice.val || css_value_is(slice, "inherit")) + return (css_slice_t){NULL, 0}; + return slice; +} + static css_styles_t parse_css_style(const char *style_value, size_t style_len) { - css_styles_t result = {false, false, false, false}; + css_styles_t result = { + false, false, false, false, {{NULL, 0}, {NULL, 0}, {NULL, 0}, {NULL, 0}}}; if (!style_value || style_len == 0) return result; @@ -351,6 +415,17 @@ static css_styles_t parse_css_style(const char *style_value, size_t style_len) { } } + result.custom.fg = find_custom_css_value(style_value, style_len, "color"); + result.custom.bg = + find_custom_css_value(style_value, style_len, "background-color"); + // `transparent` must not overwrite the parent span's background-color value + if (css_value_is(result.custom.bg, "transparent")) + result.custom.bg = (css_slice_t){NULL, 0}; + result.custom.font_size = + find_custom_css_value(style_value, style_len, "font-size"); + result.custom.font_family = + find_custom_css_value(style_value, style_len, "font-family"); + return result; } @@ -388,6 +463,26 @@ static void emit_styles_close(buffer_t *out, css_styles_t s) { buffer_append_str(out, ""); } +static bool custom_style_is_empty(custom_style_t s) { + return !s.fg.len && !s.bg.len && !s.font_size.len && !s.font_family.len; +} + +static void emit_custom_style_open(buffer_t *out, custom_style_t s) { + bool first = true; + buffer_append_str(out, ""); +} + /* ------------------------------------------------------------------ */ /* Attribute emission helpers */ /* ------------------------------------------------------------------ */ @@ -542,17 +637,45 @@ static bool is_google_docs_wrapper(GumboElement *el, const char *tag_name) { return (id_len > 20 && strncmp(id_val, "docs-internal-guid-", 19) == 0); } +/* ------------------------------------------------------------------ */ +/* Nested custom style spans */ +/* ------------------------------------------------------------------ */ + +static bool is_styled_span(GumboNode *node) { + char buf[64]; + const char *n = get_tag_name(node, buf, sizeof(buf)); + if (!n || strcmp(n, "span") != 0) + return false; + const char *sval = get_attr(&node->v.element, "style"); + return !custom_style_is_empty( + parse_css_style(sval, sval ? strlen(sval) : 0).custom); +} + +static bool has_styled_span_descendant(GumboNode *node) { + if (!is_element(node)) + return false; + GumboVector *children = &node->v.element.children; + for (unsigned int i = 0; i < children->length; i++) { + GumboNode *c = children->data[i]; + if (is_styled_span(c) || has_styled_span_descendant(c)) + return true; + } + return false; +} + /* ------------------------------------------------------------------ */ /* Recursive DOM tree walker */ /* ------------------------------------------------------------------ */ -static void walk_node(GumboNode *node, buffer_t *out); +static void walk_node(GumboNode *node, buffer_t *out, + const custom_style_t *inherited); /* ------------------------------------------------------------------ */ /* Blockquote content flattening */ /* ------------------------------------------------------------------ */ -static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out); +static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out, + const custom_style_t *inherited); /** True if buf is empty or contains only ASCII whitespace. */ static bool is_whitespace_only(const char *data, size_t len) { @@ -564,6 +687,18 @@ static bool is_whitespace_only(const char *data, size_t len) { return true; } +/** Whitespace-only text next to blocks or between table parts is just layout. + * E.g. `

123

\n

456

`. */ +static bool is_layout_whitespace(GumboNode *node) { + if (node->type != GUMBO_NODE_WHITESPACE) + return false; + char buf[64]; + const char *parent_name = get_tag_name(node->parent, buf, sizeof(buf)); + return parent_name && + (has_block_or_bq_child(node->parent) || + (is_table_tag(parent_name) && !is_table_text_tag(parent_name))); +} + /** * Flush buffered inline content as a

. Inter-block whitespace (newlines / * spaces between block tags in pretty-printed HTML) is discarded so it does @@ -584,20 +719,22 @@ static bool flush_inline_p(buffer_t *ib, buffer_t *out, return emitted; } -static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out) { +static void flatten_bq_children(GumboNode *node, buffer_t *ib, buffer_t *out, + const custom_style_t *inherited) { if (!is_element(node)) return; GumboVector *children = &node->v.element.children; for (unsigned int i = 0; i < children->length; i++) { - flatten_bq_node(children->data[i], ib, out); + flatten_bq_node(children->data[i], ib, out, inherited); } } -static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out) { +static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out, + const custom_style_t *inherited) { if (!node) return; if (is_text(node)) { - walk_node(node, ib); + walk_node(node, ib, inherited); return; } if (!is_element(node)) { @@ -613,12 +750,12 @@ static void flatten_bq_node(GumboNode *node, buffer_t *ib, buffer_t *out) { } if (is_block_producing(node) || is_blockquote_node(node)) { flush_inline_p(ib, out, NULL); - flatten_bq_children(node, ib, out); + flatten_bq_children(node, ib, out, inherited); // The flattened block becomes a

; carry over its text-align (if any). flush_inline_p(ib, out, &node->v.element); return; } - walk_node(node, ib); + walk_node(node, ib, inherited); } /* ------------------------------------------------------------------ */ @@ -632,6 +769,7 @@ typedef struct { int *nested_count; int max_nested; bool has_emitted; + const custom_style_t *inherited; } li_ctx_t; static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, @@ -666,7 +804,7 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, if (!node) return; if (is_text(node)) { - walk_node(node, ib); + walk_node(node, ib, ctx->inherited); return; } if (!is_element(node)) { @@ -701,14 +839,15 @@ static void flatten_li_node(GumboNode *node, buffer_t *ib, buffer_t *out, flush_li_buffer(ib, out, ctx); return; } - walk_node(node, ib); + walk_node(node, ib, ctx->inherited); } /* ------------------------------------------------------------------ */ /* walk_children — the main child-iteration driver */ /* ------------------------------------------------------------------ */ -static void walk_children(GumboNode *node, buffer_t *out) { +static void walk_children(GumboNode *node, buffer_t *out, + const custom_style_t *inherited) { if (!is_element(node)) return; @@ -730,7 +869,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* Flatten list-inside-list */ if (parent_is_list && is_list_node(child)) { - walk_children(child, out); + walk_children(child, out, inherited); i++; continue; } @@ -740,7 +879,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { buffer_append_str(out, "

"); buffer_t bq_ib = buffer_create(64); while (i < children->length && is_blockquote_node(children->data[i])) { - flatten_bq_children(children->data[i], &bq_ib, out); + flatten_bq_children(children->data[i], &bq_ib, out, inherited); i++; } flush_inline_p(&bq_ib, out, NULL); @@ -766,11 +905,11 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* Transparent inline wrapper for block/bq children */ if (is_element(child) && has_block_or_bq_child(child)) { flush_inline_p(&ib, out, NULL); - walk_children(child, out); + walk_children(child, out, inherited); i++; continue; } - walk_node(child, &ib); + walk_node(child, &ib, inherited); i++; } flush_inline_p(&ib, out, NULL); @@ -778,7 +917,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { continue; } - walk_node(child, out); + walk_node(child, out, inherited); i++; } } @@ -787,7 +926,8 @@ static void walk_children(GumboNode *node, buffer_t *out) { /* walk_node — process a single DOM node */ /* ------------------------------------------------------------------ */ -static void walk_node(GumboNode *node, buffer_t *out) { +static void walk_node(GumboNode *node, buffer_t *out, + const custom_style_t *inherited) { if (!node) return; @@ -796,6 +936,9 @@ static void walk_node(GumboNode *node, buffer_t *out) { const char *text_raw = node->v.text.text; if (text_raw) { size_t text_len = strlen(text_raw); + bool wrap = inherited && !is_layout_whitespace(node); + if (wrap) + emit_custom_style_open(out, *inherited); for (size_t i = 0; i < text_len; i++) { char c = text_raw[i]; switch (c) { @@ -813,19 +956,21 @@ static void walk_node(GumboNode *node, buffer_t *out) { break; } } + if (wrap) + buffer_append_str(out, ""); } return; } if (!is_element(node)) { - walk_children(node, out); + walk_children(node, out, inherited); return; } GumboElement *el = &node->v.element; char name_buf[64]; if (!get_tag_name(node, name_buf, sizeof(name_buf))) { - walk_children(node, out); + walk_children(node, out, inherited); return; } @@ -837,7 +982,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { /* Google Docs wrapper */ if (is_google_docs_wrapper(el, name_buf)) { - walk_children(node, out); + walk_children(node, out, inherited); return; } @@ -853,43 +998,43 @@ static void walk_node(GumboNode *node, buffer_t *out) { size_t slen = sval ? strlen(sval) : 0; css_styles_t s = parse_css_style(sval, slen); - size_t fg_len = 0; - const char *fg = find_css_value(sval, slen, "color", &fg_len); - - size_t bg_len = 0; - const char *bg = find_css_value(sval, slen, "background-color", &bg_len); - - int has_fg = (fg && fg_len > 0); - int has_bg = (bg && bg_len > 0); - - // emit the wrapper span if colors exist - if (has_fg || has_bg) { - buffer_append_str(out, ""); + } + if (wrap) { + emit_custom_style_open(out, style); } // handle inner formatting (, , etc.) and children emit_styles_open(out, s); - walk_children(node, out); + walk_children(node, out, children_style); emit_styles_close(out, s); // close the wrapper span - if (has_fg || has_bg) { + if (wrap) { buffer_append_str(out, ""); } @@ -921,7 +1066,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { buffer_clear(&pb); continue; } - walk_node(dc, &pb); + walk_node(dc, &pb, inherited); } if (pb.len > 0) { buffer_append_str(out, "

"); @@ -933,20 +1078,16 @@ static void walk_node(GumboNode *node, buffer_t *out) { free(pb.data); } else { emit_styles_open(out, s); - walk_children(node, out); + walk_children(node, out, inherited); emit_styles_close(out, s); } return; } /* --- Table elements --- */ - if (strcmp(name_buf, "table") == 0 || strcmp(name_buf, "thead") == 0 || - strcmp(name_buf, "tbody") == 0 || strcmp(name_buf, "tfoot") == 0 || - strcmp(name_buf, "tr") == 0 || strcmp(name_buf, "td") == 0 || - strcmp(name_buf, "th") == 0 || strcmp(name_buf, "caption") == 0 || - strcmp(name_buf, "colgroup") == 0 || strcmp(name_buf, "col") == 0) { + if (is_table_tag(name_buf)) { if (strcmp(name_buf, "td") == 0 || strcmp(name_buf, "th") == 0) { - walk_children(node, out); + walk_children(node, out, inherited); /* Check if there's a next sibling element */ GumboNode *parent = node->parent; if (parent && is_element(parent)) { @@ -966,7 +1107,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { } } else if (strcmp(name_buf, "tr") == 0) { buffer_t row = buffer_create(64); - walk_children(node, &row); + walk_children(node, &row, inherited); if (row.len > 0) { buffer_append_str(out, "

"); buffer_append(out, row.data, row.len); @@ -974,7 +1115,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { } free(row.data); } else { - walk_children(node, out); + walk_children(node, out, inherited); } return; } @@ -983,7 +1124,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { switch (cls) { case TAG_CLASS_PASS: case TAG_CLASS_SKIP: - walk_children(node, out); + walk_children(node, out, inherited); break; case TAG_CLASS_SELF_CLOSING: @@ -1004,7 +1145,8 @@ static void walk_node(GumboNode *node, buffer_t *out) { GumboNode *nested_lists[16]; int nested_count = 0; buffer_t li_ib = buffer_create(64); - li_ctx_t ctx = {el, es, nested_lists, &nested_count, 16}; + li_ctx_t ctx = {el, es, nested_lists, &nested_count, + 16, false, inherited}; flatten_li_children(node, &li_ib, out, &ctx); flush_li_buffer(&li_ib, out, &ctx); @@ -1017,7 +1159,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { free(li_ib.data); for (int k = 0; k < nested_count; k++) - walk_children(nested_lists[k], out); + walk_children(nested_lists[k], out, inherited); break; } @@ -1027,7 +1169,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { buffer_append_str(out, ""); if (wrap) buffer_append_str(out, "

"); - walk_children(node, out); + walk_children(node, out, inherited); if (wrap) buffer_append_str(out, "

"); buffer_append_str(out, ""); @@ -1040,7 +1182,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { emit_attributes(el, out_name, out); buffer_append_str(out, ">"); emit_styles_open(out, es); - walk_children(node, out); + walk_children(node, out, inherited); emit_styles_close(out, es); buffer_append_str(out, "root; buffer_t buf = buffer_create(len * 2); - walk_children(body, &buf); + walk_children(body, &buf, NULL); gumbo_destroy_output(&kGumboDefaultOptions, output); return buffer_finish(&buf); diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index fefbe99e9..6b9efb114 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -255,64 +255,53 @@ TEST(GumboParserTest, SpanRemappings) { "x"); // Foreground color only - EXPECT_EQ( - GumboParser::normalizeHtml("x"), - "x"); + EXPECT_EQ(GumboParser::normalizeHtml("x"), + "x"); EXPECT_EQ( GumboParser::normalizeHtml("x"), "x"); - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); // Background color only - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); // Both colors combined - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); // Color + Single Formatter - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); // Color + Multiple inline styles - EXPECT_EQ( - GumboParser::normalizeHtml( - "x"), - "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); // Mix of colors and inline styles EXPECT_EQ( @@ -322,6 +311,46 @@ TEST(GumboParserTest, SpanRemappings) { "font-weight: bold;\">x"), "x"); + + // Quotes can't break out of the style attribute + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + + // Font size + EXPECT_EQ( + GumboParser::normalizeHtml("x"), + "x"); + + // Font family: the whole stack is passed through, picking the family is up + // to the parsers + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + + // All custom styles combined, with inline styles + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + + // `&` and `"` are escaped + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml("x"), + "x"); } TEST(GumboParserTest, EnrichedTagRemappings) { @@ -687,3 +716,117 @@ TEST(GumboParserTest, InterBlockWhitespace) { EXPECT_EQ(GumboParser::normalizeHtml("hello world"), "hello world"); } + +TEST(GumboParserTest, NestedSpans) { + // Child overrides the parent + EXPECT_EQ(GumboParser::normalizeHtml( + "ABC"), + "ABC"); + + // Child inherits properties it doesn't set + EXPECT_EQ(GumboParser::normalizeHtml( + "AB"), + "AB"); + + // Unstyled spans are transparent + EXPECT_EQ(GumboParser::normalizeHtml( + "ABC"), + "ABC"); + + // Styles are pushed down through other inline tags + EXPECT_EQ(GumboParser::normalizeHtml( + "AB"), + "AB"); + + // A wrapper inside a pushed-down span merges the inherited style, and its + // text isn't wrapped again + EXPECT_EQ(GumboParser::normalizeHtml( + "ABCD"), + "ABCD"); + + // Styles are inherited across multiple levels + EXPECT_EQ( + GumboParser::normalizeHtml( + "ABC"), + "ABC"); + + // Transparent background doesn't override the inherited one + EXPECT_EQ(GumboParser::normalizeHtml( + "AB"), + "AB"); + + // `inherit` doesn't override the inherited value + EXPECT_EQ( + GumboParser::normalizeHtml("AB"), + "AB"); + + // Transparent background alone is treated as no style + EXPECT_EQ(GumboParser::normalizeHtml( + "A"), + "A"); + + // Whitespace between blocks is layout and doesn't become a paragraph + EXPECT_EQ(GumboParser::normalizeHtml( + "

A

B

"), + "

A

B

"); + + // Whitespace between table parts is layout + EXPECT_EQ(GumboParser::normalizeHtml( + "A\n\n\n\n
B
"), + "

A

\n

\nB\n

"); + + // Whitespace inside a table cell is inline and keeps the style + EXPECT_EQ(GumboParser::normalizeHtml( + "A
B C
"), + "

A

B " + "C

"); + + // Unstyled spans outside styled ones don't wrap anything + EXPECT_EQ(GumboParser::normalizeHtml( + "ABC"), + "ABC"); + + // Inherited values are escaped like the wrapper's own ones + EXPECT_EQ(GumboParser::normalizeHtml( + "AB"), + "AB"); + + // Context reaches list items and blockquotes + EXPECT_EQ(GumboParser::normalizeHtml( + "
  • AB
"), + "
  • AB
"); + EXPECT_EQ(GumboParser::normalizeHtml( + "
AB
"), + "

AB

"); +} diff --git a/ios/extensions/ColorExtension.mm b/ios/extensions/ColorExtension.mm index 8c1f7ff9a..212762a44 100644 --- a/ios/extensions/ColorExtension.mm +++ b/ios/extensions/ColorExtension.mm @@ -255,6 +255,11 @@ + (UIColor *_Nullable)colorFromCSSString:(NSString *_Nullable)cssString { r = ((value >> 8) & 0xF) / 15.0; g = ((value >> 4) & 0xF) / 15.0; b = (value & 0xF) / 15.0; + } else if (len == 4) { + r = ((value >> 12) & 0xF) / 15.0; + g = ((value >> 8) & 0xF) / 15.0; + b = ((value >> 4) & 0xF) / 15.0; + a = (value & 0xF) / 15.0; } else if (len == 6) { r = ((value >> 16) & 0xFF) / 255.0; g = ((value >> 8) & 0xFF) / 255.0; diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index 13cfc65dc..57d306531 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1514,15 +1514,29 @@ + (NSString *)tagContentForStyle:(NSNumber *)style if (data.fontFamily.length > 0) { if (cssProps.length > 0) [cssProps appendString:@" "]; - if ([data.fontFamily rangeOfString:@" "].location != NSNotFound) { - // font family contains spaces, wrap in single quotes + if ([data.fontFamily rangeOfString:@" "].location != NSNotFound || + [data.fontFamily rangeOfString:@","].location != NSNotFound) { + // font family contains spaces or commas, wrap in single quotes [cssProps appendFormat:@"font-family: '%@';", data.fontFamily]; } else { [cssProps appendFormat:@"font-family: %@;", data.fontFamily]; } } if (cssProps.length > 0) { - return [NSString stringWithFormat:@"span style=\"%@\"", cssProps]; + NSString *escapedStyle = + [cssProps stringByReplacingOccurrencesOfString:@"&" + withString:@"&"]; + escapedStyle = + [escapedStyle stringByReplacingOccurrencesOfString:@"<" + withString:@"<"]; + escapedStyle = + [escapedStyle stringByReplacingOccurrencesOfString:@">" + withString:@">"]; + escapedStyle = + [escapedStyle stringByReplacingOccurrencesOfString:@"\"" + withString:@"""]; + return + [NSString stringWithFormat:@"span style=\"%@\"", escapedStyle]; } } } @@ -1554,6 +1568,22 @@ + (NSString *)prepareCssStyleString:(NSInteger)location return @""; } ++ (NSString *)firstInFontFamilyString:(NSString *)value { + unichar quote = 0; + for (NSUInteger i = 0; i < value.length; i++) { + unichar c = [value characterAtIndex:i]; + if (quote != 0) { + if (c == quote) + quote = 0; + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == ',') { + return [value substringToIndex:i]; + } + } + return value; +} + + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: (NSString *)params { static NSRegularExpression *styleAttrRegex; @@ -1564,7 +1594,8 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ styleAttrRegex = [NSRegularExpression - regularExpressionWithPattern:@"style\\s*=\\s*[\"']([^\"']*)[\"']" + regularExpressionWithPattern: + @"style\\s*=\\s*[\"']((?<=\")[^\"]*(?=\")|(?<=')[^']*(?='))[\"']" options:NSRegularExpressionCaseInsensitive error:nil]; // Captures everything after "color:" until a semicolon or end of string @@ -1600,6 +1631,14 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: return nil; NSString *css = [params substringWithRange:[attrMatch rangeAtIndex:1]]; + // decode entities escaped by the serializers + for (NSArray *entity in @[ + @[ @""", @"\"" ], @[ @"'", @"'" ], @[ @"<", @"<" ], + @[ @">", @">" ], @[ @"&", @"&" ] + ]) { + css = [css stringByReplacingOccurrencesOfString:entity[0] + withString:entity[1]]; + } CustomStyleData *data = [[CustomStyleData alloc] init]; NSTextCheckingResult *fgMatch = @@ -1645,17 +1684,14 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: // If font family contains a comma, use the first part e.g. "Nunito, // sans-serif" -> "Nunito" - NSRange commaRange = [fontFamilyString rangeOfString:@","]; - if (commaRange.location != NSNotFound) { - fontFamilyString = - [[fontFamilyString substringToIndex:commaRange.location] - stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]]; - } - if (([fontFamilyString hasPrefix:@"'"] && - [fontFamilyString hasSuffix:@"'"]) || - ([fontFamilyString hasPrefix:@"\""] && - [fontFamilyString hasSuffix:@"\""])) { + fontFamilyString = [[self firstInFontFamilyString:fontFamilyString] + stringByTrimmingCharactersInSet:[NSCharacterSet + whitespaceAndNewlineCharacterSet]]; + if (fontFamilyString.length >= 2 && + (([fontFamilyString hasPrefix:@"'"] && + [fontFamilyString hasSuffix:@"'"]) || + ([fontFamilyString hasPrefix:@"\""] && + [fontFamilyString hasSuffix:@"\""]))) { fontFamilyString = [fontFamilyString substringWithRange:NSMakeRange(1, fontFamilyString.length - 2)]; } diff --git a/src/web/__tests__/deleteCharacterBackward.test.ts b/src/web/__tests__/deleteCharacterBackward.test.ts new file mode 100644 index 000000000..6ea37ba26 --- /dev/null +++ b/src/web/__tests__/deleteCharacterBackward.test.ts @@ -0,0 +1,74 @@ +import { Schema, type Node as PMNode } from '@tiptap/pm/model'; +import { EditorState, TextSelection } from '@tiptap/pm/state'; +import { + deleteCharacterBackward, + lastGraphemeLength, +} from '../utils/deleteCharacterBackward'; + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'inline*', group: 'block' }, + text: { group: 'inline' }, + mention: { inline: true, group: 'inline', atom: true }, + }, +}); + +// initialize tiptap's state and place the cursor at its end +function stateWith(...content: PMNode[]) { + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, content), + ]); + const state = EditorState.create({ doc }); + return state.apply( + state.tr.setSelection( + TextSelection.create(state.doc, state.doc.content.size - 1) + ) + ); +} + +describe('lastGraphemeLength', () => { + test.each([ + ['abc', 1], + ['a😀', 2], + ['a👨‍👩‍👧', '👨‍👩‍👧'.length], + ['a🇵🇱', 4], + ['aé', 2], + ['', 0], + ])('%j -> %i', (text, expected) => { + expect(lastGraphemeLength(text)).toBe(expected); + }); +}); + +describe('deleteCharacterBackward', () => { + test('deletes a whole emoji', () => { + const state = stateWith(schema.text('a😀')); + const tr = deleteCharacterBackward(state); + expect(tr?.doc.textContent).toBe('a'); + }); + + test('deletes a base letter together with its combining mark', () => { + const state = stateWith(schema.text('aé')); + expect(deleteCharacterBackward(state)?.doc.textContent).toBe('a'); + }); + + test('deletes a non-text inline node as a whole', () => { + const state = stateWith(schema.text('a'), schema.node('mention')); + const tr = deleteCharacterBackward(state); + expect(tr?.doc.firstChild?.childCount).toBe(1); + expect(tr?.doc.textContent).toBe('a'); + }); + + test('returns null at the start of a paragraph', () => { + const state = stateWith(); + expect(deleteCharacterBackward(state)).toBeNull(); + }); + + test('returns null for a non-empty selection', () => { + const base = stateWith(schema.text('abc')); + const state = base.apply( + base.tr.setSelection(TextSelection.create(base.doc, 1, 3)) + ); + expect(deleteCharacterBackward(state)).toBeNull(); + }); +}); diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index 04547a2de..8fec44dcf 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -626,4 +626,83 @@ describe('htmlNormalizer', () => { expect(normalizeHtml(input)).toBe(expected); }); }); + + describe('NestedSpans', () => { + test.each([ + // Child overrides the parent + [ + 'ABC', + 'ABC', + ], + // Child inherits properties it doesn't set + [ + 'AB', + 'AB', + ], + // Unstyled spans are transparent + [ + 'ABC', + 'ABC', + ], + // Styles are pushed down through other inline tags + [ + 'AB', + 'AB', + ], + // A wrapper inside a pushed-down span merges the inherited style, and its + // text isn't wrapped again + [ + 'ABCD', + 'ABCD', + ], + // Styles are inherited across multiple levels + [ + 'ABC', + 'ABC', + ], + // Transparent background doesn't override the inherited one + [ + 'AB', + 'AB', + ], + // `inherit` doesn't override the inherited value + [ + 'AB', + 'AB', + ], + // Transparent background alone is treated as no style + ['A', 'A'], + // Whitespace between blocks is layout and doesn't become a paragraph + [ + '

A

B

', + '

A

B

', + ], + // Whitespace between table parts is layout + [ + 'A\n\n\n\n
B
', + '

A

\n

\nB\n

', + ], + // Whitespace inside a table cell is inline and keeps the style + [ + 'A
B C
', + '

A

B C

', + ], + // Unstyled spans outside styled ones don't wrap anything + [ + 'ABC', + 'ABC', + ], + // Context reaches list items and blockquotes + [ + '
  • AB
', + '
  • AB
', + ], + [ + '
AB
', + '

AB

', + ], + ])('%s → %s', (input, expected) => { + expect(normalizeHtml(input)).toBe(expected); + }); + }); }); diff --git a/src/web/__tests__/normalizeFontFamily.test.ts b/src/web/__tests__/normalizeFontFamily.test.ts new file mode 100644 index 000000000..67a55960e --- /dev/null +++ b/src/web/__tests__/normalizeFontFamily.test.ts @@ -0,0 +1,20 @@ +import { normalizeFontFamily } from '../formats/EnrichedCustomStyle'; + +describe('normalizeFontFamily', () => { + it.each([ + // first family of the stack + ['Roboto, sans-serif', 'Roboto'], + ['"Open Sans", Arial', 'Open Sans'], + ["'Open Sans'", 'Open Sans'], + // commas inside quotes are part of the family name + ['"SWM, Inc", Arial', 'SWM, Inc'], + ["'SWM,Inc', Arial", 'SWM,Inc'], + // an empty first family is no family + [', Arial', null], + ["'", null], + ['', null], + [null, null], + ])('%p → %p', (input, expected) => { + expect(normalizeFontFamily(input)).toBe(expected); + }); +}); diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index b64ddbcf3..fd524c977 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -1,6 +1,7 @@ import { Mark } from '@tiptap/core'; import type { Attrs } from '@tiptap/pm/model'; import { withBaseFontFamilyFallback } from '../styleConversion/enrichedStyleToCSSVariables'; +import { deleteCharacterBackward } from '../utils/deleteCharacterBackward'; type CustomStyleAttrs = { foregroundColor?: string | null; @@ -9,14 +10,27 @@ type CustomStyleAttrs = { fontFamily?: string | null; }; -function normalizeFontFamily(value: string | null | undefined): string | null { +function firstInFontFamilyString(value: string): string { + let quote = ''; + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (quote) { + if (c === quote) quote = ''; + } else if (c === "'" || c === '"') { + quote = c; + } else if (c === ',') { + return value.slice(0, i); + } + } + return value; +} + +export function normalizeFontFamily( + value: string | null | undefined +): string | null { if (!value) return null; - let fontFamily = value.trim(); - const commaIndex = fontFamily.indexOf(','); - if (commaIndex !== -1) { - fontFamily = fontFamily.slice(0, commaIndex).trim(); - } + let fontFamily = firstInFontFamilyString(value.trim()).trim(); if ( (fontFamily.startsWith("'") && fontFamily.endsWith("'")) || @@ -55,8 +69,8 @@ function buildStyle(attrs: Attrs, withFontFamilyFallback = false): string { } if (attrs.fontFamily) { const fontFamily = attrs.fontFamily as string; - // if the font family contains a space, wrap it in quotes - const family = /\s/.test(fontFamily) ? `'${fontFamily}'` : fontFamily; + // if the font family contains a space or a comma, wrap it in quotes + const family = /[\s,]/.test(fontFamily) ? `'${fontFamily}'` : fontFamily; // add a fall-back to the input's inherited font, if needed parts.push( `font-family: ${withFontFamilyFallback ? withBaseFontFamilyFallback(family) : family}` @@ -156,7 +170,8 @@ export const EnrichedCustomStyle = Mark.create({ // Cursor sits right at the plain-styled boundary. // Handle deletion ourselves so Chrome's contenteditable // normalization never touches the styled . - const tr = state.tr.delete($from.pos - 1, $from.pos); + const tr = deleteCharacterBackward(state); + if (!tr) return false; editor.view.dispatch(tr); return true; }, @@ -185,7 +200,7 @@ export const EnrichedCustomStyle = Mark.create({ patch.fontSize = resolveFontSize(attrs.fontSize); } if ('fontFamily' in attrs) { - patch.fontFamily = normalizeFontFamily(attrs.fontFamily); + patch.fontFamily = attrs.fontFamily?.trim() || null; } const mergeAttrs = ( @@ -194,7 +209,7 @@ export const EnrichedCustomStyle = Mark.create({ foregroundColor: existing?.foregroundColor || null, backgroundColor: existing?.backgroundColor || null, fontSize: resolveFontSize(existing?.fontSize), - fontFamily: normalizeFontFamily(existing?.fontFamily), + fontFamily: existing?.fontFamily ?? null, ...patch, }); diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 257463a07..23fbc7e99 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -60,6 +60,9 @@ const TABLE_TAGS = new Set([ 'col', ]); +// table tags that hold inline text rather than other table parts +const TABLE_TEXT_TAGS = new Set(['td', 'th', 'caption']); + function canonicalName(name: string): string { switch (name) { case 'strong': @@ -204,6 +207,45 @@ function parseCssStyle(style: string | null): CssStyles { return result; } +type CustomStyle = { + fg: string; + bg: string; + fontSize: string; + fontFamily: string; +}; + +function ownCssValue(value: string): string { + // `inherit` must not overwrite the parent span's value + return value.toLowerCase() === 'inherit' ? '' : value; +} + +function parseCustomStyle(el: Element): CustomStyle { + const style = (el as HTMLElement).style; + return { + fg: ownCssValue(style.color), + // we don't want `transparent` to overwrite it's parent span's background-color + bg: + style.backgroundColor.toLowerCase() === 'transparent' + ? '' + : ownCssValue(style.backgroundColor), + fontSize: ownCssValue(style.fontSize), + fontFamily: escapeText(ownCssValue(style.fontFamily)), + }; +} + +function customStyleIsEmpty(s: CustomStyle): boolean { + return !s.fg && !s.bg && !s.fontSize && !s.fontFamily; +} + +function emitCustomStyleOpen(s: CustomStyle): string { + const styleParts: string[] = []; + if (s.fg) styleParts.push(`color: ${s.fg}`); + if (s.bg) styleParts.push(`background-color: ${s.bg}`); + if (s.fontSize) styleParts.push(`font-size: ${s.fontSize}`); + if (s.fontFamily) styleParts.push(`font-family: ${s.fontFamily}`); + return ``; +} + function extraStyles(s: CssStyles, tag: string): CssStyles { return { bold: s.bold && tag !== 'b', @@ -329,6 +371,24 @@ function isGoogleDocsWrapper(el: Element, tag: string): boolean { return !!id && id.startsWith('docs-internal-guid-') && id.length > 20; } +// --- Nested custom style spans --- + +function isStyledSpan(node: Node): boolean { + return ( + tagName(node) === 'span' && + !customStyleIsEmpty(parseCustomStyle(node as Element)) + ); +} + +function hasStyledSpanDescendant(node: Element): boolean { + for (const c of Array.from(node.childNodes)) { + if (isStyledSpan(c) || (isElement(c) && hasStyledSpanDescendant(c))) { + return true; + } + } + return false; +} + function escapeText(s: string): string { return s.replace(/[&<>"']/g, (match) => { switch (match) { @@ -361,6 +421,20 @@ function isWhitespaceOnly(value: string): boolean { return true; } +/** Whitespace-only text next to blocks or between table parts is just layout. + * E.g. `

123

\n

456

`. */ +function isLayoutWhitespace(node: Node): boolean { + const parent = node.parentNode; + if (!isWhitespaceOnly(node.textContent ?? '') || !isElement(parent)) { + return false; + } + const parentName = parent.tagName.toLowerCase(); + return ( + hasBlockOrBqChild(parent) || + (TABLE_TAGS.has(parentName) && !TABLE_TEXT_TAGS.has(parentName)) + ); +} + /** * Flush buffered inline content as a

. Inter-block whitespace (newlines / * spaces between block tags in pretty-printed HTML) is discarded so it does @@ -382,21 +456,22 @@ function flushInlineP( function flattenBqChildren( node: Element, ib: { buf: string }, - out: { buf: string } + out: { buf: string }, + inherited: CustomStyle | null ): void { for (const child of Array.from(node.childNodes)) { - flattenBqNode(child, ib, out); + flattenBqNode(child, ib, out, inherited); } } function flattenBqNode( node: Node, ib: { buf: string }, - out: { buf: string } + out: { buf: string }, + inherited: CustomStyle | null ): void { if (isText(node)) { - const t = node.textContent ?? ''; - ib.buf += escapeText(t); + walkNode(node, ib, inherited); return; } if (!isElement(node)) return; @@ -410,12 +485,12 @@ function flattenBqNode( } if (isBlockProducing(node) || isBlockquoteNode(node)) { flushInlineP(ib, out); - flattenBqChildren(node, ib, out); + flattenBqChildren(node, ib, out, inherited); // The flattened block becomes a

; carry over its text-align (if any). flushInlineP(ib, out, emitAlignment(node, 'p')); return; } - walkNode(node, ib); + walkNode(node, ib, inherited); } // --- List item content flattening --- @@ -425,6 +500,7 @@ type LiCtx = { styles: CssStyles; nestedLists: Element[]; hasEmitted: boolean; + inherited: CustomStyle | null; }; function flushLiBuffer( @@ -460,7 +536,7 @@ function flattenLiNode( ctx: LiCtx ): void { if (isText(node)) { - ib.buf += escapeText(node.textContent ?? ''); + walkNode(node, ib, ctx.inherited); return; } if (!isElement(node)) return; @@ -487,12 +563,16 @@ function flattenLiNode( flushLiBuffer(ib, out, ctx); return; } - walkNode(node, ib); + walkNode(node, ib, ctx.inherited); } // --- Main walker --- -function walkChildren(node: Element, out: { buf: string }): void { +function walkChildren( + node: Element, + out: { buf: string }, + inherited: CustomStyle | null +): void { const children = Array.from(node.childNodes); const parentIsList = isListNode(node); @@ -510,7 +590,7 @@ function walkChildren(node: Element, out: { buf: string }): void { // Flatten list-inside-list if (parentIsList && isElement(child) && isListNode(child)) { - walkChildren(child, out); + walkChildren(child, out, inherited); i++; continue; } @@ -522,7 +602,7 @@ function walkChildren(node: Element, out: { buf: string }): void { while (i < children.length) { const cur = children[i]; if (!cur || !isElement(cur) || !isBlockquoteNode(cur)) break; - flattenBqChildren(cur, bqIb, out); + flattenBqChildren(cur, bqIb, out, inherited); i++; } flushInlineP(bqIb, out); @@ -557,25 +637,33 @@ function walkChildren(node: Element, out: { buf: string }): void { // Transparent inline wrapper for block/bq children if (isElement(cur) && hasBlockOrBqChild(cur)) { flushInlineP(ib, out); - walkChildren(cur, out); + walkChildren(cur, out, inherited); i++; continue; } - walkNode(cur, ib); + walkNode(cur, ib, inherited); i++; } flushInlineP(ib, out); continue; } - walkNode(child, out); + walkNode(child, out, inherited); i++; } } -function walkNode(node: Node, out: { buf: string }): void { +function walkNode( + node: Node, + out: { buf: string }, + inherited: CustomStyle | null +): void { if (isText(node)) { - out.buf += escapeText(node.textContent ?? ''); + const text = node.textContent ?? ''; + const wrap = !!inherited && !isLayoutWhitespace(node); + if (wrap) out.buf += emitCustomStyleOpen(inherited); + out.buf += escapeText(text); + if (wrap) out.buf += ''; return; } if (!isElement(node)) return; @@ -585,7 +673,7 @@ function walkNode(node: Node, out: { buf: string }): void { if (STRIPPED_TAGS.has(name)) return; if (isGoogleDocsWrapper(node, name)) { - walkChildren(node, out); + walkChildren(node, out, inherited); return; } @@ -594,35 +682,36 @@ function walkNode(node: Node, out: { buf: string }): void { // : CSS style → inline tags if (name === 'span') { - const htmlNode = node as HTMLElement; const s = parseCssStyle(node.getAttribute('style')); - const fg = htmlNode.style.color; - const bg = htmlNode.style.backgroundColor; - const fontSize = htmlNode.style.fontSize; - const fontFamily = escapeText(htmlNode.style.fontFamily); - - // build the preserved span if colors exist - let spanOpen = ''; - let spanClose = ''; - const styleParts: string[] = []; - - if (fg) styleParts.push(`color: ${fg}`); - if (bg) styleParts.push(`background-color: ${bg}`); - if (fontSize) styleParts.push(`font-size: ${fontSize}`); - if (fontFamily) styleParts.push(`font-family: ${fontFamily}`); - - if (styleParts.length > 0) { - spanOpen = ``; - spanClose = ''; + // own properties override the inherited ones + const own = parseCustomStyle(node); + const style: CustomStyle = { + fg: own.fg || inherited?.fg || '', + bg: own.bg || inherited?.bg || '', + fontSize: own.fontSize || inherited?.fontSize || '', + fontFamily: own.fontFamily || inherited?.fontFamily || '', + }; + + let childrenStyle = inherited; + let wrap = false; + if (!customStyleIsEmpty(own)) { + // if there are no nested spans deeper, we can emit a wrapper immediately, + // otherwise we hold off with that and we'll emit span tags deeper, after flattening + if (hasStyledSpanDescendant(node)) { + childrenStyle = style; + } else { + wrap = true; + childrenStyle = null; + } } // output everything in the correct nested order - out.buf += spanOpen; + if (wrap) out.buf += emitCustomStyleOpen(style); out.buf += emitStylesOpen(s); - walkChildren(node, out); + walkChildren(node, out, childrenStyle); out.buf += emitStylesClose(s); - out.buf += spanClose; + if (wrap) out.buf += ''; return; } @@ -641,14 +730,14 @@ function walkNode(node: Node, out: { buf: string }): void { pb.buf = ''; continue; } - walkNode(dc, pb); + walkNode(dc, pb, inherited); } if (pb.buf.length > 0) { out.buf += `

${emitStylesOpen(s)}${pb.buf}${emitStylesClose(s)}

`; } } else { out.buf += emitStylesOpen(s); - walkChildren(node, out); + walkChildren(node, out, inherited); out.buf += emitStylesClose(s); } return; @@ -657,25 +746,25 @@ function walkNode(node: Node, out: { buf: string }): void { // Table elements if (TABLE_TAGS.has(name)) { if (name === 'td' || name === 'th') { - walkChildren(node, out); + walkChildren(node, out, inherited); // Append space if there is a next element sibling let sib: Node | null = node.nextSibling; while (sib && !isElement(sib)) sib = sib.nextSibling; if (sib) out.buf += ' '; } else if (name === 'tr') { const row = { buf: '' }; - walkChildren(node, row); + walkChildren(node, row, inherited); if (row.buf.length > 0) { out.buf += `

${row.buf}

`; } } else { - walkChildren(node, out); + walkChildren(node, out, inherited); } return; } if (cls === 'pass' || cls === 'skip') { - walkChildren(node, out); + walkChildren(node, out, inherited); return; } @@ -692,7 +781,13 @@ function walkNode(node: Node, out: { buf: string }): void { if (outName === 'li') { const nestedLists: Element[] = []; const liIb = { buf: '' }; - const ctx: LiCtx = { el: node, styles: es, nestedLists, hasEmitted: false }; + const ctx: LiCtx = { + el: node, + styles: es, + nestedLists, + hasEmitted: false, + inherited, + }; flattenLiChildren(node, liIb, out, ctx); flushLiBuffer(liIb, out, ctx); @@ -701,7 +796,7 @@ function walkNode(node: Node, out: { buf: string }): void { out.buf += ``; } - for (const nl of nestedLists) walkChildren(nl, out); + for (const nl of nestedLists) walkChildren(nl, out, inherited); return; } @@ -710,7 +805,7 @@ function walkNode(node: Node, out: { buf: string }): void { const wrap = isPurelyInline(node); out.buf += ''; if (wrap) out.buf += '

'; - walkChildren(node, out); + walkChildren(node, out, inherited); if (wrap) out.buf += '

'; out.buf += '
'; return; @@ -719,7 +814,7 @@ function walkNode(node: Node, out: { buf: string }): void { // Generic block/inline out.buf += `<${outName}${emitAttributes(node, outName)}>`; out.buf += emitStylesOpen(es); - walkChildren(node, out); + walkChildren(node, out, inherited); out.buf += emitStylesClose(es); out.buf += ``; } @@ -729,6 +824,6 @@ export function normalizeHtml(html: string): string { const doc = parser.parseFromString(`${html}`, 'text/html'); const body = doc.body; const out = { buf: '' }; - walkChildren(body, out); + walkChildren(body, out, null); return out.buf; } diff --git a/src/web/utils/deleteCharacterBackward.ts b/src/web/utils/deleteCharacterBackward.ts new file mode 100644 index 000000000..ae5bd866e --- /dev/null +++ b/src/web/utils/deleteCharacterBackward.ts @@ -0,0 +1,41 @@ +import type { EditorState, Transaction } from '@tiptap/pm/state'; + +// UTF-16 length of the last user-perceived character, so that deletion +// never splits combining marks, e.g. emojis. +export function lastGraphemeLength(text: string): number { + if (!text) return 0; + + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + let last = ''; + for (const { segment } of new Intl.Segmenter(undefined, { + granularity: 'grapheme', + }).segment(text)) { + last = segment; + } + return last.length; + } + + // Fallback: at least keep surrogate pairs together + const codePoints = Array.from(text); + return codePoints[codePoints.length - 1]?.length ?? 0; +} + +// Deletes one character before the collapsed cursor. Returns null +// when there is no inline content to delete. +export function deleteCharacterBackward( + state: EditorState +): Transaction | null { + const { selection } = state; + if (!selection.empty) return null; + + const { $from } = selection; + const nodeBefore = $from.nodeBefore; + if (!nodeBefore) return null; + + const length = nodeBefore.isText + ? lastGraphemeLength(nodeBefore.text ?? '') + : nodeBefore.nodeSize; + if (length === 0) return null; + + return state.tr.delete($from.pos - length, $from.pos); +}