import 'package:flutter/material.dart'; import '../api/oa_client.dart'; import '../theme.dart'; import 'auth_image.dart'; /// 公告 HTML:按块渲染标题、段落、列表、加粗,不依赖 flutter_html。 class HtmlBody extends StatelessWidget { const HtmlBody({super.key, required this.html, this.api}); final String html; final OaClient? api; @override Widget build(BuildContext context) { final blocks = parseHtmlBlocks(html); if (blocks.isEmpty) { return const Text('暂无正文', style: TextStyle(color: kMute, fontSize: 15)); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final b in blocks) ...[ if (b.kind == 'img' && b.src != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: _img(b.src!), ) else Padding( padding: EdgeInsets.only(bottom: b.kind.startsWith('h') ? 10 : 12, left: b.kind == 'li' ? 12 : 0), child: Text.rich( TextSpan(children: b.spans), style: _style(b.kind), ), ), ], ], ); } TextStyle _style(String kind) { switch (kind) { case 'h1': return const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.4); case 'h2': return const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk, height: 1.4); case 'h3': return const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kInk, height: 1.45); case 'li': return const TextStyle(fontSize: 16, color: kInk, height: 1.7); default: return const TextStyle(fontSize: 16, color: kInk, height: 1.7); } } Widget _img(String src) { final m = RegExp(r'/files/([0-9a-f-]{36})', caseSensitive: false).firstMatch(src); if (m != null && api != null) { return ClipRRect( borderRadius: BorderRadius.circular(6), child: AuthImage(api: api!, fileId: m.group(1)!, fit: BoxFit.contain), ); } return const SizedBox.shrink(); } } class HtmlBlock { HtmlBlock(this.kind, this.spans, {this.src}); final String kind; final List spans; final String? src; } List parseHtmlBlocks(String raw) { var s = raw.trim(); if (s.isEmpty) return []; s = s.replaceAll(RegExp(r'<(script|style)[^>]*>[\s\S]*?', caseSensitive: false), ''); if (!RegExp(r'<[a-z][\s\S]*>', caseSensitive: false).hasMatch(s)) { return s .split(RegExp(r'\n+')) .where((e) => e.trim().isNotEmpty) .map((e) => HtmlBlock('p', _inline(e.trim()))) .toList(); } s = s .replaceAll(RegExp(r'', caseSensitive: false), '\n') .replaceAll(RegExp(r'', caseSensitive: false), '\n') .replaceAll(RegExp(r'<(p|div|blockquote)[^>]*>', caseSensitive: false), '\n') .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#h1#') .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#h2#') .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#h3#') .replaceAll(RegExp(r']*>', caseSensitive: false), '\n#li#') .replaceAll(RegExp(r']*>', caseSensitive: false), '\n') .replaceAll(RegExp(r'<(strong|b)[^>]*>', caseSensitive: false), '«b»') .replaceAll(RegExp(r'', caseSensitive: false), '«/b»'); final imgRe = RegExp(r']*src=["'']([^"'']+)["''][^>]*>', caseSensitive: false); s = s.replaceAllMapped(imgRe, (m) => '\n#img#${m.group(1)}#\n'); s = s.replaceAll(RegExp(r'<[^>]+>'), ''); s = decodeEntities(s); final out = []; for (final line in s.split('\n')) { final t = line.trim(); if (t.isEmpty) continue; if (t.startsWith('#img#') && t.endsWith('#')) { out.add(HtmlBlock('img', const [], src: t.substring(5, t.length - 1))); continue; } var kind = 'p'; var text = t; if (t.startsWith('#h1#')) { kind = 'h1'; text = t.substring(4); } else if (t.startsWith('#h2#')) { kind = 'h2'; text = t.substring(4); } else if (t.startsWith('#h3#')) { kind = 'h3'; text = t.substring(4); } else if (t.startsWith('#li#')) { kind = 'li'; text = '• ${t.substring(4)}'; } text = text.trim(); if (text.isEmpty) continue; out.add(HtmlBlock(kind, _inline(text))); } return out; } List _inline(String text) { final out = []; final re = RegExp(r'«b»(.*?)«/b»'); var i = 0; for (final m in re.allMatches(text)) { if (m.start > i) out.add(TextSpan(text: text.substring(i, m.start))); out.add(TextSpan(text: m.group(1), style: const TextStyle(fontWeight: FontWeight.w700))); i = m.end; } if (i < text.length) out.add(TextSpan(text: text.substring(i))); if (out.isEmpty) out.add(TextSpan(text: text)); return out; } String decodeEntities(String s) { return s .replaceAll(' ', ' ') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAllMapped(RegExp(r'&#(\d+);'), (m) { final n = int.tryParse(m.group(1)!); return n == null ? m.group(0)! : String.fromCharCode(n); }); }