master
章文轩 2 years ago
parent eb80d3c916
commit 35c1130191

@ -296,7 +296,7 @@ class _ChatTypePageState extends State<ChatTypePage> {
print('h5 chat');
// : ->->()-> URL
String url =
"https://h2.kefux.cn/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&ph=ph";
"https://h2.kefux.com/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&ph=ph";
String title = 'H5在线客服演示';
BytedeskKefu.startH5Chat(context, url, title);
},

@ -7,6 +7,9 @@ list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
@ -15,3 +18,8 @@ foreach(plugin ${FLUTTER_PLUGIN_LIST})
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

@ -30,7 +30,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.bytedesk.bytedesk_kefu_example"
minSdkVersion 19
minSdkVersion 20
targetSdkVersion 30
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>9.0</string>
<string>11.0</string>
</dict>
</plist>

@ -5,14 +5,14 @@ PODS:
- Flutter
- devicelocale (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.2):
- DKImagePickerController/Core (4.3.4):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.2)
- DKImagePickerController/PhotoGallery (4.3.2):
- DKImagePickerController/ImageDataManager (4.3.4)
- DKImagePickerController/PhotoGallery (4.3.4):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.2)
- DKImagePickerController/Resource (4.3.4)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
@ -54,9 +54,9 @@ PODS:
- Flutter
- path_provider_ios (0.0.1):
- Flutter
- SDWebImage (5.12.5):
- SDWebImage/Core (= 5.12.5)
- SDWebImage/Core (5.12.5)
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
- shared_preferences_ios (0.0.1):
- Flutter
- sqflite (0.0.2):
@ -138,17 +138,17 @@ SPEC CHECKSUMS:
bytedesk_kefu: ca69f7b243932a665dc7001be5a8e04fe7f30c57
device_info: d7d233b645a32c40dfdc212de5cf646ca482f175
devicelocale: b22617f40038496deffba44747101255cee005b0
DKImagePickerController: b5eb7f7a388e4643264105d648d01f727110fc3d
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
image_gallery_saver: 259eab68fb271cfd57d599904f7acdc7832e7ef2
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
SDWebImage: 0905f1b7760fc8ac4198cae0036600d67478751e
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
@ -156,8 +156,8 @@ SPEC CHECKSUMS:
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
PODFILE CHECKSUM: 3992017b8e295cef2daf25d4508056e8c5de3123
PODFILE CHECKSUM: b91427fd94287f9a2f30769828f3c2f6d1e8889a
COCOAPODS: 1.11.3

@ -354,7 +354,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@ -429,7 +429,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@ -478,7 +478,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;

@ -70,11 +70,15 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>NSLocalNetworkUsageDescription</key>
<string>allow flutter to access localhost network</string>
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
</dict>
</plist>

@ -70,5 +70,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
</dict>
</plist>

@ -70,5 +70,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
</dict>
</plist>

@ -224,8 +224,9 @@ class _ChatTypePageState extends State<ChatTypePage> {
onTap: () {
print('h5 chat');
// : ->->()-> URL
String url =
"https://h2.kefux.cn/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&ph=ph";
String url = "https://h2.kefux.com/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=&hidenav=1&p";
// String url =
// "http://127.0.0.1:8887/chat/h5/index.html?sub=vip&uid=201808221551193&wid=201807171659201&type=workGroup&aid=201808221551193&history=0&lang=cn&v2robot=0&p";
String title = 'H5在线客服演示';
BytedeskKefu.startH5Chat(context, url, title);
},
@ -236,7 +237,7 @@ class _ChatTypePageState extends State<ChatTypePage> {
}
void _getUnreadCountVisitor() {
// 线
//
BytedeskKefu.getUnreadCountVisitor().then((count) => {
print('unreadcount:' + count),
setState(() {

@ -5,20 +5,22 @@
import FlutterMacOS
import Foundation
import audioplayers
import bytedesk_kefu
import devicelocale
import package_info
import path_provider_macos
import shared_preferences_macos
import sqflite
import url_launcher_macos
import wakelock_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersPlugin.register(with: registry.registrar(forPlugin: "AudioplayersPlugin"))
BytedeskKefuPlugin.register(with: registry.registrar(forPlugin: "BytedeskKefuPlugin"))
DevicelocalePlugin.register(with: registry.registrar(forPlugin: "DevicelocalePlugin"))
FLTPackageInfoPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin"))
}

@ -6,7 +6,7 @@ description: Demonstrates how to use the bytedesk_kefu plugin.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
environment:
sdk: ">=2.12.0 <3.0.0"
sdk: ">=2.14.0 <3.0.0"
dependencies:
flutter:

@ -1,19 +1,27 @@
import 'dart:async';
// import 'dart:convert';
import 'dart:io';
// import 'dart:typed_data';
// import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
// import 'package:flutter_webview_pro/webview_flutter.dart';
// import 'package:path_provider/path_provider.dart';
// TODO: 访
// TODO:
class ChatWebViewPage extends StatefulWidget {
const ChatWebViewPage({
Key? key,
@required this.title,
@required this.url,
// this.cookieManager
}) : super(key: key);
final String? title;
final String? url;
// final CookieManager? cookieManager;
@override
_ChatWebViewPageState createState() => _ChatWebViewPageState();
@ -24,42 +32,22 @@ class _ChatWebViewPageState extends State<ChatWebViewPage> {
Completer<WebViewController>();
@override
Widget build(BuildContext context) {
return FutureBuilder<WebViewController>(
future: _controller.future,
builder: (context, snapshot) {
return WillPopScope(
onWillPop: () async {
if (snapshot.hasData) {
var canGoBack = await snapshot.data!.canGoBack();
if (canGoBack) {
//
await snapshot.data!.goBack();
return Future.value(false);
void initState() {
super.initState();
if (Platform.isAndroid) {
WebView.platform = SurfaceAndroidWebView();
}
}
return Future.value(true);
},
child: Scaffold(
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title!),
elevation: 0,
actions: [
// Align(
// alignment: Alignment.centerRight,
// child: Container(
// padding: new EdgeInsets.only(right: 10),
// child: InkWell(
// onTap: () {
// BytedeskUtils.printLog('share');
// // showShareSheet(context);
// },
// child: Image.asset(
// // 'assets/images/weibo/icon_more.png',
// 'assets/images/weibo/video_detail_share.png',
// width: 23.0,
// height: 23.0,
// )))),
// NavigationControls(_controller.future),
// SampleMenu(_controller.future, widget.cookieManager),
],
),
body: WebView(
@ -68,64 +56,39 @@ class _ChatWebViewPageState extends State<ChatWebViewPage> {
onWebViewCreated: (WebViewController webViewController) {
_controller.complete(webViewController);
},
)),
onProgress: (int progress) {
print('WebView is loading (progress : $progress%)');
},
javascriptChannels: <JavascriptChannel>{
_toasterJavascriptChannel(context),
},
navigationDelegate: (NavigationRequest request) {
// if (request.url.startsWith('')) {
// print('blocking navigation to $request}');
// return NavigationDecision.prevent;
// }
print('allowing navigation to $request');
return NavigationDecision.navigate;
},
onPageStarted: (String url) {
print('Page started loading: $url');
},
onPageFinished: (String url) {
print('Page finished loading: $url');
},
gestureNavigationEnabled: true,
// geolocationEnabled: false,//support geolocation or not
));
}
JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
return JavascriptChannel(
name: 'Toaster',
onMessageReceived: (JavascriptMessage message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message.message)),
);
});
}
// void showShareSheet(BuildContext context) {
// showFLBottomSheet(
// context: context,
// builder: (BuildContext context) {
// return FLCupertinoOperationSheet(
// sheetStyle: FLCupertinoActionSheetStyle.filled,
// cancelButton: CupertinoActionSheetAction(
// child: Text(
// '取消',
// ),
// isDefaultAction: true,
// onPressed: () {
// Navigator.pop(context, 'cancel');
// },
// ),
// header: Container(
// padding: EdgeInsets.symmetric(vertical: 20, horizontal: 30),
// child: Text('分享', style: TextStyle(fontSize: 18)),
// ),
// itemList: [
// [
// FLCupertinoOperationSheetItem(
// imagePath: 'assets/images/circle/weibo.png',
// title: '复制链接', // TODO
// onPressed: () {
// Navigator.pop(context, 'weibo');
// },
// ),
// FLCupertinoOperationSheetItem(
// imagePath: 'assets/images/circle/weibo.png',
// title: '浏览器打开', // TODO
// onPressed: () {
// Navigator.pop(context, 'weibo');
// },
// ),
// FLCupertinoOperationSheetItem(
// imagePath: 'assets/images/circle/weibo.png',
// title: '刷新', // TODO
// onPressed: () {
// Navigator.pop(context, 'weibo');
// },
// ),
// ],
// ],
// );
// }).then((value) {
// //
// BytedeskUtils.printLog('share $value');
// if (value == 'wechat') {
// // TODO:
// } else if (value == 'weibo') {
// // TODO:
// }
// });
// }
}

@ -12,7 +12,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:bytedesk_kefu/vendors/flutter_html/flutter_html.dart';
import 'package:bytedesk_kefu/util/bytedesk_utils.dart';
//

@ -2,7 +2,6 @@ import 'package:bytedesk_kefu/blocs/help_bloc/bloc.dart';
import 'package:bytedesk_kefu/model/helpArticle.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// import 'package:flutter_html/flutter_html.dart';
class HelpArticleDetailPage extends StatefulWidget {
final HelpArticle? helpArticle;

@ -1,4 +1,4 @@
import './category_icon.dart';
import '../src/category_icon.dart';
import 'package:flutter/material.dart';
/// Class used to define all the [CategoryIcon] shown for each [Category]

@ -1,7 +1,7 @@
import 'dart:math';
import './category_icons.dart';
import './emoji_picker.dart';
import '../src/category_icons.dart';
import '../src/emoji_picker.dart';
import 'package:flutter/material.dart';
/// Default Widget if no recent is available

@ -1,8 +1,8 @@
import '../emoji_picker_flutter.dart';
import './category_emoji.dart';
import './emoji_picker_internal_utils.dart';
import './emoji_skin_tones.dart';
import './triangle_shape.dart';
import '../src/category_emoji.dart';
import '../src/emoji_picker_internal_utils.dart';
import '../src/emoji_skin_tones.dart';
import '../src/triangle_shape.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

@ -739,6 +739,7 @@ final Map<String, String> animals = Map.fromIterables([
'Fox Face',
'Bear Face',
'Panda Face',
'Koala Face',
'Tiger Face',
'Lion Face',
'Cow Face',
@ -787,7 +788,6 @@ final Map<String, String> animals = Map.fromIterables([
'Chipmunk',
'Hedgehog',
'Bat',
'Koala',
'Kangaroo',
'Badger',
'Paw Prints',

@ -1,10 +1,10 @@
import './category_emoji.dart';
import './config.dart';
import './default_emoji_picker_view.dart';
import './emoji.dart';
import './emoji_picker_internal_utils.dart';
import './emoji_view_state.dart';
import './recent_emoji.dart';
import '../src/category_emoji.dart';
import '../src/config.dart';
import '../src/default_emoji_picker_view.dart';
import '../src/emoji.dart';
import '../src/emoji_picker_internal_utils.dart';
import '../src/emoji_view_state.dart';
import '../src/recent_emoji.dart';
import 'package:flutter/material.dart';
/// All the possible categories that [Emoji] can be put into
@ -98,7 +98,8 @@ class EmojiPicker extends StatefulWidget {
/// EmojiPicker for flutter
const EmojiPicker({
Key? key,
required this.onEmojiSelected,
this.textEditingController,
this.onEmojiSelected,
this.onBackspacePressed,
this.config = const Config(),
this.customWidget,
@ -107,8 +108,13 @@ class EmojiPicker extends StatefulWidget {
/// Custom widget
final EmojiViewBuilder? customWidget;
/// If you provide the [TextEditingController] that is linked to a
/// [TextField] this widget handles inserting and deleting for you
/// automatically.
final TextEditingController? textEditingController;
/// The function called when the emoji is selected
final OnEmojiSelected onEmojiSelected;
final OnEmojiSelected? onEmojiSelected;
/// The function called when backspace button is pressed
final OnBackspacePressed? onBackspacePressed;
@ -183,7 +189,9 @@ class EmojiPickerState extends State<EmojiPicker> {
var state = EmojiViewState(
_categoryEmoji,
_getOnEmojiListener(),
widget.onBackspacePressed,
widget.onBackspacePressed == null && widget.textEditingController == null
? null
: _onBackspacePressed,
);
// Build
@ -192,6 +200,30 @@ class EmojiPickerState extends State<EmojiPicker> {
: widget.customWidget!(widget.config, state);
}
void _onBackspacePressed() {
if (widget.textEditingController != null) {
final controller = widget.textEditingController!;
final selection = controller.value.selection;
final text = controller.value.text;
final cursorPosition = controller.selection.base.offset;
if (cursorPosition < 0) {
widget.onBackspacePressed?.call();
return;
}
final newTextBeforeCursor =
selection.textBefore(text).characters.skipLast(1).toString();
controller
..text = newTextBeforeCursor + selection.textAfter(text)
..selection = TextSelection.fromPosition(
TextPosition(offset: newTextBeforeCursor.length));
}
widget.onBackspacePressed?.call();
}
// Add recent emoji handling to tap listener
OnEmojiSelected _getOnEmojiListener() {
return (category, emoji) {
@ -207,7 +239,32 @@ class EmojiPickerState extends State<EmojiPicker> {
})
});
}
widget.onEmojiSelected(category, emoji);
if (widget.textEditingController != null) {
// based on https://stackoverflow.com/a/60058972/10975692
final controller = widget.textEditingController!;
final text = controller.text;
final selection = controller.selection;
final cursorPosition = controller.selection.base.offset;
if (cursorPosition < 0) {
controller.text += emoji.emoji;
widget.onEmojiSelected?.call(category, emoji);
return;
}
final newText =
text.replaceRange(selection.start, selection.end, emoji.emoji);
final emojiLength = emoji.emoji.length;
controller
..text = newText
..selection = selection.copyWith(
baseOffset: selection.start + emojiLength,
extentOffset: selection.start + emojiLength,
);
}
widget.onEmojiSelected?.call(category, emoji);
};
}

@ -1,5 +1,5 @@
import './config.dart';
import './emoji_view_state.dart';
import '../src/config.dart';
import '../src/emoji_view_state.dart';
import 'package:flutter/material.dart';
/// Template class for custom implementation

@ -3,7 +3,7 @@ import 'dart:io';
import 'dart:math';
import '../emoji_picker_flutter.dart';
import './emoji_skin_tones.dart';
import '../src/emoji_skin_tones.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'emoji_lists.dart' as emoji_list;

@ -1,5 +1,5 @@
import '../emoji_picker_flutter.dart';
import './recent_emoji.dart';
import '../src/recent_emoji.dart';
import 'package:flutter/material.dart';
import 'emoji_picker_internal_utils.dart';

@ -1,5 +1,5 @@
import '../emoji_picker_flutter.dart';
import './category_emoji.dart';
import '../src/category_emoji.dart';
/// State that holds current emoji data
class EmojiViewState {

@ -1,4 +1,4 @@
import './emoji.dart';
import '../src/emoji.dart';
/// Class that holds an recent emoji
/// Recent Emoji has an instance of the emoji

@ -0,0 +1,635 @@
import 'package:collection/collection.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import './flutter_html.dart';
import './src/utils.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.children.isNotEmpty ||
context.tree.element?.localName == "hr");
};
CustomRenderMatcher listElementMatcher() => (context) {
return context.tree.style.display == Display.LIST_ITEM;
};
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) => [
if (childTree.style.display == Display.BLOCK &&
i > 0 &&
context.tree.children[i - 1] is ReplacedElement)
TextSpan(text: "\n"),
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")
TextSpan(text: "\n"),
])
.toList(),
);
}
return WidgetSpan(
child: ContainerSpan(
key: context.key,
newContext: context,
style: style ?? context.tree.style,
shrinkWrap: context.parser.shrinkWrap,
children: children ??
context.tree.children
.expandIndexed((i, childTree) => [
if (context.parser.shrinkWrap &&
childTree.style.display == Display.BLOCK &&
i > 0 &&
context.tree.children[i - 1] is ReplacedElement)
TextSpan(text: "\n"),
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")
TextSpan(text: "\n"),
])
.toList(),
));
});
CustomRender listElementRender(
{Style? style, Widget? child, List<InlineSpan>? children}) =>
CustomRender.inlineSpan(
inlineSpan: (context, buildChildren) => WidgetSpan(
child: ContainerSpan(
key: context.key,
newContext: context,
style: style ?? context.tree.style,
shrinkWrap: context.parser.shrinkWrap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
textDirection:
style?.direction ?? context.tree.style.direction,
children: [
(style?.listStylePosition ??
context.tree.style.listStylePosition) ==
ListStylePosition.OUTSIDE
? Padding(
padding: style?.padding?.nonNegative ??
context.tree.style.padding?.nonNegative ??
EdgeInsets.only(
left: (style?.direction ??
context.tree.style.direction) !=
TextDirection.rtl
? 10.0
: 0.0,
right: (style?.direction ??
context.tree.style.direction) ==
TextDirection.rtl
? 10.0
: 0.0),
child: style?.markerContent ??
context.style.markerContent)
: Container(height: 0, width: 0),
Text("\u0020",
textAlign: TextAlign.right,
style: TextStyle(fontWeight: FontWeight.w400)),
Expanded(
child: Padding(
padding: (style?.listStylePosition ??
context.tree.style.listStylePosition) ==
ListStylePosition.INSIDE
? EdgeInsets.only(
left: (style?.direction ??
context.tree.style.direction) !=
TextDirection.rtl
? 10.0
: 0.0,
right: (style?.direction ??
context.tree.style.direction) ==
TextDirection.rtl
? 10.0
: 0.0)
: EdgeInsets.zero,
child: StyledText(
textSpan: TextSpan(
children: _getListElementChildren(
style?.listStylePosition ??
context.tree.style.listStylePosition,
buildChildren)
..insertAll(
0,
context.tree.style.listStylePosition ==
ListStylePosition.INSIDE
? [
WidgetSpan(
alignment:
PlaceholderAlignment
.middle,
child: style?.markerContent ??
context.style
.markerContent ??
Container(
height: 0, width: 0))
]
: []),
style: style?.generateTextStyle() ??
context.style.generateTextStyle(),
),
style: style ?? context.style,
renderContext: context,
)))
],
),
),
));
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? 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 imageInfo, bool 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(ImageConfiguration()).removeListener(listener!);
}
}, onError: (object, stacktrace) {
if (!completer.isCompleted) {
completer.completeError(object);
image.image.resolve(ImageConfiguration()).removeListener(listener!);
}
});
image.image.resolve(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 buildContext, AsyncSnapshot<Size> 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: StyledText(
textSpan: TextSpan(
style: style?.generateTextStyle() ??
context.style.generateTextStyle(),
children: children ?? buildChildren.call(),
),
style: context.style,
renderContext: context,
),
),
));
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")
TextSpan(text: "\n"),
])
.toList(),
));
final Map<CustomRenderMatcher, CustomRender> defaultRenders = {
blockElementMatcher(): blockElementRender(),
listElementMatcher(): listElementRender(),
textContentElementMatcher(): textContentElementRender(),
dataUriMatcher(): base64ImageRender(),
assetUriMatcher(): assetImageRender(),
networkSourceMatcher(): networkImageRender(),
replacedElementMatcher(): replacedElementRender(),
interactableElementMatcher(): interactableElementRender(),
layoutElementMatcher(): layoutElementRender(),
verticalAlignMatcher(): verticalAlignRender(),
fallbackMatcher(): fallbackRender(),
};
List<InlineSpan> _getListElementChildren(
ListStylePosition? position, Function() buildChildren) {
List<InlineSpan> children = buildChildren.call();
if (position == ListStylePosition.INSIDE) {
final tabSpan = WidgetSpan(
child: Text("\t",
textAlign: TextAlign.right,
style: TextStyle(fontWeight: FontWeight.w400)),
);
children.insert(0, tabSpan);
}
return children;
}
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!.size! / 2.5;
case VerticalAlign.SUPER:
return tree.style.fontSize!.size! / -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 =>
this.clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity));
}

