Files
flutter-rimworld-modman/lib/bbcode_converter.dart

226 lines
7.9 KiB
Dart

/// Utility class to convert BBCode to Markdown for RimWorld mod descriptions
import 'dart:math' as math;
class BBCodeConverter {
/// Converts BBCode formatted text to Markdown format
static String toMarkdown(String bbcode) {
if (bbcode.isEmpty) return '';
// First, normalize line endings and escape any literal backslashes
String result = bbcode.replaceAll('\r\n', '\n').replaceAll('\\', '\\\\');
// Ensure paragraphs - double newlines between paragraphs
// First normalize any consecutive newlines to a single one
result = result.replaceAll(RegExp(r'\n+'), '\n');
// Then add empty line after each paragraph where needed
result = result.replaceAll('.\n', '.\n\n');
result = result.replaceAll('!\n', '!\n\n');
result = result.replaceAll('?\n', '?\n\n');
// Fix unclosed tags - RimWorld descriptions often have unclosed tags
final List<String> tagTypes = ['b', 'i', 'color', 'size', 'url', 'code', 'quote'];
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] -> [text](http://example.com)
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)!,
'[$text]($url)'
);
});
// Simple URL [url]http://example.com[/url] -> <http://example.com>
result = result.replaceAllMapped(
RegExp(r'\[url\](.*?)\[/url\]', dotAll: true),
(match) => '<${match.group(1)}>'
);
// Bold
result = result.replaceAll('[b]', '**').replaceAll('[/b]', '**');
// Italic
result = result.replaceAll('[i]', '_').replaceAll('[/i]', '_');
// Headers - ensure they start on their own line
result = result.replaceAllMapped(
RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true),
(match) => '\n\n# ${match.group(1)?.trim()}\n\n'
);
result = result.replaceAllMapped(
RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true),
(match) => '\n\n## ${match.group(1)?.trim()}\n\n'
);
result = result.replaceAllMapped(
RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true),
(match) => '\n\n### ${match.group(1)?.trim()}\n\n'
);
// Lists - handle nested lists too
result = result.replaceAll('[list]', '\n').replaceAll('[/list]', '\n');
// Handle list items - giving them proper indentation
result = result.replaceAllMapped(
RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
return '\n* $content\n';
}
);
// Color - convert to bold since Markdown doesn't support color
result = result.replaceAllMapped(
RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true),
(match) {
final content = match.group(2) ?? '';
if (content.trim().isEmpty) return '';
return '**${match.group(2)}**';
}
);
// Images
// [img]url[/img] -> ![Image](url)
result = result.replaceAllMapped(
RegExp(r'\[img\](.*?)\[/img\]', dotAll: true),
(match) => '![Image](${match.group(1)})'
);
// Image with size [img width=300]url[/img] -> ![Image](url)
result = result.replaceAllMapped(
RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true),
(match) => '![Image](${match.group(2)})'
);
// Tables - convert tables to markdown tables
if (result.contains('[table]')) {
// Process tables
final tableRegex = RegExp(r'\[table\](.*?)\[/table\]', dotAll: true);
final tables = tableRegex.allMatches(result);
for (final tableMatch in tables) {
final tableContent = tableMatch.group(1) ?? '';
final rows = RegExp(r'\[tr\](.*?)\[/tr\]', dotAll: true).allMatches(tableContent);
// Only process tables that have rows
if (rows.isEmpty) {
result = result.replaceFirst(tableMatch.group(0)!, '');
continue;
}
final markdownTable = StringBuffer('\n');
var isFirstRow = true;
// First determine the number of columns by examining all rows
int maxColumns = 0;
for (final rowMatch in rows) {
final rowContent = rowMatch.group(1) ?? '';
final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length;
maxColumns = math.max(maxColumns, cellCount);
}
// Ensure we have at least 1 column
maxColumns = math.max(1, maxColumns);
for (final rowMatch in rows) {
final rowContent = rowMatch.group(1) ?? '';
final cells = RegExp(r'\[td\](.*?)\[/td\]', dotAll: true).allMatches(rowContent);
if (cells.isEmpty) continue;
final rowBuffer = StringBuffer('| ');
int cellsAdded = 0;
for (final cellMatch in cells) {
final cellContent = cellMatch.group(1)?.trim() ?? '';
// Clean up any newlines inside cell content
final cleanCell = cellContent.replaceAll('\n', ' ').trim();
rowBuffer.write('$cleanCell | ');
cellsAdded++;
}
// Add empty cells if needed to maintain table structure
while (cellsAdded < maxColumns) {
rowBuffer.write(' | ');
cellsAdded++;
}
markdownTable.writeln(rowBuffer.toString());
// Add header separator after first row
if (isFirstRow) {
final headerRow = StringBuffer('| ');
for (int i = 0; i < maxColumns; i++) {
headerRow.write('--- | ');
}
markdownTable.writeln(headerRow.toString());
isFirstRow = false;
}
}
markdownTable.write('\n');
result = result.replaceFirst(tableMatch.group(0)!, markdownTable.toString());
}
}
// Size - remove since Markdown doesn't directly support font size
result = result.replaceAllMapped(
RegExp(r'\[size=[^\]]+\](.*?)\[/size\]', dotAll: true),
(match) => match.group(1) ?? ''
);
// Code
result = result.replaceAll('[code]', '\n```\n').replaceAll('[/code]', '\n```\n');
// Quote
result = result.replaceAllMapped(
RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
if (content.isEmpty) return '';
return '\n> ${content.replaceAll('\n', '\n> ')}\n\n';
}
);
// Handle any remaining custom BBCode tags - just remove them
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) => '\n\n# ${match.group(1)?.trim()}\n\n'
);
// Convert simple newlines to double newlines for proper markdown rendering
// But avoid doing this for lines that are already marked up as headings, lists, or tables
result = result.replaceAllMapped(
RegExp(r'([^\n#*>|])\n([^\n#*>|-])'),
(match) => '${match.group(1)}\n\n${match.group(2)}'
);
// Normalize multiple spaces
result = result.replaceAll(RegExp(r' {2,}'), ' ');
// Remove excessive newlines (more than 2 consecutive)
result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n');
// Ensure document starts and ends cleanly
result = result.trim();
return result;
}
}