/// 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 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 - 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; } }