import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:sonnat/core/html/html_parser.dart'; import 'package:sonnat/core/html/src/css_box_widget.dart'; import 'package:sonnat/core/html/src/html_elements.dart'; import 'package:sonnat/core/html/src/layout_element.dart'; import 'package:sonnat/core/html/src/utils.dart'; import 'package:sonnat/core/html/style.dart'; typedef CustomRenderMatcher = bool Function(RenderContext context); CustomRenderMatcher tagMatcher(String tag) => (context) { return context.tree.element?.localName == tag; }; CustomRenderMatcher blockElementMatcher() => (context) { return (context.tree.style.display == Display.block || context.tree.style.display == Display.inlineBlock) && (context.tree.children.isNotEmpty || context.tree.element?.localName == 'hr'); }; CustomRenderMatcher listElementMatcher() => (context) { return context.tree.style.display == Display.listItem; }; CustomRenderMatcher replacedElementMatcher() => (context) { return context.tree is ReplacedElement; }; CustomRenderMatcher dataUriMatcher({String? encoding = 'base64', String? mime}) => (context) { if (context.tree.element?.attributes == null || _src(context.tree.element!.attributes.cast()) == null) { return false; } final dataUri = _dataUriFormat.firstMatch(_src(context.tree.element!.attributes.cast())!); return dataUri != null && dataUri.namedGroup('mime') != 'image/svg+xml' && (mime == null || dataUri.namedGroup('mime') == mime) && (encoding == null || dataUri.namedGroup('encoding') == ';$encoding'); }; CustomRenderMatcher networkSourceMatcher({ List schemas = const ['https', 'http'], List? domains, String? extension, }) => (context) { if (context.tree.element?.attributes.cast() == null || _src(context.tree.element!.attributes.cast()) == null) { return false; } try { final src = Uri.parse(_src(context.tree.element!.attributes.cast())!); return schemas.contains(src.scheme) && (domains == null || domains.contains(src.host)) && (extension == null || src.path.endsWith('.$extension')); } catch (e) { return false; } }; CustomRenderMatcher assetUriMatcher() => (context) => context.tree.element?.attributes.cast() != null && _src(context.tree.element!.attributes.cast()) != null && _src(context.tree.element!.attributes.cast())!.startsWith('asset:') && !_src(context.tree.element!.attributes.cast())!.endsWith('.svg'); CustomRenderMatcher textContentElementMatcher() => (context) { return context.tree is TextContentElement; }; CustomRenderMatcher interactableElementMatcher() => (context) { return context.tree is InteractableElement; }; CustomRenderMatcher layoutElementMatcher() => (context) { return context.tree is LayoutElement; }; CustomRenderMatcher verticalAlignMatcher() => (context) { return context.tree.style.verticalAlign != null && context.tree.style.verticalAlign != VerticalAlign.baseline; }; CustomRenderMatcher fallbackMatcher() => (context) { return true; }; class CustomRender { final InlineSpan Function(RenderContext, List Function())? inlineSpan; final Widget Function(RenderContext, List Function())? widget; CustomRender.inlineSpan({ required this.inlineSpan, }) : widget = null; CustomRender.widget({ required this.widget, }) : inlineSpan = null; } class SelectableCustomRender extends CustomRender { final TextSpan Function(RenderContext, List Function()) textSpan; SelectableCustomRender.fromTextSpan({ required this.textSpan, }) : super.inlineSpan(inlineSpan: null); } CustomRender blockElementRender({Style? style, List? children}) => CustomRender.inlineSpan(inlineSpan: (context, buildChildren) { if (context.parser.selectable) { return TextSpan( style: context.style.generateTextStyle(), children: (children as List?) ?? context.tree.children .expandIndexed((i, childTree) => [ context.parser.parseTree(context, childTree), if (i != context.tree.children.length - 1 && childTree.style.display == Display.block && childTree.element?.localName != 'html' && childTree.element?.localName != 'body') const TextSpan(text: '\n'), ]) .toList(), ); } return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: CssBoxWidget.withInlineSpanChildren( key: context.key, style: style ?? context.tree.style, shrinkWrap: context.parser.shrinkWrap, childIsReplaced: HtmlElements.replacedExternalElements.contains(context.tree.name), children: children ?? context.tree.children .expandIndexed((i, childTree) => [ context.parser.parseTree(context, childTree), if (i != context.tree.children.length - 1 && childTree.style.display == Display.block && childTree.element?.localName != 'html' && childTree.element?.localName != 'body') const TextSpan(text: '\n'), ]) .toList(), ), ); }); CustomRender listElementRender({Style? style, Widget? child, List? children}) { return CustomRender.inlineSpan( inlineSpan: (context, buildChildren) { return WidgetSpan( child: CssBoxWidget.withInlineSpanChildren( key: context.key, style: style ?? context.style, shrinkWrap: context.parser.shrinkWrap, children: buildChildren(), ), ); }, ); } CustomRender replacedElementRender({PlaceholderAlignment? alignment, TextBaseline? baseline, Widget? child}) => CustomRender.inlineSpan( inlineSpan: (context, buildChildren) => WidgetSpan( alignment: alignment ?? (context.tree as ReplacedElement).alignment, baseline: baseline ?? TextBaseline.alphabetic, child: child ?? (context.tree as ReplacedElement).toWidget(context)!, )); CustomRender textContentElementRender({String? text}) => CustomRender.inlineSpan( inlineSpan: (context, buildChildren) => TextSpan( style: context.style.generateTextStyle(), text: (text ?? (context.tree as TextContentElement).text)?.transformed(context.tree.style.textTransform), ), ); CustomRender base64ImageRender() => CustomRender.widget(widget: (context, buildChildren) { final decodedImage = base64.decode(_src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim()); precacheImage( MemoryImage(decodedImage), context.buildContext, onError: (exception, stackTrace) { context.parser.onImageError?.call(exception, stackTrace); }, ); final widget = Image.memory( decodedImage, frameBuilder: (ctx, child, frame, _) { if (frame == null) { return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); } return child; }, ); return Builder( key: context.key, builder: (buildContext) { return GestureDetector( child: widget, onTap: () { if (MultipleTapGestureDetector.of(buildContext) != null) { MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); } context.parser.onImageTap?.call( _src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim(), context, context.tree.element!.attributes.cast(), context.tree.element); }, ); }); }); CustomRender assetImageRender({ double? width, double? height, }) => CustomRender.widget(widget: (context, buildChildren) { final assetPath = _src(context.tree.element!.attributes.cast())!.replaceFirst('asset:', ''); final widget = Image.asset( assetPath, width: width ?? _width(context.tree.element!.attributes.cast()), height: height ?? _height(context.tree.element!.attributes.cast()), frameBuilder: (ctx, child, frame, _) { if (frame == null) { return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); } return child; }, ); return Builder( key: context.key, builder: (buildContext) { return GestureDetector( child: widget, onTap: () { if (MultipleTapGestureDetector.of(buildContext) != null) { MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); } context.parser.onImageTap ?.call(assetPath, context, context.tree.element!.attributes.cast(), context.tree.element); }, ); }); }); CustomRender networkImageRender({ Map? headers, String Function(String?)? mapUrl, double? width, double? height, Widget Function(String?)? altWidget, Widget Function()? loadingWidget, }) => CustomRender.widget(widget: (context, buildChildren) { final src = mapUrl?.call(_src(context.tree.element!.attributes.cast())) ?? _src(context.tree.element!.attributes.cast())!; Completer completer = Completer(); if (context.parser.cachedImageSizes[src] != null) { completer.complete(context.parser.cachedImageSizes[src]); } else { Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) { if (frame == null) { if (!completer.isCompleted) { completer.completeError('error'); } return child; } else { return child; } }); ImageStreamListener? listener; listener = ImageStreamListener((imageInfo, synchronousCall) { var myImage = imageInfo.image; Size size = Size(myImage.width.toDouble(), myImage.height.toDouble()); if (!completer.isCompleted) { context.parser.cachedImageSizes[src] = size; completer.complete(size); image.image.resolve(const ImageConfiguration()).removeListener(listener!); } }, onError: (object, stacktrace) { if (!completer.isCompleted) { completer.completeError(object); image.image.resolve(const ImageConfiguration()).removeListener(listener!); } }); image.image.resolve(const ImageConfiguration()).addListener(listener); } final attributes = context.tree.element!.attributes.cast(); final widget = FutureBuilder( future: completer.future, initialData: context.parser.cachedImageSizes[src], builder: (buildContext, snapshot) { if (snapshot.hasData) { return Container( constraints: BoxConstraints( maxWidth: width ?? _width(attributes) ?? snapshot.data!.width, maxHeight: (width ?? _width(attributes) ?? snapshot.data!.width) / _aspectRatio(attributes, snapshot)), child: AspectRatio( aspectRatio: _aspectRatio(attributes, snapshot), child: Image.network( src, headers: headers, width: width ?? _width(attributes) ?? snapshot.data!.width, height: height ?? _height(attributes), frameBuilder: (ctx, child, frame, _) { if (frame == null) { return altWidget?.call(_alt(attributes)) ?? Text(_alt(attributes) ?? '', style: context.style.generateTextStyle()); } return child; }, ), ), ); } else if (snapshot.hasError) { return altWidget?.call(_alt(context.tree.element!.attributes.cast())) ?? Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); } else { return loadingWidget?.call() ?? const CircularProgressIndicator(); } }, ); return Builder( key: context.key, builder: (buildContext) { return GestureDetector( child: widget, onTap: () { if (MultipleTapGestureDetector.of(buildContext) != null) { MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); } context.parser.onImageTap ?.call(src, context, context.tree.element!.attributes.cast(), context.tree.element); }, ); }); }); CustomRender interactableElementRender({List? children}) => CustomRender.inlineSpan( inlineSpan: (context, buildChildren) => TextSpan( children: children ?? (context.tree as InteractableElement) .children .map((tree) => context.parser.parseTree(context, tree)) .map((childSpan) { return _getInteractableChildren(context, context.tree as InteractableElement, childSpan, context.style.generateTextStyle().merge(childSpan.style)); }).toList(), )); CustomRender layoutElementRender({Widget? child}) => CustomRender.inlineSpan( inlineSpan: (context, buildChildren) => WidgetSpan( child: child ?? (context.tree as LayoutElement).toWidget(context)!, )); CustomRender verticalAlignRender({double? verticalOffset, Style? style, List? children}) => CustomRender.inlineSpan( inlineSpan: (context, buildChildren) => WidgetSpan( child: Transform.translate( key: context.key, offset: Offset(0, verticalOffset ?? _getVerticalOffset(context.tree)), child: CssBoxWidget.withInlineSpanChildren( children: children ?? buildChildren.call(), style: context.style, ), ), )); CustomRender fallbackRender({Style? style, List? children}) => CustomRender.inlineSpan( inlineSpan: (context, buildChildren) => TextSpan( style: style?.generateTextStyle() ?? context.style.generateTextStyle(), children: context.tree.children .expand((tree) => [ context.parser.parseTree(context, tree), if (tree.style.display == Display.block && tree.element?.parent?.localName != 'th' && tree.element?.parent?.localName != 'td' && tree.element?.localName != 'html' && tree.element?.localName != 'body') const TextSpan(text: '\n'), ]) .toList(), )); Map generateDefaultRenders() { return { blockElementMatcher(): blockElementRender(), listElementMatcher(): listElementRender(), textContentElementMatcher(): textContentElementRender(), dataUriMatcher(): base64ImageRender(), assetUriMatcher(): assetImageRender(), networkSourceMatcher(): networkImageRender(), replacedElementMatcher(): replacedElementRender(), interactableElementMatcher(): interactableElementRender(), layoutElementMatcher(): layoutElementRender(), verticalAlignMatcher(): verticalAlignRender(), fallbackMatcher(): fallbackRender(), }; } InlineSpan _getInteractableChildren( RenderContext context, InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) { if (childSpan is TextSpan) { return TextSpan( text: childSpan.text, children: childSpan.children ?.map((e) => _getInteractableChildren(context, tree, e, childStyle.merge(childSpan.style))) .toList(), style: context.style .generateTextStyle() .merge(childSpan.style == null ? childStyle : childStyle.merge(childSpan.style)), semanticsLabel: childSpan.semanticsLabel, recognizer: TapGestureRecognizer() ..onTap = context.parser.internalOnAnchorTap != null ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) : null, ); } else { return WidgetSpan( child: MultipleTapGestureDetector( onTap: context.parser.internalOnAnchorTap != null ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) : null, child: GestureDetector( key: context.key, onTap: context.parser.internalOnAnchorTap != null ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) : null, child: (childSpan as WidgetSpan).child, ), ), ); } } final _dataUriFormat = RegExp('^(?data):(?image\\/[\\w\\+\\-\\.]+)(?;base64)?\\,(?.*)'); double _getVerticalOffset(StyledElement tree) { switch (tree.style.verticalAlign) { case VerticalAlign.sub: return tree.style.fontSize!.value / 2.5; case VerticalAlign.sup: return tree.style.fontSize!.value / -2.5; default: return 0; } } String? _src(Map attributes) { return attributes['src']; } String? _alt(Map attributes) { return attributes['alt']; } double? _height(Map attributes) { final heightString = attributes['height']; return heightString == null ? heightString as double? : double.tryParse(heightString); } double? _width(Map attributes) { final widthString = attributes['width']; return widthString == null ? widthString as double? : double.tryParse(widthString); } double _aspectRatio(Map attributes, AsyncSnapshot calculated) { final heightString = attributes['height']; final widthString = attributes['width']; if (heightString != null && widthString != null) { final height = double.tryParse(heightString); final width = double.tryParse(widthString); return height == null || width == null ? calculated.data!.aspectRatio : width / height; } return calculated.data!.aspectRatio; } extension ClampedEdgeInsets on EdgeInsetsGeometry { EdgeInsetsGeometry get nonNegative => clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity)); }