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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .maestro/enrichedInput/screenshots/android/checkbox_toggle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.swmansion.enriched.common.parser

object HtmlWrapperExtractor {
private const val HTML_OPEN = "<html>"
private const val HTML_CLOSE = "</html>"
private const val BODY_OPEN = "<body>"
private const val BODY_CLOSE = "</body>"

/**
* Strips the <html> tags and, if there is a <body> 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)
Comment thread
hejsztynx marked this conversation as resolved.
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,44 +196,20 @@ class EnrichedTextView : AppCompatTextView {
text: String,
style: EnrichedTextStyle,
): CharSequence? {
val isInternalHtml = text.startsWith("<html>") && text.endsWith("</html>")

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("<html>") && text.endsWith("</html>")
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(
Expand Down
2 changes: 1 addition & 1 deletion cpp/parser/GumboNormalizer.c
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,7 @@ static void walk_node(GumboNode *node, buffer_t *out) {

/* <codeblock>: wrap inline content in <p> */
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, "<codeblock>");
if (wrap)
buffer_append_str(out, "<p>");
Expand Down
4 changes: 3 additions & 1 deletion ios/EnrichedTextInputView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
74 changes: 38 additions & 36 deletions ios/htmlParser/HtmlParser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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:@"<html>"] &&
[lastSeven isEqualToString:@"</html>"]) {
// remove html tags, might be with newlines or without them
fixedHtml = [htmlWithoutSpaces copy];
// firstly remove newlined html tags if any:
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"<html>\n"
withString:@""];
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n</html>"
withString:@""];
// fallback; remove html tags without their newlines
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"<html>"
withString:@""];
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"</html>"
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:@"<html>"] &&
[htmlWithoutSpaces hasSuffix:@"</html>"]) {
Comment thread
hejsztynx marked this conversation as resolved.
// remove html tags, might be with newlines or without them
fixedHtml = [htmlWithoutSpaces copy];
// firstly remove newlined html tags if any:
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"<html>\n"
withString:@""];
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n</html>"
withString:@""];
// fallback; remove html tags without their newlines
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"<html>"
withString:@""];
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"</html>"
withString:@""];
Comment thread
hejsztynx marked this conversation as resolved.

// Additionally, try getting the content from between body tags if there are
// some:
Expand All @@ -251,13 +236,30 @@ + (NSString *_Nullable)initiallyProcessHtml:(NSString *_Nonnull)html
fixedHtml = [fixedHtml stringByReplacingOccurrencesOfString:@"\n</body>"
withString:@"</body>"];
// Then, if there actually are body tags, use the content between them.
NSRange openingBodyRange = [htmlWithoutSpaces rangeOfString:@"<body>"];
NSRange closingBodyRange = [htmlWithoutSpaces rangeOfString:@"</body>"];
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:@"<body>"];
if (openingBodyRange.length != 0) {
NSUInteger newStart = NSMaxRange(openingBodyRange);
// only look for the closing tag after the opening one
NSRange closingBodyRange = [fixedHtml
rangeOfString:@"</body>"
options:0
range:NSMakeRange(newStart, fixedHtml.length - newStart)];
if (closingBodyRange.length != 0) {
fixedHtml = [fixedHtml
substringWithRange:NSMakeRange(newStart, closingBodyRange.location -
newStart)];
}
}
}

Comment thread
hejsztynx marked this conversation as resolved.
if (useHtmlNormalizer) {
Comment thread
hejsztynx marked this conversation as resolved.
// 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;
}
}

Expand Down
11 changes: 9 additions & 2 deletions ios/inputHtmlParser/InputHtmlParser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,15 @@ - (void)applyProcessedAlignments:(NSArray<AlignmentEntry *> *)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
10 changes: 2 additions & 8 deletions src/web/EnrichedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(
Expand Down
29 changes: 29 additions & 0 deletions src/web/normalization/htmlWrapperExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const HTML_OPEN = '<html>';
const HTML_CLOSE = '</html>';
const BODY_OPEN = '<body>';
const BODY_CLOSE = '</body>';

/**
* Strips the <html> tags and, if there is a <body> 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 <body> 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);
Comment thread
hejsztynx marked this conversation as resolved.
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);
}
8 changes: 7 additions & 1 deletion src/web/normalization/prepareHtmlForWeb.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions src/web/normalization/tiptapHtmlNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading