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

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<String> schemas = const ['https', 'http'],
List<String>? 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<InlineSpan> Function())? inlineSpan;
final Widget Function(RenderContext, List<InlineSpan> 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<TextSpan> Function()) textSpan;
SelectableCustomRender.fromTextSpan({
required this.textSpan,
}) : super.inlineSpan(inlineSpan: null);
}
CustomRender blockElementRender({Style? style, List<InlineSpan>? children}) =>
CustomRender.inlineSpan(inlineSpan: (context, buildChildren) {
if (context.parser.selectable) {
return TextSpan(
style: context.style.generateTextStyle(),
children: (children as List<TextSpan>?) ??
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<InlineSpan>? 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<String, String>? 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<Size> 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<String, String>();
final widget = FutureBuilder<Size>(
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<InlineSpan>? 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<InlineSpan>? 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<InlineSpan>? 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<CustomRenderMatcher, CustomRender> 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('^(?<scheme>data):(?<mime>image\\/[\\w\\+\\-\\.]+)(?<encoding>;base64)?\\,(?<data>.*)');
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<String, String> attributes) {
return attributes['src'];
}
String? _alt(Map<String, String> attributes) {
return attributes['alt'];
}
double? _height(Map<String, String> attributes) {
final heightString = attributes['height'];
return heightString == null ? heightString as double? : double.tryParse(heightString);
}
double? _width(Map<String, String> attributes) {
final widthString = attributes['width'];
return widthString == null ? widthString as double? : double.tryParse(widthString);
}
double _aspectRatio(Map<String, String> attributes, AsyncSnapshot<Size> 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));
}