@ -0,0 +1,371 @@
library flutter_html;
import 'package:flutter/material.dart';
import './custom_render.dart';
import './html_parser.dart';
import './src/html_elements.dart';
import './style.dart';
import 'package:html/dom.dart' as dom;
export './custom_render.dart';
//export render context api
export './html_parser.dart';
//export render context api
export './html_parser.dart';
//export src for advanced custom render uses (e.g. casting context.tree)
export './src/anchor.dart';
export './src/interactable_element.dart';
export './src/layout_element.dart';
export './src/replaced_element.dart';
export './src/styled_element.dart';
//export style api
export './style.dart';
class Html extends StatefulWidget {
/// The `Html` widget takes HTML as input and displays a RichText
/// tree of the parsed HTML content.
///
/// **Attributes**
/// **data** *required* takes in a String of HTML data (required only for `Html` constructor).
/// **document** *required* takes in a Document of HTML data (required only for `Html.fromDom` constructor).
///
/// **onLinkTap** This function is called whenever a link (`<a href>`)
/// is tapped.
/// **customRender** This function allows you to return your own widgets
/// for existing or custom HTML tags.
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/All-About-customRender) for more info.
///
/// **onImageError** This is called whenever an image fails to load or
/// display on the page.
///
/// **shrinkWrap** This makes the Html widget take up only the width it
/// needs and no more.
///
/// **onImageTap** This is called whenever an image is tapped.
///
/// **tagsList** Tag names in this array will be the only tags rendered. By default all supported HTML tags are rendered.
///
/// **style** Pass in the style information for the Html here.
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/Style) for more info.
Html({
Key? key,
GlobalKey? anchorKey,
required this.data,
this.onLinkTap,
this.onAnchorTap,
this.customRenders = const {},
this.onCssParseError,
this.onImageError,
this.shrinkWrap = false,
this.onImageTap,
this.tagsList = const [],
this.style = const {},
}) : documentElement = null,
assert(data != null),
_anchorKey = anchorKey ?? GlobalKey(),
super(key: key);
Html.fromDom({
Key? key,
GlobalKey? anchorKey,
@required dom.Document? document,
this.onLinkTap,
this.onAnchorTap,
this.customRenders = const {},
this.onCssParseError,
this.onImageError,
this.shrinkWrap = false,
this.onImageTap,
this.tagsList = const [],
this.style = const {},
}) : data = null,
assert(document != null),
this.documentElement = document!.documentElement,
_anchorKey = anchorKey ?? GlobalKey(),
super(key: key);
Html.fromElement({
Key? key,
GlobalKey? anchorKey,
@required this.documentElement,
this.onLinkTap,
this.onAnchorTap,
this.customRenders = const {},
this.onCssParseError,
this.onImageError,
this.shrinkWrap = false,
this.onImageTap,
this.tagsList = const [],
this.style = const {},
}) : data = null,
assert(documentElement != null),
_anchorKey = anchorKey ?? GlobalKey(),
super(key: key);
/// A unique key for this Html widget to ensure uniqueness of anchors
final GlobalKey _anchorKey;
/// The HTML data passed to the widget as a String
final String? data;
/// The HTML data passed to the widget as a pre-processed [dom.Element]
final dom.Element? documentElement;
/// A function that defines what to do when a link is tapped
final OnTap? onLinkTap;
/// A function that defines what to do when an anchor link is tapped. When this value is set,
/// the default anchor behaviour is overwritten.
final OnTap? onAnchorTap;
/// A function that defines what to do when CSS fails to parse
final OnCssParseError? onCssParseError;
/// A function that defines what to do when an image errors
final ImageErrorListener? onImageError;
/// A parameter that should be set when the HTML widget is expected to be
/// flexible
final bool shrinkWrap;
/// A function that defines what to do when an image is tapped
final OnTap? onImageTap;
/// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered.
final List<String> tagsList;
/// Either return a custom widget for specific node types or return null to
/// fallback to the default rendering.
final Map<CustomRenderMatcher, CustomRender> customRenders;
/// An API that allows you to override the default style for any HTML element
final Map<String, Style> style;
static List<String> get tags => new List<String>.from(STYLED_ELEMENTS)
..addAll(INTERACTABLE_ELEMENTS)
..addAll(REPLACED_ELEMENTS)
..addAll(LAYOUT_ELEMENTS)
..addAll(TABLE_CELL_ELEMENTS)
..addAll(TABLE_DEFINITION_ELEMENTS)
..addAll(EXTERNAL_ELEMENTS);
@override
State<StatefulWidget> createState() => _HtmlState();
}
class _HtmlState extends State<Html> {
late dom.Element documentElement;
@override
void initState() {
super.initState();
documentElement = widget.data != null
? HtmlParser.parseHTML(widget.data!)
: widget.documentElement!;
}
@override
void didUpdateWidget(Html oldWidget) {
super.didUpdateWidget(oldWidget);
if ((widget.data != null && oldWidget.data != widget.data) ||
oldWidget.documentElement != widget.documentElement) {
documentElement = widget.data != null
? HtmlParser.parseHTML(widget.data!)
: widget.documentElement!;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width,
child: HtmlParser(
key: widget._anchorKey,
htmlData: documentElement,
onLinkTap: widget.onLinkTap,
onAnchorTap: widget.onAnchorTap,
onImageTap: widget.onImageTap,
onCssParseError: widget.onCssParseError,
onImageError: widget.onImageError,
shrinkWrap: widget.shrinkWrap,
selectable: false,
style: widget.style,
customRenders: {}
..addAll(widget.customRenders)
..addAll(defaultRenders),
tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList,
),
);
}
}
class SelectableHtml extends StatefulWidget {
/// The `SelectableHtml` widget takes HTML as input and displays a RichText
/// tree of the parsed HTML content (which is selectable)
///
/// **Attributes**
/// **data** *required* takes in a String of HTML data (required only for `Html` constructor).
/// **documentElement** *required* takes in a Element of HTML data (required only for `Html.fromDom` and `Html.fromElement` constructor).
///
/// **onLinkTap** This function is called whenever a link (`<a href>`)
/// is tapped.
///
/// **onAnchorTap** This function is called whenever an anchor (#anchor-id)
/// is tapped.
///
/// **tagsList** Tag names in this array will be the only tags rendered. By default, all tags that support selectable content are rendered.
///
/// **style** Pass in the style information for the Html here.
/// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/Style) for more info.
///
/// **PLEASE NOTE**
///
/// There are a few caveats due to Flutter [#38474](https://github.com/flutter/flutter/issues/38474):
///
/// 1. The list of tags that can be rendered is significantly reduced.
/// Key omissions include no support for images/video/audio, table, and ul/ol because they all require widgets and `WidgetSpan`s.
///
/// 2. No support for `customRender`, `customImageRender`, `onImageError`, `onImageTap`, `onMathError`, and `navigationDelegateForIframe`.
///
/// 3. Styling support is significantly reduced. Only text-related styling works
/// (e.g. bold or italic), while container related styling (e.g. borders or padding/margin)
/// do not work because we can't use the `ContainerSpan` class (it needs an enclosing `WidgetSpan`).
SelectableHtml({
Key? key,
GlobalKey? anchorKey,
required this.data,
this.onLinkTap,
this.onAnchorTap,
this.onCssParseError,
this.shrinkWrap = false,
this.style = const {},
this.customRenders = const {},
this.tagsList = const [],
this.selectionControls,
this.scrollPhysics,
}) : documentElement = null,
assert(data != null),
_anchorKey = anchorKey ?? GlobalKey(),
super(key: key);
SelectableHtml.fromDom({
Key? key,
GlobalKey? anchorKey,
@required dom.Document? document,
this.onLinkTap,
this.onAnchorTap,
this.onCssParseError,
this.shrinkWrap = false,
this.style = const {},
this.customRenders = const {},
this.tagsList = const [],
this.selectionControls,
this.scrollPhysics,
}) : data = null,
assert(document != null),
this.documentElement = document!.documentElement,
_anchorKey = anchorKey ?? GlobalKey(),
super(key: key);
SelectableHtml.fromElement({
Key? key,
GlobalKey? anchorKey,
@required this.documentElement,
this.onLinkTap,
this.onAnchorTap,
this.onCssParseError,
this.shrinkWrap = false,
this.style = const {},
this.customRenders = const {},
this.tagsList = const [],
this.selectionControls,
this.scrollPhysics,
}) : data = null,
assert(documentElement != null),
_anchorKey = anchorKey ?? GlobalKey(),
super(key: key);
/// A unique key for this Html widget to ensure uniqueness of anchors
final GlobalKey _anchorKey;
/// The HTML data passed to the widget as a String
final String? data;
/// The HTML data passed to the widget as a pre-processed [dom.Element]
final dom.Element? documentElement;
/// A function that defines what to do when a link is tapped
final OnTap? onLinkTap;
/// A function that defines what to do when an anchor link is tapped. When this value is set,
/// the default anchor behaviour is overwritten.
final OnTap? onAnchorTap;
/// A function that defines what to do when CSS fails to parse
final OnCssParseError? onCssParseError;
/// A parameter that should be set when the HTML widget is expected to be
/// flexible
final bool shrinkWrap;
/// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered.
final List<String> tagsList;
/// An API that allows you to override the default style for any HTML element
final Map<String, Style> style;
/// Custom Selection controls allows you to override default toolbar and build custom toolbar
/// options
final TextSelectionControls? selectionControls;
/// Allows you to override the default scrollPhysics for [SelectableText.rich]
final ScrollPhysics? scrollPhysics;
/// Either return a custom widget for specific node types or return null to
/// fallback to the default rendering.
final Map<CustomRenderMatcher, SelectableCustomRender> customRenders;
static List<String> get tags => new List<String>.from(SELECTABLE_ELEMENTS);
@override
State<StatefulWidget> createState() => _SelectableHtmlState();
}
class _SelectableHtmlState extends State<SelectableHtml> {
late final dom.Element documentElement;
@override
void initState() {
super.initState();
documentElement = widget.data != null
? HtmlParser.parseHTML(widget.data!)
: widget.documentElement!;
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width,
child: HtmlParser(
key: widget._anchorKey,
htmlData: documentElement,
onLinkTap: widget.onLinkTap,
onAnchorTap: widget.onAnchorTap,
onImageTap: null,
onCssParseError: widget.onCssParseError,
onImageError: null,
shrinkWrap: widget.shrinkWrap,
selectable: true,
style: widget.style,
customRenders: {}
..addAll(widget.customRenders)
..addAll(defaultRenders),
tagsList:
widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList,
selectionControls: widget.selectionControls,
scrollPhysics: widget.scrollPhysics,
),
);
}
}

