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 parse(String data) { if (renderNewlines) { data = data.replaceAll("\n", "
"); } 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: [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 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 _parseNodeList(List 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; } }