From fe6523bc5caf6c662468ac3895387126c82ad5d8 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 12:35:48 +0200 Subject: [PATCH 01/15] fix(android): escape quote characters in html span generation --- .../swmansion/enriched/common/parser/EnrichedParser.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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..e30e5a25a 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 @@ -380,7 +380,9 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, cssProps.append("font-family: ").append(fontFamily).append(";"); } } - out.append(""); + out.append(""); } else { out.append(""); } @@ -1032,7 +1034,7 @@ private static void startSpan(Editable text, Attributes attributes) { private static String parseCssFontFamily(String raw) { if (raw == null) return null; - String value = raw.trim(); + String value = raw.split(",", 2)[0].trim(); if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) { value = value.substring(1, value.length() - 1); From 2633b99535c1a7d814c077d4e766220c4b4789ca Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 13:17:24 +0200 Subject: [PATCH 02/15] fix(ios): extended color hex parsing --- ios/extensions/ColorExtension.mm | 5 +++++ 1 file changed, 5 insertions(+) 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; From 0890e5e51bffaf7719a5388f55bb7639f5aeab7d Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 13:26:43 +0200 Subject: [PATCH 03/15] fix(ios): properly escape and decode characters inside the spans's style string --- ios/htmlParser/HtmlParser.mm | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index 13cfc65dc..451b57617 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1522,7 +1522,20 @@ + (NSString *)tagContentForStyle:(NSNumber *)style } } 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]; } } } @@ -1564,7 +1577,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 +1614,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 = From 4f5b3983b81622761cd8ec644d0aca23ed35d1e6 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 14:49:18 +0200 Subject: [PATCH 04/15] fix(mobile): allow font-size and font-family in the normalizer --- cpp/parser/GumboNormalizer.c | 91 +++++++++++++++++++----- cpp/tests/GumboParserTest.cpp | 127 +++++++++++++++++++++------------- 2 files changed, 152 insertions(+), 66 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 2ccbcd885..25781aed3 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -281,6 +281,51 @@ static const char *find_css_value(const char *style, size_t style_len, return NULL; } +/* First family of a font stack, unquoted: `"Open Sans", Arial` → Open Sans. */ +static const char *first_font_family(const char *val, size_t *val_len) { + if (!val) + return NULL; + const char *start = val; + const char *end = val + *val_len; + const char *comma = memchr(start, ',', *val_len); + if (comma) + end = comma; + while (start < end && isspace((unsigned char)*start)) + start++; + while (end > start && isspace((unsigned char)*(end - 1))) + end--; + // strip the quotes if the value is quoted + if (end - start >= 2 && (*start == '\'' || *start == '"') && + *(end - 1) == *start) { + start++; + end--; + } + *val_len = (size_t)(end - start); + if (memchr(start, '\'', *val_len)) + return NULL; + return start; +} + +static void append_style_prop(buffer_t *out, const char *prefix, + const char *val, size_t val_len, + const char *suffix, bool *first) { + if (!*first) + buffer_append_str(out, "; "); + *first = false; + buffer_append_str(out, prefix); + // only `&` and `"` are significant inside a double-quoted attribute + // and need to be escaped + 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 + buffer_append(out, &val[i], 1); + } + buffer_append_str(out, suffix); +} + static bool css_val_contains(const char *val, size_t val_len, const char *needle) { size_t nlen = strlen(needle); @@ -859,27 +904,35 @@ static void walk_node(GumboNode *node, buffer_t *out) { 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); + size_t fs_len = 0; + const char *fs = find_css_value(sval, slen, "font-size", &fs_len); + + size_t ff_len = 0; + const char *ff = first_font_family( + find_css_value(sval, slen, "font-family", &ff_len), &ff_len); - // emit the wrapper span if colors exist - if (has_fg || has_bg) { + bool has_fg = fg && fg_len > 0; + bool has_bg = bg && bg_len > 0; + bool has_fs = fs && fs_len > 0; + bool has_ff = ff && ff_len > 0; + bool has_custom_style = has_fg || has_bg || has_fs || has_ff; + + // emit the wrapper span if custom styles exist + if (has_custom_style) { + bool first = true; buffer_append_str(out, ""); } @@ -889,7 +942,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { emit_styles_close(out, s); // close the wrapper span - if (has_fg || has_bg) { + if (has_custom_style) { buffer_append_str(out, ""); } diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index fefbe99e9..b54106d53 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,50 @@ 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: first family of the stack, unquoted, quoted again if it + // contains spaces + 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"); + + // Family names with single quotes can't be quoted, so they're dropped + EXPECT_EQ(GumboParser::normalizeHtml( + "x"), + "x"); } TEST(GumboParserTest, EnrichedTagRemappings) { From 9925ab9dec453d9cb8954a4b52e366f0c71efa38 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 15:20:59 +0200 Subject: [PATCH 05/15] fix(android): invalidate layout on setStyle --- .../com/swmansion/enriched/textinput/EnrichedTextInputView.kt | 1 + 1 file changed, 1 insertion(+) 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) { From ba59c51b5a3347bce72696257af3c0df0b2e48ce Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 15:21:41 +0200 Subject: [PATCH 06/15] fix(android): properly rebuild custom style spans with fontSize on allowFontScaling prop changes --- .../enriched/common/spans/EnrichedCustomStyleSpan.kt | 2 -- .../java/com/swmansion/enriched/text/EnrichedTextStyle.kt | 2 ++ .../enriched/text/spans/EnrichedTextCustomStyleSpan.kt | 2 +- .../textinput/spans/EnrichedInputCustomStyleSpan.kt | 6 ++++-- .../com/swmansion/enriched/textinput/styles/HtmlStyle.kt | 5 ++++- 5 files changed, 11 insertions(+), 6 deletions(-) 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/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( From d511e0b6a62232ca3866792ee89fc3f2398fee66 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Fri, 16 Oct 2026 16:42:18 +0200 Subject: [PATCH 07/15] fix(web): manual content deletion respects grapheme clusters --- .../__tests__/deleteCharacterBackward.test.ts | 74 +++++++++++++++++++ src/web/formats/EnrichedCustomStyle.ts | 4 +- src/web/utils/deleteCharacterBackward.ts | 41 ++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/web/__tests__/deleteCharacterBackward.test.ts create mode 100644 src/web/utils/deleteCharacterBackward.ts 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/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index b64ddbcf3..dd1838b44 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; @@ -156,7 +157,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; }, 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); +} From 5073cf9020ca146929a8d67116fb33323eb0e678 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 11:01:03 +0200 Subject: [PATCH 08/15] fix(ios, android): flatten nested span tags in the normalizer --- cpp/parser/GumboNormalizer.c | 231 ++++++++++++++++++++++++---------- cpp/tests/GumboParserTest.cpp | 72 +++++++++++ 2 files changed, 234 insertions(+), 69 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 25781aed3..ab8c3a3fa 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -236,11 +236,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, @@ -346,8 +359,18 @@ static bool css_val_contains(const char *val, size_t val_len, return false; } +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); + if (!slice.val) + slice.len = 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; @@ -396,6 +419,18 @@ 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"); + result.custom.font_size = + find_custom_css_value(style_value, style_len, "font-size"); + css_slice_t family = + find_custom_css_value(style_value, style_len, "font-family"); + family.val = first_font_family(family.val, &family.len); + if (!family.val) + family.len = 0; + result.custom.font_family = family; + return result; } @@ -433,6 +468,31 @@ 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 */ /* ------------------------------------------------------------------ */ @@ -587,17 +647,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) { @@ -629,20 +717,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)) { @@ -658,12 +748,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); } /* ------------------------------------------------------------------ */ @@ -677,6 +767,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, @@ -711,7 +802,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)) { @@ -746,14 +837,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; @@ -775,7 +867,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; } @@ -785,7 +877,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); @@ -811,11 +903,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); @@ -823,7 +915,7 @@ static void walk_children(GumboNode *node, buffer_t *out) { continue; } - walk_node(child, out); + walk_node(child, out, inherited); i++; } } @@ -832,7 +924,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; @@ -841,6 +934,11 @@ 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); + // we don't wrap braeaklines (GUMBO_NODE_WHITESPACE + '\n') + bool wrap = inherited && !(node->type == GUMBO_NODE_WHITESPACE && + strchr(text_raw, '\n')); + if (wrap) + emit_custom_style_open(out, *inherited); for (size_t i = 0; i < text_len; i++) { char c = text_raw[i]; switch (c) { @@ -858,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; } @@ -882,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; } @@ -898,51 +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); - - size_t fs_len = 0; - const char *fs = find_css_value(sval, slen, "font-size", &fs_len); - - size_t ff_len = 0; - const char *ff = first_font_family( - find_css_value(sval, slen, "font-family", &ff_len), &ff_len); - - bool has_fg = fg && fg_len > 0; - bool has_bg = bg && bg_len > 0; - bool has_fs = fs && fs_len > 0; - bool has_ff = ff && ff_len > 0; - bool has_custom_style = has_fg || has_bg || has_fs || has_ff; - - // emit the wrapper span if custom styles exist - if (has_custom_style) { - bool first = true; - 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_custom_style) { + if (wrap) { buffer_append_str(out, ""); } @@ -974,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, "

"); @@ -986,7 +1078,7 @@ 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; @@ -999,7 +1091,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { strcmp(name_buf, "th") == 0 || strcmp(name_buf, "caption") == 0 || strcmp(name_buf, "colgroup") == 0 || strcmp(name_buf, "col") == 0) { 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)) { @@ -1019,7 +1111,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); @@ -1027,7 +1119,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { } free(row.data); } else { - walk_children(node, out); + walk_children(node, out, inherited); } return; } @@ -1036,7 +1128,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: @@ -1057,7 +1149,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); @@ -1070,7 +1163,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; } @@ -1080,7 +1173,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, ""); @@ -1093,7 +1186,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 b54106d53..9e8cd4936 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -720,3 +720,75 @@ 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"); + + // Unstyled spans outside styled ones don't wrap anything + EXPECT_EQ(GumboParser::normalizeHtml( + "ABC"), + "ABC"); + + // Inherited values are escaped and quoted 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