@ -0,0 +1,961 @@
import 'dart:collection';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:csslib/parser.dart' as cssparser;
import 'package:csslib/visitor.dart' as css;
import 'package:flutter/material.dart';
import './flutter_html.dart';
import './src/css_parser.dart';
import './src/html_elements.dart';
import './src/utils.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as htmlparser;
import 'package:numerus/numerus.dart';
typedef OnTap = void Function(
String? url,
RenderContext context,
Map<String, String> attributes,
dom.Element? element,
);
typedef OnCssParseError = String? Function(
String css,
List<cssparser.Message> errors,
);
class HtmlParser extends StatelessWidget {
final Key? key;
final dom.Element htmlData;
final OnTap? onLinkTap;
final OnTap? onAnchorTap;
final OnTap? onImageTap;
final OnCssParseError? onCssParseError;
final ImageErrorListener? onImageError;
final bool shrinkWrap;
final bool selectable;
final Map<String, Style> style;
final Map<CustomRenderMatcher, CustomRender> customRenders;
final List<String> tagsList;
final OnTap? internalOnAnchorTap;
final Html? root;
final TextSelectionControls? selectionControls;
final ScrollPhysics? scrollPhysics;
final Map<String, Size> cachedImageSizes = {};
HtmlParser({
required this.key,
required this.htmlData,
required this.onLinkTap,
required this.onAnchorTap,
required this.onImageTap,
required this.onCssParseError,
required this.onImageError,
required this.shrinkWrap,
required this.selectable,
required this.style,
required this.customRenders,
required this.tagsList,
this.root,
this.selectionControls,
this.scrollPhysics,
}) : this.internalOnAnchorTap = onAnchorTap != null
? onAnchorTap
: key != null
? _handleAnchorTap(key, onLinkTap)
: onLinkTap,
super(key: key);
@override
Widget build(BuildContext context) {
Map<String, Map<String, List<css.Expression>>> declarations = _getExternalCssDeclarations(htmlData.getElementsByTagName("style"), onCssParseError);
StyledElement lexedTree = lexDomTree(
htmlData,
customRenders.keys.toList(),
tagsList,
context,
this,
);
StyledElement? externalCssStyledTree;
if (declarations.isNotEmpty) {
externalCssStyledTree = _applyExternalCss(declarations, lexedTree);
}
StyledElement inlineStyledTree = _applyInlineStyles(externalCssStyledTree ?? lexedTree, onCssParseError);
StyledElement customStyledTree = _applyCustomStyles(style, inlineStyledTree);
StyledElement cascadedStyledTree = _cascadeStyles(style, customStyledTree);
StyledElement cleanedTree = cleanTree(cascadedStyledTree);
InlineSpan parsedTree = parseTree(
RenderContext(
buildContext: context,
parser: this,
tree: cleanedTree,
style: cleanedTree.style,
),
cleanedTree,
);
// This is the final scaling that assumes any other StyledText instances are
// using textScaleFactor = 1.0 (which is the default). This ensures the correct
// scaling is used, but relies on https://github.com/flutter/flutter/pull/59711
// to wrap everything when larger accessibility fonts are used.
if (selectable) {
return StyledText.selectable(
textSpan: parsedTree as TextSpan,
style: cleanedTree.style,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
renderContext: RenderContext(
buildContext: context,
parser: this,
tree: cleanedTree,
style: cleanedTree.style,
),
selectionControls: selectionControls,
scrollPhysics: scrollPhysics,
);
}
return StyledText(
textSpan: parsedTree,
style: cleanedTree.style,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
renderContext: RenderContext(
buildContext: context,
parser: this,
tree: cleanedTree,
style: cleanedTree.style,
),
);
}
/// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library.
static dom.Element parseHTML(String data) {
return htmlparser.parse(data).documentElement!;
}
/// [parseCss] converts a string of CSS to a CSS stylesheet using the dart `csslib` library.
static css.StyleSheet parseCss(String data) {
return cssparser.parse(data);
}
/// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s.
static StyledElement lexDomTree(
dom.Element html,
List<CustomRenderMatcher> customRenderMatchers,
List<String> tagsList,
BuildContext context,
HtmlParser parser,
) {
StyledElement tree = StyledElement(
name: "[Tree Root]",
children: <StyledElement>[],
node: html,
style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!),
);
html.nodes.forEach((node) {
tree.children.add(_recursiveLexer(
node,
customRenderMatchers,
tagsList,
context,
parser,
));
});
return tree;
}
/// [_recursiveLexer] is the recursive worker function for [lexDomTree].
///
/// It runs the parse functions of every type of
/// element and returns a [StyledElement] tree representing the element.
static StyledElement _recursiveLexer(
dom.Node node,
List<CustomRenderMatcher> customRenderMatchers,
List<String> tagsList,
BuildContext context,
HtmlParser parser,
) {
List<StyledElement> children = <StyledElement>[];
node.nodes.forEach((childNode) {
children.add(_recursiveLexer(
childNode,
customRenderMatchers,
tagsList,
context,
parser,
));
});
//TODO(Sub6Resources): There's probably a more efficient way to look this up.
if (node is dom.Element) {
if (!tagsList.contains(node.localName)) {
return EmptyContentElement();
}
if (STYLED_ELEMENTS.contains(node.localName)) {
return parseStyledElement(node, children);
} else if (INTERACTABLE_ELEMENTS.contains(node.localName)) {
return parseInteractableElement(node, children);
} else if (REPLACED_ELEMENTS.contains(node.localName)) {
return parseReplacedElement(node, children);
} else if (LAYOUT_ELEMENTS.contains(node.localName)) {
return parseLayoutElement(node, children);
} else if (TABLE_CELL_ELEMENTS.contains(node.localName)) {
return parseTableCellElement(node, children);
} else if (TABLE_DEFINITION_ELEMENTS.contains(node.localName)) {
return parseTableDefinitionElement(node, children);
} else {
final StyledElement tree = parseStyledElement(node, children);
for (final entry in customRenderMatchers) {
if (entry.call(
RenderContext(
buildContext: context,
parser: parser,
tree: tree,
style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!),
),
)) {
return tree;
}
}
return EmptyContentElement();
}
} else if (node is dom.Text) {
return TextContentElement(text: node.text, style: Style(), element: node.parent, node: node);
} else {
return EmptyContentElement();
}
}
static Map<String, Map<String, List<css.Expression>>> _getExternalCssDeclarations(List<dom.Element> styles, OnCssParseError? errorHandler) {
String fullCss = "";
for (final e in styles) {
fullCss = fullCss + e.innerHtml;
}
if (fullCss.isNotEmpty) {
final declarations = parseExternalCss(fullCss, errorHandler);
return declarations;
} else {
return {};
}
}
static StyledElement _applyExternalCss(Map<String, Map<String, List<css.Expression>>> declarations, StyledElement tree) {
declarations.forEach((key, style) {
try {
if (tree.matchesSelector(key)) {
tree.style = tree.style.merge(declarationsToStyle(style));
}
} catch (_) {}
});
tree.children.forEach((e) => _applyExternalCss(declarations, e));
return tree;
}
static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) {
if (tree.attributes.containsKey("style")) {
final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler);
if (newStyle != null) {
tree.style = tree.style.merge(newStyle);
}
}
tree.children.forEach((e) => _applyInlineStyles(e, errorHandler));
return tree;
}
/// [applyCustomStyles] applies the [Style] objects passed into the [Html]
/// widget onto the [StyledElement] tree, no cascading of styles is done at this point.
static StyledElement _applyCustomStyles(Map<String, Style> style, StyledElement tree) {
style.forEach((key, style) {
try {
if (tree.matchesSelector(key)) {
tree.style = tree.style.merge(style);
}
} catch (_) {}
});
tree.children.forEach((e) => _applyCustomStyles(style, e));
return tree;
}
/// [_cascadeStyles] cascades all of the inherited styles down the tree, applying them to each
/// child that doesn't specify a different style.
static StyledElement _cascadeStyles(Map<String, Style> style, StyledElement tree) {
tree.children.forEach((child) {
child.style = tree.style.copyOnlyInherited(child.style);
_cascadeStyles(style, child);
});
return tree;
}
/// [cleanTree] optimizes the [StyledElement] tree so all [BlockElement]s are
/// on the first level, redundant levels are collapsed, empty elements are
/// removed, and specialty elements are processed.
static StyledElement cleanTree(StyledElement tree) {
tree = _processInternalWhitespace(tree);
tree = _processInlineWhitespace(tree);
tree = _removeEmptyElements(tree);
tree = _processListCharacters(tree);
tree = _processBeforesAndAfters(tree);
tree = _collapseMargins(tree);
tree = _processFontSize(tree);
return tree;
}
/// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree.
///
/// [parseTree] is responsible for handling the [customRenders] parameter and
/// deciding what different `Style.display` options look like as Widgets.
InlineSpan parseTree(RenderContext context, StyledElement tree) {
// Merge this element's style into the context so that children
// inherit the correct style
RenderContext newContext = RenderContext(
buildContext: context.buildContext,
parser: this,
tree: tree,
style: context.style.copyOnlyInherited(tree.style),
key: AnchorKey.of(key, tree),
);
for (final entry in customRenders.keys) {
if (entry.call(newContext)) {
final buildChildren = () => tree.children.map((tree) => parseTree(newContext, tree)).toList();
if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) {
final selectableBuildChildren = () => tree.children.map((tree) => parseTree(newContext, tree) as TextSpan).toList();
return (customRenders[entry] as SelectableCustomRender).textSpan.call(newContext, selectableBuildChildren);
}
if (newContext.parser.selectable) {
return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren) as TextSpan;
}
if (customRenders[entry]?.inlineSpan != null) {
return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren);
}
return WidgetSpan(
child: ContainerSpan(
newContext: newContext,
style: tree.style,
shrinkWrap: newContext.parser.shrinkWrap,
child: customRenders[entry]!.widget!.call(newContext, buildChildren),
),
);
}
}
return WidgetSpan(child: Container(height: 0, width: 0));
}
static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) =>
(String? url, RenderContext context, Map<String, String> attributes, dom.Element? element) {
if (url?.startsWith("#") == true) {
final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext;
if (anchorContext != null) {
Scrollable.ensureVisible(anchorContext);
}
return;
}
onLinkTap?.call(url, context, attributes, element);
};
/// [processWhitespace] removes unnecessary whitespace from the StyledElement tree.
///
/// The criteria for determining which whitespace is replaceable is outlined
/// at https://www.w3.org/TR/css-text-3/
/// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33
static StyledElement _processInternalWhitespace(StyledElement tree) {
if ((tree.style.whiteSpace ?? WhiteSpace.NORMAL) == WhiteSpace.PRE) {
// Preserve this whitespace
} else if (tree is TextContentElement) {
tree.text = _removeUnnecessaryWhitespace(tree.text!);
} else {
tree.children.forEach(_processInternalWhitespace);
}
return tree;
}
/// [_processInlineWhitespace] is responsible for removing redundant whitespace
/// between and among inline elements. It does so by creating a boolean [Context]
/// and passing it to the [_processInlineWhitespaceRecursive] function.
static StyledElement _processInlineWhitespace(StyledElement tree) {
tree = _processInlineWhitespaceRecursive(tree, Context(false));
return tree;
}
/// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different
/// inline elements, and replaces any instance of two or more spaces with a single space, according
/// to the w3's HTML whitespace processing specification linked to above.
static StyledElement _processInlineWhitespaceRecursive(
StyledElement tree,
Context<bool> keepLeadingSpace,
) {
if (tree is TextContentElement) {
/// initialize indices to negative numbers to make conditionals a little easier
int textIndex = -1;
int elementIndex = -1;
/// initialize parent after to a whitespace to account for elements that are
/// the last child in the list of elements
String parentAfterText = " ";
/// find the index of the text in the current tree
if ((tree.element?.nodes.length ?? 0) >= 1) {
textIndex = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1;
}
/// get the parent nodes
dom.NodeList? parentNodes = tree.element?.parent?.nodes;
/// find the index of the tree itself in the parent nodes
if ((parentNodes?.length ?? 0) >= 1) {
elementIndex = parentNodes?.indexWhere((element) => element == tree.element) ?? -1;
}
/// if the tree is any node except the last node in the node list and the
/// next node in the node list is a text node, then get its text. Otherwise
/// the next node will be a [dom.Element], so keep unwrapping that until
/// we get the underlying text node, and finally get its text.
if (elementIndex < (parentNodes?.length ?? 1) - 1 && parentNodes?[elementIndex + 1] is dom.Text) {
parentAfterText = parentNodes?[elementIndex + 1].text ?? " ";
} else if (elementIndex < (parentNodes?.length ?? 1) - 1) {
var parentAfter = parentNodes?[elementIndex + 1];
while (parentAfter is dom.Element) {
if (parentAfter.nodes.isNotEmpty) {
parentAfter = parentAfter.nodes.first;
} else {
break;
}
}
parentAfterText = parentAfter?.text ?? " ";
}
/// If the text is the first element in the current tree node list, it
/// starts with a whitespace, it isn't a line break, either the
/// whitespace is unnecessary or it is a block element, and either it is
/// first element in the parent node list or the previous element
/// in the parent node list ends with a whitespace, delete it.
///
/// We should also delete the whitespace at any point in the node list
/// if the previous element is a <br> because that tag makes the element
/// act like a block element.
if (textIndex < 1
&& tree.text!.startsWith(' ')
&& tree.element?.localName != "br"
&& (!keepLeadingSpace.data
|| tree.style.display == Display.BLOCK)
&& (elementIndex < 1
|| (elementIndex >= 1
&& parentNodes?[elementIndex - 1] is dom.Text
&& parentNodes![elementIndex - 1].text!.endsWith(" ")))
) {
tree.text = tree.text!.replaceFirst(' ', '');
} else if (textIndex >= 1
&& tree.text!.startsWith(' ')
&& tree.element?.nodes[textIndex - 1] is dom.Element
&& (tree.element?.nodes[textIndex - 1] as dom.Element).localName == "br"
) {
tree.text = tree.text!.replaceFirst(' ', '');
}
/// If the text is the last element in the current tree node list, it isn't
/// a line break, and the next text node starts with a whitespace,
/// update the [Context] to signify to that next text node whether it should
/// keep its whitespace. This is based on whether the current text ends with a
/// whitespace.
if (textIndex == (tree.element?.nodes.length ?? 1) - 1
&& tree.element?.localName != "br"
&& parentAfterText.startsWith(' ')
) {
keepLeadingSpace.data = !tree.text!.endsWith(' ');
}
}
tree.children.forEach((e) => _processInlineWhitespaceRecursive(e, keepLeadingSpace));
return tree;
}
/// [removeUnnecessaryWhitespace] removes "unnecessary" white space from the given String.
///
/// The steps for removing this whitespace are as follows:
/// (1) Remove any whitespace immediately preceding or following a newline.
/// (2) Replace all newlines with a space
/// (3) Replace all tabs with a space
/// (4) Replace any instances of two or more spaces with a single space.
static String _removeUnnecessaryWhitespace(String text) {
return text
.replaceAll(RegExp("\ *(?=\n)"), "\n")
.replaceAll(RegExp("(?:\n)\ *"), "\n")
.replaceAll("\n", " ")
.replaceAll("\t", " ")
.replaceAll(RegExp(" {2,}"), " ");
}
/// [processListCharacters] adds list characters to the front of all list items.
///
/// The function uses the [_processListCharactersRecursive] function to do most of its work.
static StyledElement _processListCharacters(StyledElement tree) {
final olStack = ListQueue<Context>();
tree = _processListCharactersRecursive(tree, olStack);
return tree;
}
/// [_processListCharactersRecursive] uses a Stack of integers to properly number and
/// bullet all list items according to the [ListStyleType] they have been given.
static StyledElement _processListCharactersRecursive(
StyledElement tree, ListQueue<Context> olStack) {
if (tree.style.listStylePosition == null) {
tree.style.listStylePosition = ListStylePosition.OUTSIDE;
}
if (tree.name == 'ol' && tree.style.listStyleType != null && tree.style.listStyleType!.type == "marker") {
switch (tree.style.listStyleType!) {
case ListStyleType.LOWER_LATIN:
case ListStyleType.LOWER_ALPHA:
case ListStyleType.UPPER_LATIN:
case ListStyleType.UPPER_ALPHA:
olStack.add(Context<String>('a'));
if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) {
var start = int.tryParse(tree.attributes['start']!) ?? 1;
var x = 1;
while (x < start) {
olStack.last.data = olStack.last.data.toString().nextLetter();
x++;
}
}
break;
default:
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
break;
}
} else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "widget") {
tree.style.markerContent = tree.style.listStyleType!.widget!;
} else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "image") {
tree.style.markerContent = Image.network(tree.style.listStyleType!.text);
} else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null) {
String marker = "";
switch (tree.style.listStyleType!) {
case ListStyleType.NONE:
break;
case ListStyleType.CIRCLE:
marker = '';
break;
case ListStyleType.SQUARE:
marker = '';
break;
case ListStyleType.DISC:
marker = '';
break;
case ListStyleType.DECIMAL:
if (olStack.isEmpty) {
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
}
olStack.last.data += 1;
marker = '${olStack.last.data}.';
break;
case ListStyleType.LOWER_LATIN:
case ListStyleType.LOWER_ALPHA:
if (olStack.isEmpty) {
olStack.add(Context<String>('a'));
if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) {
var start = int.tryParse(tree.attributes['start']!) ?? 1;
var x = 1;
while (x < start) {
olStack.last.data = olStack.last.data.toString().nextLetter();
x++;
}
}
}
marker = olStack.last.data.toString() + ".";
olStack.last.data = olStack.last.data.toString().nextLetter();
break;
case ListStyleType.UPPER_LATIN:
case ListStyleType.UPPER_ALPHA:
if (olStack.isEmpty) {
olStack.add(Context<String>('a'));
if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) {
var start = int.tryParse(tree.attributes['start']!) ?? 1;
var x = 1;
while (x < start) {
olStack.last.data = olStack.last.data.toString().nextLetter();
x++;
}
}
}
marker = olStack.last.data.toString().toUpperCase() + ".";
olStack.last.data = olStack.last.data.toString().nextLetter();
break;
case ListStyleType.LOWER_ROMAN:
if (olStack.isEmpty) {
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
}
olStack.last.data += 1;
if (olStack.last.data <= 0) {
marker = '${olStack.last.data}.';
} else {
marker = (olStack.last.data as int).toRomanNumeralString()!.toLowerCase() + ".";
}
break;
case ListStyleType.UPPER_ROMAN:
if (olStack.isEmpty) {
olStack.add(Context<int>((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1));
}
olStack.last.data += 1;
if (olStack.last.data <= 0) {
marker = '${olStack.last.data}.';
} else {
marker = (olStack.last.data as int).toRomanNumeralString()! + ".";
}
break;
}
tree.style.markerContent = Text(
marker,
textAlign: TextAlign.right,
style: tree.style.generateTextStyle(),
);
}
tree.children.forEach((e) => _processListCharactersRecursive(e, olStack));
if (tree.name == 'ol') {
olStack.removeLast();
}
return tree;
}
/// [_processBeforesAndAfters] adds text content to the beginning and end of
/// the list of the trees children according to the `before` and `after` Style
/// properties.
static StyledElement _processBeforesAndAfters(StyledElement tree) {
if (tree.style.before != null) {
tree.children.insert(
0, TextContentElement(text: tree.style.before, style: tree.style.copyWith(beforeAfterNull: true, display: Display.INLINE)));
}
if (tree.style.after != null) {
tree.children
.add(TextContentElement(text: tree.style.after, style: tree.style.copyWith(beforeAfterNull: true, display: Display.INLINE)));
}
tree.children.forEach(_processBeforesAndAfters);
return tree;
}
/// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS21/box.html#collapsing-margins
/// for collapsing margins of block-level boxes. This prevents the doubling of margins between
/// boxes, and makes for a more correct rendering of the html content.
///
/// Paraphrased from the CSS specification:
/// Margins are collapsed if both belong to vertically-adjacent box edges, i.e form one of the following pairs:
/// (1) Top margin of a box and top margin of its first in-flow child
/// (2) Bottom margin of a box and top margin of its next in-flow following sibling
/// (3) Bottom margin of a last in-flow child and bottom margin of its parent (if the parent's height is not explicit)
/// (4) Top and Bottom margins of a box with a height of zero or no in-flow children.
static StyledElement _collapseMargins(StyledElement tree) {
//Short circuit if we've reached a leaf of the tree
if (tree.children.isEmpty) {
// Handle case (4) from above.
if ((tree.style.height ?? 0) == 0) {
tree.style.margin = EdgeInsets.zero;
}
return tree;
}
//Collapsing should be depth-first.
tree.children.forEach(_collapseMargins);
//The root boxes do not collapse.
if (tree.name == '[Tree Root]' || tree.name == 'html') {
return tree;
}
// Handle case (1) from above.
// Top margins cannot collapse if the element has padding
if ((tree.style.padding?.top ?? 0) == 0) {
final parentTop = tree.style.margin?.top ?? 0;
final firstChildTop = tree.children.first.style.margin?.top ?? 0;
final newOuterMarginTop = max(parentTop, firstChildTop);
// Set the parent's margin
if (tree.style.margin == null) {
tree.style.margin = EdgeInsets.only(top: newOuterMarginTop);
} else {
tree.style.margin = tree.style.margin!.copyWith(top: newOuterMarginTop);
}
// And remove the child's margin
if (tree.children.first.style.margin == null) {
tree.children.first.style.margin = EdgeInsets.zero;
} else {
tree.children.first.style.margin =
tree.children.first.style.margin!.copyWith(top: 0);
}
}
// Handle case (3) from above.
// Bottom margins cannot collapse if the element has padding
if ((tree.style.padding?.bottom ?? 0) == 0) {
final parentBottom = tree.style.margin?.bottom ?? 0;
final lastChildBottom = tree.children.last.style.margin?.bottom ?? 0;
final newOuterMarginBottom = max(parentBottom, lastChildBottom);
// Set the parent's margin
if (tree.style.margin == null) {
tree.style.margin = EdgeInsets.only(bottom: newOuterMarginBottom);
} else {
tree.style.margin =
tree.style.margin!.copyWith(bottom: newOuterMarginBottom);
}
// And remove the child's margin
if (tree.children.last.style.margin == null) {
tree.children.last.style.margin = EdgeInsets.zero;
} else {
tree.children.last.style.margin =
tree.children.last.style.margin!.copyWith(bottom: 0);
}
}
// Handle case (2) from above.
if (tree.children.length > 1) {
for (int i = 1; i < tree.children.length; i++) {
final previousSiblingBottom =
tree.children[i - 1].style.margin?.bottom ?? 0;
final thisTop = tree.children[i].style.margin?.top ?? 0;
final newInternalMargin = max(previousSiblingBottom, thisTop) / 2;
if (tree.children[i - 1].style.margin == null) {
tree.children[i - 1].style.margin =
EdgeInsets.only(bottom: newInternalMargin);
} else {
tree.children[i - 1].style.margin = tree.children[i - 1].style.margin!
.copyWith(bottom: newInternalMargin);
}
if (tree.children[i].style.margin == null) {
tree.children[i].style.margin =
EdgeInsets.only(top: newInternalMargin);
} else {
tree.children[i].style.margin =
tree.children[i].style.margin!.copyWith(top: newInternalMargin);
}
}
}
return tree;
}
/// [removeEmptyElements] recursively removes empty elements.
///
/// An empty element is any [EmptyContentElement], any empty [TextContentElement],
/// or any block-level [TextContentElement] that contains only whitespace and doesn't follow
/// a block element or a line break.
static StyledElement _removeEmptyElements(StyledElement tree) {
List<StyledElement> toRemove = <StyledElement>[];
bool lastChildBlock = true;
tree.children.forEachIndexed((index, child) {
if (child is EmptyContentElement || child is EmptyLayoutElement) {
toRemove.add(child);
} else if (child is TextContentElement
&& ((tree.name == "body"
&& (index == 0
|| index + 1 == tree.children.length
|| tree.children[index - 1].style.display == Display.BLOCK
|| tree.children[index + 1].style.display == Display.BLOCK))
|| tree.name == "ul")
&& child.text!.replaceAll(' ', '').isEmpty) {
toRemove.add(child);
} else if (child is TextContentElement
&& child.text!.isEmpty
&& child.style.whiteSpace != WhiteSpace.PRE) {
toRemove.add(child);
} else if (child is TextContentElement &&
child.style.whiteSpace != WhiteSpace.PRE &&
tree.style.display == Display.BLOCK &&
child.text!.isEmpty &&
lastChildBlock) {
toRemove.add(child);
} else if (child.style.display == Display.NONE) {
toRemove.add(child);
} else {
_removeEmptyElements(child);
}
// This is used above to check if the previous element is a block element or a line break.
lastChildBlock = (child.style.display == Display.BLOCK ||
child.style.display == Display.LIST_ITEM ||
(child is TextContentElement && child.text == '\n'));
});
tree.children.removeWhere((element) => toRemove.contains(element));
return tree;
}
/// [_processFontSize] changes percent-based font sizes (negative numbers in this implementation)
/// to pixel-based font sizes.
static StyledElement _processFontSize(StyledElement tree) {
double? parentFontSize = tree.style.fontSize?.size ?? FontSize.medium.size;
tree.children.forEach((child) {
if ((child.style.fontSize?.size ?? parentFontSize)! < 0) {
child.style.fontSize =
FontSize(parentFontSize! * -child.style.fontSize!.size!);
}
_processFontSize(child);
});
return tree;
}
}
/// The [RenderContext] is available when parsing the tree. It contains information
/// about the [BuildContext] of the `Html` widget, contains the configuration available
/// in the [HtmlParser], and contains information about the [Style] of the current
/// tree root.
class RenderContext {
final BuildContext buildContext;
final HtmlParser parser;
final StyledElement tree;
final Style style;
final AnchorKey? key;
RenderContext({
required this.buildContext,
required this.parser,
required this.tree,
required this.style,
this.key,
});
}
/// A [ContainerSpan] is a widget with an [InlineSpan] child or children.
///
/// A [ContainerSpan] can have a border, background color, height, width, padding, and margin
/// and can represent either an INLINE or BLOCK-level element.
class ContainerSpan extends StatelessWidget {
final AnchorKey? key;
final Widget? child;
final List<InlineSpan>? children;
final Style style;
final RenderContext newContext;
final bool shrinkWrap;
ContainerSpan({
this.key,
this.child,
this.children,
required this.style,
required this.newContext,
this.shrinkWrap = false,
}): super(key: key);
@override
Widget build(BuildContext _) {
return Container(
decoration: BoxDecoration(
border: style.border,
color: style.backgroundColor,
),
height: style.height,
width: style.width,
padding: style.padding?.nonNegative,
margin: style.margin?.nonNegative,
alignment: shrinkWrap ? null : style.alignment,
child: child ??
StyledText(
textSpan: TextSpan(
style: newContext.style.generateTextStyle(),
children: children,
),
style: newContext.style,
renderContext: newContext,
),
);
}
}
class StyledText extends StatelessWidget {
final InlineSpan textSpan;
final Style style;
final double textScaleFactor;
final RenderContext renderContext;
final AnchorKey? key;
final bool _selectable;
final TextSelectionControls? selectionControls;
final ScrollPhysics? scrollPhysics;
const StyledText({
required this.textSpan,
required this.style,
this.textScaleFactor = 1.0,
required this.renderContext,
this.key,
this.selectionControls,
this.scrollPhysics,
}) : _selectable = false,
super(key: key);
const StyledText.selectable({
required TextSpan textSpan,
required this.style,
this.textScaleFactor = 1.0,
required this.renderContext,
this.key,
this.selectionControls,
this.scrollPhysics,
}) : textSpan = textSpan,
_selectable = true,
super(key: key);
@override
Widget build(BuildContext context) {
if (_selectable) {
return SelectableText.rich(
textSpan as TextSpan,
style: style.generateTextStyle(),
textAlign: style.textAlign,
textDirection: style.direction,
textScaleFactor: textScaleFactor,
maxLines: style.maxLines,
selectionControls: selectionControls,
scrollPhysics: scrollPhysics,
);
}
return SizedBox(
width: consumeExpandedBlock(style.display, renderContext),
child: Text.rich(
textSpan,
style: style.generateTextStyle(),
textAlign: style.textAlign,
textDirection: style.direction,
textScaleFactor: textScaleFactor,
maxLines: style.maxLines,
overflow: style.textOverflow,
),
);
}
double? consumeExpandedBlock(Display? display, RenderContext context) {
if ((display == Display.BLOCK || display == Display.LIST_ITEM) && !renderContext.parser.shrinkWrap) {
return double.infinity;
}
return null;
}
}
extension IterateLetters on String {
String nextLetter() {
String s = this.toLowerCase();
if (s == "z") {
return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa
} else {
var lastChar = s.substring(s.length - 1);
var sub = s.substring(0, s.length - 1);
if (lastChar == "z") {
// If a string of length > 1 ends in Z/z,
// increment the string (excluding the last Z/z) recursively,
// and append A/a (depending on casing) to it
return sub.nextLetter() + 'a';
} else {
// (take till last char) append with (increment last char)
return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1);
}
}
}
}

