Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#import "../../ios/parser/MarkdownASTNode.h"
#import "../../ios/styles/StyleConfig.h"
#import "../../ios/utils/HTMLGenerator.h"
#import "../../ios/utils/LastElementUtils.h"
#import "../../ios/utils/MarkdownASTSerializer.h"
#import <XCTest/XCTest.h>

@interface ENRMClipboardHTMLTests : XCTestCase
@end

@implementation ENRMClipboardHTMLTests

- (void)testTableLineBreaksAreExportedAsHTMLBreaks
{
for (NSString *separator in @[ @"\n", @"\r", @"\r\n", @"\u2028", @"\u2029" ]) {
NSAttributedString *text =
[[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@"first%@second", separator]];
NSString *html = generateTableHTML(@[ @[ @{@"attributedText" : text} ] ], [[StyleConfig alloc] init]);
XCTAssertTrue([html containsString:@"first<br>second"], @"%@", html);
XCTAssertFalse([html containsString:@"<br><br>"], @"%@", html);
}
}

- (void)testCodeBlockNewlinesRemainLiteral
{
NSAttributedString *text = [[NSAttributedString alloc] initWithString:@"first\nsecond"
attributes:@{CodeBlockAttributeName : @YES}];
NSString *html = generateHTML(text, [[StyleConfig alloc] init]);
XCTAssertFalse([html containsString:@"<br>"], @"%@", html);
XCTAssertTrue([html containsString:@"first\nsecond"], @"%@", html);
}

- (void)testSerializedTableFormulaKeepsInlineDelimiters
{
MarkdownASTNode *formula = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeLatexMathInline];
MarkdownASTNode *text = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeText];
text.content = @"x_1";
[formula addChild:text];
MarkdownASTNode *cell = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeTableCell];
[cell addChild:formula];
XCTAssertEqualObjects(markdownFromASTNodeChildren(cell), @"$x_1$");
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ object MarkdownASTSerializer {
buffer.append("==")
}

NodeType.LatexMathInline -> {
buffer.append("\$")
appendChildren(node, buffer)
buffer.append("\$")
}

