Browse Source

add html viewer

fix_bug
mohsen zamani 2 years ago
parent
commit
e4f5daabdc
  1. 11
      android/app/build.gradle
  2. 24
      android/app/src/main/AndroidManifest.xml
  3. 4
      android/build.gradle
  4. 4007
      assets/lottie/loading.json
  5. 18
      lib/core/extensions/number_extension.dart
  6. 489
      lib/core/html/custom_render.dart
  7. 326
      lib/core/html/flutter_html.dart
  8. 877
      lib/core/html/html_parser.dart
  9. 251
      lib/core/html/html_viewer.dart
  10. 45
      lib/core/html/src/anchor.dart
  11. 734
      lib/core/html/src/css_box_widget.dart
  12. 1172
      lib/core/html/src/css_parser.dart
  13. 207
      lib/core/html/src/html_elements.dart
  14. 64
      lib/core/html/src/interactable_element.dart
  15. 209
      lib/core/html/src/layout_element.dart
  16. 167
      lib/core/html/src/replaced_element.dart
  17. 30
      lib/core/html/src/style/fontsize.dart
  18. 64
      lib/core/html/src/style/length.dart
  19. 24
      lib/core/html/src/style/lineheight.dart
  20. 73
      lib/core/html/src/style/margin.dart
  21. 35
      lib/core/html/src/style/marker.dart
  22. 15
      lib/core/html/src/style/size.dart
  23. 411
      lib/core/html/src/styled_element.dart
  24. 89
      lib/core/html/src/utils.dart
  25. 220
      lib/core/html/string_proccess.dart
  26. 551
      lib/core/html/style.dart
  27. 11
      lib/core/language/language_cubit.dart
  28. 9
      lib/core/language/languages.dart
  29. 1
      lib/core/language/translator.dart
  30. 74
      lib/core/player_widgets/audio_player.dart
  31. 67
      lib/core/player_widgets/video_player.dart
  32. 144
      lib/core/theme/app_colors.dart
  33. 130
      lib/core/theme/app_theme.dart
  34. 4
      lib/core/theme/cubit/theme_cubit.dart
  35. 8
      lib/core/theme/panel_colors.dart
  36. 4
      lib/core/theme/panel_theme.dart
  37. 6
      lib/core/theme/panel_typography.dart
  38. 37
      lib/core/theme/reader_theme.dart
  39. 23
      lib/core/utils/app_utils.dart
  40. 86
      lib/core/utils/url_launcher.dart
  41. 45
      lib/core/utils/utilities.dart
  42. 52
      lib/core/widgets/custom_rich_text.dart
  43. 54
      lib/core/widgets/global_loading.dart
  44. 63
      lib/core/widgets/show_image_widget.dart
  45. 17
      lib/features/main/main_screen.dart
  46. 2
      lib/features/main/widget/main_item_widget.dart
  47. 121
      lib/features/posts/screen/posts_screen.dart
  48. 2
      lib/features/posts/widgets/filter_item_widget.dart
  49. 153
      lib/features/single_post/screen/single_post_screen.dart
  50. 120
      lib/features/single_post/view_models/post.dart
  51. 31
      lib/features/single_post/view_models/thumbnail.dart
  52. 278
      pubspec.lock
  53. 13
      pubspec.yaml

11
android/app/build.gradle

@ -44,20 +44,15 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.example.sonnat"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
minSdkVersion 16
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
}
}
@ -68,5 +63,5 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0"
}

24
android/app/src/main/AndroidManifest.xml

@ -1,31 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="sonnat"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="sonnat">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />

4
android/build.gradle