"); +} From 1d063ff2bf4cee8295380435a8953df092f9d49c Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 11:18:05 +0200 Subject: [PATCH 09/15] fix(web): flatten nested spans in the normalizer --- src/web/__tests__/htmlNormalizer.test.ts | 52 +++++++ src/web/normalization/htmlNormalizer.ts | 173 ++++++++++++++++------- 2 files changed, 174 insertions(+), 51 deletions(-) diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index 04547a2de..d60d3a468 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -626,4 +626,56 @@ 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', + ], + // 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/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 257463a07..70b67bed4 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -204,6 +204,36 @@ function parseCssStyle(style: string | null): CssStyles { return result; } +type CustomStyle = { + fg: string; + bg: string; + fontSize: string; + fontFamily: string; +}; + +function parseCustomStyle(el: Element): CustomStyle { + const style = (el as HTMLElement).style; + return { + fg: style.color, + bg: style.backgroundColor, + fontSize: style.fontSize, + fontFamily: escapeText(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 +359,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) { @@ -382,21 +430,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 +459,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 +474,7 @@ type LiCtx = { styles: CssStyles; nestedLists: Element[]; hasEmitted: boolean; + inherited: CustomStyle | null; }; function flushLiBuffer( @@ -460,7 +510,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 +537,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 +564,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 +576,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 +611,35 @@ 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 ?? ''; + // we don't wrap breaklines (whitespace-only text containing '\n') + const wrap = + !!inherited && !(isWhitespaceOnly(text) && text.includes('\n')); + if (wrap) out.buf += emitCustomStyleOpen(inherited); + out.buf += escapeText(text); + if (wrap) out.buf += ''; return; } if (!isElement(node)) return; @@ -585,7 +649,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 +658,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 +706,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 +722,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 +757,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 +772,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 +781,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 +790,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 +800,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; } From 2868a773b852e0a8e44bf915df7bba4258429d40 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 13:50:35 +0200 Subject: [PATCH 10/15] fix: escape bracket characters --- cpp/parser/GumboNormalizer.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index ab8c3a3fa..254030547 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -326,13 +326,15 @@ static void append_style_prop(buffer_t *out, const char *prefix, buffer_append_str(out, "; "); *first = false; buffer_append_str(out, prefix); - // only `&` and `"` are significant inside a double-quoted attribute - // and need to be escaped 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); } From a1daebdd6745a690f08bcfa9745f152416733d37 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 14:25:22 +0200 Subject: [PATCH 11/15] fix: 'transparent' does not overwrite its ancestor spans during flattening --- cpp/parser/GumboNormalizer.c | 8 +++++++- cpp/tests/GumboParserTest.cpp | 13 +++++++++++++ src/web/__tests__/htmlNormalizer.test.ts | 7 +++++++ src/web/normalization/htmlNormalizer.ts | 6 +++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 254030547..b35a6d416 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -28,6 +28,7 @@ #include #include #include +#include /* ------------------------------------------------------------------ */ /* Dynamic string buffer */ @@ -422,8 +423,13 @@ 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 = + css_slice_t background_color = find_custom_css_value(style_value, style_len, "background-color"); + // we don't want `transparent` to overwrite it's parent span's background-color + if (!(background_color.len == strlen("transparent") && + strncasecmp(background_color.val, "transparent", background_color.len) == 0)) { + result.custom.bg = background_color; + } result.custom.font_size = find_custom_css_value(style_value, style_len, "font-size"); css_slice_t family = diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index 9e8cd4936..9b1549404 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -768,6 +768,19 @@ TEST(GumboParserTest, NestedSpans) { "style=\"color: blue; background-color: yellow; font-size: " "20px\">C
"); + // Transparent background doesn't override the inherited one + EXPECT_EQ(GumboParser::normalizeHtml( + "AB"), + "AB"); + + // Transparent background alone is treated as no style + EXPECT_EQ(GumboParser::normalizeHtml( + "A"), + "A"); + // Unstyled spans outside styled ones don't wrap anything EXPECT_EQ(GumboParser::normalizeHtml( "ABC"), diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index d60d3a468..b0ce6822c 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -660,6 +660,13 @@ describe('htmlNormalizer', () => { 'ABC', 'ABC', ], + // Transparent background doesn't override the inherited one + [ + 'AB', + 'AB', + ], + // Transparent background alone is treated as no style + ['A', 'A'], // Unstyled spans outside styled ones don't wrap anything [ 'ABC', diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 70b67bed4..361518f62 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -215,7 +215,11 @@ function parseCustomStyle(el: Element): CustomStyle { const style = (el as HTMLElement).style; return { fg: style.color, - bg: style.backgroundColor, + // we don't want `transparent` to overwrite it's parent span's background-color + bg: + style.backgroundColor.toLowerCase() === 'transparent' + ? '' + : style.backgroundColor, fontSize: style.fontSize, fontFamily: escapeText(style.fontFamily), }; From 0f989671120946df6d5e6c93701cc78006cd47e4 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 20:29:42 +0200 Subject: [PATCH 12/15] fix: distinguish html layout whitespaces when applying span tags --- cpp/parser/GumboNormalizer.c | 36 ++++++++++++++++++------ cpp/tests/GumboParserTest.cpp | 23 +++++++++++++++ src/web/__tests__/htmlNormalizer.test.ts | 15 ++++++++++ src/web/normalization/htmlNormalizer.ts | 21 ++++++++++++-- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index b35a6d416..d531d7367 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -220,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)) @@ -705,6 +719,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 @@ -942,9 +968,7 @@ 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); - // we don't wrap braeaklines (GUMBO_NODE_WHITESPACE + '\n') - bool wrap = inherited && !(node->type == GUMBO_NODE_WHITESPACE && - strchr(text_raw, '\n')); + bool wrap = inherited && !is_layout_whitespace(node); if (wrap) emit_custom_style_open(out, *inherited); for (size_t i = 0; i < text_len; i++) { @@ -1093,11 +1117,7 @@ static void walk_node(GumboNode *node, buffer_t *out, } /* --- 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, inherited); /* Check if there's a next sibling element */ diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index 9b1549404..fe65bfa9c 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -781,6 +781,29 @@ TEST(GumboParserTest, NestedSpans) { "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"), diff --git a/src/web/__tests__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index b0ce6822c..c0a6ce7ea 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -667,6 +667,21 @@ describe('htmlNormalizer', () => { ], // 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', diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index 361518f62..30760e631 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': @@ -413,6 +416,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 @@ -638,9 +655,7 @@ function walkNode( ): void { if (isText(node)) { const text = node.textContent ?? ''; - // we don't wrap breaklines (whitespace-only text containing '\n') - const wrap = - !!inherited && !(isWhitespaceOnly(text) && text.includes('\n')); + const wrap = !!inherited && !isLayoutWhitespace(node); if (wrap) out.buf += emitCustomStyleOpen(inherited); out.buf += escapeText(text); if (wrap) out.buf += ''; From 8bf9d41da8127b0188ecc3a41360ab81cb71fb7f Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 22:14:49 +0200 Subject: [PATCH 13/15] fix: font family parsing/generation, proper font-families handling with commas in their name --- .../common/parser/EnrichedParser.java | 19 +++++- cpp/parser/GumboNormalizer.c | 58 ++++--------------- cpp/tests/GumboParserTest.cpp | 24 ++++---- ios/htmlParser/HtmlParser.mm | 31 +++++++--- src/web/__tests__/normalizeFontFamily.test.ts | 19 ++++++ src/web/formats/EnrichedCustomStyle.ts | 29 +++++++--- 6 files changed, 100 insertions(+), 80 deletions(-) create mode 100644 src/web/__tests__/normalizeFontFamily.test.ts 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 e30e5a25a..312be0c6d 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,7 +374,7 @@ 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(";"); @@ -1034,7 +1034,7 @@ private static void startSpan(Editable text, Attributes attributes) { private static String parseCssFontFamily(String raw) { if (raw == null) return null; - String value = raw.split(",", 2)[0].trim(); + String value = firstInFontFamilyString(raw).trim(); if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) { value = value.substring(1, value.length() - 1); @@ -1042,6 +1042,21 @@ private static String parseCssFontFamily(String raw) { 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/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index d531d7367..a05bce907 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -309,34 +309,8 @@ static const char *find_css_value(const char *style, size_t style_len, return NULL; } -/* First family of a font stack, unquoted: `"Open Sans", Arial` → Open Sans. */ -static const char *first_font_family(const char *val, size_t *val_len) { - if (!val) - return NULL; - const char *start = val; - const char *end = val + *val_len; - const char *comma = memchr(start, ',', *val_len); - if (comma) - end = comma; - while (start < end && isspace((unsigned char)*start)) - start++; - while (end > start && isspace((unsigned char)*(end - 1))) - end--; - // strip the quotes if the value is quoted - if (end - start >= 2 && (*start == '\'' || *start == '"') && - *(end - 1) == *start) { - start++; - end--; - } - *val_len = (size_t)(end - start); - if (memchr(start, '\'', *val_len)) - return NULL; - return start; -} - static void append_style_prop(buffer_t *out, const char *prefix, - const char *val, size_t val_len, - const char *suffix, bool *first) { + const char *val, size_t val_len, bool *first) { if (!*first) buffer_append_str(out, "; "); *first = false; @@ -353,7 +327,6 @@ static void append_style_prop(buffer_t *out, const char *prefix, else buffer_append(out, &val[i], 1); } - buffer_append_str(out, suffix); } static bool css_val_contains(const char *val, size_t val_len, @@ -446,12 +419,8 @@ static css_styles_t parse_css_style(const char *style_value, size_t style_len) { } result.custom.font_size = find_custom_css_value(style_value, style_len, "font-size"); - css_slice_t family = + result.custom.font_family = find_custom_css_value(style_value, style_len, "font-family"); - family.val = first_font_family(family.val, &family.len); - if (!family.val) - family.len = 0; - result.custom.font_family = family; return result; } @@ -498,20 +467,15 @@ static void emit_custom_style_open(buffer_t *out, custom_style_t s) { bool first = true; buffer_append_str(out, ""); } @@ -726,9 +690,9 @@ static bool is_layout_whitespace(GumboNode *node) { 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))); + return parent_name && + (has_block_or_bq_child(node->parent) || + (is_table_tag(parent_name) && !is_table_text_tag(parent_name))); } /** diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index fe65bfa9c..2fc77896e 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -322,14 +322,15 @@ TEST(GumboParserTest, SpanRemappings) { GumboParser::normalizeHtml("x"), "x"); - // Font family: first family of the stack, unquoted, quoted again if it - // contains spaces + // 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"); + "x"); + EXPECT_EQ( + GumboParser::normalizeHtml( + "x"), + "x"); EXPECT_EQ(GumboParser::normalizeHtml( "x"), "x"); @@ -349,12 +350,7 @@ TEST(GumboParserTest, SpanRemappings) { EXPECT_EQ( GumboParser::normalizeHtml("x"), - "x"); - - // Family names with single quotes can't be quoted, so they're dropped - EXPECT_EQ(GumboParser::normalizeHtml( - "x"), - "x"); + "x"); } TEST(GumboParserTest, EnrichedTagRemappings) { @@ -809,12 +805,12 @@ TEST(GumboParserTest, NestedSpans) { "ABC"), "ABC"); - // Inherited values are escaped and quoted like the wrapper's own ones + // Inherited values are escaped like the wrapper's own ones EXPECT_EQ(GumboParser::normalizeHtml( "AB"), "AB"); + "a"b; font-family: "Open Sans"\">B"); // Context reaches list items and blockquotes EXPECT_EQ(GumboParser::normalizeHtml( diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index 451b57617..e33509f0e 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1514,8 +1514,9 @@ + (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]; @@ -1567,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; @@ -1667,13 +1684,9 @@ + (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]]; - } + fontFamilyString = [[self firstInFontFamilyString:fontFamilyString] + stringByTrimmingCharactersInSet:[NSCharacterSet + whitespaceAndNewlineCharacterSet]]; if (([fontFamilyString hasPrefix:@"'"] && [fontFamilyString hasSuffix:@"'"]) || ([fontFamilyString hasPrefix:@"\""] && diff --git a/src/web/__tests__/normalizeFontFamily.test.ts b/src/web/__tests__/normalizeFontFamily.test.ts new file mode 100644 index 000000000..09e78898f --- /dev/null +++ b/src/web/__tests__/normalizeFontFamily.test.ts @@ -0,0 +1,19 @@ +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], + ])('%p → %p', (input, expected) => { + expect(normalizeFontFamily(input)).toBe(expected); + }); +}); diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index dd1838b44..4384a658c 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -10,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("'")) || @@ -56,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}` From 9aebd94f59c28acaa3daff8481bf813bbf280dc2 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 19 Oct 2026 23:54:15 +0200 Subject: [PATCH 14/15] fix: make 'inherit' not overwrite the parent style span --- .../common/parser/EnrichedParser.java | 5 +++-- cpp/parser/GumboNormalizer.c | 20 +++++++++++-------- cpp/tests/GumboParserTest.cpp | 6 ++++++ ios/htmlParser/HtmlParser.mm | 9 +++++---- src/web/__tests__/htmlNormalizer.test.ts | 5 +++++ src/web/__tests__/normalizeFontFamily.test.ts | 1 + src/web/formats/EnrichedCustomStyle.ts | 4 ++-- src/web/normalization/htmlNormalizer.ts | 13 ++++++++---- 8 files changed, 43 insertions(+), 20 deletions(-) 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 312be0c6d..293d5c0f6 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 @@ -1035,8 +1035,9 @@ private static void startSpan(Editable text, Attributes attributes) { private static String parseCssFontFamily(String raw) { if (raw == null) return null; String value = firstInFontFamilyString(raw).trim(); - if ((value.startsWith("'") && value.endsWith("'")) - || (value.startsWith("\"") && value.endsWith("\""))) { + if (value.length() >= 2 + && ((value.startsWith("'") && value.endsWith("'")) + || (value.startsWith("\"") && value.endsWith("\"")))) { value = value.substring(1, value.length() - 1); } return value.isEmpty() ? null : value; diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index a05bce907..25a20af49 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -349,12 +349,18 @@ 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); - if (!slice.val) - slice.len = 0; + // `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; } @@ -410,13 +416,11 @@ 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"); - css_slice_t background_color = + result.custom.bg = find_custom_css_value(style_value, style_len, "background-color"); - // we don't want `transparent` to overwrite it's parent span's background-color - if (!(background_color.len == strlen("transparent") && - strncasecmp(background_color.val, "transparent", background_color.len) == 0)) { - result.custom.bg = 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 = diff --git a/cpp/tests/GumboParserTest.cpp b/cpp/tests/GumboParserTest.cpp index 2fc77896e..6b9efb114 100644 --- a/cpp/tests/GumboParserTest.cpp +++ b/cpp/tests/GumboParserTest.cpp @@ -772,6 +772,12 @@ TEST(GumboParserTest, NestedSpans) { "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"), diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index e33509f0e..57d306531 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -1687,10 +1687,11 @@ + (CustomStyleData *_Nullable)parseCustomStyleDataFromSpanParams: fontFamilyString = [[self firstInFontFamilyString:fontFamilyString] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (([fontFamilyString hasPrefix:@"'"] && - [fontFamilyString hasSuffix:@"'"]) || - ([fontFamilyString hasPrefix:@"\""] && - [fontFamilyString hasSuffix:@"\""])) { + 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__/htmlNormalizer.test.ts b/src/web/__tests__/htmlNormalizer.test.ts index c0a6ce7ea..8fec44dcf 100644 --- a/src/web/__tests__/htmlNormalizer.test.ts +++ b/src/web/__tests__/htmlNormalizer.test.ts @@ -665,6 +665,11 @@ describe('htmlNormalizer', () => { '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 diff --git a/src/web/__tests__/normalizeFontFamily.test.ts b/src/web/__tests__/normalizeFontFamily.test.ts index 09e78898f..67a55960e 100644 --- a/src/web/__tests__/normalizeFontFamily.test.ts +++ b/src/web/__tests__/normalizeFontFamily.test.ts @@ -11,6 +11,7 @@ describe('normalizeFontFamily', () => { ["'SWM,Inc', Arial", 'SWM,Inc'], // an empty first family is no family [', Arial', null], + ["'", null], ['', null], [null, null], ])('%p → %p', (input, expected) => { diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index 4384a658c..fd524c977 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -200,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 = ( @@ -209,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 30760e631..23fbc7e99 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -214,17 +214,22 @@ type CustomStyle = { 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: style.color, + fg: ownCssValue(style.color), // we don't want `transparent` to overwrite it's parent span's background-color bg: style.backgroundColor.toLowerCase() === 'transparent' ? '' - : style.backgroundColor, - fontSize: style.fontSize, - fontFamily: escapeText(style.fontFamily), + : ownCssValue(style.backgroundColor), + fontSize: ownCssValue(style.fontSize), + fontFamily: escapeText(ownCssValue(style.fontFamily)), }; } From 8fc428779be7836bcdfd35903f356f83bcf01481 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 20 Oct 2026 12:23:31 +0200 Subject: [PATCH 15/15] fix(android): escape other characters when generating html for consistency --- .../swmansion/enriched/common/parser/EnrichedParser.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 293d5c0f6..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 @@ -381,7 +381,13 @@ private static void withinParagraph(StringBuilder out, Spanned text, int start, } } out.append("", ">") + .replace("\"", """)) .append("\">"); } else { out.append("");