@ -0,0 +1,42 @@
import 'package:flutter/widgets.dart';
import './styled_element.dart';
class AnchorKey extends GlobalKey {
static final Set<AnchorKey> _registry = <AnchorKey>{};
final Key parentKey;
final String id;
const AnchorKey._(this.parentKey, this.id) : super.constructor();
static AnchorKey? of(Key? parentKey, StyledElement? id) {
final key = forId(parentKey, id?.elementId);
if (key == null || _registry.contains(key)) {
// Invalid id or already created a key with this id: silently ignore
return null;
}
_registry.add(key);
return key;
}
static AnchorKey? forId(Key? parentKey, String? id) {
if (parentKey == null || id == null || id.isEmpty || id == "[[No ID]]") {
return null;
}
return AnchorKey._(parentKey, id);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AnchorKey && runtimeType == other.runtimeType && parentKey == other.parentKey && id == other.id;
@override
int get hashCode => parentKey.hashCode ^ id.hashCode;
@override
String toString() {
return 'AnchorKey{parentKey: $parentKey, id: #$id}';
}
}

@ -0,0 +1,981 @@
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:csslib/visitor.dart' as css;
import 'package:csslib/parser.dart' as cssparser;
import 'package:flutter/material.dart';
import '../flutter_html.dart';
import './utils.dart';
Style declarationsToStyle(Map<String, List<css.Expression>> declarations) {
Style style = new Style();
declarations.forEach((property, value) {
if (value.isNotEmpty) {
switch (property) {
case 'background-color':
style.backgroundColor = ExpressionMapping.expressionToColor(value.first) ?? style.backgroundColor;
break;
case 'border':
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
&& element.text != "medium" && element.text != "thick"
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
&& !(element is css.EmTerm) && !(element is css.RemTerm)
&& !(element is css.NumberTerm))
);
List<css.Expression?>? borderColors = value.where((element) => ExpressionMapping.expressionToColor(element) != null).toList();
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
List<css.LiteralTerm?>? borderStyles = potentialStyles;
style.border = ExpressionMapping.expressionToBorder(borderWidths, borderStyles, borderColors);
break;
case 'border-left':
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
&& element.text != "medium" && element.text != "thick"
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
&& !(element is css.EmTerm) && !(element is css.RemTerm)
&& !(element is css.NumberTerm))
);
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
Border newBorder = Border(
left: style.border?.left.copyWith(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor),
) ?? BorderSide(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
),
right: style.border?.right ?? BorderSide.none,
top: style.border?.top ?? BorderSide.none,
bottom: style.border?.bottom ?? BorderSide.none,
);
style.border = newBorder;
break;
case 'border-right':
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
&& element.text != "medium" && element.text != "thick"
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
&& !(element is css.EmTerm) && !(element is css.RemTerm)
&& !(element is css.NumberTerm))
);
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
Border newBorder = Border(
left: style.border?.left ?? BorderSide.none,
right: style.border?.right.copyWith(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor),
) ?? BorderSide(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
),
top: style.border?.top ?? BorderSide.none,
bottom: style.border?.bottom ?? BorderSide.none,
);
style.border = newBorder;
break;
case 'border-top':
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
&& element.text != "medium" && element.text != "thick"
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
&& !(element is css.EmTerm) && !(element is css.RemTerm)
&& !(element is css.NumberTerm))
);
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
Border newBorder = Border(
left: style.border?.left ?? BorderSide.none,
right: style.border?.right ?? BorderSide.none,
top: style.border?.top.copyWith(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor),
) ?? BorderSide(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
),
bottom: style.border?.bottom ?? BorderSide.none,
);
style.border = newBorder;
break;
case 'border-bottom':
List<css.LiteralTerm?>? borderWidths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping]
borderWidths.removeWhere((element) => element == null || (element.text != "thin"
&& element.text != "medium" && element.text != "thick"
&& !(element is css.LengthTerm) && !(element is css.PercentageTerm)
&& !(element is css.EmTerm) && !(element is css.RemTerm)
&& !(element is css.NumberTerm))
);
css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null);
css.Expression? borderColor = value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null);
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
/// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future.
List<String> possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"];
/// List<css.LiteralTerm> might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping]
potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text));
css.LiteralTerm? borderStyle = potentialStyles.firstOrNull;
Border newBorder = Border(
left: style.border?.left ?? BorderSide.none,
right: style.border?.right ?? BorderSide.none,
top: style.border?.top ?? BorderSide.none,
bottom: style.border?.bottom.copyWith(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor),
) ?? BorderSide(
width: ExpressionMapping.expressionToBorderWidth(borderWidth),
style: ExpressionMapping.expressionToBorderStyle(borderStyle),
color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black,
),
);
style.border = newBorder;
break;
case 'color':
style.color = ExpressionMapping.expressionToColor(value.first) ?? style.color;
break;
case 'direction':
style.direction = ExpressionMapping.expressionToDirection(value.first);
break;
case 'display':
style.display = ExpressionMapping.expressionToDisplay(value.first);
break;
case 'line-height':
style.lineHeight = ExpressionMapping.expressionToLineHeight(value.first);
break;
case 'font-family':
style.fontFamily = ExpressionMapping.expressionToFontFamily(value.first) ?? style.fontFamily;
break;
case 'font-feature-settings':
style.fontFeatureSettings = ExpressionMapping.expressionToFontFeatureSettings(value);
break;
case 'font-size':
style.fontSize = ExpressionMapping.expressionToFontSize(value.first) ?? style.fontSize;
break;
case 'font-style':
style.fontStyle = ExpressionMapping.expressionToFontStyle(value.first);
break;
case 'font-weight':
style.fontWeight = ExpressionMapping.expressionToFontWeight(value.first);
break;
case 'list-style':
css.LiteralTerm? position = value.firstWhereOrNull((e) => e is css.LiteralTerm && (e.text == "outside" || e.text == "inside")) as css.LiteralTerm?;
css.UriTerm? image = value.firstWhereOrNull((e) => e is css.UriTerm) as css.UriTerm?;
css.LiteralTerm? type = value.firstWhereOrNull((e) => e is css.LiteralTerm && e.text != "outside" && e.text != "inside") as css.LiteralTerm?;
if (position != null) {
switch (position.text) {
case 'outside':
style.listStylePosition = ListStylePosition.OUTSIDE;
break;
case 'inside':
style.listStylePosition = ListStylePosition.INSIDE;
break;
}
}
if (image != null) {
style.listStyleType = ExpressionMapping.expressionToListStyleType(image) ?? style.listStyleType;
} else if (type != null) {
style.listStyleType = ExpressionMapping.expressionToListStyleType(type) ?? style.listStyleType;
}
break;
case 'list-style-image':
if (value.first is css.UriTerm) {
style.listStyleType = ExpressionMapping.expressionToListStyleType(value.first as css.UriTerm) ?? style.listStyleType;
}
break;
case 'list-style-position':
if (value.first is css.LiteralTerm) {
switch ((value.first as css.LiteralTerm).text) {
case 'outside':
style.listStylePosition = ListStylePosition.OUTSIDE;
break;
case 'inside':
style.listStylePosition = ListStylePosition.INSIDE;
break;
}
}
break;
case 'height':
style.height = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.height;
break;
case 'list-style-type':
if (value.first is css.LiteralTerm) {
style.listStyleType = ExpressionMapping.expressionToListStyleType(value.first as css.LiteralTerm) ?? style.listStyleType;
}
break;
case 'margin':
List<css.LiteralTerm>? marginLengths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for margin length, so make sure to remove those before passing it to [ExpressionMapping]
marginLengths.removeWhere((element) => !(element is css.LengthTerm)
&& !(element is css.EmTerm)
&& !(element is css.RemTerm)
&& !(element is css.NumberTerm)
);
List<double?> margin = ExpressionMapping.expressionToPadding(marginLengths);
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
left: margin[0],
right: margin[1],
top: margin[2],
bottom: margin[3],
);
break;
case 'margin-left':
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
left: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'margin-right':
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
right: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'margin-top':
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
top: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'margin-bottom':
style.margin = (style.margin ?? EdgeInsets.zero).copyWith(
bottom: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'padding':
List<css.LiteralTerm>? paddingLengths = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for padding length, so make sure to remove those before passing it to [ExpressionMapping]
paddingLengths.removeWhere((element) => !(element is css.LengthTerm)
&& !(element is css.EmTerm)
&& !(element is css.RemTerm)
&& !(element is css.NumberTerm)
);
List<double?> padding = ExpressionMapping.expressionToPadding(paddingLengths);
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
left: padding[0],
right: padding[1],
top: padding[2],
bottom: padding[3],
);
break;
case 'padding-left':
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
left: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'padding-right':
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
right: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'padding-top':
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
top: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'padding-bottom':
style.padding = (style.padding ?? EdgeInsets.zero).copyWith(
bottom: ExpressionMapping.expressionToPaddingLength(value.first));
break;
case 'text-align':
style.textAlign = ExpressionMapping.expressionToTextAlign(value.first);
break;
case 'text-decoration':
List<css.LiteralTerm?>? textDecorationList = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [textDecorationList], so make sure to remove those before passing it to [ExpressionMapping]
textDecorationList.removeWhere((element) => element == null || (element.text != "none"
&& element.text != "overline" && element.text != "underline" && element.text != "line-through"));
List<css.Expression?>? nullableList = value;
css.Expression? textDecorationColor;
textDecorationColor = nullableList.firstWhereOrNull(
(element) => element is css.HexColorTerm || element is css.FunctionTerm);
List<css.LiteralTerm?>? potentialStyles = value.whereType<css.LiteralTerm>().toList();
/// List<css.LiteralTerm> might include other values than the ones we want for [textDecorationStyle], so make sure to remove those before passing it to [ExpressionMapping]
potentialStyles.removeWhere((element) => element == null || (element.text != "solid"
&& element.text != "double" && element.text != "dashed" && element.text != "dotted" && element.text != "wavy"));
css.LiteralTerm? textDecorationStyle = potentialStyles.isNotEmpty ? potentialStyles.last : null;
style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList);
if (textDecorationColor != null) style.textDecorationColor = ExpressionMapping.expressionToColor(textDecorationColor)
?? style.textDecorationColor;
if (textDecorationStyle != null) style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(textDecorationStyle);
break;
case 'text-decoration-color':
style.textDecorationColor = ExpressionMapping.expressionToColor(value.first) ?? style.textDecorationColor;
break;
case 'text-decoration-line':
List<css.LiteralTerm?>? textDecorationList = value.whereType<css.LiteralTerm>().toList();
style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList);
break;
case 'text-decoration-style':
style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(value.first as css.LiteralTerm);
break;
case 'text-shadow':
style.textShadow = ExpressionMapping.expressionToTextShadow(value);
break;
case 'text-transform':
final val = (value.first as css.LiteralTerm).text;
if (val == 'uppercase') {
style.textTransform = TextTransform.uppercase;
} else if (val == 'lowercase') {
style.textTransform = TextTransform.lowercase;
} else if (val == 'capitalize') {
style.textTransform = TextTransform.capitalize;
} else {
style.textTransform = TextTransform.none;
}
break;
case 'width':
style.width = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.width;
break;
}
}
});
return style;
}
Style? inlineCssToStyle(String? inlineStyle, OnCssParseError? errorHandler) {
var errors = <cssparser.Message>[];
final sheet = cssparser.parse("*{$inlineStyle}", errors: errors);
if (errors.isEmpty) {
final declarations = DeclarationVisitor().getDeclarations(sheet);
return declarationsToStyle(declarations["*"]!);
} else if (errorHandler != null) {
String? newCss = errorHandler.call(inlineStyle ?? "", errors);
if (newCss != null) {
return inlineCssToStyle(newCss, errorHandler);
}
}
return null;
}
Map<String, Map<String, List<css.Expression>>> parseExternalCss(String css, OnCssParseError? errorHandler) {
var errors = <cssparser.Message>[];
final sheet = cssparser.parse(css, errors: errors);
if (errors.isEmpty) {
return DeclarationVisitor().getDeclarations(sheet);
} else if (errorHandler != null) {
String? newCss = errorHandler.call(css, errors);
if (newCss != null) {
return parseExternalCss(newCss, errorHandler);
}
}
return {};
}
class DeclarationVisitor extends css.Visitor {
Map<String, Map<String, List<css.Expression>>> _result = {};
Map<String, List<css.Expression>> _properties = {};
late String _selector;
late String _currentProperty;
Map<String, Map<String, List<css.Expression>>> getDeclarations(css.StyleSheet sheet) {
sheet.topLevels.forEach((element) {
if (element.span != null) {
_selector = element.span!.text;
element.visit(this);
if (_result[_selector] != null) {
_properties.forEach((key, value) {
if (_result[_selector]![key] != null) {
_result[_selector]![key]!.addAll(new List<css.Expression>.from(value));
} else {
_result[_selector]![key] = new List<css.Expression>.from(value);
}
});
} else {
_result[_selector] = new Map<String, List<css.Expression>>.from(_properties);
}
_properties.clear();
}
});
return _result;
}
@override
void visitDeclaration(css.Declaration node) {
_currentProperty = node.property;
_properties[_currentProperty] = <css.Expression>[];
node.expression!.visit(this);
}
@override
void visitExpressions(css.Expressions node) {
if (_properties[_currentProperty] != null) {
_properties[_currentProperty]!.addAll(node.expressions);
} else {
_properties[_currentProperty] = node.expressions;
}
}
}
//Mapping functions
class ExpressionMapping {
static Border expressionToBorder(List<css.Expression?>? borderWidths, List<css.LiteralTerm?>? borderStyles, List<css.Expression?>? borderColors) {
CustomBorderSide left = CustomBorderSide();
CustomBorderSide top = CustomBorderSide();
CustomBorderSide right = CustomBorderSide();
CustomBorderSide bottom = CustomBorderSide();
if (borderWidths != null && borderWidths.isNotEmpty) {
top.width = expressionToBorderWidth(borderWidths.first);
if (borderWidths.length == 4) {
right.width = expressionToBorderWidth(borderWidths[1]);
bottom.width = expressionToBorderWidth(borderWidths[2]);
left.width = expressionToBorderWidth(borderWidths.last);
}
if (borderWidths.length == 3) {
left.width = expressionToBorderWidth(borderWidths[1]);
right.width = expressionToBorderWidth(borderWidths[1]);
bottom.width = expressionToBorderWidth(borderWidths.last);
}
if (borderWidths.length == 2) {
bottom.width = expressionToBorderWidth(borderWidths.first);
left.width = expressionToBorderWidth(borderWidths.last);
right.width = expressionToBorderWidth(borderWidths.last);
}
if (borderWidths.length == 1) {
bottom.width = expressionToBorderWidth(borderWidths.first);
left.width = expressionToBorderWidth(borderWidths.first);
right.width = expressionToBorderWidth(borderWidths.first);
}
}
if (borderStyles != null && borderStyles.isNotEmpty) {
top.style = expressionToBorderStyle(borderStyles.first);
if (borderStyles.length == 4) {
right.style = expressionToBorderStyle(borderStyles[1]);
bottom.style = expressionToBorderStyle(borderStyles[2]);
left.style = expressionToBorderStyle(borderStyles.last);
}
if (borderStyles.length == 3) {
left.style = expressionToBorderStyle(borderStyles[1]);
right.style = expressionToBorderStyle(borderStyles[1]);
bottom.style = expressionToBorderStyle(borderStyles.last);
}
if (borderStyles.length == 2) {
bottom.style = expressionToBorderStyle(borderStyles.first);
left.style = expressionToBorderStyle(borderStyles.last);
right.style = expressionToBorderStyle(borderStyles.last);
}
if (borderStyles.length == 1) {
bottom.style = expressionToBorderStyle(borderStyles.first);
left.style = expressionToBorderStyle(borderStyles.first);
right.style = expressionToBorderStyle(borderStyles.first);
}
}
if (borderColors != null && borderColors.isNotEmpty) {
top.color = expressionToColor(borderColors.first);
if (borderColors.length == 4) {
right.color = expressionToColor(borderColors[1]);
bottom.color = expressionToColor(borderColors[2]);
left.color = expressionToColor(borderColors.last);
}
if (borderColors.length == 3) {
left.color = expressionToColor(borderColors[1]);
right.color = expressionToColor(borderColors[1]);
bottom.color = expressionToColor(borderColors.last);
}
if (borderColors.length == 2) {
bottom.color = expressionToColor(borderColors.first);
left.color = expressionToColor(borderColors.last);
right.color = expressionToColor(borderColors.last);
}
if (borderColors.length == 1) {
bottom.color = expressionToColor(borderColors.first);
left.color = expressionToColor(borderColors.first);
right.color = expressionToColor(borderColors.first);
}
}
return Border(
top: BorderSide(width: top.width, color: top.color ?? Colors.black, style: top.style),
right: BorderSide(width: right.width, color: right.color ?? Colors.black, style: right.style),
bottom: BorderSide(width: bottom.width, color: bottom.color ?? Colors.black, style: bottom.style),
left: BorderSide(width: left.width, color: left.color ?? Colors.black, style: left.style)
);
}
static double expressionToBorderWidth(css.Expression? value) {
if (value is css.NumberTerm) {
return double.tryParse(value.text) ?? 1.0;
} else if (value is css.PercentageTerm) {
return (double.tryParse(value.text) ?? 400) / 100;
} else if (value is css.EmTerm) {
return double.tryParse(value.text) ?? 1.0;
} else if (value is css.RemTerm) {
return double.tryParse(value.text) ?? 1.0;
} else if (value is css.LengthTerm) {
return double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? 1.0;
} else if (value is css.LiteralTerm) {
switch (value.text) {
case "thin":
return 2.0;
case "medium":
return 4.0;
case "thick":
return 6.0;
}
}
return 4.0;
}
static BorderStyle expressionToBorderStyle(css.LiteralTerm? value) {
if (value != null && value.text != "none" && value.text != "hidden") {
return BorderStyle.solid;
}
return BorderStyle.none;
}
static Color? expressionToColor(css.Expression? value) {
if (value != null) {
if (value is css.HexColorTerm) {
return stringToColor(value.text);
} else if (value is css.FunctionTerm) {
if (value.text == 'rgba' || value.text == 'rgb') {
return rgbOrRgbaToColor(value.span!.text);
} else if (value.text == 'hsla' || value.text == 'hsl') {
return hslToRgbToColor(value.span!.text);
}
} else if (value is css.LiteralTerm) {
return namedColorToColor(value.text);
}
}
return null;
}
static TextDirection expressionToDirection(css.Expression value) {
if (value is css.LiteralTerm) {
switch(value.text) {
case "ltr":
return TextDirection.ltr;
case "rtl":
return TextDirection.rtl;
}
}
return TextDirection.ltr;
}
static Display expressionToDisplay(css.Expression value) {
if (value is css.LiteralTerm) {
switch(value.text) {
case 'block':
return Display.BLOCK;
case 'inline-block':
return Display.INLINE_BLOCK;
case 'inline':
return Display.INLINE;
case 'list-item':
return Display.LIST_ITEM;
case 'none':
return Display.NONE;
}
}
return Display.INLINE;
}
static List<FontFeature> expressionToFontFeatureSettings(List<css.Expression> value) {
List<FontFeature> fontFeatures = [];
for (int i = 0; i < value.length; i++) {
css.Expression exp = value[i];
if (exp is css.LiteralTerm) {
if (exp.text != "on" && exp.text != "off" && exp.text != "1" && exp.text != "0") {
if (i < value.length - 1) {
css.Expression nextExp = value[i+1];
if (nextExp is css.LiteralTerm && (nextExp.text == "on" || nextExp.text == "off" || nextExp.text == "1" || nextExp.text == "0")) {
fontFeatures.add(FontFeature(exp.text, nextExp.text == "on" || nextExp.text == "1" ? 1 : 0));
} else {
fontFeatures.add(FontFeature.enable(exp.text));
}
} else {
fontFeatures.add(FontFeature.enable(exp.text));
}
}
}
}
List<FontFeature> finalFontFeatures = fontFeatures.toSet().toList();
return finalFontFeatures;
}
static FontSize? expressionToFontSize(css.Expression value) {
if (value is css.NumberTerm) {
return FontSize(double.tryParse(value.text));
} else if (value is css.PercentageTerm) {
return FontSize.percent(double.tryParse(value.text)!);
} else if (value is css.EmTerm) {
return FontSize.em(double.tryParse(value.text));
} else if (value is css.RemTerm) {
return FontSize.rem(double.tryParse(value.text)!);
} else if (value is css.LengthTerm) {
return FontSize(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')));
} else if (value is css.LiteralTerm) {
switch (value.text) {
case "xx-small":
return FontSize.xxSmall;
case "x-small":
return FontSize.xSmall;
case "small":
return FontSize.small;
case "medium":
return FontSize.medium;
case "large":
return FontSize.large;
case "x-large":
return FontSize.xLarge;
case "xx-large":
return FontSize.xxLarge;
}
}
return null;
}
static FontStyle expressionToFontStyle(css.Expression value) {
if (value is css.LiteralTerm) {
switch(value.text) {
case "italic":
case "oblique":
return FontStyle.italic;
}
return FontStyle.normal;
}
return FontStyle.normal;
}
static FontWeight expressionToFontWeight(css.Expression value) {
if (value is css.NumberTerm) {
switch (value.text) {
case "100":
return FontWeight.w100;
case "200":
return FontWeight.w200;
case "300":
return FontWeight.w300;
case "400":
return FontWeight.w400;
case "500":
return FontWeight.w500;
case "600":
return FontWeight.w600;
case "700":
return FontWeight.w700;
case "800":
return FontWeight.w800;
case "900":
return FontWeight.w900;
}
} else if (value is css.LiteralTerm) {
switch(value.text) {
case "bold":
return FontWeight.bold;
case "bolder":
return FontWeight.w900;
case "lighter":
return FontWeight.w200;
}
return FontWeight.normal;
}
return FontWeight.normal;
}
static String? expressionToFontFamily(css.Expression value) {
if (value is css.LiteralTerm) return value.text;
return null;
}
static LineHeight expressionToLineHeight(css.Expression value) {
if (value is css.NumberTerm) {
return LineHeight.number(double.tryParse(value.text)!);
} else if (value is css.PercentageTerm) {
return LineHeight.percent(double.tryParse(value.text)!);
} else if (value is css.EmTerm) {
return LineHeight.em(double.tryParse(value.text)!);
} else if (value is css.RemTerm) {
return LineHeight.rem(double.tryParse(value.text)!);
} else if (value is css.LengthTerm) {
return LineHeight(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), '')), units: "length");
}
return LineHeight.normal;
}
static ListStyleType? expressionToListStyleType(css.LiteralTerm value) {
if (value is css.UriTerm) {
return ListStyleType.fromImage(value.text);
}
switch (value.text) {
case 'disc':
return ListStyleType.DISC;
case 'circle':
return ListStyleType.CIRCLE;
case 'decimal':
return ListStyleType.DECIMAL;
case 'lower-alpha':
return ListStyleType.LOWER_ALPHA;
case 'lower-latin':
return ListStyleType.LOWER_LATIN;
case 'lower-roman':
return ListStyleType.LOWER_ROMAN;
case 'square':
return ListStyleType.SQUARE;
case 'upper-alpha':
return ListStyleType.UPPER_ALPHA;
case 'upper-latin':
return ListStyleType.UPPER_LATIN;
case 'upper-roman':
return ListStyleType.UPPER_ROMAN;
case 'none':
return ListStyleType.NONE;
}
return null;
}
static List<double?> expressionToPadding(List<css.Expression>? lengths) {
double? left;
double? right;
double? top;
double? bottom;
if (lengths != null && lengths.isNotEmpty) {
top = expressionToPaddingLength(lengths.first);
if (lengths.length == 4) {
right = expressionToPaddingLength(lengths[1]);
bottom = expressionToPaddingLength(lengths[2]);
left = expressionToPaddingLength(lengths.last);
}
if (lengths.length == 3) {
left = expressionToPaddingLength(lengths[1]);
right = expressionToPaddingLength(lengths[1]);
bottom = expressionToPaddingLength(lengths.last);
}
if (lengths.length == 2) {
bottom = expressionToPaddingLength(lengths.first);
left = expressionToPaddingLength(lengths.last);
right = expressionToPaddingLength(lengths.last);
}
if (lengths.length == 1) {
bottom = expressionToPaddingLength(lengths.first);
left = expressionToPaddingLength(lengths.first);
right = expressionToPaddingLength(lengths.first);
}
}
return [left, right, top, bottom];
}
static double? expressionToPaddingLength(css.Expression value) {
if (value is css.NumberTerm) {
return double.tryParse(value.text);
} else if (value is css.EmTerm) {
return double.tryParse(value.text);
} else if (value is css.RemTerm) {
return double.tryParse(value.text);
} else if (value is css.LengthTerm) {
return double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), ''));
}
return null;
}
static TextAlign expressionToTextAlign(css.Expression value) {
if (value is css.LiteralTerm) {
switch(value.text) {
case "center":
return TextAlign.center;
case "left":
return TextAlign.left;
case "right":
return TextAlign.right;
case "justify":
return TextAlign.justify;
case "end":
return TextAlign.end;
case "start":
return TextAlign.start;
}
}
return TextAlign.start;
}
static TextDecoration expressionToTextDecorationLine(List<css.LiteralTerm?> value) {
List<TextDecoration> decorationList = [];
for (css.LiteralTerm? term in value) {
if (term != null) {
switch(term.text) {
case "overline":
decorationList.add(TextDecoration.overline);
break;
case "underline":
decorationList.add(TextDecoration.underline);
break;
case "line-through":
decorationList.add(TextDecoration.lineThrough);
break;
default:
decorationList.add(TextDecoration.none);
break;
}
}
}
if (decorationList.contains(TextDecoration.none)) decorationList = [TextDecoration.none];
return TextDecoration.combine(decorationList);
}
static TextDecorationStyle expressionToTextDecorationStyle(css.LiteralTerm value) {
switch(value.text) {
case "wavy":
return TextDecorationStyle.wavy;
case "dotted":
return TextDecorationStyle.dotted;
case "dashed":
return TextDecorationStyle.dashed;
case "double":
return TextDecorationStyle.double;
default:
return TextDecorationStyle.solid;
}
}
static List<Shadow> expressionToTextShadow(List<css.Expression> value) {
List<Shadow> shadow = [];
List<int> indices = [];
List<List<css.Expression>> valueList = [];
for (css.Expression e in value) {
if (e is css.OperatorComma) {
indices.add(value.indexOf(e));
}
}
indices.add(value.length);
int previousIndex = 0;
for (int i in indices) {
valueList.add(value.sublist(previousIndex, i));
previousIndex = i + 1;
}
for (List<css.Expression> list in valueList) {
css.Expression? offsetX;
css.Expression? offsetY;
css.Expression? blurRadius;
css.Expression? color;
int expressionIndex = 0;
list.forEach((element) {
if (element is css.HexColorTerm || element is css.FunctionTerm) {
color = element;
} else if (expressionIndex == 0) {
offsetX = element;
expressionIndex++;
} else if (expressionIndex++ == 1) {
offsetY = element;
expressionIndex++;
} else {
blurRadius = element;
}
});
RegExp nonNumberRegex = RegExp(r'\s+(\d+\.\d+)\s+');
if (offsetX is css.LiteralTerm && offsetY is css.LiteralTerm) {
if (color != null && ExpressionMapping.expressionToColor(color) != null) {
shadow.add(Shadow(
color: expressionToColor(color)!,
offset: Offset(
double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!,
double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!),
blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0,
));
} else {
shadow.add(Shadow(
offset: Offset(
double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!,
double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!),
blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0,
));
}
}
}
List<Shadow> finalShadows = shadow.toSet().toList();
return finalShadows;
}
static Color stringToColor(String _text) {
var text = _text.replaceFirst('#', '');
if (text.length == 3)
text = text.replaceAllMapped(
RegExp(r"[a-f]|\d", caseSensitive: false),
(match) => '${match.group(0)}${match.group(0)}'
);
if (text.length > 6) {
text = "0x" + text;
} else {
text = "0xFF" + text;
}
return new Color(int.parse(text));
}
static Color? rgbOrRgbaToColor(String text) {
final rgbaText = text.replaceAll(')', '').replaceAll(' ', '');
try {
final rgbaValues =
rgbaText.split(',').map((value) => double.parse(value)).toList();
if (rgbaValues.length == 4) {
return Color.fromRGBO(
rgbaValues[0].toInt(),
rgbaValues[1].toInt(),
rgbaValues[2].toInt(),
rgbaValues[3],
);
} else if (rgbaValues.length == 3) {
return Color.fromRGBO(
rgbaValues[0].toInt(),
rgbaValues[1].toInt(),
rgbaValues[2].toInt(),
1.0,
);
}
return null;
} catch (e) {
return null;
}
}
static Color hslToRgbToColor(String text) {
final hslText = text.replaceAll(')', '').replaceAll(' ', '');
final hslValues = hslText.split(',').toList();
List<double?> parsedHsl = [];
hslValues.forEach((element) {
if (element.contains("%") && double.tryParse(element.replaceAll("%", "")) != null) {
parsedHsl.add(double.tryParse(element.replaceAll("%", ""))! * 0.01);
} else {
if (element != hslValues.first && (double.tryParse(element) == null || double.tryParse(element)! > 1)) {
parsedHsl.add(null);
} else {
parsedHsl.add(double.tryParse(element));
}
}
});
if (parsedHsl.length == 4 && !parsedHsl.contains(null)) {
return HSLColor.fromAHSL(parsedHsl.last!, parsedHsl.first!, parsedHsl[1]!, parsedHsl[2]!).toColor();
} else if (parsedHsl.length == 3 && !parsedHsl.contains(null)) {
return HSLColor.fromAHSL(1.0, parsedHsl.first!, parsedHsl[1]!, parsedHsl.last!).toColor();
} else return Colors.black;
}
static Color? namedColorToColor(String text) {
String namedColor = namedColors.keys.firstWhere((element) => element.toLowerCase() == text.toLowerCase(), orElse: () => "");
if (namedColor != "") {
return stringToColor(namedColors[namedColor]!);
} else return null;
}
}

