/// Utility class to convert BBCode to Markdown for RimWorld mod descriptions class BBCodeConverter { /// Converts BBCode formatted text to Markdown format static String toMarkdown(String bbcode) { if (bbcode.isEmpty) return ''; // First, normalize line endings String result = bbcode.replaceAll('\r\n', '\n'); // Fix unclosed tags - RimWorld descriptions often have unclosed tags final List 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] -> 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 result = result.replaceAllMapped( RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true), (match) => '# ${match.group(1)}' ); result = result.replaceAllMapped( RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true), (match) => '## ${match.group(1)}' ); result = result.replaceAllMapped( RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true), (match) => '### ${match.group(1)}' ); // Lists - handle nested lists too result = result.replaceAll('[list]', '\n').replaceAll('[/list]', '\n'); // Handle list items - giving them proper indentation int listLevel = 0; result = result.replaceAllMapped( RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true), (match) { final content = match.group(1)?.trim() ?? ''; return '* $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); final markdownTable = StringBuffer(); var isFirstRow = true; 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('|'); 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 |'); } markdownTable.writeln(rowBuffer.toString()); // Add header separator after first row if (isFirstRow) { final cellCount = RegExp(r'\[td\]').allMatches(rowContent).length; markdownTable.writeln('|${' --- |' * cellCount}'); isFirstRow = false; } } result = result.replaceFirst(tableMatch.group(0)!, '\n${markdownTable.toString()}\n'); } } // 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'; } ); // 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) => '# ${match.group(1)}\n' ); // Replace multiple newlines with at most two newlines result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n'); return result; } }