309 lines
9.8 KiB
Dart
309 lines
9.8 KiB
Dart
import 'dart:math' as math;
|
|
|
|
/// Utility class to convert mixed format content (BBCode, Markdown, and HTML) to HTML
|
|
class FormatConverter {
|
|
/// Converts mixed format text (BBCode, Markdown, HTML) to pure HTML
|
|
static String toHtml(String content) {
|
|
if (content.isEmpty) return '';
|
|
|
|
// First, normalize line endings and escape any literal backslashes that aren't already escaped
|
|
String result = content.replaceAll('\r\n', '\n');
|
|
|
|
// Handle BBCode format
|
|
result = _convertBBCodeToHtml(result);
|
|
|
|
// Handle Markdown format
|
|
result = _convertMarkdownToHtml(result);
|
|
|
|
// Sanitize HTML
|
|
result = _sanitizeHtml(result);
|
|
|
|
// Wrap the final content in a container with styles
|
|
result =
|
|
'<div style="line-height: 1.5; word-wrap: break-word;">$result</div>';
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Converts BBCode to HTML
|
|
static String _convertBBCodeToHtml(String bbcode) {
|
|
String result = bbcode;
|
|
|
|
// Fix unclosed tags - RimWorld descriptions often have unclosed BBCode tags
|
|
final List<String> tagTypes = [
|
|
'b',
|
|
'i',
|
|
'color',
|
|
'size',
|
|
'url',
|
|
'code',
|
|
'quote',
|
|
'list',
|
|
'table',
|
|
'tr',
|
|
'td',
|
|
];
|
|
for (final tag in tagTypes) {
|
|
final openCount = '[$tag'.allMatches(result).length;
|
|
final closeCount = '[/$tag]'.allMatches(result).length;
|
|
if (openCount > closeCount) {
|
|
result = result + '[/$tag]' * (openCount - closeCount);
|
|
}
|
|
}
|
|
|
|
// URLs
|
|
// [url=http://example.com]text[/url] -> <a href="http://example.com">text</a>
|
|
result = RegExp(
|
|
r'\[url=([^\]]+)\](.*?)\[/url\]',
|
|
dotAll: true,
|
|
).allMatches(result).fold(result, (prev, match) {
|
|
final url = match.group(1);
|
|
final text = match.group(2);
|
|
return prev.replaceFirst(
|
|
match.group(0)!,
|
|
'<a href="$url" target="_blank">$text</a>',
|
|
);
|
|
});
|
|
|
|
// Simple URL [url]http://example.com[/url] -> <a href="http://example.com">http://example.com</a>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[url\](.*?)\[/url\]', dotAll: true),
|
|
(match) =>
|
|
'<a href="${match.group(1)}" target="_blank">${match.group(1)}</a>',
|
|
);
|
|
|
|
// Bold
|
|
result = result
|
|
.replaceAll('[b]', '<strong>')
|
|
.replaceAll('[/b]', '</strong>');
|
|
|
|
// Italic
|
|
result = result.replaceAll('[i]', '<em>').replaceAll('[/i]', '</em>');
|
|
|
|
// Headers
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true),
|
|
(match) =>
|
|
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
|
|
);
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true),
|
|
(match) =>
|
|
'<h2 style="margin-top: 12px; margin-bottom: 6px;">${match.group(1)?.trim()}</h2>',
|
|
);
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true),
|
|
(match) =>
|
|
'<h3 style="margin-top: 10px; margin-bottom: 4px;">${match.group(1)?.trim()}</h3>',
|
|
);
|
|
|
|
// Lists
|
|
result = result
|
|
.replaceAll(
|
|
'[list]',
|
|
'<ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;">',
|
|
)
|
|
.replaceAll('[/list]', '</ul>');
|
|
|
|
// List items
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true),
|
|
(match) {
|
|
final content = match.group(1)?.trim() ?? '';
|
|
return '<li style="margin-bottom: 4px;">$content</li>';
|
|
},
|
|
);
|
|
|
|
// Color
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true),
|
|
(match) {
|
|
final color = match.group(1) ?? '';
|
|
final content = match.group(2) ?? '';
|
|
if (content.trim().isEmpty) return '';
|
|
return '<span style="color:$color">$content</span>';
|
|
},
|
|
);
|
|
|
|
// Images
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[img\](.*?)\[/img\]', dotAll: true),
|
|
(match) =>
|
|
'<img src="${match.group(1)}" alt="Image" style="max-width: 100%;" />',
|
|
);
|
|
|
|
// Image with size
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true),
|
|
(match) {
|
|
final width = match.group(1) ?? '';
|
|
final url = match.group(2) ?? '';
|
|
return '<img src="$url" alt="Image" width="$width" style="max-width: 100%;" />';
|
|
},
|
|
);
|
|
|
|
// Tables
|
|
result = result
|
|
.replaceAll(
|
|
'[table]',
|
|
'<table border="1" style="border-collapse: collapse; width: 100%; margin: 10px 0;">',
|
|
)
|
|
.replaceAll('[/table]', '</table>');
|
|
result = result.replaceAll('[tr]', '<tr>').replaceAll('[/tr]', '</tr>');
|
|
result = result
|
|
.replaceAll('[td]', '<td style="padding: 8px;">')
|
|
.replaceAll('[/td]', '</td>');
|
|
|
|
// Size
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[size=([^\]]+)\](.*?)\[/size\]', dotAll: true),
|
|
(match) {
|
|
final size = match.group(1) ?? '';
|
|
final content = match.group(2) ?? '';
|
|
return '<span style="font-size:${size}px">$content</span>';
|
|
},
|
|
);
|
|
|
|
// Code
|
|
result = result
|
|
.replaceAll(
|
|
'[code]',
|
|
'<pre style="background-color: rgba(0,0,0,0.1); padding: 8px; border-radius: 4px; overflow-x: auto;"><code>',
|
|
)
|
|
.replaceAll('[/code]', '</code></pre>');
|
|
|
|
// Quote
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true),
|
|
(match) {
|
|
final content = match.group(1)?.trim() ?? '';
|
|
if (content.isEmpty) return '';
|
|
return '<blockquote style="border-left: 4px solid rgba(128,128,128,0.5); padding-left: 10px; margin: 10px 0; color: rgba(255,255,255,0.8);">$content</blockquote>';
|
|
},
|
|
);
|
|
|
|
// Handle any remaining custom BBCode tags
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true),
|
|
(match) => match.group(2) ?? '',
|
|
);
|
|
|
|
// Handle RimWorld-specific patterns
|
|
// [h1] without closing tag is common
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[h1\]([^\[]+)'),
|
|
(match) =>
|
|
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Converts Markdown to HTML
|
|
static String _convertMarkdownToHtml(String markdown) {
|
|
String result = markdown;
|
|
|
|
// Headers
|
|
// Convert # Header to <h1>Header</h1>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'^#\s+(.*?)$', multiLine: true),
|
|
(match) =>
|
|
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
|
|
);
|
|
|
|
// Convert ## Header to <h2>Header</h2>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'^##\s+(.*?)$', multiLine: true),
|
|
(match) =>
|
|
'<h2 style="margin-top: 12px; margin-bottom: 6px;">${match.group(1)?.trim()}</h2>',
|
|
);
|
|
|
|
// Convert ### Header to <h3>Header</h3>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'^###\s+(.*?)$', multiLine: true),
|
|
(match) =>
|
|
'<h3 style="margin-top: 10px; margin-bottom: 4px;">${match.group(1)?.trim()}</h3>',
|
|
);
|
|
|
|
// Bold - **text** to <strong>text</strong>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\*\*(.*?)\*\*'),
|
|
(match) => '<strong>${match.group(1)}</strong>',
|
|
);
|
|
|
|
// Italic - *text* or _text_ to <em>text</em>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\*(.*?)\*|_(.*?)_'),
|
|
(match) => '<em>${match.group(1) ?? match.group(2)}</em>',
|
|
);
|
|
|
|
// Inline code - `code` to <code>code</code>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'`(.*?)`'),
|
|
(match) =>
|
|
'<code style="background-color: rgba(0,0,0,0.1); padding: 2px 4px; border-radius: 3px;">${match.group(1)}</code>',
|
|
);
|
|
|
|
// Links - [text](url) to <a href="url">text</a>
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'\[(.*?)\]\((.*?)\)'),
|
|
(match) =>
|
|
'<a href="${match.group(2)}" target="_blank">${match.group(1)}</a>',
|
|
);
|
|
|
|
// Images -  to <img src="url" alt="alt" />
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'!\[(.*?)\]\((.*?)\)'),
|
|
(match) =>
|
|
'<img src="${match.group(2)}" alt="${match.group(1)}" style="max-width: 100%;" />',
|
|
);
|
|
|
|
// Lists - Convert Markdown bullet lists to HTML lists
|
|
// This is a simple implementation and might not handle all cases
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'^(\s*)\*\s+(.*?)$', multiLine: true),
|
|
(match) => '<li style="margin-bottom: 4px;">${match.group(2)}</li>',
|
|
);
|
|
|
|
// Wrap adjacent list items in <ul> tags (simple approach)
|
|
result = result.replaceAll('</li>\n<li>', '</li><li>');
|
|
result = result.replaceAll(
|
|
'<li>',
|
|
'<ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;"><li>',
|
|
);
|
|
result = result.replaceAll('</li>', '</li></ul>');
|
|
|
|
// Remove duplicated </ul><ul> tags
|
|
result = result.replaceAll(
|
|
'</ul><ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;">',
|
|
'',
|
|
);
|
|
|
|
// Paragraphs - Convert newlines to <br>, but skipping where tags already exist
|
|
result = result.replaceAllMapped(
|
|
RegExp(r'(?<!>)\n(?!<)'),
|
|
(match) => '<br />',
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Performs basic sanitization and fixes for the HTML
|
|
static String _sanitizeHtml(String html) {
|
|
// Remove potentially dangerous elements and attributes
|
|
final String result = html
|
|
// Remove any script tags
|
|
.replaceAll(RegExp(r'<script.*?>.*?</script>', dotAll: true), '')
|
|
// Remove on* event handlers
|
|
.replaceAll(RegExp(r'\son\w+=".*?"'), '')
|
|
// Ensure newlines are converted to <br /> if not already handled
|
|
.replaceAll(RegExp(r'(?<!>)\n(?!<)'), '<br />');
|
|
|
|
// Fix double paragraph or break issues
|
|
return result
|
|
.replaceAll('<br /><br />', '<br />')
|
|
.replaceAll('<br></br>', '<br />')
|
|
.replaceAll('<p></p>', '');
|
|
}
|
|
}
|