@ -0,0 +1,283 @@
export 'styled_element.dart';
export 'interactable_element.dart';
export 'replaced_element.dart';
const STYLED_ELEMENTS = [
"abbr",
"acronym",
"address",
"b",
"bdi",
"bdo",
"big",
"cite",
"code",
"data",
"del",
"dfn",
"em",
"font",
"i",
"ins",
"kbd",
"mark",
"q",
"rt",
"s",
"samp",
"small",
"span",
"strike",
"strong",
"sub",
"sup",
"time",
"tt",
"u",
"var",
"wbr",
//BLOCK ELEMENTS
"article",
"aside",
"blockquote",
"body",
"center",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"html",
"li",
"main",
"nav",
"noscript",
"ol",
"p",
"pre",
"section",
"summary",
"ul",
];
const BLOCK_ELEMENTS = [
"article",
"aside",
"blockquote",
"body",
"center",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"html",
"li",
"main",
"nav",
"noscript",
"ol",
"p",
"pre",
"section",
"summary",
"ul",
];
const INTERACTABLE_ELEMENTS = [
"a",
];
const REPLACED_ELEMENTS = [
"br",
"template",
"rp",
"rt",
"ruby",
];
const LAYOUT_ELEMENTS = [
"details",
"tr",
"tbody",
"tfoot",
"thead",
];
const TABLE_CELL_ELEMENTS = ["th", "td"];
const TABLE_DEFINITION_ELEMENTS = ["col", "colgroup"];
const EXTERNAL_ELEMENTS = ["audio", "iframe", "img", "math", "svg", "table", "video"];
const SELECTABLE_ELEMENTS = [
"br",
"a",
"article",
"aside",
"blockquote",
"body",
"center",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"html",
"main",
"nav",
"noscript",
"p",
"pre",
"section",
"summary",
"abbr",
"acronym",
"address",
"b",
"bdi",
"bdo",
"big",
"cite",
"code",
"data",
"del",
"dfn",
"em",
"font",
"i",
"ins",
"kbd",
"mark",
"q",
"s",
"samp",
"small",
"span",
"strike",
"strong",
"time",
"tt",
"u",
"var",
"wbr",
];
/**
Here is a list of elements with planned support:
a - i [x]
abbr - s [x]
acronym - s [x]
address - s [x]
audio - c [x]
article - b [x]
aside - b [x]
b - s [x]
bdi - s [x]
bdo - s [x]
big - s [x]
blockquote- b [x]
body - b [x]
br - b [x]
button - i [ ]
caption - b [ ]
center - b [x]
cite - s [x]
code - s [x]
data - s [x]
dd - b [x]
del - s [x]
dfn - s [x]
div - b [x]
dl - b [x]
dt - b [x]
em - s [x]
figcaption- b [x]
figure - b [x]
font - s [x]
footer - b [x]
h1 - b [x]
h2 - b [x]
h3 - b [x]
h4 - b [x]
h5 - b [x]
h6 - b [x]
head - e [x]
header - b [x]
hr - b [x]
html - b [x]
i - s [x]
img - c [x]
ins - s [x]
kbd - s [x]
li - b [x]
main - b [x]
mark - s [x]
nav - b [x]
noscript - b [x]
ol - b [x] post
p - b [x]
pre - b [x]
q - s [x] post
rp - s [x]
rt - s [x]
ruby - s [x]
s - s [x]
samp - s [x]
section - b [x]
small - s [x]
source - [-] child of content
span - s [x]
strike - s [x]
strong - s [x]
sub - s [x]
sup - s [x]
svg - c [x]
table - b [x]
tbody - b [x]
td - s [ ]
template - e [x]
tfoot - b [x]
th - s [ ]
thead - b [x]
time - s [x]
tr - ? [ ]
track - [-] child of content
tt - s [x]
u - s [x]
ul - b [x] post
var - s [x]
video - c [x]
wbr - s [x]
*/

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import './html_elements.dart';
import '../style.dart';
import 'package:html/dom.dart' as dom;
/// An [InteractableElement] is a [StyledElement] that takes user gestures (e.g. tap).
class InteractableElement extends StyledElement {
String? href;
InteractableElement({
required String name,
required List<StyledElement> children,
required Style style,
required this.href,
required dom.Node node,
required String elementId,
}) : super(name: name, children: children, style: style, node: node as dom.Element?, elementId: elementId);
}
/// A [Gesture] indicates the type of interaction by a user.
enum Gesture {
TAP,
}
StyledElement parseInteractableElement(
dom.Element element, List<StyledElement> children) {
switch (element.localName) {
case "a":
if (element.attributes.containsKey('href')) {
return InteractableElement(
name: element.localName!,
children: children,
href: element.attributes['href'],
style: Style(
color: Colors.blue,
textDecoration: TextDecoration.underline,
),
node: element,
elementId: element.id
);
}
// When <a> tag have no href, it must be non clickable and without decoration.
return StyledElement(
name: element.localName!,
children: children,
style: Style(),
node: element,
elementId: element.id,
);
/// will never be called, just to suppress missing return warning
default:
return InteractableElement(
name: element.localName!,
children: children,
node: element,
href: '',
style: Style(),
elementId: "[[No ID]]"
);
}
}