@ -1,12 +1,12 @@
buildscript {
ext.kotlin_version = '1.7.10'
ext.kotlin_version = '1.8.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
classpath 'com.android.tools.build:gradle:7.3.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

4007
assets/lottie/loading.json
File diff suppressed because it is too large
View File

18
lib/core/extensions/number_extension.dart

@ -0,0 +1,18 @@
import 'package:sonnat/core/utils/app_constants.dart';
extension NumberExtension on num {
double get sw {
if (this == 1) {
return double.maxFinite;
}
return toDouble() * AppConstants.instance.appWidth;
}
double get sh => toDouble() * AppConstants.instance.appHeight;
double get w => toDouble();
double get h => toDouble();
double get sp => toDouble();
}

489
lib/core/html/custom_render.dart

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

326
lib/core/html/flutter_html.dart

@ -0,0 +1,326 @@
library flutter_html;
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:sonnat/core/html/custom_render.dart';
import 'package:sonnat/core/html/html_parser.dart';
import 'package:sonnat/core/html/src/html_elements.dart';
import 'package:sonnat/core/html/style.dart';
class Html extends StatefulWidget {
Html({
super.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();
Html.fromDom({
super.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),
documentElement = document!.documentElement,
_anchorKey = anchorKey ?? GlobalKey();
Html.fromElement({
super.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();
/// 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 => List<String>.from(HtmlElements.styledElements)
..addAll(HtmlElements.interactableElements)
..addAll(HtmlElements.replacedElements)
..addAll(HtmlElements.layoutElements)
..addAll(HtmlElements.tableCellElements)
..addAll(HtmlElements.tableDefinitionElements)
..addAll(HtmlElements.externalElements);
@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 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(generateDefaultRenders()),
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({
super.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();
SelectableHtml.fromDom({
super.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),
documentElement = document!.documentElement,
_anchorKey = anchorKey ?? GlobalKey();
SelectableHtml.fromElement({
super.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();
/// 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
/// have a flexible width, that doesn't always fill its maximum width
/// constraints. For example, auto horizontal margins are ignored, and
/// block-level elements only take up the width they need.
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 =>
List<String>.from(HtmlElements.selectableElements);
@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 SizedBox(
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(generateDefaultRenders()),
tagsList:
widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList,
selectionControls: widget.selectionControls,
scrollPhysics: widget.scrollPhysics,
),
);
}
}

877
lib/core/html/html_parser.dart

@ -0,0 +1,877 @@
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 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as htmlparser;
import 'package:list_counter/list_counter.dart';
import 'package:sonnat/core/html/custom_render.dart';
import 'package:sonnat/core/html/flutter_html.dart';
import 'package:sonnat/core/html/src/anchor.dart';
import 'package:sonnat/core/html/src/css_box_widget.dart';
import 'package:sonnat/core/html/src/css_parser.dart';
import 'package:sonnat/core/html/src/html_elements.dart';
import 'package:sonnat/core/html/src/layout_element.dart';
import 'package:sonnat/core/html/src/style/fontsize.dart';
import 'package:sonnat/core/html/src/style/length.dart';
import 'package:sonnat/core/html/src/style/margin.dart';
import 'package:sonnat/core/html/src/style/marker.dart';
import 'package:sonnat/core/html/src/utils.dart';
import 'package:sonnat/core/html/style.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 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 super.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,
}) : internalOnAnchorTap = onAnchorTap ?? (key != null ? _handleAnchorTap(key, onLinkTap) : onLinkTap);
@override
Widget build(BuildContext context) {
// Lexing Step
StyledElement lexedTree = lexDomTree(
htmlData,
customRenders.keys.toList(),
tagsList,
context,
this,
);
// Styling Step
StyledElement styledTree = styleTree(lexedTree, htmlData, style, onCssParseError);
// Processing Step
StyledElement processedTree = processTree(styledTree, MediaQuery.of(context).devicePixelRatio);
// Parsing Step
InlineSpan parsedTree = parseTree(
RenderContext(
buildContext: context,
parser: this,
tree: processedTree,
style: processedTree.style,
),
processedTree,
);
return CssBoxWidget.withInlineSpanChildren(
style: processedTree.style,
children: [parsedTree],
selectable: selectable,
scrollPhysics: scrollPhysics,
selectionControls: selectionControls,
shrinkWrap: shrinkWrap,
);
}
/// [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.bodyMedium!),
);
for (var node in html.nodes) {
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>[];
for (var childNode in node.nodes) {
children.add(_recursiveLexer(
childNode,
customRenderMatchers,
tagsList,
context,
parser,
));
}
if (node is dom.Element) {
if (!tagsList.contains(node.localName)) {
return EmptyContentElement();
}
if (HtmlElements.styledElements.contains(node.localName)) {
return parseStyledElement(node, children);
}
if (HtmlElements.interactableElements.contains(node.localName)) {
return parseInteractableElement(node, children);
}
if (HtmlElements.replacedElements.contains(node.localName)) {
return parseReplacedElement(node, children);
}
if (HtmlElements.layoutElements.contains(node.localName)) {
return parseLayoutElement(node, children);
}
if (HtmlElements.tableCellElements.contains(node.localName)) {
return parseTableCellElement(node, children);
}
if (HtmlElements.tableDefinitionElements.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.bodyMedium!),
),
)) {
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 (_) {}
});
for (var element in tree.children) {
_applyExternalCss(declarations, element);
}
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);
}
}
for (var element in tree.children) {
_applyInlineStyles(element, 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 (_) {}
});
for (var element in tree.children) {
_applyCustomStyles(style, element);
}
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) {
for (var child in tree.children) {
child.style = tree.style.copyOnlyInherited(child.style);
_cascadeStyles(style, child);
}
return tree;
}
/// [styleTree] takes the lexed [StyleElement] tree and applies external,
/// inline, and custom CSS/Flutter styles, and then cascades the styles down the tree.
static StyledElement styleTree(
StyledElement tree, dom.Element htmlData, Map<String, Style> style, OnCssParseError? onCssParseError) {
Map<String, Map<String, List<css.Expression>>> declarations =
_getExternalCssDeclarations(htmlData.getElementsByTagName('style'), onCssParseError);
StyledElement? externalCssStyledTree;
if (declarations.isNotEmpty) {
externalCssStyledTree = _applyExternalCss(declarations, tree);
}
tree = _applyInlineStyles(externalCssStyledTree ?? tree, onCssParseError);
tree = _applyCustomStyles(style, tree);
tree = _cascadeStyles(style, tree);
return tree;
}
/// [processTree] 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 processTree(StyledElement tree, double devicePixelRatio) {
tree = _processInternalWhitespace(tree);
tree = _processInlineWhitespace(tree);
tree = _removeEmptyElements(tree);
tree = _calculateRelativeValues(tree, devicePixelRatio);
tree = _preprocessListMarkers(tree);
tree = _processCounters(tree);
tree = _processListMarkers(tree);
tree = _processBeforesAndAfters(tree);
tree = _collapseMargins(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)) {
List<InlineSpan> buildChildren() => tree.children.map((tree) => parseTree(newContext, tree)).toList();
if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) {
List<TextSpan> 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: CssBoxWidget(
style: tree.style,
shrinkWrap: newContext.parser.shrinkWrap,
childIsReplaced: true,
child: customRenders[entry]!.widget!.call(newContext, buildChildren),
),
);
}
}
return const WidgetSpan(child: SizedBox(height: 0, width: 0));
}
static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => (url, context, attributes, 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(' ');
}
}
for (var element in tree.children) {
_processInlineWhitespaceRecursive(element, 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,}'), ' ');
}
/// [preprocessListMarkers] adds marker pseudo elements to the front of all list
/// items.
static StyledElement _preprocessListMarkers(StyledElement tree) {
tree.style.listStylePosition ??= ListStylePosition.outside;
if (tree.style.display == Display.listItem) {
// Add the marker pseudo-element if it doesn't exist
tree.style.marker ??= Marker(
content: Content.normal,