NodeType.Code -> {
buffer.append("`")
appendChildren(node, buffer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ object HTMLGenerator {
}
}

escapeHTMLTo(html, content.trimEnd('\n'))
escapeHTMLTo(html, content.trimEnd('\n'), preserveLineBreaks = !isCodeBlock)

if (isUnderline && link == null) html.append("</u>")
if (isStrikethrough) html.append("</s>")
Expand Down Expand Up @@ -1001,7 +1001,7 @@ object HTMLGenerator {
if (cellText is Spannable) {
append(generateInlineHTML(cellText, 0, cellText.length, cachedStyles, false))
} else {
escapeHTMLTo(this, cellText.toString())
escapeHTMLTo(this, cellText.toString(), preserveLineBreaks = true)
}
}

Expand Down Expand Up @@ -1042,14 +1042,17 @@ object HTMLGenerator {
private fun escapeHTMLTo(
output: StringBuilder,
text: String,
preserveLineBreaks: Boolean = false,
) {
val len = text.length

// Fast path: skip if no escaping needed
var needsEscape = false
for (i in 0 until len) {
val c = text[i]
if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'') {
if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'' ||
(preserveLineBreaks && (c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029'))
) {
needsEscape = true
break
}
Expand All @@ -1059,8 +1062,15 @@ object HTMLGenerator {
return
}

for (i in 0 until len) {
when (val c = text[i]) {
var index = 0
while (index < len) {
val c = text[index++]
if (preserveLineBreaks && (c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029')) {
if (c == '\r' && index < len && text[index] == '\n') index++
output.append("<br>")
continue
}
when (c) {
'&' -> output.append("&amp;")
'<' -> output.append("&lt;")
'>' -> output.append("&gt;")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.swmansion.enriched.markdown

import android.graphics.Color
import android.text.Layout
import android.text.SpannableString
import com.facebook.react.bridge.JavaOnlyMap
import com.swmansion.enriched.markdown.parser.MarkdownASTNode
import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.common.serialization.MarkdownASTSerializer
import com.swmansion.enriched.markdown.utils.text.conversion.HTMLGenerator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [30])
class HtmlClipboardRoundTripTest {
private val context = RuntimeEnvironment.getApplication()

@Test
fun tableLineBreaksAreExportedAsHtmlBreaks() {
for (separator in listOf("\n", "\r", "\r\n", "\u2028", "\u2029")) {
for (text in listOf<CharSequence>(SpannableString("first${separator}second"), "first${separator}second")) {
val rows = listOf(listOf(Triple(text, false, Layout.Alignment.ALIGN_NORMAL)))
val html = HTMLGenerator.generateTableHTML(rows, style())
assertTrue(html, html.contains("first<br>second"))
assertFalse(html, html.contains("<br><br>"))
}
}
}

@Test
fun serializedTableFormulaKeepsItsInlineDelimiters() {
val formula =
MarkdownASTNode(MarkdownASTNode.NodeType.LatexMathInline, children = listOf(MarkdownASTNode(MarkdownASTNode.NodeType.Text, "x_1")))
val cell = MarkdownASTNode(MarkdownASTNode.NodeType.TableCell, children = listOf(formula))
assertEquals("\$x_1\$", MarkdownASTSerializer.serializeChildren(cell))
}

private fun style(): StyleConfig {
com.facebook.react.uimanager.DisplayMetricsHolder
.initDisplayMetricsIfNotInitialized(context)

fun element() =
JavaOnlyMap().apply {
listOf(
"aspectRatio",
"bulletSize",
"checkboxBorderRadius",
"checkboxSize",
"height",
"itemSpacing",
"markerMinWidth",
"maxHeight",
"cellPaddingHorizontal",
"cellPaddingVertical",
"horizontalOverflow",
"lineHeight",
"marginTop",
"marginBottom",
"padding",
"borderRadius",
"borderWidth",
"gapWidth",
"marginLeft",
).forEach {
putDouble(it, 0.0)
}
listOf(
"bulletColor",
"checkedColor",
"checkedTextColor",
"checkmarkColor",
"headerBackgroundColor",
"headerTextColor",
"markerColor",
"rowEvenBackgroundColor",
"rowOddBackgroundColor",
"color",
"borderColor",
).forEach {
putInt(it, Color.BLACK)
}
putString("resizeMode", "contain")
putString("fontFamily", "")
putString("fontWeight", "normal")
putDouble("fontSize", 16.0)
putBoolean("underline", false)
putInt("backgroundColor", Color.TRANSPARENT)
}
val style = JavaOnlyMap()
listOf(
"table",
"paragraph",
"codeBlock",
"code",
"blockquote",
"list",
"link",
"strong",
"em",
"strikethrough",
"underline",
"highlight",
"image",
).forEach { style.putMap(it, element()) }
for (level in 1..6) style.putMap("h$level", element())
style.putMap(
"taskList",
JavaOnlyMap.of(
"checkedColor",
Color.BLACK,
"borderColor",
Color.BLACK,
"checkmarkColor",
Color.BLACK,
"checkedTextColor",
Color.BLACK,
"checkboxSize",
16.0,
"checkboxBorderRadius",
0.0,
"checkedStrikethrough",
false,
),
)
return StyleConfig(style, context, false, 0f)
}
}
36 changes: 26 additions & 10 deletions packages/react-native-enriched-markdown/ios/utils/HTMLGenerator.m
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ - (instancetype)init

#pragma mark - HTML Escaping

static void appendEscapedHTML(NSMutableString *output, NSString *text)
static void appendEscapedHTML(NSMutableString *output, NSString *text, BOOL preserveLineBreaks)
{
NSUInteger length = text.length;
if (length == 0)
Expand All @@ -191,26 +191,42 @@ static void appendEscapedHTML(NSMutableString *output, NSString *text)
case '\'':
[output appendString:@"&#39;"];
break;
case '\r':
case '\n':
case 0x2028:
case 0x2029:
if (preserveLineBreaks) {
if (c == '\r' && i + 1 < length && [text characterAtIndex:i + 1] == '\n')
i++;
[output appendString:@"<br>"];
} else {
[output appendFormat:@"%C", c];
}
break;
default:
[output appendFormat:@"%C", c];
break;
}
}
}

static NSString *escapeHTML(NSString *text)
static NSString *escapeHTML(NSString *text, BOOL preserveLineBreaks)
{
// Fast path: skip if no special chars
static NSCharacterSet *escapeChars = nil;
static NSCharacterSet *inlineEscapeChars = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{ escapeChars = [NSCharacterSet characterSetWithCharactersInString:@"&<>\"'"]; });
dispatch_once(&onceToken, ^{
escapeChars = [NSCharacterSet characterSetWithCharactersInString:@"&<>\"'"];
inlineEscapeChars = [NSCharacterSet characterSetWithCharactersInString:@"&<>\"'\n\r\u2028\u2029"];
});

if ([text rangeOfCharacterFromSet:escapeChars].location == NSNotFound) {
if ([text rangeOfCharacterFromSet:preserveLineBreaks ? inlineEscapeChars : escapeChars].location == NSNotFound) {
return text;
}

NSMutableString *escaped = [NSMutableString stringWithCapacity:text.length + 16];
appendEscapedHTML(escaped, text);
appendEscapedHTML(escaped, text, preserveLineBreaks);
return escaped;
}

Expand Down Expand Up @@ -496,7 +512,7 @@ static void generateInlineHTML(NSMutableString *html, NSAttributedString *attrib
@"padding: %.0fpx %.0fpx; border-radius: %.0fpx; "
@"font-size: 1em; font-family: Menlo, Monaco, Consolas, monospace;\">%@</code>",
styles.codeBackgroundColor, styles.codeColor, kCodePadding, kCodePadding * 2,
kCodeBorderRadius, escapeHTML(math.latex)];
kCodeBorderRadius, escapeHTML(math.latex, NO)];
}
#endif
}
Expand Down Expand Up @@ -535,11 +551,11 @@ static void generateInlineHTML(NSMutableString *html, NSAttributedString *attrib
if (styles.linkFontFamily.length > 0) {
[html appendFormat:
@"<a href=\"%@\" style=\"color: %@; text-decoration: %@; font-family: '%@';\">",
escapeHTML(href), styles.linkColor, styles.linkUnderline ? @"underline" : @"none",
styles.linkFontFamily];
escapeHTML(href, NO), styles.linkColor,
styles.linkUnderline ? @"underline" : @"none", styles.linkFontFamily];
} else {
[html appendFormat:@"<a href=\"%@\" style=\"color: %@; text-decoration: %@;\">",
escapeHTML(href), styles.linkColor,
escapeHTML(href, NO), styles.linkColor,
styles.linkUnderline ? @"underline" : @"none"];
}
}
Expand Down Expand Up @@ -584,7 +600,7 @@ static void generateInlineHTML(NSMutableString *html, NSAttributedString *attrib
}
}

[html appendString:escapeHTML(text)];
[html appendString:escapeHTML(text, !isCodeBlock)];

// Reverse order
if ([underline integerValue] > 0 && !linkAttr)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ static void serializeNode(MarkdownASTNode *node, NSMutableString *buffer)
[buffer appendString:@"=="];
break;

case MarkdownNodeTypeLatexMathInline:
[buffer appendString:@"$"];
serializeChildren(node, buffer);
[buffer appendString:@"$"];
break;

case MarkdownNodeTypeCode:
[buffer appendString:@"`"];
serializeChildren(node, buffer);
Expand Down