@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../html_parser.dart';
import './anchor.dart';
import './html_elements.dart';
import './styled_element.dart';
import '../style.dart';
import 'package:html/dom.dart' as dom;
/// A [LayoutElement] is an element that breaks the normal Inline flow of
/// an html document with a more complex layout. LayoutElements handle
abstract class LayoutElement extends StyledElement {
LayoutElement({
String name = "[[No Name]]",
required List<StyledElement> children,
String? elementId,
dom.Element? node,
}) : super(name: name, children: children, style: Style(), node: node, elementId: elementId ?? "[[No ID]]");
Widget? toWidget(RenderContext context);
}
class TableSectionLayoutElement extends LayoutElement {
TableSectionLayoutElement({
required String name,
required List<StyledElement> children,
}) : super(name: name, children: children);
@override
Widget toWidget(RenderContext context) {
// Not rendered; TableLayoutElement will instead consume its children
return Container(child: Text("TABLE SECTION"));
}
}
class TableRowLayoutElement extends LayoutElement {
TableRowLayoutElement({
required String name,
required List<StyledElement> children,
required dom.Element node,
}) : super(name: name, children: children, node: node);
@override
Widget toWidget(RenderContext context) {
// Not rendered; TableLayoutElement will instead consume its children
return Container(child: Text("TABLE ROW"));
}
}
class TableCellElement extends StyledElement {
int colspan = 1;
int rowspan = 1;
TableCellElement({
required String name,
required String elementId,
required List<String> elementClasses,
required List<StyledElement> children,
required Style style,
required dom.Element node,
}) : super(name: name, elementId: elementId, elementClasses: elementClasses, children: children, style: style, node: node) {
colspan = _parseSpan(this, "colspan");
rowspan = _parseSpan(this, "rowspan");
}
static int _parseSpan(StyledElement element, String attributeName) {
final spanValue = element.attributes[attributeName];
return spanValue == null ? 1 : int.tryParse(spanValue) ?? 1;
}
}
TableCellElement parseTableCellElement(
dom.Element element,
List<StyledElement> children,
) {
final cell = TableCellElement(
name: element.localName!,
elementId: element.id,
elementClasses: element.classes.toList(),
children: children,
node: element,
style: Style(),
);
if (element.localName == "th") {
cell.style = Style(
fontWeight: FontWeight.bold,
);
}
return cell;
}
class TableStyleElement extends StyledElement {
TableStyleElement({
required String name,
required List<StyledElement> children,
required Style style,
required dom.Element node,
}) : super(name: name, children: children, style: style, node: node);
}
TableStyleElement parseTableDefinitionElement(
dom.Element element,
List<StyledElement> children,
) {
switch (element.localName) {
case "colgroup":
case "col":
return TableStyleElement(
name: element.localName!,
children: children,
node: element,
style: Style(),
);
default:
return TableStyleElement(
name: "[[No Name]]",
children: children,
node: element,
style: Style(),
);
}
}
class DetailsContentElement extends LayoutElement {
List<dom.Element> elementList;
DetailsContentElement({
required String name,
required List<StyledElement> children,
required dom.Element node,
required this.elementList,
}) : super(name: name, node: node, children: children, elementId: node.id);
@override
Widget toWidget(RenderContext context) {
List<InlineSpan>? childrenList = children.map((tree) => context.parser.parseTree(context, tree)).toList();
List<InlineSpan> toRemove = [];
for (InlineSpan child in childrenList) {
if (child is TextSpan && child.text != null && child.text!.trim().isEmpty) {
toRemove.add(child);
}
}
for (InlineSpan child in toRemove) {
childrenList.remove(child);
}
InlineSpan? firstChild = childrenList.isNotEmpty == true ? childrenList.first : null;
return ExpansionTile(
key: AnchorKey.of(context.parser.key, this),
expandedAlignment: Alignment.centerLeft,
title: elementList.isNotEmpty == true && elementList.first.localName == "summary" ? StyledText(
textSpan: TextSpan(
style: style.generateTextStyle(),
children: firstChild == null ? [] : [firstChild],
),
style: style,
renderContext: context,
) : Text("Details"),
children: [
StyledText(
textSpan: TextSpan(
style: style.generateTextStyle(),
children: getChildren(childrenList, context, elementList.isNotEmpty == true && elementList.first.localName == "summary" ? firstChild : null)
),
style: style,
renderContext: context,
),
]
);
}
List<InlineSpan> getChildren(List<InlineSpan> children, RenderContext context, InlineSpan? firstChild) {
if (firstChild != null) children.removeAt(0);
return children;
}
}
class EmptyLayoutElement extends LayoutElement {
EmptyLayoutElement({required String name}) : super(name: name, children: []);
@override
Widget? toWidget(_) => null;
}
LayoutElement parseLayoutElement(
dom.Element element,
List<StyledElement> children,
) {
switch (element.localName) {
case "details":
if (children.isEmpty) {
return EmptyLayoutElement(name: "empty");
}
return DetailsContentElement(
node: element,
name: element.localName!,
children: children,
elementList: element.children
);
case "thead":
case "tbody":
case "tfoot":
return TableSectionLayoutElement(
name: element.localName!,
children: children,
);
case "tr":
return TableRowLayoutElement(
name: element.localName!,
children: children,
node: element,
);
default:
return EmptyLayoutElement(name: "[[No Name]]");
}
}

