415 lines
13 KiB
Dart
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;
|
|
}
|
|
}
|