初始化
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
|
||||
class HtmlProParser {
|
||||
static Color? spanBg(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
try {
|
||||
final splitArr = value.toLowerCase().split(":");
|
||||
if (splitArr.length != 2 || !splitArr.last.contains("rgb")) return null;
|
||||
final rgbStr = splitArr.last.replaceAll("rgb(", "").replaceAll(");", "");
|
||||
final rgbArr = rgbStr.split(",");
|
||||
if (rgbArr.length != 3) return null;
|
||||
final r = int.tryParse(rgbArr[0]);
|
||||
final g = int.tryParse(rgbArr[1]);
|
||||
final b = int.tryParse(rgbArr[2]);
|
||||
if (r == null || g == null || b == null) return null;
|
||||
return Color.fromRGBO(r, g, b, 1);
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static AlignmentGeometry alignment(String? value) {
|
||||
if (value == "text-align:center;") {
|
||||
return Alignment.topCenter;
|
||||
} else if (value == "text-align:right;") {
|
||||
return Alignment.topRight;
|
||||
} else {
|
||||
return Alignment.topLeft;
|
||||
}
|
||||
}
|
||||
|
||||
static WrapAlignment wrapAlignment(String? value) {
|
||||
if (value == "text-align:right;") {
|
||||
return WrapAlignment.end;
|
||||
} else if (value == "text-align:center;") {
|
||||
return WrapAlignment.center;
|
||||
} else {
|
||||
return WrapAlignment.start;
|
||||
}
|
||||
}
|
||||
|
||||
static double? lineHeight(String? value) {
|
||||
try {
|
||||
if (value?.contains("line-height:") == true) {
|
||||
final sizeStr =
|
||||
value!.replaceAll("line-height:", "").replaceAll(";", "");
|
||||
return double.tryParse(sizeStr);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class HexColor extends Color {
|
||||
static int _getColorFromHex(String hexColor) {
|
||||
try {
|
||||
hexColor = hexColor.toUpperCase().replaceAll("#", "");
|
||||
if (hexColor.length == 6) {
|
||||
hexColor = "FF$hexColor";
|
||||
}
|
||||
return int.parse(hexColor, radix: 16);
|
||||
} catch (e) {
|
||||
return 0xffffffff;
|
||||
}
|
||||
}
|
||||
|
||||
HexColor(final String hexColor) : super(_getColorFromHex(hexColor));
|
||||
}
|
||||
Reference in New Issue
Block a user