@ -0,0 +1,166 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../html_parser.dart';
import './anchor.dart';
import './html_elements.dart';
import '../style.dart';
import 'package:html/dom.dart' as dom;
/// A [ReplacedElement] is a type of [StyledElement] that does not require its [children] to be rendered.
///
/// A [ReplacedElement] may use its children nodes to determine relevant information
/// (e.g. <video>'s <source> tags), but the children nodes will not be saved as [children].
abstract class ReplacedElement extends StyledElement {
PlaceholderAlignment alignment;
ReplacedElement({
required String name,
required Style style,
required String elementId,
List<StyledElement>? children,
dom.Element? node,
this.alignment = PlaceholderAlignment.aboveBaseline,
}) : super(name: name, children: children ?? [], style: style, node: node, elementId: elementId);
static List<String?> parseMediaSources(List<dom.Element> elements) {
return elements
.where((element) => element.localName == 'source')
.map((element) {
return element.attributes['src'];
}).toList();
}
Widget? toWidget(RenderContext context);
}
/// [TextContentElement] is a [ContentElement] with plaintext as its content.
class TextContentElement extends ReplacedElement {
String? text;
dom.Node? node;
TextContentElement({
required Style style,
required this.text,
this.node,
dom.Element? element,
}) : super(name: "[text]", style: style, node: element, elementId: "[[No ID]]");
@override
String toString() {
return "\"${text!.replaceAll("\n", "\\n")}\"";
}
@override
Widget? toWidget(_) => null;
}
class EmptyContentElement extends ReplacedElement {
EmptyContentElement({String name = "empty"}) : super(name: name, style: Style(), elementId: "[[No ID]]");
@override
Widget? toWidget(_) => null;
}
class RubyElement extends ReplacedElement {
dom.Element element;
RubyElement({
required this.element,
required List<StyledElement> children,
String name = "ruby"
}) : super(name: name, alignment: PlaceholderAlignment.middle, style: Style(), elementId: element.id, children: children);
@override
Widget toWidget(RenderContext context) {
StyledElement? node;
List<Widget> widgets = <Widget>[];
final rubySize = context.parser.style['rt']?.fontSize?.size ?? max(9.0, context.style.fontSize!.size! / 2);
final rubyYPos = rubySize + rubySize / 2;
List<StyledElement> children = [];
context.tree.children.forEachIndexed((index, element) {
if (!((element is TextContentElement)
&& (element.text ?? "").trim().isEmpty
&& index > 0
&& index + 1 < context.tree.children.length
&& !(context.tree.children[index - 1] is TextContentElement)
&& !(context.tree.children[index + 1] is TextContentElement))) {
children.add(element);
}
});
children.forEach((c) {
if (c.name == "rt" && node != null) {
final widget = Stack(
alignment: Alignment.center,
children: <Widget>[
Container(
alignment: Alignment.bottomCenter,
child: Center(
child: Transform(
transform:
Matrix4.translationValues(0, -(rubyYPos), 0),
child: ContainerSpan(
newContext: RenderContext(
buildContext: context.buildContext,
parser: context.parser,
style: c.style,
tree: c,
),
style: c.style,
child: Text(c.element!.innerHtml,
style: c.style
.generateTextStyle()
.copyWith(fontSize: rubySize)),
)))),
ContainerSpan(
newContext: context,
style: context.style,
child: node is TextContentElement ? Text((node as TextContentElement).text?.trim() ?? "",
style: context.style.generateTextStyle()) : null,
children: node is TextContentElement ? null : [context.parser.parseTree(context, node!)]),
],
);
widgets.add(widget);
} else {
node = c;
}
});
return Padding(
padding: EdgeInsets.only(top: rubySize),
child: Wrap(
key: AnchorKey.of(context.parser.key, this),
runSpacing: rubySize,
children: widgets.map((e) => Row(
crossAxisAlignment: CrossAxisAlignment.end,
textBaseline: TextBaseline.alphabetic,
mainAxisSize: MainAxisSize.min,
children: [e],
)).toList(),
),
);
}
}
ReplacedElement parseReplacedElement(
dom.Element element,
List<StyledElement> children,
) {
switch (element.localName) {
case "br":
return TextContentElement(
text: "\n",
style: Style(whiteSpace: WhiteSpace.PRE),
element: element,
node: element
);
case "ruby":
return RubyElement(
element: element,
children: children,
);
default:
return EmptyContentElement(name: element.localName == null ? "[[No Name]]" : element.localName!);
}
}

@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import './css_parser.dart';
import '../style.dart';
import 'package:html/dom.dart' as dom;
//TODO(Sub6Resources): don't use the internal code of the html package as it may change unexpectedly.
//ignore: implementation_imports
import 'package:html/src/query_selector.dart';
/// A [StyledElement] applies a style to all of its children.
class StyledElement {
final String name;
final String elementId;
final List<String> elementClasses;
List<StyledElement> children;
Style style;
final dom.Element? _node;
StyledElement({
this.name = "[[No name]]",
this.elementId = "[[No ID]]",
this.elementClasses = const [],
required this.children,
required this.style,
required dom.Element? node,
}) : this._node = node;
bool matchesSelector(String selector) =>
(_node != null && matches(_node!, selector)) || name == selector;
Map<String, String> get attributes =>
_node?.attributes.map((key, value) {
return MapEntry(key.toString(), value);
}) ??
Map<String, String>();
dom.Element? get element => _node;
@override
String toString() {
String selfData =
"[$name] ${children.length} ${elementClasses.isNotEmpty == true ? 'C:${elementClasses.toString()}' : ''}${elementId.isNotEmpty == true ? 'ID: $elementId' : ''}";
children.forEach((child) {
selfData += ("\n${child.toString()}")
.replaceAll(RegExp("^", multiLine: true), "-");
});
return selfData;
}
}
StyledElement parseStyledElement(
dom.Element element, List<StyledElement> children) {
StyledElement styledElement = StyledElement(
name: element.localName!,
elementId: element.id,
elementClasses: element.classes.toList(),
children: children,
node: element,
style: Style(),
);
switch (element.localName) {
case "abbr":
case "acronym":
styledElement.style = Style(
textDecoration: TextDecoration.underline,
textDecorationStyle: TextDecorationStyle.dotted,
);
break;
case "address":
continue italics;
case "article":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "aside":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
bold:
case "b":
styledElement.style = Style(
fontWeight: FontWeight.bold,
);
break;
case "bdo":
TextDirection textDirection =
((element.attributes["dir"] ?? "ltr") == "rtl")
? TextDirection.rtl
: TextDirection.ltr;
styledElement.style = Style(
direction: textDirection,
);
break;
case "big":
styledElement.style = Style(
fontSize: FontSize.larger,
);
break;
case "blockquote":
//TODO(Sub6Resources) this is a workaround for collapsing margins. Remove.
if (element.parent!.localName == "blockquote") {
styledElement.style = Style(
margin: const EdgeInsets.only(left: 40.0, right: 40.0, bottom: 14.0),
display: Display.BLOCK,
);
} else {
styledElement.style = Style(
margin: const EdgeInsets.symmetric(horizontal: 40.0, vertical: 14.0),
display: Display.BLOCK,
);
}
break;
case "body":
styledElement.style = Style(
margin: EdgeInsets.all(8.0),
display: Display.BLOCK,
);
break;
case "center":
styledElement.style = Style(
alignment: Alignment.center,
display: Display.BLOCK,
);
break;
case "cite":
continue italics;
monospace:
case "code":
styledElement.style = Style(
fontFamily: 'Monospace',
);
break;
case "dd":
styledElement.style = Style(
margin: EdgeInsets.only(left: 40.0),
display: Display.BLOCK,
);
break;
strikeThrough:
case "del":
styledElement.style = Style(
textDecoration: TextDecoration.lineThrough,
);
break;
case "dfn":
continue italics;
case "div":
styledElement.style = Style(
margin: EdgeInsets.all(0),
display: Display.BLOCK,
);
break;
case "dl":
styledElement.style = Style(
margin: EdgeInsets.symmetric(vertical: 14.0),
display: Display.BLOCK,
);
break;
case "dt":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "em":
continue italics;
case "figcaption":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "figure":
styledElement.style = Style(
margin: EdgeInsets.symmetric(vertical: 14.0, horizontal: 40.0),
display: Display.BLOCK,
);
break;
case "footer":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "font":
styledElement.style = Style(
color: element.attributes['color'] != null ?
element.attributes['color']!.startsWith("#") ?
ExpressionMapping.stringToColor(element.attributes['color']!) :
ExpressionMapping.namedColorToColor(element.attributes['color']!) :
null,
fontFamily: element.attributes['face']?.split(",").first,
fontSize: element.attributes['size'] != null ? numberToFontSize(element.attributes['size']!) : null,
);
break;
case "h1":
styledElement.style = Style(
fontSize: FontSize.xxLarge,
fontWeight: FontWeight.bold,
margin: EdgeInsets.symmetric(vertical: 18.67),
display: Display.BLOCK,
);
break;
case "h2":
styledElement.style = Style(
fontSize: FontSize.xLarge,
fontWeight: FontWeight.bold,
margin: EdgeInsets.symmetric(vertical: 17.5),
display: Display.BLOCK,
);
break;
case "h3":
styledElement.style = Style(
fontSize: FontSize(16.38),
fontWeight: FontWeight.bold,
margin: EdgeInsets.symmetric(vertical: 16.5),
display: Display.BLOCK,
);
break;
case "h4":
styledElement.style = Style(
fontSize: FontSize.medium,
fontWeight: FontWeight.bold,
margin: EdgeInsets.symmetric(vertical: 18.5),
display: Display.BLOCK,
);
break;
case "h5":
styledElement.style = Style(
fontSize: FontSize(11.62),
fontWeight: FontWeight.bold,
margin: EdgeInsets.symmetric(vertical: 19.25),
display: Display.BLOCK,
);
break;
case "h6":
styledElement.style = Style(
fontSize: FontSize(9.38),
fontWeight: FontWeight.bold,
margin: EdgeInsets.symmetric(vertical: 22),
display: Display.BLOCK,
);
break;
case "header":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "hr":
styledElement.style = Style(
margin: EdgeInsets.symmetric(vertical: 7.0),
width: double.infinity,
height: 1,
backgroundColor: Colors.black,
display: Display.BLOCK,
);
break;
case "html":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
italics:
case "i":
styledElement.style = Style(
fontStyle: FontStyle.italic,
);
break;
case "ins":
continue underline;
case "kbd":
continue monospace;
case "li":
styledElement.style = Style(
display: Display.LIST_ITEM,
);
break;
case "main":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "mark":
styledElement.style = Style(
color: Colors.black,
backgroundColor: Colors.yellow,
);
break;
case "nav":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "noscript":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "ol":
case "ul":
//TODO(Sub6Resources): This is a workaround for collapsed margins. Remove.
if (element.parent!.localName == "li") {
styledElement.style = Style(
// margin: EdgeInsets.only(left: 30.0),
display: Display.BLOCK,
listStyleType: element.localName == "ol"
? ListStyleType.DECIMAL
: ListStyleType.DISC,
);
} else {
styledElement.style = Style(
// margin: EdgeInsets.only(left: 30.0, top: 14.0, bottom: 14.0),
display: Display.BLOCK,
listStyleType: element.localName == "ol"
? ListStyleType.DECIMAL
: ListStyleType.DISC,
);
}
break;
case "p":
styledElement.style = Style(
margin: EdgeInsets.symmetric(vertical: 14.0),
display: Display.BLOCK,
);
break;
case "pre":
styledElement.style = Style(
fontFamily: 'monospace',
margin: EdgeInsets.symmetric(vertical: 14.0),
whiteSpace: WhiteSpace.PRE,
display: Display.BLOCK,
);
break;
case "q":
styledElement.style = Style(
before: "\"",
after: "\"",
);
break;
case "s":
continue strikeThrough;
case "samp":
continue monospace;
case "section":
styledElement.style = Style(
display: Display.BLOCK,
);
break;
case "small":
styledElement.style = Style(
fontSize: FontSize.smaller,
);
break;
case "strike":
continue strikeThrough;
case "strong":
continue bold;
case "sub":
styledElement.style = Style(
fontSize: FontSize.smaller,
verticalAlign: VerticalAlign.SUB,
);
break;
case "sup":
styledElement.style = Style(
fontSize: FontSize.smaller,
verticalAlign: VerticalAlign.SUPER,
);
break;
case "tt":
continue monospace;
underline:
case "u":
styledElement.style = Style(
textDecoration: TextDecoration.underline,
);
break;
case "var":
continue italics;
}
return styledElement;
}
typedef ListCharacter = String Function(int i);
FontSize numberToFontSize(String num) {
switch (num) {
case "1":
return FontSize.xxSmall;
case "2":
return FontSize.xSmall;
case "3":
return FontSize.small;
case "4":
return FontSize.medium;
case "5":
return FontSize.large;
case "6":
return FontSize.xLarge;
case "7":
return FontSize.xxLarge;
}
if (num.startsWith("+")) {
final relativeNum = double.tryParse(num.substring(1)) ?? 0;
return numberToFontSize((3 + relativeNum).toString());
}
if (num.startsWith("-")) {
final relativeNum = double.tryParse(num.substring(1)) ?? 0;
return numberToFontSize((3 - relativeNum).toString());
}
return FontSize.medium;
}

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../style.dart';
Map<String, String> namedColors = {
"White": "#FFFFFF",
"Silver": "#C0C0C0",
"Gray": "#808080",
"Black": "#000000",
"Red": "#FF0000",
"Maroon": "#800000",
"Yellow": "#FFFF00",
"Olive": "#808000",
"Lime": "#00FF00",
"Green": "#008000",
"Aqua": "#00FFFF",
"Teal": "#008080",
"Blue": "#0000FF",
"Navy": "#000080",
"Fuchsia": "#FF00FF",
"Purple": "#800080",
};
class Context<T> {
T data;
Context(this.data);
}
// This class is a workaround so that both an image
// and a link can detect taps at the same time.
class MultipleTapGestureDetector extends InheritedWidget {
final void Function()? onTap;
const MultipleTapGestureDetector({
Key? key,
required Widget child,
required this.onTap,
}) : super(key: key, child: child);
static MultipleTapGestureDetector? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MultipleTapGestureDetector>();
}
@override
bool updateShouldNotify(MultipleTapGestureDetector oldWidget) => false;
}
class CustomBorderSide {
CustomBorderSide({
this.color = const Color(0xFF000000),
this.width = 1.0,
this.style = BorderStyle.none,
}) : assert(width >= 0.0);
Color? color;
double width;
BorderStyle style;
}
extension TextTransformUtil on String? {
String? transformed(TextTransform? transform) {
if (this == null) return null;
if (transform == TextTransform.uppercase) {
return this!.toUpperCase();
} else if (transform == TextTransform.lowercase) {
return this!.toLowerCase();
} else if (transform == TextTransform.capitalize) {
final stringBuffer = StringBuffer();
var capitalizeNext = true;
for (final letter in this!.toLowerCase().codeUnits) {
// UTF-16: A-Z => 65-90, a-z => 97-122.
if (capitalizeNext && letter >= 97 && letter <= 122) {
stringBuffer.writeCharCode(letter - 32);
capitalizeNext = false;
} else {
// UTF-16: 32 == space, 46 == period
if (letter == 32 || letter == 46) capitalizeNext = true;
stringBuffer.writeCharCode(letter);
}
}
return stringBuffer.toString();
} else {
return this;
}
}
}

