diff --git a/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png b/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png index b3b661a85..2ebc3fd4b 100644 Binary files a/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png and b/.maestro/enrichedInput/screenshots/android/checkbox_toggle.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png index 74947bcfd..8fa6291ad 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png index 7a053e6c5..c092fb52d 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png index d80b9a756..cb34c00b1 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png index 7bb6e38db..4a01cdb95 100644 Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_lists.png differ diff --git a/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png b/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png index 3278d7428..ffeb45b81 100644 Binary files a/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png and b/.maestro/enrichedInput/screenshots/android/paragraph_styles_no_crash.png differ diff --git a/.maestro/enrichedText/screenshots/android/custom_styles_display.png b/.maestro/enrichedText/screenshots/android/custom_styles_display.png index 97f2e6a81..22db51667 100644 Binary files a/.maestro/enrichedText/screenshots/android/custom_styles_display.png and b/.maestro/enrichedText/screenshots/android/custom_styles_display.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/parser/HtmlWrapperExtractor.kt b/android/src/main/java/com/swmansion/enriched/common/parser/HtmlWrapperExtractor.kt new file mode 100644 index 000000000..13ba5ac5e --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/parser/HtmlWrapperExtractor.kt @@ -0,0 +1,28 @@ +package com.swmansion.enriched.common.parser + +object HtmlWrapperExtractor { + private const val HTML_OPEN = "" + private const val HTML_CLOSE = "" + private const val BODY_OPEN = "" + private const val BODY_CLOSE = "" + + /** + * Strips the tags and, if there is a pair inside, keeps only its content + * (anything outside of it is dropped). + */ + fun extract(html: String): String { + val trimmed = html.trim() + if (!trimmed.startsWith(HTML_OPEN) || !trimmed.endsWith(HTML_CLOSE)) return html + + val inner = trimmed.substring(HTML_OPEN.length, trimmed.length - HTML_CLOSE.length) + val bodyStart = inner.indexOf(BODY_OPEN) + if (bodyStart == -1) return inner + + val contentStart = bodyStart + BODY_OPEN.length + // only look for the closing tag after the opening one + val bodyEnd = inner.indexOf(BODY_CLOSE, contentStart) + if (bodyEnd == -1) return inner + + return inner.substring(contentStart, bodyEnd) + } +} diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt index 357016da3..021af0083 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -26,6 +26,7 @@ import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser +import com.swmansion.enriched.common.parser.HtmlWrapperExtractor import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.text.spans.EnrichedTextImageSpan import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan @@ -195,44 +196,20 @@ class EnrichedTextView : AppCompatTextView { text: String, style: EnrichedTextStyle, ): CharSequence? { - val isInternalHtml = text.startsWith("") && text.endsWith("") - - if (isInternalHtml) { - try { - val parsed = EnrichedParser.fromHtml(text, style, spannableFactory) - return parsed.trimEnd('\n') - } catch (e: Exception) { - Log.e(TAG, "Error parsing HTML: ${e.message}") - return normalizeHtmlIfNeeded(text, style) - } - } - - return normalizeHtmlIfNeeded(text, style) - } - - private fun normalizeHtmlIfNeeded( - text: String, - style: EnrichedTextStyle, - ): CharSequence? { - if (!useHtmlNormalizer) return null - return parseNormalizedHtml(text, style) - } + var html = HtmlWrapperExtractor.extract(text) + if (useHtmlNormalizer) html = normalizeHtml(html) - private fun parseNormalizedHtml( - text: String, - style: EnrichedTextStyle, - ): CharSequence? { - val normalized = GumboNormalizer.normalizeHtml(text) ?: return null - - return try { - val parsed: Spanned = EnrichedParser.fromHtml(normalized, style, spannableFactory) - parsed.trimEnd('\n') + try { + val parsed = EnrichedParser.fromHtml(html, style, spannableFactory) + return parsed.trimEnd('\n') } catch (e: Exception) { - Log.e(TAG, "Error parsing normalized HTML: ${e.message}") - null + Log.e(TAG, "Error parsing HTML: ${e.message}") + return null } } + private fun normalizeHtml(text: String): String = GumboNormalizer.normalizeHtml(text) ?: text + private fun observeAsyncImages() { val spanned = parsedText as? Spanned ?: return val spans = spanned.getSpans(0, spanned.length, EnrichedTextImageSpan::class.java) 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 e61435f35..a82188280 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -42,6 +42,7 @@ import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser +import com.swmansion.enriched.common.parser.HtmlWrapperExtractor import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.textinput.events.MentionHandler import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent @@ -428,33 +429,19 @@ class EnrichedTextInputView : setSelection(selection?.start ?: text?.length ?: 0) } - private fun normalizeHtmlIfNeeded(text: CharSequence): CharSequence { - if (!useHtmlNormalizer) return text - val normalized = GumboNormalizer.normalizeHtml(text.toString()) ?: return text - - return try { - val parsed = EnrichedParser.fromHtml(normalized, htmlStyle, spannableFactory, linkRegex) - parsed.trimEnd('\n') - } catch (e: Exception) { - Log.e(TAG, "Error parsing normalized HTML: ${e.message}") - text - } - } + private fun normalizeHtml(text: String): String = GumboNormalizer.normalizeHtml(text) ?: text private fun parseText(text: CharSequence): CharSequence { - val isInternalHtml = text.startsWith("") && text.endsWith("") + var html = HtmlWrapperExtractor.extract(text.toString()) + if (useHtmlNormalizer) html = normalizeHtml(html) - if (isInternalHtml) { - try { - val parsed = EnrichedParser.fromHtml(text.toString(), htmlStyle, spannableFactory, linkRegex) - return parsed.trimEnd('\n') - } catch (e: Exception) { - Log.e(TAG, "Error parsing HTML: ${e.message}") - return normalizeHtmlIfNeeded(text) - } + try { + val parsed = EnrichedParser.fromHtml(html, htmlStyle, spannableFactory, linkRegex) + return parsed.trimEnd('\n') + } catch (e: Exception) { + Log.e(TAG, "Error parsing HTML: ${e.message}") + return text } - - return normalizeHtmlIfNeeded(text) } fun setValue( diff --git a/cpp/parser/GumboNormalizer.c b/cpp/parser/GumboNormalizer.c index 5a6210f15..368d37eb1 100644 --- a/cpp/parser/GumboNormalizer.c +++ b/cpp/parser/GumboNormalizer.c @@ -985,7 +985,7 @@ static void walk_node(GumboNode *node, buffer_t *out) { /* : wrap inline content in

*/ if (strcmp(out_name, "codeblock") == 0) { - bool wrap = is_purely_inline(node); + bool wrap = is_purely_inline(node) && el->children.length > 0; buffer_append_str(out, ""); if (wrap) buffer_append_str(out, "

"); diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c44d4d9dd..458dfda21 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -2097,7 +2097,9 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { parseToHtmlFromRange:NSMakeRange(0, textView.textStorage.string.length) host:self]; NSString *initiallyProcessedHtml = [parser initiallyProcessHtml:currentHtml]; - [parser replaceWholeFromHtml:initiallyProcessedHtml]; + if (initiallyProcessedHtml != nullptr) { + [parser replaceWholeFromHtml:initiallyProcessedHtml]; + } textView.selectedRange = prevSelectedRange; [self anyTextMayHaveBeenModified]; diff --git a/ios/htmlParser/HtmlParser.mm b/ios/htmlParser/HtmlParser.mm index f710a7411..0d6f84882 100644 --- a/ios/htmlParser/HtmlParser.mm +++ b/ios/htmlParser/HtmlParser.mm @@ -212,35 +212,20 @@ + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html NSString *htmlWithoutSpaces = [self stripExtraWhiteSpacesAndNewlines:html]; NSString *fixedHtml = nullptr; - if (htmlWithoutSpaces.length >= 13) { - NSString *firstSix = - [htmlWithoutSpaces substringWithRange:NSMakeRange(0, 6)]; - NSString *lastSeven = [htmlWithoutSpaces - substringWithRange:NSMakeRange(htmlWithoutSpaces.length - 7, 7)]; - - if ([firstSix isEqualToString:@""] && - [lastSeven isEqualToString:@""]) { - // remove html tags, might be with newlines or without them - fixedHtml = [htmlWithoutSpaces copy]; - // firstly remove newlined html tags if any: - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" - withString:@""]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" - withString:@""]; - // fallback; remove html tags without their newlines - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" - withString:@""]; - fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" - withString:@""]; - } else if (useHtmlNormalizer) { - // External HTML (from Google Docs, Word, web pages, etc.) - // Run through the Gumbo-based normalizer to convert arbitrary HTML - // into our canonical tag subset. - NSString *normalized = [self normalizeExternalHtml:html]; - if (normalized != nil) { - fixedHtml = normalized; - } - } + if ([htmlWithoutSpaces hasPrefix:@""] && + [htmlWithoutSpaces hasSuffix:@""]) { + // remove html tags, might be with newlines or without them + fixedHtml = [htmlWithoutSpaces copy]; + // firstly remove newlined html tags if any: + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" + withString:@""]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" + withString:@""]; + // fallback; remove html tags without their newlines + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" + withString:@""]; + fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"" + withString:@""]; // Additionally, try getting the content from between body tags if there are // some: @@ -251,13 +236,30 @@ + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n" withString:@""]; // Then, if there actually are body tags, use the content between them. - NSRange openingBodyRange = [htmlWithoutSpaces rangeOfString:@""]; - NSRange closingBodyRange = [htmlWithoutSpaces rangeOfString:@""]; - if (openingBodyRange.length != 0 && closingBodyRange.length != 0) { - NSInteger newStart = openingBodyRange.location + 6; - NSInteger newEnd = closingBodyRange.location - 1; - fixedHtml = [htmlWithoutSpaces - substringWithRange:NSMakeRange(newStart, newEnd - newStart + 1)]; + NSRange openingBodyRange = [fixedHtml rangeOfString:@""]; + if (openingBodyRange.length != 0) { + NSUInteger newStart = NSMaxRange(openingBodyRange); + // only look for the closing tag after the opening one + NSRange closingBodyRange = [fixedHtml + rangeOfString:@"" + options:0 + range:NSMakeRange(newStart, fixedHtml.length - newStart)]; + if (closingBodyRange.length != 0) { + fixedHtml = [fixedHtml + substringWithRange:NSMakeRange(newStart, closingBodyRange.location - + newStart)]; + } + } + } + + if (useHtmlNormalizer) { + // External HTML (from Google Docs, Word, web pages, etc.) + // Run through the Gumbo-based normalizer to convert arbitrary HTML + // into our canonical tag subset. + NSString *toNormalize = fixedHtml != nullptr ? fixedHtml : html; + NSString *normalized = [self normalizeExternalHtml:toNormalize]; + if (normalized != nil) { + fixedHtml = normalized; } } diff --git a/ios/inputHtmlParser/InputHtmlParser.mm b/ios/inputHtmlParser/InputHtmlParser.mm index 8dec7819e..4138b544b 100644 --- a/ios/inputHtmlParser/InputHtmlParser.mm +++ b/ios/inputHtmlParser/InputHtmlParser.mm @@ -221,8 +221,15 @@ - (void)applyProcessedAlignments:(NSArray *)alignments } - (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html { - return [HtmlParser initiallyProcessHtml:html - useHtmlNormalizer:_input->useHtmlNormalizer]; + @try { + return [HtmlParser initiallyProcessHtml:html + useHtmlNormalizer:_input->useHtmlNormalizer]; + } @catch (NSException *exception) { + RCTLogWarn(@"[EnrichedTextInput]: Failed to process HTML: (%@), falling " + @"back to raw input.", + exception.reason); + return nil; + } } @end diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 1786c5a4f..e06a2418c 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -17,7 +17,6 @@ import { } from './constants/classNames'; import { enrichedTextThemingToCSSProperties } from './styleConversion/enrichedThemingToCSSProperties'; import { buildMentionRulesCSS } from './styleConversion/buildMentionRulesCSS'; -import { sanitizeHtml } from './sanitization/htmlSanitizer'; import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables'; import { useImageErrorFallback } from './htmlExtensions/useImageErrorFallback'; @@ -63,14 +62,9 @@ export const EnrichedText = memo( }, })); - const sanitizedHtml = useMemo( - () => sanitizeHtml(children, sanitizationConfig), - [children, sanitizationConfig] - ); - const finalHtml = useMemo( - () => prepareHtmlForWeb(sanitizedHtml, useHtmlNormalizer), - [sanitizedHtml, useHtmlNormalizer] + () => prepareHtmlForWeb(children, useHtmlNormalizer, sanitizationConfig), + [children, useHtmlNormalizer, sanitizationConfig] ); const [clampedHtml, setClampedHtml] = useState( diff --git a/src/web/normalization/htmlWrapperExtractor.ts b/src/web/normalization/htmlWrapperExtractor.ts new file mode 100644 index 000000000..dfa7792f5 --- /dev/null +++ b/src/web/normalization/htmlWrapperExtractor.ts @@ -0,0 +1,29 @@ +const HTML_OPEN = ''; +const HTML_CLOSE = ''; +const BODY_OPEN = ''; +const BODY_CLOSE = ''; + +/** + * Strips the tags and, if there is a pair inside, keeps only its content + * (anything outside of it is dropped). + * + * Must run before sanitization, as DOMPurify drops these tags and would leave + * content from outside of in the result. + */ +export function extractHtmlContent(html: string): string { + const trimmed = html.trim(); + if (!trimmed.startsWith(HTML_OPEN) || !trimmed.endsWith(HTML_CLOSE)) { + return html; + } + + const inner = trimmed.slice(HTML_OPEN.length, -HTML_CLOSE.length); + const bodyStart = inner.indexOf(BODY_OPEN); + if (bodyStart === -1) return inner; + + const contentStart = bodyStart + BODY_OPEN.length; + // only look for the closing tag after the opening one + const bodyEnd = inner.indexOf(BODY_CLOSE, contentStart); + if (bodyEnd === -1) return inner; + + return inner.slice(contentStart, bodyEnd); +} diff --git a/src/web/normalization/prepareHtmlForWeb.ts b/src/web/normalization/prepareHtmlForWeb.ts index 8da6d63e8..1e492e4d8 100644 --- a/src/web/normalization/prepareHtmlForWeb.ts +++ b/src/web/normalization/prepareHtmlForWeb.ts @@ -1,9 +1,15 @@ +import type { SanitizationConfig } from '../../types'; +import { sanitizeHtml } from '../sanitization/htmlSanitizer'; import { normalizeHtml } from './htmlNormalizer'; +import { extractHtmlContent } from './htmlWrapperExtractor'; export function prepareHtmlForWeb( html: string, - useHtmlNormalizer: boolean | undefined + useHtmlNormalizer: boolean | undefined, + sanitizationConfig?: SanitizationConfig ): string { + html = extractHtmlContent(html); + html = sanitizeHtml(html, sanitizationConfig); if (useHtmlNormalizer) { html = normalizeHtml(html); } diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts index cadbda43a..b75806612 100644 --- a/src/web/normalization/tiptapHtmlNormalizer.ts +++ b/src/web/normalization/tiptapHtmlNormalizer.ts @@ -5,12 +5,14 @@ import { checkboxHtmlFromTiptap, } from './checkboxHtmlNormalizer'; import { normalizeHtml } from './htmlNormalizer'; +import { extractHtmlContent } from './htmlWrapperExtractor'; export function prepareHtmlForTiptap( html: string, useHtmlNormalizer: boolean | undefined, sanitizationConfig?: SanitizationConfig ): string { + html = extractHtmlContent(html); html = sanitizeHtml(html, sanitizationConfig); if (useHtmlNormalizer) { html = normalizeHtml(html);