Files
huangguo_android/lib/tools_base/richTextParsing/html_parser.dart
T
2026-09-15 15:44:13 +07:00

415 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import '../widget/net_image_widget.dart';
import 'html_pro_parser.dart';
class HtmlParser {
HtmlParser({
required this.width,
this.onLinkTap,
this.imgLinkTap,
this.renderNewlines = false,
});
final double width;
final Function(String)? onLinkTap;
final Function(String)? imgLinkTap;
final bool renderNewlines;
/// 解析 html 字符串,返回 body 对应的 widget 列表
List<Widget> parse(String data) {
if (renderNewlines) {
data = data.replaceAll("\n", "<br />");
}
final document = parser.parse(data);
return [_parseNode(document.body)];
}
Widget _parseNode(dom.Node? node) {
if (node is dom.Element) {
switch (node.localName) {
case "a":
return GestureDetector(
onTap: () {
if (node.attributes.containsKey('href') && onLinkTap != null) {
String? url = node.attributes['href'] ?? "";
onLinkTap?.call(url);
}
},
child: DefaultTextStyle.merge(
child: Wrap(children: _parseNodeList(node.nodes)),
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blueAccent,
decorationColor: Colors.blueAccent,
),
),
);
case "abbr":
case "acronym":
return _styledWrap(
node,
const TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
),
);
case "address":
case "cite":
case "dfn":
case "em":
case "i":
case "var":
return _styledWrap(node, const TextStyle(fontStyle: FontStyle.italic));
case "article":
case "aside":
case "body":
case "div":
case "footer":
case "header":
case "main":
case "nav":
case "noscript":
case "section":
return _widthWrap(node);
case "b":
case "strong":
return _styledWrap(node, const TextStyle(fontWeight: FontWeight.bold));
case "bdi":
case "data":
case "dt":
case "figcaption":
case "rp":
case "rt":
case "ruby":
case "time":
return _wrapList(node);
case "bdo":
if (node.attributes["dir"] != null) {
return Directionality(
textDirection: node.attributes["dir"] == "rtl" ? TextDirection.rtl : TextDirection.ltr,
child: Wrap(children: _parseNodeList(node.nodes)),
);
}
//Direction attribute is required, just render the text normally now.
return _wrapList(node);
case "big":
return _styledWrap(node, const TextStyle(fontSize: 20.0));
case "blockquote":
return Padding(
padding: const EdgeInsets.fromLTRB(40.0, 14.0, 40.0, 14.0),
child: _widthWrap(node),
);
case "br":
if (_isNotFirstBreakTag(node)) {
return const Text("\n");
}
return SizedBox(width: width);
case "caption":
case "center":
return SizedBox(
width: width,
child: Wrap(
alignment: WrapAlignment.center,
children: _parseNodeList(node.nodes),
),
);
case "code":
case "kbd":
case "samp":
case "tt":
return _styledWrap(node, const TextStyle(fontFamily: 'monospace'));
case "dd":
return Padding(
padding: const EdgeInsets.only(left: 40.0),
child: _widthWrap(node),
);
case "del":
case "s":
case "strike":
return _styledWrap(node, const TextStyle(decoration: TextDecoration.lineThrough));
case "dl":
return Padding(
padding: const EdgeInsets.only(top: 14.0, bottom: 14.0),
child: _columnStartList(node),
);
case "figure":
return Padding(
padding: const EdgeInsets.fromLTRB(40.0, 14.0, 40.0, 14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: _parseNodeList(node.nodes),
),
);
case "h1":
return _heading(node, 28.0);
case "h2":
return _heading(node, 21.0);
case "h3":
return _heading(node, 16.0);
case "h4":
return _heading(node, 14.0);
case "h5":
return _heading(node, 12.0);
case "h6":
return _heading(node, 10.0);
case "hr":
return Padding(
padding: const EdgeInsets.only(top: 7.0, bottom: 7.0),
child: Container(
height: 1,
decoration: const BoxDecoration(color: Colors.white),
),
);
case "img":
final src = node.attributes['src'];
final alt = node.attributes['alt'];
if (src != null) {
// 原代码此处计算的 isMaxWidth 会被无条件覆盖为 true,等价简化为永远 maxFinite
return GestureDetector(
onTap: () => imgLinkTap?.call(src),
child: NetworkImageLoader(
imageUrl: src,
fit: BoxFit.contain,
width: double.maxFinite,
borderRadius: 0,
placeHolderWidget: Container(color: Colors.white),
),
);
} else if (alt != null) {
//Temp fix for https://github.com/flutter/flutter/issues/736
if (alt.endsWith(" ")) {
return Container(padding: const EdgeInsets.only(right: 2.0), child: Text(alt));
}
return Text(alt);
}
return Container();
case "ins":
case "u":
return _styledWrap(node, const TextStyle(decoration: TextDecoration.underline));
case "li":
String type = node.parent?.localName ?? ""; // Parent type; usually ol or ul
const EdgeInsets markPadding = EdgeInsets.symmetric(horizontal: 4.0);
Widget mark;
switch (type) {
case "ul":
mark = Container(padding: markPadding, child: Text('•'));
break;
case "ol":
int index = (node.parent?.children.indexOf(node) ?? 0) + 1;
mark = Container(padding: markPadding, child: Text("$index."));
break;
default: //Fallback to middle dot
mark = const SizedBox.shrink();
break;
}
return SizedBox(
width: width,
child: Wrap(
children: <Widget>[mark, Wrap(children: _parseNodeList(node.nodes))],
),
);
case "mark":
return _styledWrap(
node,
TextStyle(color: Colors.black, background: _getPaint(Colors.yellow)),
);
case "ol":
case "ul":
case "table":
case "tbody":
case "tfoot":
case "thead":
return _columnStartList(node);
case "p":
return DefaultTextStyle.merge(
child: Padding(
padding: EdgeInsets.only(top: 6.0, bottom: 6.0),
child: Container(
alignment: HtmlProParser.alignment(node.attributes["style"]),
width: width,
child: Wrap(
alignment: HtmlProParser.wrapAlignment(node.attributes["style"]),
children: _parseNodeList(node.nodes),
),
),
),
style: TextStyle(height: HtmlProParser.lineHeight(node.attributes["style"])),
);
case "font":
Color? textColor;
String colorStr = node.attributes["color"] ?? "";
if (colorStr.length == 7) {
textColor = HexColor(colorStr);
}
double fontSize = 14;
try {
//字体大小和h5那边约定
Map<String, double> fontSizeMap = {"1": 14, "2": 17, "3": 20, "4": 23, "5": 26, "6": 29, "7": 32};
if (node.attributes["size"] is String) {
fontSize = fontSizeMap[node.attributes["size"]] ?? 14;
}
} catch (_) {}
return _styledWrap(
node,
TextStyle(
color: textColor,
fontSize: fontSize,
backgroundColor: HtmlProParser.spanBg(node.attributes["style"]),
),
);
case "pre":
return Padding(
padding: const EdgeInsets.all(14.0),
child: DefaultTextStyle.merge(
child: Text(node.innerHtml),
style: const TextStyle(fontFamily: 'monospace'),
),
);
case "q":
return DefaultTextStyle.merge(
child: Wrap(children: [
const Text("\""),
..._parseNodeList(node.nodes),
const Text("\""),
]),
style: const TextStyle(fontStyle: FontStyle.italic),
);
case "small":
return _styledWrap(node, const TextStyle(fontSize: 10.0));
case "span":
return Container(
color: HtmlProParser.spanBg(node.attributes['style']),
child: Wrap(children: _parseNodeList(node.nodes)),
);
case "td":
int colspan = 1;
if (node.attributes['colspan'] != null) {
colspan = int.tryParse(node.attributes['colspan'] ?? "1") ?? 1;
}
return Expanded(
flex: colspan,
child: Wrap(children: _parseNodeList(node.nodes)),
);
case "template":
//Not usually displayed in HTML
return Container();
case "th":
int colspan = 1;
if (node.attributes['colspan'] != null) {
colspan = int.tryParse(node.attributes['colspan'] ?? "1") ?? 1;
}
return DefaultTextStyle.merge(
child: Expanded(
flex: colspan,
child: Wrap(
alignment: WrapAlignment.center,
children: _parseNodeList(node.nodes),
),
),
style: const TextStyle(fontWeight: FontWeight.bold),
);
case "tr":
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: _parseNodeList(node.nodes),
);
}
} else if (node is dom.Text) {
//We don't need to worry about rendering extra whitespace
if (node.text.trim() == "" && !node.text.contains(" ")) {
return const Wrap();
}
if (node.text.trim() == "" && node.text.contains(" ")) {
node.text = " ";
}
String finalText = trimStringHtml(node.text);
//Temp fix for https://github.com/flutter/flutter/issues/736
if (finalText.endsWith(" ")) {
return Container(padding: EdgeInsets.only(right: 2.0), child: Text(finalText));
} else {
return Text(finalText);
}
}
return const Wrap();
}
// ========== 私有 helper ==========
/// `DefaultTextStyle.merge(Wrap(parseNodes), style)` 模式复用
Widget _styledWrap(dom.Element node, TextStyle style) {
return DefaultTextStyle.merge(
child: Wrap(children: _parseNodeList(node.nodes)),
style: style,
);
}
/// `SizedBox(width: width, child: Wrap(parseNodes))` 模式复用
Widget _widthWrap(dom.Element node) {
return SizedBox(
width: width,
child: Wrap(children: _parseNodeList(node.nodes)),
);
}
/// 单纯包一层 Wrap
Widget _wrapList(dom.Element node) {
return Wrap(children: _parseNodeList(node.nodes));
}
/// `Column(crossAxisAlignment: start, children: parseNodes)` 模式复用
Widget _columnStartList(dom.Element node) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _parseNodeList(node.nodes),
);
}
/// h1-h6 共用:bold + 指定 fontSize + 全宽 Wrap
Widget _heading(dom.Element node, double fontSize) {
return DefaultTextStyle.merge(
child: SizedBox(
width: width,
child: Wrap(children: _parseNodeList(node.nodes)),
),
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold),
);
}
List<Widget> _parseNodeList(List<dom.Node> nodeList) {
return nodeList.map(_parseNode).toList();
}
Paint _getPaint(Color color) => Paint()..color = color;
String trimStringHtml(String stringToTrim) {
// 去掉换行,并把连续空格合并为单个空格
return stringToTrim.replaceAll("\n", "").replaceAll(RegExp(' +'), ' ');
}
bool _isNotFirstBreakTag(dom.Node? node) {
int? index = node?.parentNode?.nodes.indexOf(node);
if (index == null) return false;
if (index == 0) {
if (node?.parentNode == null) {
return false;
}
return _isNotFirstBreakTag(node?.parentNode);
} else if (node?.parentNode?.nodes[index - 1] is dom.Element) {
if ((node?.parentNode?.nodes[index - 1] as dom.Element).localName == "br") {
return true;
}
return false;
} else if (node?.parentNode?.nodes[index - 1] is dom.Text) {
if ((node?.parentNode?.nodes[index - 1] as dom.Text).text.trim() == "") {
return _isNotFirstBreakTag(node?.parentNode?.nodes[index - 1]);
} else {
return false;
}
}
return false;
}
}