@ -0,0 +1,590 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import './flutter_html.dart';
import './src/css_parser.dart';
///This class represents all the available CSS attributes
///for this package.
class Style {
/// CSS attribute "`background-color`"
///
/// Inherited: no,
/// Default: Colors.transparent,
Color? backgroundColor;
/// CSS attribute "`color`"
///
/// Inherited: yes,
/// Default: unspecified,
Color? color;
/// CSS attribute "`direction`"
///
/// Inherited: yes,
/// Default: TextDirection.ltr,
TextDirection? direction;
/// CSS attribute "`display`"
///
/// Inherited: no,
/// Default: unspecified,
Display? display;
/// CSS attribute "`font-family`"
///
/// Inherited: yes,
/// Default: Theme.of(context).style.textTheme.body1.fontFamily
String? fontFamily;
/// The list of font families to fall back on when a glyph cannot be found in default font family.
///
/// Inherited: yes,
/// Default: null
List<String>? fontFamilyFallback;
/// CSS attribute "`font-feature-settings`"
///
/// Inherited: yes,
/// Default: normal
List<FontFeature>? fontFeatureSettings;
/// CSS attribute "`font-size`"
///
/// Inherited: yes,
/// Default: FontSize.medium
FontSize? fontSize;
/// CSS attribute "`font-style`"
///
/// Inherited: yes,
/// Default: FontStyle.normal,
FontStyle? fontStyle;
/// CSS attribute "`font-weight`"
///
/// Inherited: yes,
/// Default: FontWeight.normal,
FontWeight? fontWeight;
/// CSS attribute "`height`"
///
/// Inherited: no,
/// Default: Unspecified (null),
double? height;
/// CSS attribute "`letter-spacing`"
///
/// Inherited: yes,
/// Default: normal (0),
double? letterSpacing;
/// CSS attribute "`list-style-type`"
///
/// Inherited: yes,
/// Default: ListStyleType.DISC
ListStyleType? listStyleType;
/// CSS attribute "`list-style-position`"
///
/// Inherited: yes,
/// Default: ListStylePosition.OUTSIDE
ListStylePosition? listStylePosition;
/// CSS attribute "`padding`"
///
/// Inherited: no,
/// Default: EdgeInsets.zero
EdgeInsets? padding;
/// CSS attribute "`margin`"
///
/// Inherited: no,
/// Default: EdgeInsets.zero
EdgeInsets? margin;
/// CSS attribute "`text-align`"
///
/// Inherited: yes,
/// Default: TextAlign.start,
TextAlign? textAlign;
/// CSS attribute "`text-decoration`"
///
/// Inherited: no,
/// Default: TextDecoration.none,
TextDecoration? textDecoration;
/// CSS attribute "`text-decoration-color`"
///
/// Inherited: no,
/// Default: Current color
Color? textDecorationColor;
/// CSS attribute "`text-decoration-style`"
///
/// Inherited: no,
/// Default: TextDecorationStyle.solid,
TextDecorationStyle? textDecorationStyle;
/// Loosely based on CSS attribute "`text-decoration-thickness`"
///
/// Uses a percent modifier based on the font size.
///
/// Inherited: no,
/// Default: 1.0 (specified by font size)
// TODO(Sub6Resources): Possibly base this more closely on the CSS attribute.
double? textDecorationThickness;
/// CSS attribute "`text-shadow`"
///
/// Inherited: yes,
/// Default: none,
List<Shadow>? textShadow;
/// CSS attribute "`vertical-align`"
///
/// Inherited: no,
/// Default: VerticalAlign.BASELINE,
VerticalAlign? verticalAlign;
/// CSS attribute "`white-space`"
///
/// Inherited: yes,
/// Default: WhiteSpace.NORMAL,
WhiteSpace? whiteSpace;
/// CSS attribute "`width`"
///
/// Inherited: no,
/// Default: unspecified (null)
double? width;
/// CSS attribute "`word-spacing`"
///
/// Inherited: yes,
/// Default: normal (0)
double? wordSpacing;
/// CSS attribute "`line-height`"
///
/// Supported values: double values
///
/// Unsupported values: normal, 80%, ..
///
/// Inherited: no,
/// Default: Unspecified (null),
LineHeight? lineHeight;
//TODO modify these to match CSS styles
String? before;
String? after;
Border? border;
Alignment? alignment;
Widget? markerContent;
/// MaxLine
///
///
///
///
int? maxLines;
/// TextOverflow
///
///
///
///
TextOverflow? textOverflow;
TextTransform? textTransform;
Style({
this.backgroundColor = Colors.transparent,
this.color,
this.direction,
this.display,
this.fontFamily,
this.fontFamilyFallback,
this.fontFeatureSettings,
this.fontSize,
this.fontStyle,
this.fontWeight,
this.height,
this.lineHeight,
this.letterSpacing,
this.listStyleType,
this.listStylePosition,
this.padding,
this.margin,
this.textAlign,
this.textDecoration,
this.textDecorationColor,
this.textDecorationStyle,
this.textDecorationThickness,
this.textShadow,
this.verticalAlign,
this.whiteSpace,
this.width,
this.wordSpacing,
this.before,
this.after,
this.border,
this.alignment,
this.markerContent,
this.maxLines,
this.textOverflow,
this.textTransform = TextTransform.none,
}) {
if (this.alignment == null &&
(display == Display.BLOCK || display == Display.LIST_ITEM)) {
this.alignment = Alignment.centerLeft;
}
}
static Map<String, Style> fromThemeData(ThemeData theme) => {
'h1': Style.fromTextStyle(theme.textTheme.headline1!),
'h2': Style.fromTextStyle(theme.textTheme.headline2!),
'h3': Style.fromTextStyle(theme.textTheme.headline3!),
'h4': Style.fromTextStyle(theme.textTheme.headline4!),
'h5': Style.fromTextStyle(theme.textTheme.headline5!),
'h6': Style.fromTextStyle(theme.textTheme.headline6!),
'body': Style.fromTextStyle(theme.textTheme.bodyText2!),
};
static Map<String, Style> fromCss(String css, OnCssParseError? onCssParseError) {
final declarations = parseExternalCss(css, onCssParseError);
Map<String, Style> styleMap = {};
declarations.forEach((key, value) {
styleMap[key] = declarationsToStyle(value);
});
return styleMap;
}
TextStyle generateTextStyle() {
return TextStyle(
backgroundColor: backgroundColor,
color: color,
decoration: textDecoration,
decorationColor: textDecorationColor,
decorationStyle: textDecorationStyle,
decorationThickness: textDecorationThickness,
fontFamily: fontFamily,
fontFamilyFallback: fontFamilyFallback,
fontFeatures: fontFeatureSettings,
fontSize: fontSize?.size,
fontStyle: fontStyle,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
shadows: textShadow,
wordSpacing: wordSpacing,
height: lineHeight?.size ?? 1.0,
//TODO background
//TODO textBaseline
);
}
@override
String toString() {
return "Style";
}
Style merge(Style other) {
return copyWith(
backgroundColor: other.backgroundColor,
color: other.color,
direction: other.direction,
display: other.display,
fontFamily: other.fontFamily,
fontFamilyFallback: other.fontFamilyFallback,
fontFeatureSettings: other.fontFeatureSettings,
fontSize: other.fontSize,
fontStyle: other.fontStyle,
fontWeight: other.fontWeight,
height: other.height,
lineHeight: other.lineHeight,
letterSpacing: other.letterSpacing,
listStyleType: other.listStyleType,
listStylePosition: other.listStylePosition,
padding: other.padding,
//TODO merge EdgeInsets
margin: other.margin,
//TODO merge EdgeInsets
textAlign: other.textAlign,
textDecoration: other.textDecoration,
textDecorationColor: other.textDecorationColor,
textDecorationStyle: other.textDecorationStyle,
textDecorationThickness: other.textDecorationThickness,
textShadow: other.textShadow,
verticalAlign: other.verticalAlign,
whiteSpace: other.whiteSpace,
width: other.width,
wordSpacing: other.wordSpacing,
before: other.before,
after: other.after,
border: other.border,
//TODO merge border
alignment: other.alignment,
markerContent: other.markerContent,
maxLines: other.maxLines,
textOverflow: other.textOverflow,
textTransform: other.textTransform,
);
}
Style copyOnlyInherited(Style child) {
FontSize? finalFontSize = child.fontSize != null ?
fontSize != null && child.fontSize?.units == "em" ?
FontSize(child.fontSize!.size! * fontSize!.size!) : child.fontSize
: fontSize != null && fontSize!.size! < 0 ?
FontSize.percent(100) : fontSize;
LineHeight? finalLineHeight = child.lineHeight != null ?
child.lineHeight?.units == "length" ?
LineHeight(child.lineHeight!.size! / (finalFontSize == null ? 14 : finalFontSize.size!) * 1.2) : child.lineHeight
: lineHeight;
return child.copyWith(
backgroundColor: child.backgroundColor != Colors.transparent ?
child.backgroundColor : backgroundColor,
color: child.color ?? color,
direction: child.direction ?? direction,
display: display == Display.NONE ? display : child.display,
fontFamily: child.fontFamily ?? fontFamily,
fontFamilyFallback: child.fontFamilyFallback ?? fontFamilyFallback,
fontFeatureSettings: child.fontFeatureSettings ?? fontFeatureSettings,
fontSize: finalFontSize,
fontStyle: child.fontStyle ?? fontStyle,
fontWeight: child.fontWeight ?? fontWeight,
lineHeight: finalLineHeight,
letterSpacing: child.letterSpacing ?? letterSpacing,
listStyleType: child.listStyleType ?? listStyleType,
listStylePosition: child.listStylePosition ?? listStylePosition,
textAlign: child.textAlign ?? textAlign,
textDecoration: TextDecoration.combine(
[child.textDecoration ?? TextDecoration.none,
textDecoration ?? TextDecoration.none]),
textShadow: child.textShadow ?? textShadow,
whiteSpace: child.whiteSpace ?? whiteSpace,
wordSpacing: child.wordSpacing ?? wordSpacing,
maxLines: child.maxLines ?? maxLines,
textOverflow: child.textOverflow ?? textOverflow,
textTransform: child.textTransform ?? textTransform,
);
}
Style copyWith({
Color? backgroundColor,
Color? color,
TextDirection? direction,
Display? display,
String? fontFamily,
List<String>? fontFamilyFallback,
List<FontFeature>? fontFeatureSettings,
FontSize? fontSize,
FontStyle? fontStyle,
FontWeight? fontWeight,
double? height,
LineHeight? lineHeight,
double? letterSpacing,
ListStyleType? listStyleType,
ListStylePosition? listStylePosition,
EdgeInsets? padding,
EdgeInsets? margin,
TextAlign? textAlign,
TextDecoration? textDecoration,
Color? textDecorationColor,
TextDecorationStyle? textDecorationStyle,
double? textDecorationThickness,
List<Shadow>? textShadow,
VerticalAlign? verticalAlign,
WhiteSpace? whiteSpace,
double? width,
double? wordSpacing,
String? before,
String? after,
Border? border,
Alignment? alignment,
Widget? markerContent,
int? maxLines,
TextOverflow? textOverflow,
TextTransform? textTransform,
bool? beforeAfterNull,
}) {
return Style(
backgroundColor: backgroundColor ?? this.backgroundColor,
color: color ?? this.color,
direction: direction ?? this.direction,
display: display ?? this.display,
fontFamily: fontFamily ?? this.fontFamily,
fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback,
fontFeatureSettings: fontFeatureSettings ?? this.fontFeatureSettings,
fontSize: fontSize ?? this.fontSize,
fontStyle: fontStyle ?? this.fontStyle,
fontWeight: fontWeight ?? this.fontWeight,
height: height ?? this.height,
lineHeight: lineHeight ?? this.lineHeight,
letterSpacing: letterSpacing ?? this.letterSpacing,
listStyleType: listStyleType ?? this.listStyleType,
listStylePosition: listStylePosition ?? this.listStylePosition,
padding: padding ?? this.padding,
margin: margin ?? this.margin,
textAlign: textAlign ?? this.textAlign,
textDecoration: textDecoration ?? this.textDecoration,
textDecorationColor: textDecorationColor ?? this.textDecorationColor,
textDecorationStyle: textDecorationStyle ?? this.textDecorationStyle,
textDecorationThickness:
textDecorationThickness ?? this.textDecorationThickness,
textShadow: textShadow ?? this.textShadow,
verticalAlign: verticalAlign ?? this.verticalAlign,
whiteSpace: whiteSpace ?? this.whiteSpace,
width: width ?? this.width,
wordSpacing: wordSpacing ?? this.wordSpacing,
before: beforeAfterNull == true ? null : before ?? this.before,
after: beforeAfterNull == true ? null : after ?? this.after,
border: border ?? this.border,
alignment: alignment ?? this.alignment,
markerContent: markerContent ?? this.markerContent,
maxLines: maxLines ?? this.maxLines,
textOverflow: textOverflow ?? this.textOverflow,
textTransform: textTransform ?? this.textTransform,
);
}
Style.fromTextStyle(TextStyle textStyle) {
this.backgroundColor = textStyle.backgroundColor;
this.color = textStyle.color;
this.textDecoration = textStyle.decoration;
this.textDecorationColor = textStyle.decorationColor;
this.textDecorationStyle = textStyle.decorationStyle;
this.textDecorationThickness = textStyle.decorationThickness;
this.fontFamily = textStyle.fontFamily;
this.fontFamilyFallback = textStyle.fontFamilyFallback;
this.fontFeatureSettings = textStyle.fontFeatures;
this.fontSize = FontSize(textStyle.fontSize);
this.fontStyle = textStyle.fontStyle;
this.fontWeight = textStyle.fontWeight;
this.letterSpacing = textStyle.letterSpacing;
this.textShadow = textStyle.shadows;
this.wordSpacing = textStyle.wordSpacing;
this.lineHeight = LineHeight(textStyle.height ?? 1.2);
this.textTransform = TextTransform.none;
}
}
enum Display {
BLOCK,
INLINE,
INLINE_BLOCK,
LIST_ITEM,
NONE,
}
class FontSize {
final double? size;
final String units;
const FontSize(this.size, {this.units = ""});
/// A percentage of the parent style's font size.
factory FontSize.percent(double percent) {
return FontSize(percent / -100.0, units: "%");
}
factory FontSize.em(double? em) {
return FontSize(em, units: "em");
}
factory FontSize.rem(double rem) {
return FontSize(rem * 16 - 2, units: "rem");
}
// These values are calculated based off of the default (`medium`)
// being 14px.
//
// TODO(Sub6Resources): This seems to override Flutter's accessibility text scaling.
//
// Negative values are computed during parsing to be a percentage of
// the parent style's font size.
static const xxSmall = FontSize(7.875);
static const xSmall = FontSize(8.75);
static const small = FontSize(11.375);
static const medium = FontSize(14.0);
static const large = FontSize(15.75);
static const xLarge = FontSize(21.0);
static const xxLarge = FontSize(28.0);
static const smaller = FontSize(-0.83);
static const larger = FontSize(-1.2);
}
class LineHeight {
final double? size;
final String units;
const LineHeight(this.size, {this.units = ""});
factory LineHeight.percent(double percent) {
return LineHeight(percent / 100.0 * 1.2, units: "%");
}
factory LineHeight.em(double em) {
return LineHeight(em * 1.2, units: "em");
}
factory LineHeight.rem(double rem) {
return LineHeight(rem * 1.2, units: "rem");
}
factory LineHeight.number(double num) {
return LineHeight(num * 1.2, units: "number");
}
static const normal = LineHeight(1.2);
}
class ListStyleType {
final String text;
final String type;
final Widget? widget;
const ListStyleType(this.text, {this.type = "marker", this.widget});
factory ListStyleType.fromImage(String url) => ListStyleType(url, type: "image");
factory ListStyleType.fromWidget(Widget widget) => ListStyleType("", widget: widget, type: "widget");
static const LOWER_ALPHA = ListStyleType("LOWER_ALPHA");
static const UPPER_ALPHA = ListStyleType("UPPER_ALPHA");
static const LOWER_LATIN = ListStyleType("LOWER_LATIN");
static const UPPER_LATIN = ListStyleType("UPPER_LATIN");
static const CIRCLE = ListStyleType("CIRCLE");
static const DISC = ListStyleType("DISC");
static const DECIMAL = ListStyleType("DECIMAL");
static const LOWER_ROMAN = ListStyleType("LOWER_ROMAN");
static const UPPER_ROMAN = ListStyleType("UPPER_ROMAN");
static const SQUARE = ListStyleType("SQUARE");
static const NONE = ListStyleType("NONE");
}
enum ListStylePosition {
OUTSIDE,
INSIDE,
}
enum TextTransform {
uppercase,
lowercase,
capitalize,
none,
}
enum VerticalAlign {
BASELINE,
SUB,
SUPER,
}
enum WhiteSpace {
NORMAL,
PRE,
}

@ -1,11 +1,11 @@
name: bytedesk_kefu
description: the best app chat sdk in china, you can chat with the agent freely at anytime. the agent can chat with the visitor by web/pc/mac/ios/android client.
version: 1.4.0
version: 1.4.3
homepage: https://www.weikefu.net
environment:
sdk: ">=2.12.0 <3.0.0"
flutter: ">=1.20.0"
sdk: ">=2.14.0 <3.0.0"
flutter: ">=2.10.0"
dependencies:
flutter:

Loading…
Cancel
Save