Sonnat Project
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

489 lines
19 KiB

  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'package:collection/collection.dart';
  4. import 'package:flutter/gestures.dart';
  5. import 'package:flutter/material.dart';
  6. import 'package:sonnat/core/html/html_parser.dart';
  7. import 'package:sonnat/core/html/src/css_box_widget.dart';
  8. import 'package:sonnat/core/html/src/html_elements.dart';
  9. import 'package:sonnat/core/html/src/layout_element.dart';
  10. import 'package:sonnat/core/html/src/utils.dart';
  11. import 'package:sonnat/core/html/style.dart';
  12. typedef CustomRenderMatcher = bool Function(RenderContext context);
  13. CustomRenderMatcher tagMatcher(String tag) => (context) {
  14. return context.tree.element?.localName == tag;
  15. };
  16. CustomRenderMatcher blockElementMatcher() => (context) {
  17. return (context.tree.style.display == Display.block || context.tree.style.display == Display.inlineBlock) &&
  18. (context.tree.children.isNotEmpty || context.tree.element?.localName == 'hr');
  19. };
  20. CustomRenderMatcher listElementMatcher() => (context) {
  21. return context.tree.style.display == Display.listItem;
  22. };
  23. CustomRenderMatcher replacedElementMatcher() => (context) {
  24. return context.tree is ReplacedElement;
  25. };
  26. CustomRenderMatcher dataUriMatcher({String? encoding = 'base64', String? mime}) => (context) {
  27. if (context.tree.element?.attributes == null || _src(context.tree.element!.attributes.cast()) == null) {
  28. return false;
  29. }
  30. final dataUri = _dataUriFormat.firstMatch(_src(context.tree.element!.attributes.cast())!);
  31. return dataUri != null &&
  32. dataUri.namedGroup('mime') != 'image/svg+xml' &&
  33. (mime == null || dataUri.namedGroup('mime') == mime) &&
  34. (encoding == null || dataUri.namedGroup('encoding') == ';$encoding');
  35. };
  36. CustomRenderMatcher networkSourceMatcher({
  37. List<String> schemas = const ['https', 'http'],
  38. List<String>? domains,
  39. String? extension,
  40. }) =>
  41. (context) {
  42. if (context.tree.element?.attributes.cast() == null || _src(context.tree.element!.attributes.cast()) == null) {
  43. return false;
  44. }
  45. try {
  46. final src = Uri.parse(_src(context.tree.element!.attributes.cast())!);
  47. return schemas.contains(src.scheme) &&
  48. (domains == null || domains.contains(src.host)) &&
  49. (extension == null || src.path.endsWith('.$extension'));
  50. } catch (e) {
  51. return false;
  52. }
  53. };
  54. CustomRenderMatcher assetUriMatcher() => (context) =>
  55. context.tree.element?.attributes.cast() != null &&
  56. _src(context.tree.element!.attributes.cast()) != null &&
  57. _src(context.tree.element!.attributes.cast())!.startsWith('asset:') &&
  58. !_src(context.tree.element!.attributes.cast())!.endsWith('.svg');
  59. CustomRenderMatcher textContentElementMatcher() => (context) {
  60. return context.tree is TextContentElement;
  61. };
  62. CustomRenderMatcher interactableElementMatcher() => (context) {
  63. return context.tree is InteractableElement;
  64. };
  65. CustomRenderMatcher layoutElementMatcher() => (context) {
  66. return context.tree is LayoutElement;
  67. };
  68. CustomRenderMatcher verticalAlignMatcher() => (context) {
  69. return context.tree.style.verticalAlign != null && context.tree.style.verticalAlign != VerticalAlign.baseline;
  70. };
  71. CustomRenderMatcher fallbackMatcher() => (context) {
  72. return true;
  73. };
  74. class CustomRender {
  75. final InlineSpan Function(RenderContext, List<InlineSpan> Function())? inlineSpan;
  76. final Widget Function(RenderContext, List<InlineSpan> Function())? widget;
  77. CustomRender.inlineSpan({
  78. required this.inlineSpan,
  79. }) : widget = null;
  80. CustomRender.widget({
  81. required this.widget,
  82. }) : inlineSpan = null;
  83. }
  84. class SelectableCustomRender extends CustomRender {
  85. final TextSpan Function(RenderContext, List<TextSpan> Function()) textSpan;
  86. SelectableCustomRender.fromTextSpan({
  87. required this.textSpan,
  88. }) : super.inlineSpan(inlineSpan: null);
  89. }
  90. CustomRender blockElementRender({Style? style, List<InlineSpan>? children}) =>
  91. CustomRender.inlineSpan(inlineSpan: (context, buildChildren) {
  92. if (context.parser.selectable) {
  93. return TextSpan(
  94. style: context.style.generateTextStyle(),
  95. children: (children as List<TextSpan>?) ??
  96. context.tree.children
  97. .expandIndexed((i, childTree) => [
  98. context.parser.parseTree(context, childTree),
  99. if (i != context.tree.children.length - 1 &&
  100. childTree.style.display == Display.block &&
  101. childTree.element?.localName != 'html' &&
  102. childTree.element?.localName != 'body')
  103. const TextSpan(text: '\n'),
  104. ])
  105. .toList(),
  106. );
  107. }
  108. return WidgetSpan(
  109. alignment: PlaceholderAlignment.baseline,
  110. baseline: TextBaseline.alphabetic,
  111. child: CssBoxWidget.withInlineSpanChildren(
  112. key: context.key,
  113. style: style ?? context.tree.style,
  114. shrinkWrap: context.parser.shrinkWrap,
  115. childIsReplaced: HtmlElements.replacedExternalElements.contains(context.tree.name),
  116. children: children ??
  117. context.tree.children
  118. .expandIndexed((i, childTree) => [
  119. context.parser.parseTree(context, childTree),
  120. if (i != context.tree.children.length - 1 &&
  121. childTree.style.display == Display.block &&
  122. childTree.element?.localName != 'html' &&
  123. childTree.element?.localName != 'body')
  124. const TextSpan(text: '\n'),
  125. ])
  126. .toList(),
  127. ),
  128. );
  129. });
  130. CustomRender listElementRender({Style? style, Widget? child, List<InlineSpan>? children}) {
  131. return CustomRender.inlineSpan(
  132. inlineSpan: (context, buildChildren) {
  133. return WidgetSpan(
  134. child: CssBoxWidget.withInlineSpanChildren(
  135. key: context.key,
  136. style: style ?? context.style,
  137. shrinkWrap: context.parser.shrinkWrap,
  138. children: buildChildren(),
  139. ),
  140. );
  141. },
  142. );
  143. }
  144. CustomRender replacedElementRender({PlaceholderAlignment? alignment, TextBaseline? baseline, Widget? child}) =>
  145. CustomRender.inlineSpan(
  146. inlineSpan: (context, buildChildren) => WidgetSpan(
  147. alignment: alignment ?? (context.tree as ReplacedElement).alignment,
  148. baseline: baseline ?? TextBaseline.alphabetic,
  149. child: child ?? (context.tree as ReplacedElement).toWidget(context)!,
  150. ));
  151. CustomRender textContentElementRender({String? text}) => CustomRender.inlineSpan(
  152. inlineSpan: (context, buildChildren) => TextSpan(
  153. style: context.style.generateTextStyle(),
  154. text: (text ?? (context.tree as TextContentElement).text)?.transformed(context.tree.style.textTransform),
  155. ),
  156. );
  157. CustomRender base64ImageRender() => CustomRender.widget(widget: (context, buildChildren) {
  158. final decodedImage = base64.decode(_src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim());
  159. precacheImage(
  160. MemoryImage(decodedImage),
  161. context.buildContext,
  162. onError: (exception, stackTrace) {
  163. context.parser.onImageError?.call(exception, stackTrace);
  164. },
  165. );
  166. final widget = Image.memory(
  167. decodedImage,
  168. frameBuilder: (ctx, child, frame, _) {
  169. if (frame == null) {
  170. return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle());
  171. }
  172. return child;
  173. },
  174. );
  175. return Builder(
  176. key: context.key,
  177. builder: (buildContext) {
  178. return GestureDetector(
  179. child: widget,
  180. onTap: () {
  181. if (MultipleTapGestureDetector.of(buildContext) != null) {
  182. MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
  183. }
  184. context.parser.onImageTap?.call(
  185. _src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim(),
  186. context,
  187. context.tree.element!.attributes.cast(),
  188. context.tree.element);
  189. },
  190. );
  191. });
  192. });
  193. CustomRender assetImageRender({
  194. double? width,
  195. double? height,
  196. }) =>
  197. CustomRender.widget(widget: (context, buildChildren) {
  198. final assetPath = _src(context.tree.element!.attributes.cast())!.replaceFirst('asset:', '');
  199. final widget = Image.asset(
  200. assetPath,
  201. width: width ?? _width(context.tree.element!.attributes.cast()),
  202. height: height ?? _height(context.tree.element!.attributes.cast()),
  203. frameBuilder: (ctx, child, frame, _) {
  204. if (frame == null) {
  205. return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle());
  206. }
  207. return child;
  208. },
  209. );
  210. return Builder(
  211. key: context.key,
  212. builder: (buildContext) {
  213. return GestureDetector(
  214. child: widget,
  215. onTap: () {
  216. if (MultipleTapGestureDetector.of(buildContext) != null) {
  217. MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
  218. }
  219. context.parser.onImageTap
  220. ?.call(assetPath, context, context.tree.element!.attributes.cast(), context.tree.element);
  221. },
  222. );
  223. });
  224. });
  225. CustomRender networkImageRender({
  226. Map<String, String>? headers,
  227. String Function(String?)? mapUrl,
  228. double? width,
  229. double? height,
  230. Widget Function(String?)? altWidget,
  231. Widget Function()? loadingWidget,
  232. }) =>
  233. CustomRender.widget(widget: (context, buildChildren) {
  234. final src =
  235. mapUrl?.call(_src(context.tree.element!.attributes.cast())) ?? _src(context.tree.element!.attributes.cast())!;
  236. Completer<Size> completer = Completer();
  237. if (context.parser.cachedImageSizes[src] != null) {
  238. completer.complete(context.parser.cachedImageSizes[src]);
  239. } else {
  240. Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) {
  241. if (frame == null) {
  242. if (!completer.isCompleted) {
  243. completer.completeError('error');
  244. }
  245. return child;
  246. } else {
  247. return child;
  248. }
  249. });
  250. ImageStreamListener? listener;
  251. listener = ImageStreamListener((imageInfo, synchronousCall) {
  252. var myImage = imageInfo.image;
  253. Size size = Size(myImage.width.toDouble(), myImage.height.toDouble());
  254. if (!completer.isCompleted) {
  255. context.parser.cachedImageSizes[src] = size;
  256. completer.complete(size);
  257. image.image.resolve(const ImageConfiguration()).removeListener(listener!);
  258. }
  259. }, onError: (object, stacktrace) {
  260. if (!completer.isCompleted) {
  261. completer.completeError(object);
  262. image.image.resolve(const ImageConfiguration()).removeListener(listener!);
  263. }
  264. });
  265. image.image.resolve(const ImageConfiguration()).addListener(listener);
  266. }
  267. final attributes = context.tree.element!.attributes.cast<String, String>();
  268. final widget = FutureBuilder<Size>(
  269. future: completer.future,
  270. initialData: context.parser.cachedImageSizes[src],
  271. builder: (buildContext, snapshot) {
  272. if (snapshot.hasData) {
  273. return Container(
  274. constraints: BoxConstraints(
  275. maxWidth: width ?? _width(attributes) ?? snapshot.data!.width,
  276. maxHeight:
  277. (width ?? _width(attributes) ?? snapshot.data!.width) / _aspectRatio(attributes, snapshot)),
  278. child: AspectRatio(
  279. aspectRatio: _aspectRatio(attributes, snapshot),
  280. child: Image.network(
  281. src,
  282. headers: headers,
  283. width: width ?? _width(attributes) ?? snapshot.data!.width,
  284. height: height ?? _height(attributes),
  285. frameBuilder: (ctx, child, frame, _) {
  286. if (frame == null) {
  287. return altWidget?.call(_alt(attributes)) ??
  288. Text(_alt(attributes) ?? '', style: context.style.generateTextStyle());
  289. }
  290. return child;
  291. },
  292. ),
  293. ),
  294. );
  295. } else if (snapshot.hasError) {
  296. return altWidget?.call(_alt(context.tree.element!.attributes.cast())) ??
  297. Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle());
  298. } else {
  299. return loadingWidget?.call() ?? const CircularProgressIndicator();
  300. }
  301. },
  302. );
  303. return Builder(
  304. key: context.key,
  305. builder: (buildContext) {
  306. return GestureDetector(
  307. child: widget,
  308. onTap: () {
  309. if (MultipleTapGestureDetector.of(buildContext) != null) {
  310. MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
  311. }
  312. context.parser.onImageTap
  313. ?.call(src, context, context.tree.element!.attributes.cast(), context.tree.element);
  314. },
  315. );
  316. });
  317. });
  318. CustomRender interactableElementRender({List<InlineSpan>? children}) => CustomRender.inlineSpan(
  319. inlineSpan: (context, buildChildren) => TextSpan(
  320. children: children ??
  321. (context.tree as InteractableElement)
  322. .children
  323. .map((tree) => context.parser.parseTree(context, tree))
  324. .map((childSpan) {
  325. return _getInteractableChildren(context, context.tree as InteractableElement, childSpan,
  326. context.style.generateTextStyle().merge(childSpan.style));
  327. }).toList(),
  328. ));
  329. CustomRender layoutElementRender({Widget? child}) => CustomRender.inlineSpan(
  330. inlineSpan: (context, buildChildren) => WidgetSpan(
  331. child: child ?? (context.tree as LayoutElement).toWidget(context)!,
  332. ));
  333. CustomRender verticalAlignRender({double? verticalOffset, Style? style, List<InlineSpan>? children}) =>
  334. CustomRender.inlineSpan(
  335. inlineSpan: (context, buildChildren) => WidgetSpan(
  336. child: Transform.translate(
  337. key: context.key,
  338. offset: Offset(0, verticalOffset ?? _getVerticalOffset(context.tree)),
  339. child: CssBoxWidget.withInlineSpanChildren(
  340. children: children ?? buildChildren.call(),
  341. style: context.style,
  342. ),
  343. ),
  344. ));
  345. CustomRender fallbackRender({Style? style, List<InlineSpan>? children}) => CustomRender.inlineSpan(
  346. inlineSpan: (context, buildChildren) => TextSpan(
  347. style: style?.generateTextStyle() ?? context.style.generateTextStyle(),
  348. children: context.tree.children
  349. .expand((tree) => [
  350. context.parser.parseTree(context, tree),
  351. if (tree.style.display == Display.block &&
  352. tree.element?.parent?.localName != 'th' &&
  353. tree.element?.parent?.localName != 'td' &&
  354. tree.element?.localName != 'html' &&
  355. tree.element?.localName != 'body')
  356. const TextSpan(text: '\n'),
  357. ])
  358. .toList(),
  359. ));
  360. Map<CustomRenderMatcher, CustomRender> generateDefaultRenders() {
  361. return {
  362. blockElementMatcher(): blockElementRender(),
  363. listElementMatcher(): listElementRender(),
  364. textContentElementMatcher(): textContentElementRender(),
  365. dataUriMatcher(): base64ImageRender(),
  366. assetUriMatcher(): assetImageRender(),
  367. networkSourceMatcher(): networkImageRender(),
  368. replacedElementMatcher(): replacedElementRender(),
  369. interactableElementMatcher(): interactableElementRender(),
  370. layoutElementMatcher(): layoutElementRender(),
  371. verticalAlignMatcher(): verticalAlignRender(),
  372. fallbackMatcher(): fallbackRender(),
  373. };
  374. }
  375. InlineSpan _getInteractableChildren(
  376. RenderContext context, InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) {
  377. if (childSpan is TextSpan) {
  378. return TextSpan(
  379. text: childSpan.text,
  380. children: childSpan.children
  381. ?.map((e) => _getInteractableChildren(context, tree, e, childStyle.merge(childSpan.style)))
  382. .toList(),
  383. style: context.style
  384. .generateTextStyle()
  385. .merge(childSpan.style == null ? childStyle : childStyle.merge(childSpan.style)),
  386. semanticsLabel: childSpan.semanticsLabel,
  387. recognizer: TapGestureRecognizer()
  388. ..onTap = context.parser.internalOnAnchorTap != null
  389. ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element)
  390. : null,
  391. );
  392. } else {
  393. return WidgetSpan(
  394. child: MultipleTapGestureDetector(
  395. onTap: context.parser.internalOnAnchorTap != null
  396. ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element)
  397. : null,
  398. child: GestureDetector(
  399. key: context.key,
  400. onTap: context.parser.internalOnAnchorTap != null
  401. ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element)
  402. : null,
  403. child: (childSpan as WidgetSpan).child,
  404. ),
  405. ),
  406. );
  407. }
  408. }
  409. final _dataUriFormat = RegExp('^(?<scheme>data):(?<mime>image\\/[\\w\\+\\-\\.]+)(?<encoding>;base64)?\\,(?<data>.*)');
  410. double _getVerticalOffset(StyledElement tree) {
  411. switch (tree.style.verticalAlign) {
  412. case VerticalAlign.sub:
  413. return tree.style.fontSize!.value / 2.5;
  414. case VerticalAlign.sup:
  415. return tree.style.fontSize!.value / -2.5;
  416. default:
  417. return 0;
  418. }
  419. }
  420. String? _src(Map<String, String> attributes) {
  421. return attributes['src'];
  422. }
  423. String? _alt(Map<String, String> attributes) {
  424. return attributes['alt'];
  425. }
  426. double? _height(Map<String, String> attributes) {
  427. final heightString = attributes['height'];
  428. return heightString == null ? heightString as double? : double.tryParse(heightString);
  429. }
  430. double? _width(Map<String, String> attributes) {
  431. final widthString = attributes['width'];
  432. return widthString == null ? widthString as double? : double.tryParse(widthString);
  433. }
  434. double _aspectRatio(Map<String, String> attributes, AsyncSnapshot<Size> calculated) {
  435. final heightString = attributes['height'];
  436. final widthString = attributes['width'];
  437. if (heightString != null && widthString != null) {
  438. final height = double.tryParse(heightString);
  439. final width = double.tryParse(widthString);
  440. return height == null || width == null ? calculated.data!.aspectRatio : width / height;
  441. }
  442. return calculated.data!.aspectRatio;
  443. }
  444. extension ClampedEdgeInsets on EdgeInsetsGeometry {
  445. EdgeInsetsGeometry get nonNegative => clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity));
  446. }