diff --git a/.circleci/config.yml b/.circleci/config.yml index 16c40c66b9..010547222b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,13 +1,40 @@ version: 2.1 orbs: codecov: codecov/codecov@1.0.2 +executors: + default-executor: + docker: + - image: cirrusci/flutter:stable + resource_class: large + shell: /bin/bash jobs: build: - docker: - - image: cirrusci/flutter + executor: default-executor steps: - - checkout - - run: flutter --version - - run: flutter test --coverage - - codecov/upload: - file: coverage/lcov.info + - checkout + - run: flutter --version + - run: + name: Set up environment + command: | + echo 'export PATH=$HOME/.pub-cache/bin:$PATH' >> $BASH_ENV + source $BASH_ENV + - run: + name: Setup melos + command: | + flutter pub global activate melos + melos --version + melos bootstrap + - run: + name: Run Test Suite + command: melos run test + - run: + name: Generate Coverage Report + command: melos run gen_coverage + - codecov/upload: + file: coverage_report/lcov.info + - run: + name: Run flutter analyze + command: melos run analyze + - run: + name: Check That Flutter Code is Formatted Correctly + command: flutter format -o none --set-exit-if-changed . diff --git a/.gitignore b/.gitignore index 34258e6aad..caf0d56dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,7 @@ modules.xml **/flutter_export_environment.sh /example/ios/Flutter/Flutter.podspec + +packages/**/pubspec_overrides.yaml +./pubspec_overrides.yaml +/example/pubspec_overrides.yaml \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..c3be203555 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..bd44fe6c28 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "editor.wordWrapColumn": 80, + "dart.lineLength": 80, // This weird short line length is used to match source repository code formatting and prevent a lot of merge conflicts in future + "editor.rulers": [ + 80 + ], + "git.mergeEditor": true, + "files.exclude": { + "**/.git": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5879aafab7..122bb26fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## 3.0.0-alpha.6 - *September 2022* + + - **FIX** #731 Resolve newline `
` issue + - **FIX** Align the baseline of inline content with the baseline of its parent flow, even if it has padding or borders + - **FIX** Improved fontSize inheritance when cascading styles + - **FIX** `auto` margins now work for any `Display.BLOCK` element. + - **FIX** `auto` width and height is now the default, rather than `null` + - **FIX** New CSSBoxWidget that handles calculations of child sizes for a more accurate HTML/CSS layout + - **BREAKING** New `Margin`, `Height`, and `Width` classes that allow `em`, `rem`, `px`, `auto`, and `%` values to be given + - **FEAT** Negative margins are now allowed + - **FIX** Updated default `p` and `h1-6` styles to use `em` for better font scaling + - **BREAKING** Package now requires Dart sdk >= Dart 2.17 + - **FIX**: Apply margins to properly. (7581ea79) + - **FIX**: Use enum instead of const int internally in length.dart. (9dc7f08c) + - **FIX**: Change CSSBoxWidget to CssBoxWidget. (a62449a7) + - **FIX**: fix textShadow color declaration handler. (77ffe7cb) + - **FIX**: ol use default style. (1c2412a2) + - **FIX**: Crash when a tr tag includes text node. (ba8301c9) + - **FEAT**: exposes fontFamilyFallback parameter. (1d65aafd) + +## [3.0.0-alpha.5] - June 9, 2022: +* Fixed hot reloads, thanks @arjenmels +* Fixed link taps not working +* Improvements in README + +## [3.0.0-alpha.3] - April 14, 2022: +* Fixed styling not being applied to list item markers +* [video] Fixed crash when iframe or video tags used unsupported/incorrect height or width + ## [3.0.0-alpha.2] - January 5, 2022: * **BREAKING** Full modularization using split packages; see our upgrade guide or use flutter_html_all diff --git a/README.md b/README.md index 326ac6ccb7..9210a56651 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ A Flutter widget for rendering HTML and CSS as Flutter widgets. Add the following to your `pubspec.yaml` file: dependencies: - flutter_html: ^3.0.0-alpha.2 + flutter_html: ^3.0.0-alpha.5 + // Or flutter_html_all: ^3.0.0-alpha.5 to include table, video, audio, iframe... ## Currently Supported HTML Tags: | | | | | | | | | | | | @@ -497,7 +498,7 @@ Widget html = Html( - + @@ -566,6 +567,11 @@ The package considers the attributes `controls`, `loop`, `src`, `autoplay`, `wid #### Registering the `CustomRender`: +Add the dependency to your pubspec.yaml: + + dependencies: + flutter_html_audio: ^3.0.0-alpha.3 + ```dart Widget html = Html( customRenders: { @@ -584,6 +590,11 @@ Sandbox controls the JavaScript mode of the webview - a value of `null` or `allo #### Registering the `CustomRender`: +Add the dependency to your pubspec.yaml: + + dependencies: + flutter_html_iframe: ^3.0.0-alpha.3 + ```dart Widget html = Html( customRenders: { @@ -620,6 +631,11 @@ Because this package is parsing MathML to Tex, it may not support some functiona #### Registering the `CustomRender`: +Add the dependency to your pubspec.yaml: + + dependencies: + flutter_html_math: ^3.0.0-alpha.3 + ```dart Widget html = Html( customRenders: { @@ -688,6 +704,11 @@ The package also exposes a few ways to render SVGs within an `` tag, specif #### Registering the `CustomRender`: +Add the dependency to your pubspec.yaml: + + dependencies: + flutter_html_svg: ^3.0.0-alpha.3 + ```dart Widget html = Html( customRenders: { @@ -707,6 +728,11 @@ When rendering table elements, the package tries to calculate the best fit for e #### Registering the `CustomRender`: +Add the dependency to your pubspec.yaml: + + dependencies: + flutter_html_table: ^3.0.0-alpha.3 + ```dart Widget html = Html( customRenders: { @@ -723,6 +749,11 @@ The package considers the attributes `controls`, `loop`, `src`, `autoplay`, `pos #### Registering the `CustomRender`: +Add the dependency to your pubspec.yaml: + + dependencies: + flutter_html_video: ^3.0.0-alpha.3 + ```dart Widget html = Html( customRenders: { @@ -750,7 +781,8 @@ Widget row = Row( ``` ## Migration Guides -- For Version 1.0 - [Guide](https://github.com/Sub6Resources/flutter_html/wiki/1.0.0-Migration-Guide) +- For Version 1.0/2.0 - [Guide](https://github.com/Sub6Resources/flutter_html/wiki/1.0.0-Migration-Guide) +- For Version 3.0 - **TODO** ## Contribution Guide > Coming soon! diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000000..f065cf1ea4 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options \ No newline at end of file diff --git a/combine_coverage.sh b/combine_coverage.sh new file mode 100755 index 0000000000..8831e1fa6a --- /dev/null +++ b/combine_coverage.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +escapedPath="$(echo `pwd` | sed 's/\//\\\//g')" + +if grep flutter pubspec.yaml > /dev/null; then + if [ -d "coverage" ]; then + # combine line coverage info from package tests to a common file + if [ ! -d "$MELOS_ROOT_PATH/coverage_report" ]; then + mkdir "$MELOS_ROOT_PATH/coverage_report" + fi + sed "s/^SF:lib/SF:$escapedPath\/lib/g" coverage/lcov.info >> "$MELOS_ROOT_PATH/coverage_report/lcov.info" + rm -rf "coverage" + fi +fi \ No newline at end of file diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 3747830b03..4fcf8de648 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -8,7 +8,7 @@ if (localPropertiesFile.exists()) { def flutterRoot = localProperties.getProperty('flutter.sdk') if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") + throw new FileNotFoundException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') @@ -25,7 +25,7 @@ apply plugin: 'com.android.application' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 31 + compileSdkVersion 33 lintOptions { disable 'InvalidPackage' diff --git a/example/lib/generated_plugin_registrant.dart b/example/lib/generated_plugin_registrant.dart index fd95b4ff6c..fc500d7eff 100644 --- a/example/lib/generated_plugin_registrant.dart +++ b/example/lib/generated_plugin_registrant.dart @@ -4,6 +4,7 @@ // ignore_for_file: directives_ordering // ignore_for_file: lines_longer_than_80_chars +// ignore_for_file: depend_on_referenced_packages import 'package:video_player_web/video_player_web.dart'; import 'package:wakelock_web/wakelock_web.dart'; diff --git a/example/lib/main.dart b/example/lib/main.dart index 550ad15bd1..3392ac14ef 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,31 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html_all/flutter_html_all.dart'; -import 'package:flutter_math_fork/flutter_math.dart'; -void main() => runApp(new MyApp()); +void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { + const MyApp({super.key}); + // This widget is the root of your application. @override Widget build(BuildContext context) { - return new MaterialApp( + return MaterialApp( title: 'Flutter Demo', - theme: new ThemeData( + theme: ThemeData( primarySwatch: Colors.deepPurple, ), - home: new MyHomePage(title: 'flutter_html Example'), + home: const MyHomePage(title: 'flutter_html Example'), ); } } class MyHomePage extends StatefulWidget { - MyHomePage({Key? key, required this.title}) : super(key: key); + const MyHomePage({Key? key, required this.title}) : super(key: key); final String title; @override - _MyHomePageState createState() => new _MyHomePageState(); + MyHomePageState createState() => MyHomePageState(); } const htmlData = r""" @@ -55,10 +56,20 @@ const htmlData = r"""

The should be BLACK with 10% alpha style='color: rgba(0, 0, 0, 0.10);

The should be GREEN style='color: rgb(0, 97, 0);

The should be GREEN style='color: rgb(0, 97, 0);

-

blasdafjklasdlkjfkl

-

blasdafjklasdlkjfkl

-

blasdafjklasdlkjfkl

-

blasdafjklasdlkjfkl

+

Text Alignment

+

Center Aligned Text

+

Right Aligned Text

+

Justified Text

+

Center Aligned Text

+

Auto Margins

+
Default Div
+
margin: auto
+
margin: 15px auto
+
margin-left: auto
+

With an image - non-block (should not center):

+ +

block image (should center):

+

Table support (with custom styling!):

Famous quote... @@ -73,7 +84,7 @@ const htmlData = r"""

- + @@ -241,98 +252,126 @@ const htmlData = r"""

Scroll to top

"""; -class _MyHomePageState extends State { +final staticAnchorKey = GlobalKey(); + +class MyHomePageState extends State { @override Widget build(BuildContext context) { - return new Scaffold( + return Scaffold( appBar: AppBar( - title: Text('flutter_html Example'), + title: const Text('flutter_html Example'), centerTitle: true, ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.arrow_downward), + onPressed: () { + final anchorContext = + AnchorKey.forId(staticAnchorKey, "bottom")?.currentContext; + if (anchorContext != null) { + Scrollable.ensureVisible(anchorContext); + } + }, + ), body: SingleChildScrollView( child: Html( + anchorKey: staticAnchorKey, data: htmlData, style: { "table": Style( - backgroundColor: Color.fromARGB(0x50, 0xee, 0xee, 0xee), + backgroundColor: const Color.fromARGB(0x50, 0xee, 0xee, 0xee), ), "tr": Style( - border: Border(bottom: BorderSide(color: Colors.grey)), + border: const Border(bottom: BorderSide(color: Colors.grey)), ), "th": Style( - padding: EdgeInsets.all(6), + padding: const EdgeInsets.all(6), backgroundColor: Colors.grey, ), "td": Style( - padding: EdgeInsets.all(6), + padding: const EdgeInsets.all(6), alignment: Alignment.topLeft, ), 'h5': Style(maxLines: 2, textOverflow: TextOverflow.ellipsis), }, tagsList: Html.tags..addAll(['tex', 'bird', 'flutter']), customRenders: { - tagMatcher("tex"): CustomRender.widget(widget: (context, buildChildren) => Math.tex( - context.tree.element?.innerHtml ?? '', - mathStyle: MathStyle.display, - textStyle: context.style.generateTextStyle(), - onErrorFallback: (FlutterMathException e) { - return Text(e.message); - }, - )), - tagMatcher("bird"): CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan(text: "🐦")), - tagMatcher("flutter"): CustomRender.widget(widget: (context, buildChildren) => FlutterLogo( - style: (context.tree.element!.attributes['horizontal'] != null) - ? FlutterLogoStyle.horizontal - : FlutterLogoStyle.markOnly, - textColor: context.style.color!, - size: context.style.fontSize!.size! * 5, - )), - tagMatcher("table"): CustomRender.widget(widget: (context, buildChildren) => SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: tableRender.call().widget!.call(context, buildChildren), - )), + tagMatcher("tex"): CustomRender.widget( + widget: (context, buildChildren) => Math.tex( + context.tree.element?.innerHtml ?? '', + mathStyle: MathStyle.display, + textStyle: + context.style.generateTextStyle(context.buildContext), + onErrorFallback: (FlutterMathException e) { + return Text(e.message); + }, + )), + tagMatcher("bird"): CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => + const TextSpan(text: "🐦")), + tagMatcher("flutter"): CustomRender.widget( + widget: (context, buildChildren) => FlutterLogo( + style: (context.tree.element!.attributes['horizontal'] != + null) + ? FlutterLogoStyle.horizontal + : FlutterLogoStyle.markOnly, + textColor: context.style.color!, + size: context.style.fontSize!.value * 5, + )), + tagMatcher("table"): CustomRender.widget( + widget: (context, buildChildren) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: tableRender + .call() + .widget! + .call(context, buildChildren), + )), audioMatcher(): audioRender(), iframeMatcher(): iframeRender(), - mathMatcher(): mathRender(onMathError: (error, exception, exceptionWithType) { - print(exception); + mathMatcher(): + mathRender(onMathError: (error, exception, exceptionWithType) { + debugPrint(exception); return Text(exception); }), svgTagMatcher(): svgTagRender(), svgDataUriMatcher(): svgDataImageRender(), svgAssetUriMatcher(): svgAssetImageRender(), svgNetworkSourceMatcher(): svgNetworkImageRender(), - networkSourceMatcher(domains: ["flutter.dev"]): CustomRender.widget( - widget: (context, buildChildren) { - return FlutterLogo(size: 36); - }), + networkSourceMatcher(domains: ["flutter.dev"]): + CustomRender.widget(widget: (context, buildChildren) { + return const FlutterLogo(size: 36); + }), networkSourceMatcher(domains: ["mydomain.com"]): networkImageRender( headers: {"Custom-Header": "some-value"}, altWidget: (alt) => Text(alt ?? ""), - loadingWidget: () => Text("Loading..."), + loadingWidget: () => const Text("Loading..."), ), // On relative paths starting with /wiki, prefix with a base url - (context) => context.tree.element?.attributes["src"] != null - && context.tree.element!.attributes["src"]!.startsWith("/wiki"): - networkImageRender(mapUrl: (url) => "https://upload.wikimedia.org" + url!), + (context) => + context.tree.element?.attributes["src"] != null && + context.tree.element!.attributes["src"]! + .startsWith("/wiki"): networkImageRender( + mapUrl: (url) => "https://upload.wikimedia.org${url!}"), // Custom placeholder image for broken links - networkSourceMatcher(): networkImageRender(altWidget: (_) => FlutterLogo()), + networkSourceMatcher(): + networkImageRender(altWidget: (_) => const FlutterLogo()), videoMatcher(): videoRender(), }, onLinkTap: (url, _, __, ___) { - print("Opening $url..."); + debugPrint("Opening $url..."); }, onImageTap: (src, _, __, ___) { - print(src); + debugPrint(src); }, onImageError: (exception, stackTrace) { - print(exception); + debugPrint(exception.toString()); }, onCssParseError: (css, messages) { - print("css that errored: $css"); - print("error messages:"); - messages.forEach((element) { - print(element); - }); + debugPrint("css that errored: $css"); + debugPrint("error messages:"); + for (var element in messages) { + debugPrint(element.toString()); + } + return ''; }, ), ), @@ -340,8 +379,11 @@ class _MyHomePageState extends State { } } -CustomRenderMatcher texMatcher() => (context) => context.tree.element?.localName == 'tex'; +CustomRenderMatcher texMatcher() => + (context) => context.tree.element?.localName == 'tex'; -CustomRenderMatcher birdMatcher() => (context) => context.tree.element?.localName == 'bird'; +CustomRenderMatcher birdMatcher() => + (context) => context.tree.element?.localName == 'bird'; -CustomRenderMatcher flutterMatcher() => (context) => context.tree.element?.localName == 'flutter'; +CustomRenderMatcher flutterMatcher() => + (context) => context.tree.element?.localName == 'flutter'; diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 1c2c9b0787..8e40e9f075 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,12 @@ import FlutterMacOS import Foundation -import wakelock_macos +import package_info_plus +import video_player_avfoundation +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 79e23feab9..8e1d7d66a5 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: none version: 1.0.0+1 environment: - sdk: '>=2.12.0 <3.0.0' + sdk: ">=3.1.0 <4.0.0" dependencies: flutter_html: @@ -13,15 +13,16 @@ dependencies: path: ../packages/flutter_html_all flutter: sdk: flutter + wakelock_web: ^0.4.0 dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^2.0.1 flutter: - uses-material-design: true assets: - assets/html5.png - - assets/mac.svg \ No newline at end of file + - assets/mac.svg diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 747db1da35..0000000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/lib/custom_render.dart b/lib/custom_render.dart index 9b4abd8890..956562e7ad 100644 --- a/lib/custom_render.dart +++ b/lib/custom_render.dart @@ -6,44 +6,51 @@ import 'dart:convert'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/src/utils.dart'; typedef CustomRenderMatcher = bool Function(RenderContext context); CustomRenderMatcher tagMatcher(String tag) => (context) { - return context.tree.element?.localName == tag; -}; + 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"); -}; + 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.LIST_ITEM; -}; + 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'); -}; + return context.tree is ReplacedElement; + }; + +CustomRenderMatcher dataUriMatcher( + {String? encoding = 'base64', String? mime}) => + (context) { + if (context.tree.element?.attributes == null || + _src(context.tree.element!.attributes.cast()) == null) return false; + final dataUri = _dataUriFormat + .firstMatch(_src(context.tree.element!.attributes.cast())!); + return dataUri != null && + dataUri.namedGroup('mime') != "image/svg+xml" && + (mime == null || dataUri.namedGroup('mime') == mime) && + (encoding == null || dataUri.namedGroup('encoding') == ';$encoding'); + }; CustomRenderMatcher networkSourceMatcher({ - List schemas: const ["https", "http"], + List schemas = const ["https", "http"], List? domains, String? extension, }) => - (context) { - if (context.tree.element?.attributes.cast() == null - || _src(context.tree.element!.attributes.cast()) == null) return false; + (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) && @@ -55,34 +62,35 @@ CustomRenderMatcher networkSourceMatcher({ }; 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"); + 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; -}; + return context.tree is TextContentElement; + }; CustomRenderMatcher interactableElementMatcher() => (context) { - return context.tree is InteractableElement; -}; + return context.tree is InteractableElement; + }; CustomRenderMatcher layoutElementMatcher() => (context) { - return context.tree is LayoutElement; -}; + return context.tree is LayoutElement; + }; CustomRenderMatcher verticalAlignMatcher() => (context) { - return context.tree.style.verticalAlign != null - && context.tree.style.verticalAlign != VerticalAlign.BASELINE; -}; + return context.tree.style.verticalAlign != null && + context.tree.style.verticalAlign != VerticalAlign.baseline; + }; CustomRenderMatcher fallbackMatcher() => (context) { - return true; -}; + return true; + }; class CustomRender { - final InlineSpan Function(RenderContext, List Function())? inlineSpan; + final InlineSpan Function(RenderContext, List Function())? + inlineSpan; final Widget Function(RenderContext, List Function())? widget; CustomRender.inlineSpan({ @@ -102,188 +110,177 @@ class SelectableCustomRender extends CustomRender { }) : super.inlineSpan(inlineSpan: null); } -CustomRender blockElementRender({ - Style? style, - List? children}) => +CustomRender blockElementRender({Style? style, List? children}) => CustomRender.inlineSpan(inlineSpan: (context, buildChildren) { - if (context.parser.selectable) { - return TextSpan( - style: context.style.generateTextStyle(), - children: (children as List?) ?? context.tree.children - .expandIndexed((i, childTree) => [ - 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 (context.parser.shrinkWrap && - i != context.tree.children.length - 1 && - childTree.style.display == Display.BLOCK && - childTree.element?.localName != "html" && - childTree.element?.localName != "body") - TextSpan(text: "\n"), - ]) - .toList(), - )); + if (context.parser.selectable) { + return TextSpan( + style: context.style.generateTextStyle(context.buildContext), + children: (children as List?) ?? + context.tree.children + .expandIndexed((i, childTree) => [ + if (childTree.style.display == Display.block && + i > 0 && + context.tree.children[i - 1] is ReplacedElement) + const 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") + const TextSpan(text: "\n"), + ]) + .toList(), + ); + } + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: CssBoxWidget.withInlineSpanChildren( + key: context.key, + context: context.buildContext, + style: style ?? context.tree.style, + shrinkWrap: context.parser.shrinkWrap, + childIsReplaced: + HtmlElements.replacedExternalElements.contains(context.tree.name), + 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) + const TextSpan(text: "\n"), + context.parser.parseTree(context, childTree), + if (context.parser.shrinkWrap && + i != context.tree.children.length - 1 && + childTree.style.display == Display.block && + childTree.element?.localName != "html" && + childTree.element?.localName != "body") + const TextSpan(text: "\n"), + ]) + .toList(), + ), + ); }); -CustomRender listElementRender({ - Style? style, - Widget? child, - List? children}) => - 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("\t", 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(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; +CustomRender listElementRender( + {Style? style, Widget? child, List? children}) { + return CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) { + return WidgetSpan( + child: CssBoxWidget.withInlineSpanChildren( + key: context.key, + context: context.buildContext, + style: style ?? context.style, + shrinkWrap: context.parser.shrinkWrap, + children: buildChildren(), + ), + ); }, ); - 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 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( + 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(context.buildContext)); + } + 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.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(context.buildContext)); + } + return child; + }, + ); + return Builder( + key: context.key, + builder: (buildContext) { + return GestureDetector( + child: widget, + onTap: () { + if (MultipleTapGestureDetector.of(buildContext) != null) { + MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); + } + context.parser.onImageTap?.call( + assetPath, + context, + context.tree.element!.attributes.cast(), + context.tree.element); + }, ); - }, - ); - } - ); -}); + }); + }); CustomRender networkImageRender({ Map? headers, @@ -292,205 +289,215 @@ CustomRender networkImageRender({ double? height, Widget Function(String?)? altWidget, Widget Function()? loadingWidget, -}) => CustomRender.widget(widget: (context, buildChildren) { - final src = mapUrl?.call(_src(context.tree.element!.attributes.cast())) - ?? _src(context.tree.element!.attributes.cast())!; - Completer completer = Completer(); - if (context.parser.cachedImageSizes[src] != null) { - completer.complete(context.parser.cachedImageSizes[src]); - } else { - Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) { - if (frame == null) { - if (!completer.isCompleted) { - completer.completeError("error"); - } - return child; +}) => + CustomRender.widget(widget: (context, buildChildren) { + final src = mapUrl?.call(_src(context.tree.element!.attributes.cast())) ?? + _src(context.tree.element!.attributes.cast())!; + Completer completer = Completer(); + if (context.parser.cachedImageSizes[src] != null) { + completer.complete(context.parser.cachedImageSizes[src]); } else { - 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 = 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(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); } - }); - - image.image.resolve(ImageConfiguration()).addListener(listener); - } - final attributes = context.tree.element!.attributes.cast(); - final widget = FutureBuilder( - future: completer.future, - initialData: context.parser.cachedImageSizes[src], - builder: (BuildContext buildContext, AsyncSnapshot 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()); + final attributes = + context.tree.element!.attributes.cast(); + final widget = FutureBuilder( + future: completer.future, + initialData: context.parser.cachedImageSizes[src], + builder: (BuildContext buildContext, AsyncSnapshot 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(context.buildContext)); + } + 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(context.buildContext)); + } 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(); } - return child; + context.parser.onImageTap?.call( + src, + context, + context.tree.element!.attributes.cast(), + context.tree.element); }, - ), - ), - ); - } else if (snapshot.hasError) { - return altWidget?.call(_alt(context.tree.element!.attributes.cast())) ?? - Text(_alt(context.tree.element!.attributes.cast()) - ?? "", style: context.style.generateTextStyle()); - } else { - return loadingWidget?.call() ?? const CircularProgressIndicator(); - } - }, - ); - return Builder( - key: context.key, - builder: (buildContext) { - return GestureDetector( - child: widget, - onTap: () { - if (MultipleTapGestureDetector.of(buildContext) != null) { - MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); - } - context.parser.onImageTap?.call( - src, - context, - context.tree.element!.attributes.cast(), - context.tree.element ); - }, - ); - } - ); -}); + }); + }); CustomRender interactableElementRender({List? children}) => - CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan( - children: children ?? (context.tree as InteractableElement).children - .map((tree) => context.parser.parseTree(context, tree)) - .map((childSpan) { - return _getInteractableChildren(context, context.tree as InteractableElement, childSpan, - context.style.generateTextStyle().merge(childSpan.style)); - }).toList(), -)); - -CustomRender layoutElementRender({Widget? child}) => - CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => WidgetSpan( - child: child ?? (context.tree as LayoutElement).toWidget(context)!, -)); - -CustomRender verticalAlignRender({ - double? verticalOffset, - Style? style, - List? children}) => - CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => WidgetSpan( - child: Transform.translate( - key: context.key, - offset: Offset(0, verticalOffset ?? _getVerticalOffset(context.tree)), - child: StyledText( - textSpan: TextSpan( - style: style?.generateTextStyle() ?? context.style.generateTextStyle(), - children: children ?? buildChildren.call(), - ), - style: context.style, - renderContext: context, - ), - ), -)); + 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(context.buildContext) + .merge(childSpan.style)); + }).toList(), + )); + +CustomRender layoutElementRender({Widget? child}) => CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => WidgetSpan( + child: child ?? (context.tree as LayoutElement).toWidget(context)!, + )); + +CustomRender verticalAlignRender( + {double? verticalOffset, Style? style, List? children}) => + CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => WidgetSpan( + child: Transform.translate( + key: context.key, + offset: Offset( + 0, verticalOffset ?? _getVerticalOffset(context.tree)), + child: CssBoxWidget.withInlineSpanChildren( + context: context.buildContext, + children: children ?? buildChildren.call(), + style: context.style, + ), + ), + )); CustomRender fallbackRender({Style? style, List? children}) => - CustomRender.inlineSpan(inlineSpan: (context, buildChildren) => TextSpan( - style: style?.generateTextStyle() ?? context.style.generateTextStyle(), - children: context.tree.children - .expand((tree) => [ - context.parser.parseTree(context, tree), - if (tree.style.display == Display.BLOCK && - tree.element?.parent?.localName != "th" && - tree.element?.parent?.localName != "td" && - tree.element?.localName != "html" && - tree.element?.localName != "body") - TextSpan(text: "\n"), - ]) - .toList(), -)); - -final Map defaultRenders = { - blockElementMatcher(): blockElementRender(), - listElementMatcher(): listElementRender(), - textContentElementMatcher(): textContentElementRender(), - dataUriMatcher(): base64ImageRender(), - assetUriMatcher(): assetImageRender(), - networkSourceMatcher(): networkImageRender(), - replacedElementMatcher(): replacedElementRender(), - interactableElementMatcher(): interactableElementRender(), - layoutElementMatcher(): layoutElementRender(), - verticalAlignMatcher(): verticalAlignRender(), - fallbackMatcher(): fallbackRender(), -}; - -List _getListElementChildren(ListStylePosition? position, Function() buildChildren) { - List 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; + CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => TextSpan( + style: style?.generateTextStyle(context.buildContext) ?? + context.style.generateTextStyle(context.buildContext), + children: context.tree.children + .expand((tree) => [ + context.parser.parseTree(context, tree), + if (tree.style.display == Display.block && + tree.element?.parent?.localName != "th" && + tree.element?.parent?.localName != "td" && + tree.element?.localName != "html" && + tree.element?.localName != "body") + const TextSpan(text: "\n"), + ]) + .toList(), + )); + +Map generateDefaultRenders() { + return { + blockElementMatcher(): blockElementRender(), + listElementMatcher(): listElementRender(), + textContentElementMatcher(): textContentElementRender(), + dataUriMatcher(): base64ImageRender(), + assetUriMatcher(): assetImageRender(), + networkSourceMatcher(): networkImageRender(), + replacedElementMatcher(): replacedElementRender(), + interactableElementMatcher(): interactableElementRender(), + layoutElementMatcher(): layoutElementRender(), + verticalAlignMatcher(): verticalAlignRender(), + fallbackMatcher(): fallbackRender(), + }; } -InlineSpan _getInteractableChildren(RenderContext context, InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) { +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))) + ?.map((e) => _getInteractableChildren( + context, tree, e, childStyle.merge(childSpan.style))) .toList(), - style: context.style.generateTextStyle().merge( + style: context.style.generateTextStyle(context.buildContext).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, + ..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) + ? () => 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) + ? () => context.parser.internalOnAnchorTap!( + tree.href, context, tree.attributes, tree.element) : null, child: (childSpan as WidgetSpan).child, ), @@ -499,14 +506,15 @@ InlineSpan _getInteractableChildren(RenderContext context, InteractableElement t } } -final _dataUriFormat = RegExp("^(?data):(?image\/[\\w\+\-\.]+)(?;base64)?\,(?.*)"); +final _dataUriFormat = RegExp( + "^(?data):(?image\\/[\\w\\+\\-\\.]+)(?;base64)?\\,(?.*)"); 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; + case VerticalAlign.sub: + return tree.style.fontSize!.value / 2.5; + case VerticalAlign.sup: + return tree.style.fontSize!.value / -2.5; default: return 0; } @@ -522,12 +530,16 @@ String? _alt(Map attributes) { double? _height(Map attributes) { final heightString = attributes["height"]; - return heightString == null ? heightString as double? : double.tryParse(heightString); + return heightString == null + ? heightString as double? + : double.tryParse(heightString); } double? _width(Map attributes) { final widthString = attributes["width"]; - return widthString == null ? widthString as double? : double.tryParse(widthString); + return widthString == null + ? widthString as double? + : double.tryParse(widthString); } double _aspectRatio( @@ -545,5 +557,6 @@ double _aspectRatio( } extension ClampedEdgeInsets on EdgeInsetsGeometry { - EdgeInsetsGeometry get nonNegative => this.clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity)); + EdgeInsetsGeometry get nonNegative => + clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity)); } diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index f0b33d547d..cc84284255 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -7,17 +7,18 @@ import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/style.dart'; import 'package:html/dom.dart' as dom; +export 'package:flutter_html/custom_render.dart'; //export render context api export 'package:flutter_html/html_parser.dart'; -//export render context api -export 'package:flutter_html/html_parser.dart'; -export 'package:flutter_html/custom_render.dart'; + //export src for advanced custom render uses (e.g. casting context.tree) export 'package:flutter_html/src/anchor.dart'; export 'package:flutter_html/src/interactable_element.dart'; export 'package:flutter_html/src/layout_element.dart'; export 'package:flutter_html/src/replaced_element.dart'; export 'package:flutter_html/src/styled_element.dart'; +//export css_box_widget for use in custom render. +export 'package:flutter_html/src/css_box_widget.dart'; //export style api export 'package:flutter_html/style.dart'; @@ -60,8 +61,12 @@ class Html extends StatefulWidget { this.onImageTap, this.tagsList = const [], this.style = const {}, - }) : documentElement = null, - assert (data != null), + this.loadingPlaceholder, + this.onContentRendered, + this.textScaleFactor, + this.shouldSkipStyle, + }) : documentElement = null, + assert(data != null), _anchorKey = anchorKey ?? GlobalKey(), super(key: key); @@ -78,9 +83,13 @@ class Html extends StatefulWidget { this.onImageTap, this.tagsList = const [], this.style = const {}, - }) : data = null, + this.loadingPlaceholder, + this.onContentRendered, + this.textScaleFactor, + this.shouldSkipStyle, + }) : data = null, assert(document != null), - this.documentElement = document!.documentElement, + documentElement = document!.documentElement, _anchorKey = anchorKey ?? GlobalKey(), super(key: key); @@ -97,7 +106,11 @@ class Html extends StatefulWidget { this.onImageTap, this.tagsList = const [], this.style = const {}, - }) : data = null, + this.loadingPlaceholder, + this.onContentRendered, + this.textScaleFactor, + this.shouldSkipStyle, + }) : data = null, assert(documentElement != null), _anchorKey = anchorKey ?? GlobalKey(), super(key: key); @@ -141,31 +154,51 @@ class Html extends StatefulWidget { /// An API that allows you to override the default style for any HTML element final Map style; - static List get tags => new List.from(STYLED_ELEMENTS) - ..addAll(INTERACTABLE_ELEMENTS) - ..addAll(REPLACED_ELEMENTS) - ..addAll(LAYOUT_ELEMENTS) - ..addAll(TABLE_CELL_ELEMENTS) - ..addAll(TABLE_DEFINITION_ELEMENTS) - ..addAll(EXTERNAL_ELEMENTS); + /// Could be used to control style assignment during parsing HTML tree + /// Note that this variable should be set with care, as skipping styles may result in unintended formatting or layout issues. + final SkipStyleFunction? shouldSkipStyle; + + final Widget? loadingPlaceholder; + final OnContentRendered? onContentRendered; + final double? textScaleFactor; + + static List get tags => List.from(HtmlElements.styledElements) + ..addAll(HtmlElements.interactableElements) + ..addAll(HtmlElements.replacedElements) + ..addAll(HtmlElements.layoutElements) + ..addAll(HtmlElements.tableCellElements) + ..addAll(HtmlElements.tableDefinitionElements) + ..addAll(HtmlElements.externalElements); @override State createState() => _HtmlState(); } class _HtmlState extends State { - late final dom.Element documentElement; + late dom.Element documentElement; @override void initState() { super.initState(); - documentElement = - widget.data != null ? HtmlParser.parseHTML(widget.data!) : widget.documentElement!; + 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( + return SizedBox( width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width, child: HtmlParser( key: widget._anchorKey, @@ -180,8 +213,12 @@ class _HtmlState extends State { style: widget.style, customRenders: {} ..addAll(widget.customRenders) - ..addAll(defaultRenders), + ..addAll(generateDefaultRenders()), tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList, + loadingPlaceholder: widget.loadingPlaceholder, + onContentRendered: widget.onContentRendered, + textScaleFactor: widget.textScaleFactor, + shouldSkipStyle: widget.shouldSkipStyle, ), ); } @@ -232,7 +269,8 @@ class SelectableHtml extends StatefulWidget { this.tagsList = const [], this.selectionControls, this.scrollPhysics, - }) : documentElement = null, + this.shouldSkipStyle, + }) : documentElement = null, assert(data != null), _anchorKey = anchorKey ?? GlobalKey(), super(key: key); @@ -250,9 +288,10 @@ class SelectableHtml extends StatefulWidget { this.tagsList = const [], this.selectionControls, this.scrollPhysics, - }) : data = null, + this.shouldSkipStyle, + }) : data = null, assert(document != null), - this.documentElement = document!.documentElement, + documentElement = document!.documentElement, _anchorKey = anchorKey ?? GlobalKey(), super(key: key); @@ -269,7 +308,8 @@ class SelectableHtml extends StatefulWidget { this.tagsList = const [], this.selectionControls, this.scrollPhysics, - }) : data = null, + this.shouldSkipStyle, + }) : data = null, assert(documentElement != null), _anchorKey = anchorKey ?? GlobalKey(), super(key: key); @@ -294,7 +334,9 @@ class SelectableHtml extends StatefulWidget { final OnCssParseError? onCssParseError; /// A parameter that should be set when the HTML widget is expected to be - /// flexible + /// 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. @@ -314,7 +356,12 @@ class SelectableHtml extends StatefulWidget { /// fallback to the default rendering. final Map customRenders; - static List get tags => new List.from(SELECTABLE_ELEMENTS); + /// Could be used to control style assignment during parsing HTML tree + /// Note that this variable should be set with care, as skipping styles may result in unintended formatting or layout issues. + final SkipStyleFunction? shouldSkipStyle; + + static List get tags => + List.from(HtmlElements.selectableElements); @override State createState() => _SelectableHtmlState(); @@ -326,12 +373,14 @@ class _SelectableHtmlState extends State { @override void initState() { super.initState(); - documentElement = widget.data != null ? HtmlParser.parseHTML(widget.data!) : widget.documentElement!; + documentElement = widget.data != null + ? HtmlParser.parseHTML(widget.data!) + : widget.documentElement!; } @override Widget build(BuildContext context) { - return Container( + return SizedBox( width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width, child: HtmlParser( key: widget._anchorKey, @@ -346,10 +395,12 @@ class _SelectableHtmlState extends State { style: widget.style, customRenders: {} ..addAll(widget.customRenders) - ..addAll(defaultRenders), - tagsList: widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList, + ..addAll(generateDefaultRenders()), + tagsList: + widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList, selectionControls: widget.selectionControls, scrollPhysics: widget.scrollPhysics, + shouldSkipStyle: widget.shouldSkipStyle, ), ); } diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 4f15b9a8a1..99809ce576 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -1,31 +1,45 @@ +import 'dart:async'; 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/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/src/css_parser.dart'; import 'package:flutter_html/src/html_elements.dart'; +import 'package:flutter_html/src/style/marker.dart'; import 'package:flutter_html/src/utils.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as htmlparser; -import 'package:numerus/numerus.dart'; +import 'package:list_counter/list_counter.dart'; typedef OnTap = void Function( - String? url, - RenderContext context, - Map attributes, - dom.Element? element, + String? url, + RenderContext context, + Map attributes, + dom.Element? element, ); + typedef OnCssParseError = String? Function( String css, List errors, ); -class HtmlParser extends StatelessWidget { - final Key? key; +typedef OnContentRendered = Function(Size size); +typedef SkipStyleFunction = bool Function( + StyleAssignmentMethod method, StyledElement tree, Style style); + +enum StyleAssignmentMethod { + external, + inline, + custom, + cascade; +} + +class HtmlParser extends StatefulWidget { final dom.Element htmlData; final OnTap? onLinkTap; final OnTap? onAnchorTap; @@ -42,11 +56,15 @@ class HtmlParser extends StatelessWidget { final Html? root; final TextSelectionControls? selectionControls; final ScrollPhysics? scrollPhysics; + final Widget? loadingPlaceholder; + final OnContentRendered? onContentRendered; + final double? textScaleFactor; + final SkipStyleFunction? shouldSkipStyle; final Map cachedImageSizes = {}; HtmlParser({ - required this.key, + required super.key, required this.htmlData, required this.onLinkTap, required this.onAnchorTap, @@ -61,71 +79,85 @@ class HtmlParser extends StatelessWidget { this.root, this.selectionControls, this.scrollPhysics, - }) : this.internalOnAnchorTap = onAnchorTap != null - ? onAnchorTap - : key != null - ? _handleAnchorTap(key, onLinkTap) - : null, - super(key: key); - + this.loadingPlaceholder, + this.onContentRendered, + this.textScaleFactor, + this.shouldSkipStyle, + }) : internalOnAnchorTap = onAnchorTap ?? + (key != null ? _handleAnchorTap(key, onLinkTap) : onLinkTap); + + /// As the widget [build]s, the HTML data is processed into a tree of [StyledElement]s, + /// which are then parsed into an [InlineSpan] tree that is then rendered to the screen by Flutter + //TODO Lazy processing of data. We don't need the processing steps done every build phase unless the data has changed. @override - Widget build(BuildContext context) { - Map>> 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, + State createState() => _HtmlParserState(); + + static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => (String? url, + RenderContext context, + Map 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); + }; + + /// [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), ); - // 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, - ); + for (final entry in customRenders.keys) { + if (entry.call(newContext)) { + buildChildren() => + tree.children.map((tree) => parseTree(newContext, tree)).toList(); + if (newContext.parser.selectable && + customRenders[entry] is SelectableCustomRender) { + 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, + textScaleFactor: textScaleFactor, + childIsReplaced: true, //TODO is this true? + child: + customRenders[entry]!.widget!.call(newContext, buildChildren), + ), + ); + } } - return StyledText( - textSpan: parsedTree, - style: cleanedTree.style, - textScaleFactor: MediaQuery.of(context).textScaleFactor, - renderContext: RenderContext( - buildContext: context, - parser: this, - tree: cleanedTree, - style: cleanedTree.style, - ), - ); + return const WidgetSpan(child: SizedBox(height: 0, width: 0)); } /// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library. @@ -150,10 +182,11 @@ class HtmlParser extends StatelessWidget { name: "[Tree Root]", children: [], node: html, - style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!), + //TODO(Sub6Resources): This seems difficult to customize + style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!), ); - html.nodes.forEach((node) { + for (var node in html.nodes) { tree.children.add(_recursiveLexer( node, customRenderMatchers, @@ -161,7 +194,7 @@ class HtmlParser extends StatelessWidget { context, parser, )); - }); + } return tree; } @@ -179,7 +212,7 @@ class HtmlParser extends StatelessWidget { ) { List children = []; - node.nodes.forEach((childNode) { + for (var childNode in node.nodes) { children.add(_recursiveLexer( childNode, customRenderMatchers, @@ -187,49 +220,58 @@ class HtmlParser extends StatelessWidget { 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)) { + if (HtmlElements.styledElements.contains(node.localName)) { return parseStyledElement(node, children); - } else if (INTERACTABLE_ELEMENTS.contains(node.localName)) { + } else if (HtmlElements.interactableElements.contains(node.localName)) { return parseInteractableElement(node, children); - } else if (REPLACED_ELEMENTS.contains(node.localName)) { + } else if (HtmlElements.replacedElements.contains(node.localName)) { return parseReplacedElement(node, children); - } else if (LAYOUT_ELEMENTS.contains(node.localName)) { + } else if (HtmlElements.layoutElements.contains(node.localName)) { return parseLayoutElement(node, children); - } else if (TABLE_CELL_ELEMENTS.contains(node.localName)) { + } else if (HtmlElements.tableCellElements.contains(node.localName)) { return parseTableCellElement(node, children); - } else if (TABLE_DEFINITION_ELEMENTS.contains(node.localName)) { + } else 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.bodyText2!), - ), - )) { + 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); + return TextContentElement( + text: node.text, + style: Style(), + element: node.parent, + node: node, + ); } else { return EmptyContentElement(); } } - static Map>> _getExternalCssDeclarations(List styles, OnCssParseError? errorHandler) { + static Map>> + _getExternalCssDeclarations( + List styles, OnCssParseError? errorHandler) { String fullCss = ""; for (final e in styles) { fullCss = fullCss + e.innerHtml; @@ -242,132 +284,141 @@ class HtmlParser extends StatelessWidget { } } - static StyledElement _applyExternalCss(Map>> declarations, StyledElement tree) { - declarations.forEach((key, style) { + static StyledElement _applyExternalCss( + Map>> declarations, + StyledElement tree, + SkipStyleFunction? shouldSkipStyle) { + declarations.forEach((key, declaration) { try { if (tree.matchesSelector(key)) { - tree.style = tree.style.merge(declarationsToStyle(style)); + final style = declarationsToStyle(declaration); + final skip = shouldSkipStyle != null + ? shouldSkipStyle(StyleAssignmentMethod.external, tree, style) + : false; + if (!skip) { + tree.style = tree.style.merge(style); + } } } catch (_) {} }); - tree.children.forEach((e) => _applyExternalCss(declarations, e)); + for (var element in tree.children) { + _applyExternalCss(declarations, element, shouldSkipStyle); + } return tree; } - static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) { + static StyledElement _applyInlineStyles(StyledElement tree, + OnCssParseError? errorHandler, SkipStyleFunction? shouldSkipStyle) { if (tree.attributes.containsKey("style")) { final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler); if (newStyle != null) { - tree.style = tree.style.merge(newStyle); + final skip = shouldSkipStyle != null + ? shouldSkipStyle(StyleAssignmentMethod.inline, tree, newStyle) + : false; + if (!skip) { + tree.style = tree.style.merge(newStyle); + } } } - tree.children.forEach((e) => _applyInlineStyles(e, errorHandler)); + for (var element in tree.children) { + _applyInlineStyles(element, errorHandler, shouldSkipStyle); + } 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 style, StyledElement tree) { + static StyledElement _applyCustomStyles(Map style, + StyledElement tree, SkipStyleFunction? shouldSkipStyle) { style.forEach((key, style) { try { if (tree.matchesSelector(key)) { - tree.style = tree.style.merge(style); + final skip = shouldSkipStyle != null + ? shouldSkipStyle(StyleAssignmentMethod.custom, tree, style) + : false; + if (!skip) { + tree.style = tree.style.merge(style); + } } } catch (_) {} }); - tree.children.forEach((e) => _applyCustomStyles(style, e)); + for (var element in tree.children) { + _applyCustomStyles(style, element, shouldSkipStyle); + } 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 style, StyledElement tree) { - tree.children.forEach((child) { - child.style = tree.style.copyOnlyInherited(child.style); - _cascadeStyles(style, child); - }); + static StyledElement _cascadeStyles(Map style, + StyledElement tree, SkipStyleFunction? shouldSkipStyle) { + for (var child in tree.children) { + final newStyle = tree.style.copyOnlyInherited(child.style); + final skip = shouldSkipStyle != null + ? shouldSkipStyle(StyleAssignmentMethod.cascade, tree, newStyle) + : false; + if (!skip) { + child.style = newStyle; + } + _cascadeStyles(style, child, shouldSkipStyle); + } return tree; } - /// [cleanTree] optimizes the [StyledElement] tree so all [BlockElement]s are + /// [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 style, + OnCssParseError? onCssParseError, + SkipStyleFunction? shouldSkipStyle) { + Map>> declarations = + _getExternalCssDeclarations( + htmlData.getElementsByTagName("style"), onCssParseError); + + StyledElement? externalCssStyledTree; + if (declarations.isNotEmpty) { + externalCssStyledTree = + _applyExternalCss(declarations, tree, shouldSkipStyle); + } + tree = _applyInlineStyles( + externalCssStyledTree ?? tree, onCssParseError, shouldSkipStyle); + tree = _applyCustomStyles(style, tree, shouldSkipStyle); + tree = _cascadeStyles(style, tree, shouldSkipStyle); + 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 cleanTree(StyledElement tree) { + static StyledElement processTree( + StyledElement tree, double devicePixelRatio) { tree = _processInternalWhitespace(tree); tree = _processInlineWhitespace(tree); tree = _removeEmptyElements(tree); - tree = _processListCharacters(tree); + + tree = _calculateRelativeValues(tree, devicePixelRatio); + tree = _preprocessListMarkers(tree); + tree = _processCounters(tree); + tree = _processListMarkers(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 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) { + if ((tree.style.whiteSpace ?? WhiteSpace.normal) == WhiteSpace.pre) { // Preserve this whitespace } else if (tree is TextContentElement) { tree.text = _removeUnnecessaryWhitespace(tree.text!); @@ -396,24 +447,33 @@ class HtmlParser extends StatelessWidget { /// 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; + 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; + 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) { + 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]; @@ -426,6 +486,7 @@ class HtmlParser extends StatelessWidget { } 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 @@ -435,38 +496,38 @@ class HtmlParser extends StatelessWidget { /// We should also delete the whitespace at any point in the node list /// if the previous element is a
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(" "))) - ) { + 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" - ) { + } 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(' ') - ) { + 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)); + for (var element in tree.children) { + _processInlineWhitespaceRecursive(element, keepLeadingSpace); + } return tree; } @@ -480,139 +541,119 @@ class HtmlParser extends StatelessWidget { /// (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(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(); - tree = _processListCharactersRecursive(tree, olStack); - return tree; - } + /// [preprocessListMarkers] adds marker pseudo elements to the front of all list + /// items. + static StyledElement _preprocessListMarkers(StyledElement tree) { + tree.style.listStylePosition ??= ListStylePosition.outside; - /// [_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 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('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((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); - break; + if (tree.style.display == Display.listItem) { + // Add the marker pseudo-element if it doesn't exist + tree.style.marker ??= Marker( + content: Content.normal, + style: tree.style, + ); + + // Inherit styles from originating widget + tree.style.marker!.style = + tree.style.copyOnlyInherited(tree.style.marker!.style ?? Style()); + + // Add the implicit counter-increment on `list-item` if it isn't set + // explicitly already + tree.style.counterIncrement ??= {}; + if (!tree.style.counterIncrement!.containsKey('list-item')) { + tree.style.counterIncrement!['list-item'] = 1; } - } 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((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('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('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((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((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; + } + + // Add the counters to ol and ul types. + if (tree.name == 'ol' || tree.name == 'ul') { + tree.style.counterReset ??= {}; + if (!tree.style.counterReset!.containsKey('list-item')) { + tree.style.counterReset!['list-item'] = 0; } - tree.style.markerContent = Text( - marker, - textAlign: TextAlign.right, - ); } - tree.children.forEach((e) => _processListCharactersRecursive(e, olStack)); + for (var child in tree.children) { + _preprocessListMarkers(child); + } + + return tree; + } + + /// [_processListCounters] adds the appropriate counter values to each + /// StyledElement on the tree. + static StyledElement _processCounters(StyledElement tree, + [ListQueue? counters]) { + // Add the counters for the current scope. + tree.counters.addAll(counters?.deepCopy() ?? []); + + // Create any new counters + if (tree.style.counterReset != null) { + tree.style.counterReset!.forEach((counterName, initialValue) { + tree.counters.add(Counter(counterName, initialValue ?? 0)); + }); + } + + // Increment any counters that are to be incremented + if (tree.style.counterIncrement != null) { + tree.style.counterIncrement!.forEach((counterName, increment) { + tree.counters + .lastWhereOrNull( + (counter) => counter.name == counterName, + ) + ?.increment(increment ?? 1); + + // If we didn't newly create the counter, increment the counter in the old copy as well. + if (tree.style.counterReset == null || + !tree.style.counterReset!.containsKey(counterName)) { + counters + ?.lastWhereOrNull( + (counter) => counter.name == counterName, + ) + ?.increment(increment ?? 1); + } + }); + } + + for (var element in tree.children) { + _processCounters(element, tree.counters); + } + + return tree; + } + + static StyledElement _processListMarkers(StyledElement tree) { + if (tree.style.display == Display.listItem) { + final listStyleType = tree.style.listStyleType ?? ListStyleType.decimal; + final counterStyle = CounterStyleRegistry.lookup( + listStyleType.counterStyle, + ); + String counterContent; + if (tree.style.marker?.content.isNormal ?? true) { + counterContent = counterStyle.generateMarkerContent( + tree.counters.lastOrNull?.value ?? 0, + ); + } else if (!(tree.style.marker?.content.display ?? true)) { + counterContent = ''; + } else { + counterContent = tree.style.marker?.content.replacementContent ?? + counterStyle.generateMarkerContent( + tree.counters.lastOrNull?.value ?? 0, + ); + } + tree.style.marker = Marker( + content: Content(counterContent), style: tree.style.marker?.style); + } - if (tree.name == 'ol') { - olStack.removeLast(); + for (var child in tree.children) { + _processListMarkers(child); } return tree; @@ -624,11 +665,20 @@ class HtmlParser extends StatelessWidget { 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))); + 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.add(TextContentElement( + text: tree.style.after, + style: + tree.style.copyWith(beforeAfterNull: true, display: Display.inline), + )); } tree.children.forEach(_processBeforesAndAfters); @@ -636,7 +686,7 @@ class HtmlParser extends StatelessWidget { return tree; } - /// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS21/box.html#collapsing-margins + /// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS22/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. /// @@ -650,8 +700,9 @@ class HtmlParser extends StatelessWidget { //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; + if (tree.style.height?.value == 0 && + tree.style.height?.unit != Unit.auto) { + tree.style.margin = tree.style.margin?.collapse() ?? Margins.zero; } return tree; } @@ -667,47 +718,49 @@ class HtmlParser extends StatelessWidget { // 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 parentTop = tree.style.margin?.top?.value ?? 0; + final firstChildTop = tree.children.first.style.margin?.top?.value ?? 0; final newOuterMarginTop = max(parentTop, firstChildTop); // Set the parent's margin if (tree.style.margin == null) { - tree.style.margin = EdgeInsets.only(top: newOuterMarginTop); + tree.style.margin = Margins.only(top: newOuterMarginTop); } else { - tree.style.margin = tree.style.margin!.copyWith(top: newOuterMarginTop); + tree.style.margin = + tree.style.margin!.copyWithEdge(top: newOuterMarginTop); } // And remove the child's margin if (tree.children.first.style.margin == null) { - tree.children.first.style.margin = EdgeInsets.zero; + tree.children.first.style.margin = Margins.zero; } else { tree.children.first.style.margin = - tree.children.first.style.margin!.copyWith(top: 0); + tree.children.first.style.margin!.copyWithEdge(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 parentBottom = tree.style.margin?.bottom?.value ?? 0; + final lastChildBottom = + tree.children.last.style.margin?.bottom?.value ?? 0; final newOuterMarginBottom = max(parentBottom, lastChildBottom); // Set the parent's margin if (tree.style.margin == null) { - tree.style.margin = EdgeInsets.only(bottom: newOuterMarginBottom); + tree.style.margin = Margins.only(bottom: newOuterMarginBottom); } else { tree.style.margin = - tree.style.margin!.copyWith(bottom: newOuterMarginBottom); + tree.style.margin!.copyWithEdge(bottom: newOuterMarginBottom); } // And remove the child's margin if (tree.children.last.style.margin == null) { - tree.children.last.style.margin = EdgeInsets.zero; + tree.children.last.style.margin = Margins.zero; } else { tree.children.last.style.margin = - tree.children.last.style.margin!.copyWith(bottom: 0); + tree.children.last.style.margin!.copyWithEdge(bottom: 0); } } @@ -715,24 +768,23 @@ class HtmlParser extends StatelessWidget { 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; + tree.children[i - 1].style.margin?.bottom?.value ?? 0; + final thisTop = tree.children[i].style.margin?.top?.value ?? 0; + final newInternalMargin = max(previousSiblingBottom, thisTop); if (tree.children[i - 1].style.margin == null) { tree.children[i - 1].style.margin = - EdgeInsets.only(bottom: newInternalMargin); + Margins.only(bottom: newInternalMargin); } else { tree.children[i - 1].style.margin = tree.children[i - 1].style.margin! - .copyWith(bottom: newInternalMargin); + .copyWithEdge(bottom: newInternalMargin); } if (tree.children[i].style.margin == null) { - tree.children[i].style.margin = - EdgeInsets.only(top: newInternalMargin); + tree.children[i].style.margin = Margins.only(top: newInternalMargin); } else { - tree.children[i].style.margin = - tree.children[i].style.margin!.copyWith(top: newInternalMargin); + tree.children[i].style.margin = tree.children[i].style.margin! + .copyWithEdge(top: newInternalMargin); } } } @@ -751,34 +803,35 @@ class HtmlParser extends StatelessWidget { 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) { + } 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) { + } 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.style.whiteSpace != WhiteSpace.pre && + tree.style.display == Display.block && child.text!.isEmpty && lastChildBlock) { toRemove.add(child); - } else if (child.style.display == Display.NONE) { + } 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 || + lastChildBlock = (child.style.display == Display.block || + child.style.display == Display.listItem || (child is TextContentElement && child.text == '\n')); }); tree.children.removeWhere((element) => toRemove.contains(element)); @@ -786,20 +839,283 @@ class HtmlParser extends StatelessWidget { 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; + /// [_calculateRelativeValues] converts rem values to px sizes and then + /// applies relative calculations + static StyledElement _calculateRelativeValues( + StyledElement tree, double devicePixelRatio) { + double remSize = (tree.style.fontSize?.value ?? FontSize.medium.value); + + //If the root element has a rem-based fontSize, then give it the default + // font size times the set rem value. + if (tree.style.fontSize?.unit == Unit.rem) { + tree.style.fontSize = FontSize(FontSize.medium.value * remSize); + } + + _applyRelativeValuesRecursive(tree, remSize, devicePixelRatio); + tree.style.setRelativeValues(remSize, remSize / devicePixelRatio); + + return tree; + } + + /// This is the recursive worker function for [_calculateRelativeValues] + static void _applyRelativeValuesRecursive( + StyledElement tree, double remFontSize, double devicePixelRatio) { + //When we get to this point, there should be a valid fontSize at every level. + assert(tree.style.fontSize != null); + + final parentFontSize = tree.style.fontSize!.value; + + for (var child in tree.children) { + if (child.style.fontSize == null) { + child.style.fontSize = FontSize(parentFontSize); + } else { + switch (child.style.fontSize!.unit) { + case Unit.em: + child.style.fontSize = + FontSize(parentFontSize * child.style.fontSize!.value); + break; + case Unit.percent: + child.style.fontSize = FontSize( + parentFontSize * (child.style.fontSize!.value / 100.0)); + break; + case Unit.rem: + child.style.fontSize = + FontSize(remFontSize * child.style.fontSize!.value); + break; + case Unit.px: + case Unit.auto: + //Ignore + break; + } + } + + // Note: it is necessary to scale down the emSize by the factor of + // devicePixelRatio since Flutter seems to calculates font sizes using + // physical pixels, but margins/padding using logical pixels. + final emSize = child.style.fontSize!.value / devicePixelRatio; + + tree.style.setRelativeValues(remFontSize, emSize); + + _applyRelativeValuesRecursive(child, remFontSize, devicePixelRatio); + } + } +} + +class _HtmlParserState extends State { + static final _renderQueue = []; + + final GlobalKey _htmlGlobalKey = GlobalKey(); + final GlobalKey _animatedSwitcherKey = GlobalKey(); + + Completer? _completer; + bool _isOffstage = true; + ParseResult? _parseResult; + + InlineSpan? _parsedTree; + StyledElement? _processedTree; + bool _disposed = false; + + @override + void dispose() { + _disposed = true; + + // If we're still waiting to run, just cancel and remove us from the render queue + if (_completer?.isCompleted == false) { + _completer!.completeError(Exception('disposed')); + _renderQueue.remove(_completer); + } + + super.dispose(); + } - tree.children.forEach((child) { - if ((child.style.fontSize?.size ?? parentFontSize)! < 0) { - child.style.fontSize = - FontSize(parentFontSize! * -child.style.fontSize!.size!); + @override + Widget build(BuildContext context) { + if (_parseResult == null) { + try { + _parseTree(context); + } catch (error) { + if (kDebugMode) { + print(error); + } } + } + + if (_parseResult != null && _isOffstage) { + WidgetsBinding.instance.addPostFrameCallback(_afterLayout); + } + + final children = []; + + children.add( + Visibility( + visible: _isOffstage || _parseResult == null, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: widget.loadingPlaceholder ?? Container(height: 1000), + ), + ); + + if (!_isOffstage && _parseResult != null) { + children.add( + CssBoxWidget.withInlineSpanChildren( + key: _htmlGlobalKey, + textScaleFactor: widget.textScaleFactor, + style: _parseResult!.style, + context: context, + children: [_parseResult!.inlineSpan], + selectable: widget.selectable, + scrollPhysics: widget.scrollPhysics, + selectionControls: widget.selectionControls, + shrinkWrap: widget.shrinkWrap, + ), + ); + } - _processFontSize(child); + return Stack( + children: [ + if (_isOffstage && _parseResult != null) + Offstage( + offstage: true, + child: CssBoxWidget.withInlineSpanChildren( + key: _htmlGlobalKey, + style: _parseResult!.style, + textScaleFactor: widget.textScaleFactor, + context: context, + children: [_parseResult!.inlineSpan], + selectable: widget.selectable, + scrollPhysics: widget.scrollPhysics, + selectionControls: widget.selectionControls, + shrinkWrap: widget.shrinkWrap, + ), + ), + AnimatedSwitcher( + key: _animatedSwitcherKey, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topLeft, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + duration: kThemeAnimationDuration, + child: Stack( + alignment: Alignment.topLeft, + fit: StackFit.loose, + key: ValueKey(_isOffstage), + children: children, + ), + ), + ], + ); + } + + void _afterLayout(Duration timeStamp) { + final renderObject = _htmlGlobalKey.currentContext?.findRenderObject(); + if (renderObject == null) return; + + final size = renderObject.semanticBounds.size; + widget.onContentRendered?.call(size); + + setState(() { + _isOffstage = false; }); - return tree; + } + + Future _parseTree(BuildContext context) async { + if (_completer != null) { + if (_renderQueue.isNotEmpty) { + _renderQueue.remove(_completer!); + } + + if (!_completer!.isCompleted) { + _completer!.completeError(Exception('replaced')); + } + } + + _completer = Completer(); + _renderQueue.add(_completer!); + + try { + if (_renderQueue.length > 1) { + if (kDebugMode) { + print('_parseTree waiting for queue'); + } + await _completer!.future; + } else { + _completer!.complete(); + } + } catch (exception) { + if (kDebugMode) { + print('_parseTree ${exception.toString()}'); + } + return; + } + + if (kDebugMode) { + print('_parseTree parsing'); + } + + try { + if (_processedTree == null && mounted) { + // Lexing Step + StyledElement lexedTree = HtmlParser.lexDomTree( + widget.htmlData, + widget.customRenders.keys.toList(), + widget.tagsList, + context, + widget, + ); + // Styling Step + StyledElement styledTree = HtmlParser.styleTree( + lexedTree, + widget.htmlData, + widget.style, + widget.onCssParseError, + widget.shouldSkipStyle, + ); + + // Processing Step + StyledElement processedTree = HtmlParser.processTree( + styledTree, MediaQuery.of(context).devicePixelRatio); + + _processedTree = processedTree; + + _parsedTree = widget.parseTree( + RenderContext( + buildContext: context, + parser: widget, + tree: processedTree, + style: processedTree.style, + ), + processedTree, + ); + } + + if (kDebugMode) { + print('_parseTree parsed'); + } + } catch (exception) { + if (kDebugMode) { + print(exception); + } + return; + } finally { + _renderQueue.remove(_completer); + _completer = null; + + if (_renderQueue.isNotEmpty && !_renderQueue[0].isCompleted) { + _renderQueue[0].complete(); + } + } + + if (!_disposed && _parsedTree != null && _processedTree != null) { + setState(() { + _parseResult = ParseResult(_parsedTree!, _processedTree!.style); + }); + } } } @@ -823,126 +1139,12 @@ class RenderContext { }); } -/// 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? 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(); + String s = toLowerCase(); if (s == "z") { - return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa + 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); @@ -950,7 +1152,7 @@ extension IterateLetters on String { // 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'; + return '${sub.nextLetter()}a'; } else { // (take till last char) append with (increment last char) return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1); @@ -958,3 +1160,10 @@ extension IterateLetters on String { } } } + +class ParseResult { + final InlineSpan inlineSpan; + final Style style; + + ParseResult(this.inlineSpan, this.style); +} diff --git a/lib/src/anchor.dart b/lib/src/anchor.dart index bdba172c97..2dbdd7a1bb 100644 --- a/lib/src/anchor.dart +++ b/lib/src/anchor.dart @@ -30,7 +30,10 @@ class AnchorKey extends GlobalKey { @override bool operator ==(Object other) => identical(this, other) || - other is AnchorKey && runtimeType == other.runtimeType && parentKey == other.parentKey && id == other.id; + other is AnchorKey && + runtimeType == other.runtimeType && + parentKey == other.parentKey && + id == other.id; @override int get hashCode => parentKey.hashCode ^ id.hashCode; diff --git a/lib/src/css_box_widget.dart b/lib/src/css_box_widget.dart new file mode 100644 index 0000000000..4d2158a134 --- /dev/null +++ b/lib/src/css_box_widget.dart @@ -0,0 +1,795 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_html/flutter_html.dart'; + +class CssBoxWidget extends StatelessWidget { + const CssBoxWidget({ + super.key, + required this.child, + required this.style, + this.textScaleFactor, + this.textDirection, + this.childIsReplaced = false, + this.shrinkWrap = false, + }); + + /// Generates a CSSBoxWidget that contains a list of InlineSpan children. + CssBoxWidget.withInlineSpanChildren({ + super.key, + required BuildContext context, + required List children, + required this.style, + this.textDirection, + this.childIsReplaced = false, + this.shrinkWrap = false, + this.textScaleFactor, + bool selectable = false, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + }) : child = selectable + ? _generateSelectableWidgetChild( + context, + children, + style, + textScaleFactor, + selectionControls, + scrollPhysics, + ) + : _generateWidgetChild(context, children, style, textScaleFactor); + + /// The child to be rendered within the CSS Box. + final Widget child; + + /// The [textScaleFactor] variable stores a value representing the text scale factor. This overrides the system's default text scale factor and is used to scale the text size + /// If this variable is not set, the application will use the default text scale factor provided by [MediaQuery.textScaleFactorOf]. + final double? textScaleFactor; + + /// The style to use to compute this box's margins/padding/box decoration/width/height/etc. + /// + /// Note that this style will only apply to this box, and will not cascade to its child. + final Style style; + + /// Sets the direction the text of this widget should flow. If unset or null, + /// the nearest Directionality ancestor is used as a default. If that cannot + /// be found, this Widget's renderer will raise an assertion. + final TextDirection? textDirection; + + /// Indicates whether this child is a replaced element that manages its own width + /// (e.g. img, video, iframe, audio, etc.) + final bool childIsReplaced; + + /// Whether or not the content should ignore auto horizontal margins and not + /// necessarily take up the full available width unless necessary + final bool shrinkWrap; + + @override + Widget build(BuildContext context) { + final markerBox = style.listStylePosition == ListStylePosition.outside + ? _generateMarkerBoxSpan(context, style, textScaleFactor) + : null; + + return _CSSBoxRenderer( + width: style.width ?? Width.auto(), + height: style.height ?? Height.auto(), + paddingSize: style.padding?.collapsedSize ?? Size.zero, + borderSize: style.border?.dimensions.collapsedSize ?? Size.zero, + margins: style.margin ?? Margins.zero, + display: style.display ?? Display.inline, + childIsReplaced: childIsReplaced, + emValue: _calculateEmValue(style, context), + textDirection: _checkTextDirection(context, textDirection), + shrinkWrap: shrinkWrap, + children: [ + Container( + decoration: BoxDecoration( + border: style.border, + color: style.backgroundColor, //Colors the padding and content boxes + ), + width: _shouldExpandToFillBlock() ? double.infinity : null, + padding: style.padding ?? EdgeInsets.zero, + child: child, + ), + if (markerBox != null) + Text.rich(markerBox, textScaleFactor: textScaleFactor), + ], + ); + } + + /// Takes a list of InlineSpan children and generates a Text.rich Widget + /// containing those children. + static Widget _generateWidgetChild(BuildContext context, + List children, Style style, double? textScaleFactor) { + if (children.isEmpty) { + return Container(); + } + + // Generate an inline marker box if the list-style-position is set to + // inside. Otherwise the marker box will be added elsewhere. + if (style.listStylePosition == ListStylePosition.inside) { + final inlineMarkerBox = + _generateMarkerBoxSpan(context, style, textScaleFactor); + if (inlineMarkerBox != null) { + children.insert(0, inlineMarkerBox); + } + } + + return RichText( + text: TextSpan( + style: style.generateTextStyle(context), + children: children, + ), + textScaleFactor: textScaleFactor ?? 1.0, + textAlign: style.textAlign ?? TextAlign.start, + textDirection: style.direction, + maxLines: style.maxLines, + overflow: style.textOverflow ?? TextOverflow.clip, + ); + } + + static Widget _generateSelectableWidgetChild( + BuildContext context, + List children, + Style style, + double? textScaleFactor, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + ) { + if (children.isEmpty) { + return Container(); + } + + return SelectableText.rich( + TextSpan( + style: style.generateTextStyle(context), + children: children, + ), + textScaleFactor: textScaleFactor, + style: style.generateTextStyle(context), + textAlign: style.textAlign, + textDirection: style.direction, + maxLines: style.maxLines, + selectionControls: selectionControls, + scrollPhysics: scrollPhysics, + ); + } + + static InlineSpan? _generateMarkerBoxSpan( + BuildContext context, Style style, double? textScaleFactor) { + if (style.display == Display.listItem) { + // First handle listStyleImage + if (style.listStyleImage != null) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Image.network( + style.listStyleImage!.uriText, + errorBuilder: (_, __, ___) { + if (style.marker?.content.replacementContent?.isNotEmpty ?? + false) { + return Text.rich( + textScaleFactor: textScaleFactor, + TextSpan( + text: style.marker!.content.replacementContent!, + style: style.marker!.style?.generateTextStyle(context), + ), + ); + } + + return Container(); + }, + ), + ); + } + + // Display list marker with given style + if (style.marker?.content.replacementContent?.isNotEmpty ?? false) { + return TextSpan( + text: style.marker!.content.replacementContent!, + style: style.marker!.style?.generateTextStyle(context), + ); + } + } + + return null; + } + + /// Whether or not the content-box should expand its width to fill the + /// width available to it or if it should just let its inner content + /// determine the content-box's width. + bool _shouldExpandToFillBlock() { + return (style.display == Display.block || + style.display == Display.listItem) && + !childIsReplaced && + !shrinkWrap; + } + + TextDirection _checkTextDirection( + BuildContext context, TextDirection? direction) { + final textDirection = direction ?? Directionality.maybeOf(context); + + assert( + textDirection != null, + "CSSBoxWidget needs either a Directionality ancestor or a provided textDirection", + ); + + return textDirection!; + } +} + +class _CSSBoxRenderer extends MultiChildRenderObjectWidget { + _CSSBoxRenderer({ + Key? key, + required super.children, + required this.display, + required this.margins, + required this.width, + required this.height, + required this.borderSize, + required this.paddingSize, + required this.textDirection, + required this.childIsReplaced, + required this.emValue, + required this.shrinkWrap, + }) : super(key: key); + + /// The Display type of the element + final Display display; + + /// The computed margin values for this element + final Margins margins; + + /// The width of the element + final Width width; + + /// The height of the element + final Height height; + + /// The collapsed size of the element's border + final Size borderSize; + + /// The collapsed size of the element's padding + final Size paddingSize; + + /// The direction for this widget's text to flow. + final TextDirection textDirection; + + /// Whether or not the child being rendered is a replaced element + /// (this changes the rules for rendering) + final bool childIsReplaced; + + /// The calculated size of 1em in pixels + final double emValue; + + /// Whether or not this container should shrinkWrap its contents. + /// (see definition on [CSSBoxWidget]) + final bool shrinkWrap; + + @override + _RenderCSSBox createRenderObject(BuildContext context) { + return _RenderCSSBox( + display: display, + width: width..normalize(emValue), + height: height..normalize(emValue), + margins: _preProcessMargins(margins, shrinkWrap), + borderSize: borderSize, + paddingSize: paddingSize, + textDirection: textDirection, + childIsReplaced: childIsReplaced, + shrinkWrap: shrinkWrap, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderCSSBox renderObject) { + renderObject + ..display = display + ..width = (width..normalize(emValue)) + ..height = (height..normalize(emValue)) + ..margins = _preProcessMargins(margins, shrinkWrap) + ..borderSize = borderSize + ..paddingSize = paddingSize + ..textDirection = textDirection + ..childIsReplaced = childIsReplaced + ..shrinkWrap = shrinkWrap; + } + + Margins _preProcessMargins(Margins margins, bool shrinkWrap) { + Margin leftMargin = margins.left ?? Margin.zero(); + Margin rightMargin = margins.right ?? Margin.zero(); + Margin topMargin = margins.top ?? Margin.zero(); + Margin bottomMargin = margins.bottom ?? Margin.zero(); + + //Preprocess margins to a pixel value + leftMargin.normalize(emValue); + rightMargin.normalize(emValue); + topMargin.normalize(emValue); + bottomMargin.normalize(emValue); + + // See https://drafts.csswg.org/css2/#inline-width + // and https://drafts.csswg.org/css2/#inline-replaced-width + // and https://drafts.csswg.org/css2/#inlineblock-width + // and https://drafts.csswg.org/css2/#inlineblock-replaced-width + if (display == Display.inline || display == Display.inlineBlock) { + if (margins.left?.unit == Unit.auto) { + leftMargin = Margin.zero(); + } + if (margins.right?.unit == Unit.auto) { + rightMargin = Margin.zero(); + } + } + + //Shrink-wrap margins if applicable + if (shrinkWrap && leftMargin.unit == Unit.auto) { + leftMargin = Margin.zero(); + } + + if (shrinkWrap && rightMargin.unit == Unit.auto) { + rightMargin = Margin.zero(); + } + + return Margins( + top: topMargin, + right: rightMargin, + bottom: bottomMargin, + left: leftMargin, + ); + } +} + +/// Implements the CSS layout algorithm +class _RenderCSSBox extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderCSSBox({ + required Display display, + required Width width, + required Height height, + required Margins margins, + required Size borderSize, + required Size paddingSize, + required TextDirection textDirection, + required bool childIsReplaced, + required bool shrinkWrap, + }) : _display = display, + _width = width, + _height = height, + _margins = margins, + _borderSize = borderSize, + _paddingSize = paddingSize, + _textDirection = textDirection, + _childIsReplaced = childIsReplaced, + _shrinkWrap = shrinkWrap; + + Display _display; + + Display get display => _display; + + set display(Display display) { + _display = display; + markNeedsLayout(); + } + + Width _width; + + Width get width => _width; + + set width(Width width) { + _width = width; + markNeedsLayout(); + } + + Height _height; + + Height get height => _height; + + set height(Height height) { + _height = height; + markNeedsLayout(); + } + + Margins _margins; + + Margins get margins => _margins; + + set margins(Margins margins) { + _margins = margins; + markNeedsLayout(); + } + + Size _borderSize; + + Size get borderSize => _borderSize; + + set borderSize(Size size) { + _borderSize = size; + markNeedsLayout(); + } + + Size _paddingSize; + + Size get paddingSize => _paddingSize; + + set paddingSize(Size size) { + _paddingSize = size; + markNeedsLayout(); + } + + TextDirection _textDirection; + + TextDirection get textDirection => _textDirection; + + set textDirection(TextDirection textDirection) { + _textDirection = textDirection; + markNeedsLayout(); + } + + bool _childIsReplaced; + + bool get childIsReplaced => _childIsReplaced; + + set childIsReplaced(bool childIsReplaced) { + _childIsReplaced = childIsReplaced; + markNeedsLayout(); + } + + bool _shrinkWrap; + + bool get shrinkWrap => _shrinkWrap; + + set shrinkWrap(bool shrinkWrap) { + _shrinkWrap = shrinkWrap; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! CSSBoxParentData) { + child.parentData = CSSBoxParentData(); + } + } + + static double getIntrinsicDimension(RenderBox? firstChild, + double Function(RenderBox child) mainChildSizeGetter) { + double extent = 0.0; + RenderBox? child = firstChild; + while (child != null) { + final CSSBoxParentData childParentData = + child.parentData! as CSSBoxParentData; + extent = math.max(extent, mainChildSizeGetter(child)); + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + return extent; + } + + @override + double computeMinIntrinsicWidth(double height) { + return getIntrinsicDimension( + firstChild, (RenderBox child) => child.getMinIntrinsicWidth(height)); + } + + @override + double computeMaxIntrinsicWidth(double height) { + return getIntrinsicDimension( + firstChild, (RenderBox child) => child.getMaxIntrinsicWidth(height)); + } + + @override + double computeMinIntrinsicHeight(double width) { + return getIntrinsicDimension( + firstChild, (RenderBox child) => child.getMinIntrinsicHeight(width)); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return getIntrinsicDimension( + firstChild, (RenderBox child) => child.getMaxIntrinsicHeight(width)); + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return firstChild?.getDistanceToActualBaseline(baseline); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + ).parentSize; + } + + _Sizes _computeSize( + {required BoxConstraints constraints, + required ChildLayouter layoutChild}) { + if (childCount == 0) { + return _Sizes(constraints.biggest, Size.zero); + } + + Size containingBlockSize = constraints.biggest; + double width = containingBlockSize.width; + double height = containingBlockSize.height; + + assert(firstChild != null); + RenderBox child = firstChild!; + + final CSSBoxParentData parentData = child.parentData! as CSSBoxParentData; + RenderBox? markerBoxChild = parentData.nextSibling; + + // Calculate child size + final childConstraints = constraints.copyWith( + maxWidth: (this.width.unit != Unit.auto) + ? this.width.value + : containingBlockSize.width - + (margins.left?.value ?? 0) - + (margins.right?.value ?? 0), + maxHeight: (this.height.unit != Unit.auto) + ? this.height.value + : containingBlockSize.height - + (margins.top?.value ?? 0) - + (margins.bottom?.value ?? 0), + minWidth: (this.width.unit != Unit.auto) ? this.width.value : 0, + minHeight: (this.height.unit != Unit.auto) ? this.height.value : 0, + ); + final Size childSize = layoutChild(child, childConstraints); + if (markerBoxChild != null) { + layoutChild(markerBoxChild, childConstraints); + } + + // Calculate used values of margins based on rules + final usedMargins = _calculateUsedMargins(childSize, containingBlockSize); + final horizontalMargins = + (usedMargins.left?.value ?? 0) + (usedMargins.right?.value ?? 0); + final verticalMargins = + (usedMargins.top?.value ?? 0) + (usedMargins.bottom?.value ?? 0); + + //Calculate Width and Height of CSS Box + height = childSize.height; + switch (display) { + case Display.block: + width = (shrinkWrap || childIsReplaced) + ? childSize.width + horizontalMargins + : containingBlockSize.width; + height = childSize.height + verticalMargins; + break; + case Display.inline: + width = childSize.width + horizontalMargins; + height = childSize.height; + break; + case Display.inlineBlock: + width = childSize.width + horizontalMargins; + height = childSize.height + verticalMargins; + break; + case Display.listItem: + width = shrinkWrap + ? childSize.width + horizontalMargins + : containingBlockSize.width; + height = childSize.height + verticalMargins; + break; + case Display.none: + width = 0; + height = 0; + break; + } + + return _Sizes(constraints.constrain(Size(width, height)), childSize); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + + final sizes = _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.layoutChild, + ); + size = sizes.parentSize; + + assert(firstChild != null); + RenderBox child = firstChild!; + + final CSSBoxParentData childParentData = + child.parentData! as CSSBoxParentData; + + // Calculate used margins based on constraints and child size + final usedMargins = + _calculateUsedMargins(sizes.childSize, constraints.biggest); + final leftMargin = usedMargins.left?.value ?? 0; + final topMargin = usedMargins.top?.value ?? 0; + + double leftOffset = 0; + double topOffset = 0; + switch (display) { + case Display.block: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.inline: + leftOffset = leftMargin; + break; + case Display.inlineBlock: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.listItem: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.none: + //No offset + break; + } + childParentData.offset = Offset(leftOffset, topOffset); + assert(child.parentData == childParentData); + + // Now, layout the marker box if it exists: + RenderBox? markerBox = childParentData.nextSibling; + if (markerBox != null) { + final markerBoxParentData = markerBox.parentData! as CSSBoxParentData; + final distance = (child.getDistanceToBaseline(TextBaseline.alphabetic, + onlyReal: true) ?? + 0) + + topOffset; + final offsetHeight = distance - + (markerBox.getDistanceToBaseline(TextBaseline.alphabetic) ?? + markerBox.size.height); + markerBoxParentData.offset = Offset(-markerBox.size.width, offsetHeight); + } + } + + Margins _calculateUsedMargins(Size childSize, Size containingBlockSize) { + //We assume that margins have already been preprocessed + // (i.e. they are non-null and either px units or auto. + assert(margins.left != null && margins.right != null); + assert(margins.left!.unit == Unit.px || margins.left!.unit == Unit.auto); + assert(margins.right!.unit == Unit.px || margins.right!.unit == Unit.auto); + + Margin marginLeft = margins.left!; + Margin marginRight = margins.right!; + + bool widthIsAuto = width.unit == Unit.auto; + bool marginLeftIsAuto = marginLeft.unit == Unit.auto; + bool marginRightIsAuto = marginRight.unit == Unit.auto; + + if (display == Display.block) { + if (childIsReplaced) { + widthIsAuto = false; + } + + if (shrinkWrap) { + widthIsAuto = false; + } + + //If width is not auto and the width of the margin box is larger than the + // width of the containing block, then consider left and right margins to + // have a 0 value. + if (!widthIsAuto) { + if ((childSize.width + marginLeft.value + marginRight.value) > + containingBlockSize.width) { + //Treat auto values of margin left and margin right as 0 for following rules + marginLeft = Margin(0); + marginRight = Margin(0); + marginLeftIsAuto = false; + marginRightIsAuto = false; + } + } + + // If all values are non-auto, the box is overconstrained. + // One of the margins will need to be adjusted so that the + // entire width of the containing block is used. + if (!widthIsAuto && + !marginLeftIsAuto && + !marginRightIsAuto && + !shrinkWrap && + !childIsReplaced) { + //Ignore either left or right margin based on textDirection. + + switch (textDirection) { + case TextDirection.rtl: + final difference = + containingBlockSize.width - childSize.width - marginRight.value; + marginLeft = Margin(difference); + break; + case TextDirection.ltr: + final difference = + containingBlockSize.width - childSize.width - marginLeft.value; + marginRight = Margin(difference); + break; + } + } + + // If there is exactly one value specified as auto, compute it value from the equality (our widths are already set) + if (widthIsAuto && !marginLeftIsAuto && !marginRightIsAuto) { + widthIsAuto = false; + } else if (!widthIsAuto && marginLeftIsAuto && !marginRightIsAuto) { + marginLeft = Margin( + containingBlockSize.width - childSize.width - marginRight.value); + marginLeftIsAuto = false; + } else if (!widthIsAuto && !marginLeftIsAuto && marginRightIsAuto) { + marginRight = Margin( + containingBlockSize.width - childSize.width - marginLeft.value); + marginRightIsAuto = false; + } + + //If width is set to auto, any other auto values become 0, and width + // follows from the resulting equality. + if (widthIsAuto) { + if (marginLeftIsAuto) { + marginLeft = Margin(0); + marginLeftIsAuto = false; + } + if (marginRightIsAuto) { + marginRight = Margin(0); + marginRightIsAuto = false; + } + widthIsAuto = false; + } + + //If both margin-left and margin-right are auto, their used values are equal. + // This horizontally centers the element within the containing block. + if (marginLeftIsAuto && marginRightIsAuto) { + final newMargin = + Margin((containingBlockSize.width - childSize.width) / 2); + marginLeft = newMargin; + marginRight = newMargin; + marginLeftIsAuto = false; + marginRightIsAuto = false; + } + + //Assert that all auto values have been assigned. + assert(!marginLeftIsAuto && !marginRightIsAuto && !widthIsAuto); + } + + return Margins( + left: marginLeft, + right: marginRight, + top: margins.top, + bottom: margins.bottom); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } +} + +extension Normalize on Dimension { + void normalize(double emValue) { + switch (unit) { + case Unit.rem: + // Because CSSBoxWidget doesn't have any information about any + // sort of tree structure, treat rem the same as em. The HtmlParser + // widget handles rem/em values before they get to CSSBoxWidget. + case Unit.em: + value *= emValue; + unit = Unit.px; + return; + case Unit.px: + case Unit.auto: + case Unit.percent: + return; + } + } +} + +double _calculateEmValue(Style style, BuildContext buildContext) { + return (style.fontSize?.emValue ?? 16) * + MediaQuery.textScaleFactorOf(buildContext) * + MediaQuery.of(buildContext).devicePixelRatio; +} + +class CSSBoxParentData extends ContainerBoxParentData {} + +class _Sizes { + final Size parentSize; + final Size childSize; + + const _Sizes(this.parentSize, this.childSize); +} diff --git a/lib/src/css_parser.dart b/lib/src/css_parser.dart index 1f5bbd90ed..28ef698e17 100644 --- a/lib/src/css_parser.dart +++ b/lib/src/css_parser.dart @@ -8,58 +8,110 @@ import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/src/utils.dart'; Style declarationsToStyle(Map> declarations) { - Style style = new Style(); + Style style = Style(); declarations.forEach((property, value) { if (value.isNotEmpty) { switch (property) { case 'background-color': - style.backgroundColor = ExpressionMapping.expressionToColor(value.first) ?? style.backgroundColor; + style.backgroundColor = + ExpressionMapping.expressionToColor(value.first) ?? + style.backgroundColor; break; case 'border': - List? borderWidths = value.whereType().toList(); + List? borderWidths = + value.whereType().toList(); + /// List 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? borderColors = value.where((element) => ExpressionMapping.expressionToColor(element) != null).toList(); - List? potentialStyles = value.whereType().toList(); + 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? borderColors = value + .where((element) => + ExpressionMapping.expressionToColor(element) != null) + .toList(); + List? potentialStyles = + value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. - List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + List possibleBorderValues = [ + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset", + "none", + "hidden" + ]; + /// List 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)); + potentialStyles.removeWhere((element) => + element == null || !possibleBorderValues.contains(element.text)); List? borderStyles = potentialStyles; - style.border = ExpressionMapping.expressionToBorder(borderWidths, borderStyles, borderColors); + style.border = ExpressionMapping.expressionToBorder( + borderWidths, borderStyles, borderColors); break; case 'border-left': - List? borderWidths = value.whereType().toList(); + List? borderWidths = + value.whereType().toList(); + /// List 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? potentialStyles = value.whereType().toList(); + 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? potentialStyles = + value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. - List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + List possibleBorderValues = [ + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset", + "none", + "hidden" + ]; + /// List 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)); + 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, - ), + 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, @@ -67,217 +119,339 @@ Style declarationsToStyle(Map> declarations) { style.border = newBorder; break; case 'border-right': - List? borderWidths = value.whereType().toList(); + List? borderWidths = + value.whereType().toList(); + /// List 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? potentialStyles = value.whereType().toList(); + 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? potentialStyles = + value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. - List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + List possibleBorderValues = [ + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset", + "none", + "hidden" + ]; + /// List 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)); + 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, - ), + 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? borderWidths = value.whereType().toList(); + List? borderWidths = + value.whereType().toList(); + /// List 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? potentialStyles = value.whereType().toList(); + 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? potentialStyles = + value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. - List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + List possibleBorderValues = [ + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset", + "none", + "hidden" + ]; + /// List 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)); + 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, - ), + 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? borderWidths = value.whereType().toList(); + List? borderWidths = + value.whereType().toList(); + /// List 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? potentialStyles = value.whereType().toList(); + 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? potentialStyles = + value.whereType().toList(); + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. - List possibleBorderValues = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden"]; + List possibleBorderValues = [ + "dotted", + "dashed", + "solid", + "double", + "groove", + "ridge", + "inset", + "outset", + "none", + "hidden" + ]; + /// List 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)); + 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, - ), + 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; + style.color = + ExpressionMapping.expressionToColor(value.first) ?? style.color; break; case 'direction': - style.direction = ExpressionMapping.expressionToDirection(value.first); + 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); + style.lineHeight = + ExpressionMapping.expressionToLineHeight(value.first); break; case 'font-family': - style.fontFamily = ExpressionMapping.expressionToFontFamily(value.first) ?? style.fontFamily; + style.fontFamily = + ExpressionMapping.expressionToFontFamily(value.first) ?? + style.fontFamily; break; case 'font-feature-settings': - style.fontFeatureSettings = ExpressionMapping.expressionToFontFeatureSettings(value); + style.fontFeatureSettings = + ExpressionMapping.expressionToFontFeatureSettings(value); break; case 'font-size': - style.fontSize = ExpressionMapping.expressionToFontSize(value.first) ?? style.fontSize; + style.fontSize = + ExpressionMapping.expressionToFontSize(value.first) ?? + style.fontSize; break; case 'font-style': - style.fontStyle = ExpressionMapping.expressionToFontStyle(value.first); + style.fontStyle = + ExpressionMapping.expressionToFontStyle(value.first); break; case 'font-weight': - style.fontWeight = ExpressionMapping.expressionToFontWeight(value.first); + style.fontWeight = + ExpressionMapping.expressionToFontWeight(value.first); + break; + case '-epub-text-align-last': + if (value.first is css.Identifier) { + css.Identifier identifier = value.first as css.Identifier; + switch (identifier.name) { + case 'left': + style.alignment = Alignment.centerLeft; + break; + case 'right': + style.alignment = Alignment.centerRight; + break; + case 'center': + style.alignment = Alignment.center; + break; + } + } 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?; + 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; + style.listStylePosition = ListStylePosition.outside; break; case 'inside': - style.listStylePosition = ListStylePosition.INSIDE; + style.listStylePosition = ListStylePosition.inside; break; } } if (image != null) { - style.listStyleType = ExpressionMapping.expressionToListStyleType(image) ?? style.listStyleType; + style.listStyleImage = + ExpressionMapping.expressionToListStyleImage(image) ?? + style.listStyleImage; } else if (type != null) { - style.listStyleType = ExpressionMapping.expressionToListStyleType(type) ?? style.listStyleType; + 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; + style.listStyleImage = ExpressionMapping.expressionToListStyleImage( + value.first as css.UriTerm) ?? + style.listStyleImage; } break; case 'list-style-position': if (value.first is css.LiteralTerm) { switch ((value.first as css.LiteralTerm).text) { case 'outside': - style.listStylePosition = ListStylePosition.OUTSIDE; + style.listStylePosition = ListStylePosition.outside; break; case 'inside': - style.listStylePosition = ListStylePosition.INSIDE; + style.listStylePosition = ListStylePosition.inside; break; } } break; case 'height': - style.height = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.height; + style.height = + ExpressionMapping.expressionToHeight(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; + style.listStyleType = ExpressionMapping.expressionToListStyleType( + value.first as css.LiteralTerm) ?? + style.listStyleType; } break; case 'margin': - List? marginLengths = value.whereType().toList(); + List? marginLengths = + value.whereType().toList(); + /// List 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 margin = ExpressionMapping.expressionToPadding(marginLengths); - style.margin = (style.margin ?? EdgeInsets.zero).copyWith( - left: margin[0], - right: margin[1], - top: margin[2], - bottom: margin[3], + marginLengths.removeWhere((element) => + element is! css.LengthTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm && + !(element.text == 'auto')); + Margins margin = ExpressionMapping.expressionToMargins(marginLengths); + style.margin = (style.margin ?? Margins.all(0)).copyWith( + left: margin.left, + right: margin.right, + top: margin.top, + bottom: margin.bottom, ); break; case 'margin-left': - style.margin = (style.margin ?? EdgeInsets.zero).copyWith( - left: ExpressionMapping.expressionToPaddingLength(value.first)); + style.margin = (style.margin ?? Margins.zero).copyWith( + left: ExpressionMapping.expressionToMargin(value.first)); break; case 'margin-right': - style.margin = (style.margin ?? EdgeInsets.zero).copyWith( - right: ExpressionMapping.expressionToPaddingLength(value.first)); + style.margin = (style.margin ?? Margins.zero).copyWith( + right: ExpressionMapping.expressionToMargin(value.first)); break; case 'margin-top': - style.margin = (style.margin ?? EdgeInsets.zero).copyWith( - top: ExpressionMapping.expressionToPaddingLength(value.first)); + style.margin = (style.margin ?? Margins.zero) + .copyWith(top: ExpressionMapping.expressionToMargin(value.first)); break; case 'margin-bottom': - style.margin = (style.margin ?? EdgeInsets.zero).copyWith( - bottom: ExpressionMapping.expressionToPaddingLength(value.first)); + style.margin = (style.margin ?? Margins.zero).copyWith( + bottom: ExpressionMapping.expressionToMargin(value.first)); break; case 'padding': - List? paddingLengths = value.whereType().toList(); + List? paddingLengths = + value.whereType().toList(); + /// List 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 padding = ExpressionMapping.expressionToPadding(paddingLengths); + paddingLengths.removeWhere((element) => + element is! css.LengthTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm); + List padding = + ExpressionMapping.expressionToPadding(paddingLengths); style.padding = (style.padding ?? EdgeInsets.zero).copyWith( left: padding[0], right: padding[1], @@ -302,36 +476,67 @@ Style declarationsToStyle(Map> declarations) { bottom: ExpressionMapping.expressionToPaddingLength(value.first)); break; case 'text-align': - style.textAlign = ExpressionMapping.expressionToTextAlign(value.first); + style.textAlign = + ExpressionMapping.expressionToTextAlign(value.first); break; case 'text-decoration': - List? textDecorationList = value.whereType().toList(); + List? textDecorationList = + value.whereType().toList(); + /// List 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")); + textDecorationList.removeWhere((element) => + element == null || + (element.text != "none" && + element.text != "overline" && + element.text != "underline" && + element.text != "line-through")); List? nullableList = value; css.Expression? textDecorationColor; - textDecorationColor = nullableList.firstWhereOrNull( - (element) => element is css.HexColorTerm || element is css.FunctionTerm); - List? potentialStyles = value.whereType().toList(); + textDecorationColor = nullableList.firstWhereOrNull((element) => + element is css.HexColorTerm || element is css.FunctionTerm); + List? potentialStyles = + value.whereType().toList(); + /// List 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); + 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; + style.textDecorationColor = + ExpressionMapping.expressionToColor(value.first) ?? + style.textDecorationColor; break; case 'text-decoration-line': - List? textDecorationList = value.whereType().toList(); - style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList); + List? textDecorationList = + value.whereType().toList(); + style.textDecoration = + ExpressionMapping.expressionToTextDecorationLine( + textDecorationList); break; case 'text-decoration-style': - style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(value.first as css.LiteralTerm); + style.textDecorationStyle = + ExpressionMapping.expressionToTextDecorationStyle( + value.first as css.LiteralTerm); break; case 'text-shadow': style.textShadow = ExpressionMapping.expressionToTextShadow(value); @@ -348,8 +553,13 @@ Style declarationsToStyle(Map> declarations) { style.textTransform = TextTransform.none; } break; + case 'vertical-align': + style.verticalAlign = ExpressionMapping.expressionToVerticalAlignTerm( + (value.first as css.LiteralTerm)); + break; case 'width': - style.width = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.width; + style.width = + ExpressionMapping.expressionToWidth(value.first) ?? style.width; break; } } @@ -372,7 +582,8 @@ Style? inlineCssToStyle(String? inlineStyle, OnCssParseError? errorHandler) { return null; } -Map>> parseExternalCss(String css, OnCssParseError? errorHandler) { +Map>> parseExternalCss( + String css, OnCssParseError? errorHandler) { var errors = []; final sheet = cssparser.parse(css, errors: errors); if (errors.isEmpty) { @@ -387,30 +598,33 @@ Map>> parseExternalCss(String css, OnCs } class DeclarationVisitor extends css.Visitor { - Map>> _result = {}; - Map> _properties = {}; + final Map>> _result = {}; + final Map> _properties = {}; late String _selector; late String _currentProperty; - Map>> getDeclarations(css.StyleSheet sheet) { - sheet.topLevels.forEach((element) { + Map>> getDeclarations( + css.StyleSheet sheet) { + for (var element in sheet.topLevels) { 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.from(value)); + _result[_selector]![key]! + .addAll(List.from(value)); } else { - _result[_selector]![key] = new List.from(value); + _result[_selector]![key] = List.from(value); } }); } else { - _result[_selector] = new Map>.from(_properties); + _result[_selector] = + Map>.from(_properties); } _properties.clear(); } - }); + } return _result; } @@ -433,8 +647,12 @@ class DeclarationVisitor extends css.Visitor { //Mapping functions class ExpressionMapping { + static final _leadingOrTrailingQuoteRegexp = RegExp(r'^"|"$'); - static Border expressionToBorder(List? borderWidths, List? borderStyles, List? borderColors) { + static Border expressionToBorder( + List? borderWidths, + List? borderStyles, + List? borderColors) { CustomBorderSide left = CustomBorderSide(); CustomBorderSide top = CustomBorderSide(); CustomBorderSide right = CustomBorderSide(); @@ -509,11 +727,22 @@ class ExpressionMapping { } } 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) - ); + 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) { @@ -526,7 +755,9 @@ class ExpressionMapping { } 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; + return double.tryParse( + value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? + 1.0; } else if (value is css.LiteralTerm) { switch (value.text) { case "thin": @@ -566,7 +797,7 @@ class ExpressionMapping { static TextDirection expressionToDirection(css.Expression value) { if (value is css.LiteralTerm) { - switch(value.text) { + switch (value.text) { case "ltr": return TextDirection.ltr; case "rtl": @@ -578,32 +809,41 @@ class ExpressionMapping { static Display expressionToDisplay(css.Expression value) { if (value is css.LiteralTerm) { - switch(value.text) { + switch (value.text) { case 'block': - return Display.BLOCK; + return Display.block; case 'inline-block': - return Display.INLINE_BLOCK; + return Display.inlineBlock; case 'inline': - return Display.INLINE; + return Display.inline; case 'list-item': - return Display.LIST_ITEM; + return Display.listItem; case 'none': - return Display.NONE; + return Display.none; } } - return Display.INLINE; + return Display.inline; } - static List expressionToFontFeatureSettings(List value) { + static List expressionToFontFeatureSettings( + List value) { List 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 (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)); + 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)); } @@ -619,15 +859,17 @@ class ExpressionMapping { static FontSize? expressionToFontSize(css.Expression value) { if (value is css.NumberTerm) { - return FontSize(double.tryParse(value.text)); + return FontSize(double.tryParse(value.text) ?? 16, Unit.px); } else if (value is css.PercentageTerm) { - return FontSize.percent(double.tryParse(value.text)!); + return FontSize(double.tryParse(value.text) ?? 100, Unit.percent); } 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)!); + return FontSize(double.tryParse(value.text) ?? 1, Unit.em); + // } else if (value is css.RemTerm) { TODO + // return FontSize.rem(double.tryParse(value.text) ?? 1, Unit.em); } else if (value is css.LengthTerm) { - return FontSize(double.tryParse(value.text.replaceAll(new RegExp(r'\s+(\d+\.\d+)\s+'), ''))); + return FontSize(double.tryParse( + value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? + 16); } else if (value is css.LiteralTerm) { switch (value.text) { case "xx-small": @@ -651,7 +893,7 @@ class ExpressionMapping { static FontStyle expressionToFontStyle(css.Expression value) { if (value is css.LiteralTerm) { - switch(value.text) { + switch (value.text) { case "italic": case "oblique": return FontStyle.italic; @@ -684,7 +926,7 @@ class ExpressionMapping { return FontWeight.w900; } } else if (value is css.LiteralTerm) { - switch(value.text) { + switch (value.text) { case "bold": return FontWeight.bold; case "bolder": @@ -698,8 +940,11 @@ class ExpressionMapping { } static String? expressionToFontFamily(css.Expression value) { - if (value is css.LiteralTerm) return value.text; - return null; + if (value is css.LiteralTerm) { + return value.text.replaceAll(_leadingOrTrailingQuoteRegexp, ''); + } else { + return value.span?.text.replaceAll(_leadingOrTrailingQuoteRegexp, ''); + } } static LineHeight expressionToLineHeight(css.Expression value) { @@ -712,40 +957,78 @@ class ExpressionMapping { } 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( + double.tryParse( + value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')), + units: "length"); } return LineHeight.normal; } + static ListStyleImage? expressionToListStyleImage(css.UriTerm value) { + return ListStyleImage(value.text); + } + static ListStyleType? expressionToListStyleType(css.LiteralTerm value) { - if (value is css.UriTerm) { - return ListStyleType.fromImage(value.text); + return ListStyleType.fromName(value.text); + } + + static Width? expressionToWidth(css.Expression value) { + if ((value is css.LiteralTerm) && value.text == 'auto') { + return Width.auto(); + } else { + final computedValue = expressionToLengthOrPercent(value); + return Width(computedValue.value, computedValue.unit); } - 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; + } + + static Height? expressionToHeight(css.Expression value) { + if ((value is css.LiteralTerm) && value.text == 'auto') { + return Height.auto(); + } else { + final computedValue = expressionToLengthOrPercent(value); + return Height(computedValue.value, computedValue.unit); } - return null; + } + + static Margin? expressionToMargin(css.Expression value) { + if ((value is css.LiteralTerm) && value.text == 'auto') { + return Margin.auto(); + } else { + final computedValue = expressionToLengthOrPercent(value); + return Margin(computedValue.value, computedValue.unit); + } + } + + static Margins expressionToMargins(List? lengths) { + Margin? left; + Margin? right; + Margin? top; + Margin? bottom; + if (lengths != null && lengths.isNotEmpty) { + top = expressionToMargin(lengths.first); + if (lengths.length == 4) { + right = expressionToMargin(lengths[1]); + bottom = expressionToMargin(lengths[2]); + left = expressionToMargin(lengths.last); + } + if (lengths.length == 3) { + left = expressionToMargin(lengths[1]); + right = expressionToMargin(lengths[1]); + bottom = expressionToMargin(lengths.last); + } + if (lengths.length == 2) { + bottom = expressionToMargin(lengths.first); + left = expressionToMargin(lengths.last); + right = expressionToMargin(lengths.last); + } + if (lengths.length == 1) { + bottom = expressionToMargin(lengths.first); + left = expressionToMargin(lengths.first); + right = expressionToMargin(lengths.first); + } + } + return Margins(left: left, right: right, top: top, bottom: bottom); } static List expressionToPadding(List? lengths) { @@ -787,14 +1070,41 @@ class ExpressionMapping { } 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 double.tryParse( + value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')); } return null; } + static LengthOrPercent expressionToLengthOrPercent(css.Expression value) { + if (value is css.NumberTerm) { + return LengthOrPercent(double.parse(value.text)); + } else if (value is css.EmTerm) { + return LengthOrPercent(double.parse(value.text), Unit.em); + // } else if (value is css.RemTerm) { + // return LengthOrPercent(double.parse(value.text), Unit.rem); + // TODO there are several other available terms processed by the CSS parser + } else if (value is css.LengthTerm) { + double number = + double.parse(value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')); + Unit unit = _unitMap(value.unit); + return LengthOrPercent(number, unit); + } + + //Ignore unparsable input + return LengthOrPercent(0); + } + + static Unit _unitMap(int cssParserUnitToken) { + switch (cssParserUnitToken) { + default: + return Unit.px; + } + } + static TextAlign expressionToTextAlign(css.Expression value) { if (value is css.LiteralTerm) { - switch(value.text) { + switch (value.text) { case "center": return TextAlign.center; case "left": @@ -812,11 +1122,12 @@ class ExpressionMapping { return TextAlign.start; } - static TextDecoration expressionToTextDecorationLine(List value) { + static TextDecoration expressionToTextDecorationLine( + List value) { List decorationList = []; for (css.LiteralTerm? term in value) { if (term != null) { - switch(term.text) { + switch (term.text) { case "overline": decorationList.add(TextDecoration.overline); break; @@ -832,12 +1143,15 @@ class ExpressionMapping { } } } - if (decorationList.contains(TextDecoration.none)) decorationList = [TextDecoration.none]; + if (decorationList.contains(TextDecoration.none)) { + decorationList = [TextDecoration.none]; + } return TextDecoration.combine(decorationList); } - static TextDecorationStyle expressionToTextDecorationStyle(css.LiteralTerm value) { - switch(value.text) { + static TextDecorationStyle expressionToTextDecorationStyle( + css.LiteralTerm value) { + switch (value.text) { case "wavy": return TextDecorationStyle.wavy; case "dotted": @@ -870,10 +1184,10 @@ class ExpressionMapping { css.Expression? offsetX; css.Expression? offsetY; css.Expression? blurRadius; - css.HexColorTerm? color; + css.Expression? color; int expressionIndex = 0; - list.forEach((element) { - if (element is css.HexColorTerm) { + for (var element in list) { + if (element is css.HexColorTerm || element is css.FunctionTerm) { color = element; } else if (expressionIndex == 0) { offsetX = element; @@ -884,23 +1198,32 @@ class ExpressionMapping { } 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) { + 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, + color: expressionToColor(color)!, + offset: Offset( + double.tryParse((offsetX).text.replaceAll(nonNumberRegex, ''))!, + double.tryParse( + (offsetY).text.replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) + ? double.tryParse( + (blurRadius).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, + offset: Offset( + double.tryParse((offsetX).text.replaceAll(nonNumberRegex, ''))!, + double.tryParse( + (offsetY).text.replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) + ? double.tryParse( + (blurRadius).text.replaceAll(nonNumberRegex, ''))! + : 0.0, )); } } @@ -909,26 +1232,25 @@ class ExpressionMapping { 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)}' - ); + static Color stringToColor(String rawText) { + var text = rawText.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; + text = "0x$text"; } else { - text = "0xFF" + text; + text = "0xFF$text"; } - return new Color(int.parse(text)); + return 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(); + rgbaText.split(',').map((value) => double.parse(value)).toList(); if (rgbaValues.length == 4) { return Color.fromRGBO( rgbaValues[0].toInt(), @@ -954,28 +1276,54 @@ class ExpressionMapping { final hslText = text.replaceAll(')', '').replaceAll(' ', ''); final hslValues = hslText.split(',').toList(); List parsedHsl = []; - hslValues.forEach((element) { - if (element.contains("%") && double.tryParse(element.replaceAll("%", "")) != null) { + for (var element in hslValues) { + 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)) { + 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(); + 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; + 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; + String namedColor = namedColors.keys.firstWhere( + (element) => element.toLowerCase() == text.toLowerCase(), + orElse: () => ""); + if (namedColor != "") { + return stringToColor(namedColors[namedColor]!); + } else { + return null; + } + } + + static VerticalAlign expressionToVerticalAlignTerm(css.LiteralTerm value) { + switch (value.text) { + case 'sub': + return VerticalAlign.sub; + case 'super': + return VerticalAlign.sup; + case 'baseline': + return VerticalAlign.baseline; + } + + return VerticalAlign.baseline; } } diff --git a/lib/src/html_elements.dart b/lib/src/html_elements.dart index 4b096d8f5d..304c9ff395 100644 --- a/lib/src/html_elements.dart +++ b/lib/src/html_elements.dart @@ -2,282 +2,206 @@ 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", +class HtmlElements { + static const styledElements = [ + "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", -]; + //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", -]; + static const blockElements = [ + "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", -]; + static const interactableElements = [ + "a", + ]; -const REPLACED_ELEMENTS = [ - "br", - "template", - "rp", - "rt", - "ruby", -]; + static const replacedElements = [ + "br", + "template", + "rp", + "rt", + "ruby", + ]; -const LAYOUT_ELEMENTS = [ - "details", - "tr", - "tbody", - "tfoot", - "thead", -]; + static const layoutElements = [ + "details", + "tr", + "tbody", + "tfoot", + "thead", + ]; -const TABLE_CELL_ELEMENTS = ["th", "td"]; + static const tableCellElements = ["th", "td"]; -const TABLE_DEFINITION_ELEMENTS = ["col", "colgroup"]; + static const tableDefinitionElements = ["col", "colgroup"]; -const EXTERNAL_ELEMENTS = ["audio", "iframe", "img", "math", "svg", "table", "video"]; + static const externalElements = [ + "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", -]; + static const replacedExternalElements = ["iframe", "img", "video", "audio"]; -/** - 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] - */ + static const selectableElements = [ + "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", + ]; +} diff --git a/lib/src/interactable_element.dart b/lib/src/interactable_element.dart index 2aab878a64..6a1bf90a4f 100644 --- a/lib/src/interactable_element.dart +++ b/lib/src/interactable_element.dart @@ -8,35 +8,37 @@ class InteractableElement extends StyledElement { String? href; InteractableElement({ - required String name, - required List children, - required Style style, + required super.name, + required super.children, + required super.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); + required super.elementId, + }) : super(node: node as dom.Element?); } /// A [Gesture] indicates the type of interaction by a user. enum Gesture { - TAP, + tap, } StyledElement parseInteractableElement( - dom.Element element, List children) { + dom.Element element, + List 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 + name: element.localName!, + children: children, + href: element.attributes['href'], + style: Style( + color: Colors.blue, + textDecoration: TextDecoration.underline, + ), + node: element, + elementId: element.id, ); } // When tag have no href, it must be non clickable and without decoration. @@ -47,6 +49,7 @@ StyledElement parseInteractableElement( node: element, elementId: element.id, ); + /// will never be called, just to suppress missing return warning default: return InteractableElement( @@ -55,7 +58,7 @@ StyledElement parseInteractableElement( node: element, href: '', style: Style(), - elementId: "[[No ID]]" + elementId: "[[No ID]]", ); } } diff --git a/lib/src/layout_element.dart b/lib/src/layout_element.dart index 33093e7493..cd0e67677c 100644 --- a/lib/src/layout_element.dart +++ b/lib/src/layout_element.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/html_parser.dart'; import 'package:flutter_html/src/anchor.dart'; +import 'package:flutter_html/src/css_box_widget.dart'; import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/src/styled_element.dart'; import 'package:flutter_html/style.dart'; @@ -11,10 +12,10 @@ import 'package:html/dom.dart' as dom; abstract class LayoutElement extends StyledElement { LayoutElement({ String name = "[[No Name]]", - required List children, + required super.children, String? elementId, - dom.Element? node, - }) : super(name: name, children: children, style: Style(), node: node, elementId: elementId ?? "[[No ID]]"); + super.node, + }) : super(name: name, style: Style(), elementId: elementId ?? "[[No ID]]"); Widget? toWidget(RenderContext context); } @@ -28,21 +29,21 @@ class TableSectionLayoutElement extends LayoutElement { @override Widget toWidget(RenderContext context) { // Not rendered; TableLayoutElement will instead consume its children - return Container(child: Text("TABLE SECTION")); + return const Text("TABLE SECTION"); } } class TableRowLayoutElement extends LayoutElement { TableRowLayoutElement({ - required String name, - required List children, - required dom.Element node, - }) : super(name: name, children: children, node: node); + required super.name, + required super.children, + required super.node, + }); @override Widget toWidget(RenderContext context) { // Not rendered; TableLayoutElement will instead consume its children - return Container(child: Text("TABLE ROW")); + return const Text("TABLE ROW"); } } @@ -51,13 +52,13 @@ class TableCellElement extends StyledElement { int rowspan = 1; TableCellElement({ - required String name, - required String elementId, - required List elementClasses, - required List children, - required Style style, - required dom.Element node, - }) : super(name: name, elementId: elementId, elementClasses: elementClasses, children: children, style: style, node: node) { + required super.name, + required super.elementId, + required super.elementClasses, + required super.children, + required super.style, + required super.node, + }) { colspan = _parseSpan(this, "colspan"); rowspan = _parseSpan(this, "rowspan"); } @@ -90,11 +91,11 @@ TableCellElement parseTableCellElement( class TableStyleElement extends StyledElement { TableStyleElement({ - required String name, - required List children, - required Style style, - required dom.Element node, - }) : super(name: name, children: children, style: style, node: node); + required super.name, + required super.children, + required super.style, + required super.node, + }); } TableStyleElement parseTableDefinitionElement( @@ -124,65 +125,77 @@ class DetailsContentElement extends LayoutElement { List elementList; DetailsContentElement({ - required String name, - required List children, + required super.name, + required super.children, required dom.Element node, required this.elementList, - }) : super(name: name, node: node, children: children, elementId: node.id); + }) : super(node: node, elementId: node.id); @override Widget toWidget(RenderContext context) { - List? childrenList = children.map((tree) => context.parser.parseTree(context, tree)).toList(); + List? childrenList = children + .map((tree) => context.parser.parseTree(context, tree)) + .toList(); List toRemove = []; for (InlineSpan child in childrenList) { - if (child is TextSpan && child.text != null && child.text!.trim().isEmpty) { + 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; + 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"), + title: elementList.isNotEmpty == true && + elementList.first.localName == "summary" + ? CssBoxWidget.withInlineSpanChildren( + context: context.buildContext, + children: firstChild == null ? [] : [firstChild], + style: style, + ) + : const Text("Details"), children: [ - StyledText( - textSpan: TextSpan( - style: style.generateTextStyle(), - children: getChildren(childrenList, context, elementList.isNotEmpty == true && elementList.first.localName == "summary" ? firstChild : null) - ), + CssBoxWidget.withInlineSpanChildren( + context: context.buildContext, + children: getChildren( + childrenList, + context, + elementList.isNotEmpty == true && + elementList.first.localName == "summary" + ? firstChild + : null), style: style, - renderContext: context, ), - ] - ); + ]); } - List getChildren(List children, RenderContext context, InlineSpan? firstChild) { + List getChildren(List 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: []); + EmptyLayoutElement({required String name}) + : super( + name: name, + children: [], + ); @override - Widget? toWidget(_) => null; + Widget? toWidget(context) => null; } LayoutElement parseLayoutElement( - dom.Element element, - List children, + dom.Element element, + List children, ) { switch (element.localName) { case "details": @@ -190,10 +203,10 @@ LayoutElement parseLayoutElement( return EmptyLayoutElement(name: "empty"); } return DetailsContentElement( - node: element, - name: element.localName!, - children: children, - elementList: element.children + node: element, + name: element.localName!, + children: children, + elementList: element.children, ); case "thead": case "tbody": diff --git a/lib/src/replaced_element.dart b/lib/src/replaced_element.dart index 81cc5d58ee..a7d9ea2d84 100644 --- a/lib/src/replaced_element.dart +++ b/lib/src/replaced_element.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_html/html_parser.dart'; import 'package:flutter_html/src/anchor.dart'; +import 'package:flutter_html/src/css_box_widget.dart'; import 'package:flutter_html/src/html_elements.dart'; import 'package:flutter_html/style.dart'; import 'package:html/dom.dart' as dom; @@ -17,13 +18,13 @@ abstract class ReplacedElement extends StyledElement { PlaceholderAlignment alignment; ReplacedElement({ - required String name, - required Style style, - required String elementId, + required super.name, + required super.style, + required super.elementId, List? children, - dom.Element? node, + super.node, this.alignment = PlaceholderAlignment.aboveBaseline, - }) : super(name: name, children: children ?? [], style: style, node: node, elementId: elementId); + }) : super(children: children ?? []); static List parseMediaSources(List elements) { return elements @@ -46,7 +47,11 @@ class TextContentElement extends ReplacedElement { required this.text, this.node, dom.Element? element, - }) : super(name: "[text]", style: style, node: element, elementId: "[[No ID]]"); + }) : super( + name: "[text]", + style: style, + node: element, + elementId: "[[No ID]]"); @override String toString() { @@ -54,90 +59,102 @@ class TextContentElement extends ReplacedElement { } @override - Widget? toWidget(_) => null; + Widget? toWidget(context) => null; } class EmptyContentElement extends ReplacedElement { - EmptyContentElement({String name = "empty"}) : super(name: name, style: Style(), elementId: "[[No ID]]"); + EmptyContentElement({String name = "empty"}) + : super(name: name, style: Style(), elementId: "[[No ID]]"); @override - Widget? toWidget(_) => null; + Widget? toWidget(context) => null; } class RubyElement extends ReplacedElement { + @override dom.Element element; RubyElement({ required this.element, required List children, - String name = "ruby" - }) : super(name: name, alignment: PlaceholderAlignment.middle, style: Style(), elementId: element.id, children: 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 widgets = []; - final rubySize = context.parser.style['rt']?.fontSize?.size ?? max(9.0, context.style.fontSize!.size! / 2); + final rubySize = context.parser.style['rt']?.fontSize?.value ?? + max(9.0, context.style.fontSize!.value / 2); final rubyYPos = rubySize + rubySize / 2; List 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))) { + 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) { + for (var c in children) { if (c.name == "rt" && node != null) { final widget = Stack( alignment: Alignment.center, children: [ 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!)]), + alignment: Alignment.bottomCenter, + child: Center( + child: Transform( + transform: Matrix4.translationValues(0, -(rubyYPos), 0), + child: CssBoxWidget( + style: c.style, + child: Text( + c.element!.innerHtml, + style: c.style + .generateTextStyle(context.buildContext) + .copyWith(fontSize: rubySize), + ), + ), + ), + ), + ), + CssBoxWidget( + style: context.style, + child: node is TextContentElement + ? Text( + node.text?.trim() ?? "", + style: + context.style.generateTextStyle(context.buildContext), + ) + : RichText(text: 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(), + children: widgets + .map((e) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + textBaseline: TextBaseline.alphabetic, + mainAxisSize: MainAxisSize.min, + children: [e], + )) + .toList(), ), ); } @@ -151,9 +168,9 @@ ReplacedElement parseReplacedElement( case "br": return TextContentElement( text: "\n", - style: Style(whiteSpace: WhiteSpace.PRE), + style: Style(whiteSpace: WhiteSpace.pre), element: element, - node: element + node: element, ); case "ruby": return RubyElement( @@ -161,6 +178,7 @@ ReplacedElement parseReplacedElement( children: children, ); default: - return EmptyContentElement(name: element.localName == null ? "[[No Name]]" : element.localName!); + return EmptyContentElement( + name: element.localName == null ? "[[No Name]]" : element.localName!); } } diff --git a/lib/src/style/fontsize.dart b/lib/src/style/fontsize.dart new file mode 100644 index 0000000000..793124fe14 --- /dev/null +++ b/lib/src/style/fontsize.dart @@ -0,0 +1,33 @@ +import 'length.dart'; + +class FontSize extends LengthOrPercent { + FontSize(double size, [Unit unit = Unit.px]) : super(size, unit); + + // These values are calculated based off of the default (`medium`) + // being 14px. + // TODO calculate from https://w3c.github.io/csswg-drafts/css-fonts-3/#absolute-size-value + static final xxSmall = FontSize(7.875); + static final xSmall = FontSize(8.75); + static final small = FontSize(11.375); + static final medium = FontSize(14.0); + static final large = FontSize(15.75); + static final xLarge = FontSize(21.0); + static final xxLarge = FontSize(28.0); + static final smaller = FontSize(83, Unit.percent); + static final larger = FontSize(120, Unit.percent); + + static FontSize? inherit(FontSize? parent, FontSize? child) { + if (child != null && parent != null) { + if (child.unit == Unit.em) { + return FontSize(child.value * parent.value); + } else if (child.unit == Unit.percent) { + return FontSize(child.value / 100.0 * parent.value); + } + return child; + } + + return parent; + } + + double get emValue => value; +} diff --git a/lib/src/style/length.dart b/lib/src/style/length.dart new file mode 100644 index 0000000000..b63e8ebb55 --- /dev/null +++ b/lib/src/style/length.dart @@ -0,0 +1,64 @@ +/// These are the base unit types +enum UnitType { + percent, + length, + auto, + lengthPercent(children: [UnitType.length, UnitType.percent]), + lengthPercentAuto( + children: [UnitType.length, UnitType.percent, UnitType.auto]); + + final List children; + + const UnitType({this.children = const []}); + + bool matches(UnitType other) { + return this == other || children.contains(other); + } +} + +/// A Unit represents a CSS unit +enum Unit { + //ch, + em(UnitType.length), + //ex, + percent(UnitType.percent), + px(UnitType.length), + rem(UnitType.length), + //Q, + //vh, + //vw, + auto(UnitType.auto); + + const Unit(this.unitType); + final UnitType unitType; +} + +/// Represents a CSS dimension https://drafts.csswg.org/css-values/#dimensions +abstract class Dimension { + double value; + Unit unit; + + Dimension(this.value, this.unit, UnitType dimensionUnitType) + : assert(dimensionUnitType.matches(unit.unitType), + "This Dimension was given a Unit that isn't specified."); +} + +/// This dimension takes a value with a length unit such as px or em. Note that +/// these can be fixed or relative (but they must not be a percent) +class Length extends Dimension { + Length(double value, [Unit unit = Unit.px]) + : super(value, unit, UnitType.length); +} + +/// This dimension takes a value with a length-percent unit such as px or em +/// or %. Note that these can be fixed or relative (but they must not be a +/// percent) +class LengthOrPercent extends Dimension { + LengthOrPercent(double value, [Unit unit = Unit.px]) + : super(value, unit, UnitType.lengthPercent); +} + +class AutoOrLengthOrPercent extends Dimension { + AutoOrLengthOrPercent(double value, [Unit unit = Unit.px]) + : super(value, unit, UnitType.lengthPercentAuto); +} diff --git a/lib/src/style/lineheight.dart b/lib/src/style/lineheight.dart new file mode 100644 index 0000000000..0550ee1a7a --- /dev/null +++ b/lib/src/style/lineheight.dart @@ -0,0 +1,25 @@ +//TODO implement dimensionality +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); +} diff --git a/lib/src/style/margin.dart b/lib/src/style/margin.dart new file mode 100644 index 0000000000..4df4e7b890 --- /dev/null +++ b/lib/src/style/margin.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/src/style/length.dart'; + +class Margin extends AutoOrLengthOrPercent { + Margin(double value, [Unit? unit = Unit.px]) : super(value, unit ?? Unit.px); + + Margin.auto() : super(0, Unit.auto); + + Margin.zero() : super(0, Unit.px); +} + +class Margins { + final Margin? left; + final Margin? right; + final Margin? top; + final Margin? bottom; + + const Margins({this.left, this.right, this.top, this.bottom}); + + /// Auto margins already have a "value" of zero so can be considered collapsed. + Margins collapse() => Margins( + left: left?.unit == Unit.auto ? left : Margin(0, Unit.px), + right: right?.unit == Unit.auto ? right : Margin(0, Unit.px), + top: top?.unit == Unit.auto ? top : Margin(0, Unit.px), + bottom: bottom?.unit == Unit.auto ? bottom : Margin(0, Unit.px), + ); + + Margins copyWith( + {Margin? left, Margin? right, Margin? top, Margin? bottom}) => + Margins( + left: left ?? this.left, + right: right ?? this.right, + top: top ?? this.top, + bottom: bottom ?? this.bottom, + ); + + Margins copyWithEdge( + {double? left, double? right, double? top, double? bottom}) => + Margins( + left: left != null ? Margin(left, this.left?.unit) : this.left, + right: right != null ? Margin(right, this.right?.unit) : this.right, + top: top != null ? Margin(top, this.top?.unit) : this.top, + bottom: + bottom != null ? Margin(bottom, this.bottom?.unit) : this.bottom, + ); + + // bool get isAutoHorizontal => (left is MarginAuto) || (right is MarginAuto); + + /// Analogous to [EdgeInsets.zero] + static Margins get zero => Margins.all(0); + + /// Analogous to [EdgeInsets.all] + Margins.all(double value, {Unit? unit}) + : left = Margin(value, unit), + right = Margin(value, unit), + top = Margin(value, unit), + bottom = Margin(value, unit); + + /// Analogous to [EdgeInsets.only] + Margins.only( + {double? left, double? right, double? top, double? bottom, Unit? unit}) + : left = Margin(left ?? 0, unit), + right = Margin(right ?? 0, unit), + top = Margin(top ?? 0, unit), + bottom = Margin(bottom ?? 0, unit); + + /// Analogous to [EdgeInsets.symmetric] + Margins.symmetric({double? horizontal, double? vertical, Unit? unit}) + : left = Margin(horizontal ?? 0, unit), + right = Margin(horizontal ?? 0, unit), + top = Margin(vertical ?? 0, unit), + bottom = Margin(vertical ?? 0, unit); +} diff --git a/lib/src/style/marker.dart b/lib/src/style/marker.dart new file mode 100644 index 0000000000..86af32f48f --- /dev/null +++ b/lib/src/style/marker.dart @@ -0,0 +1,35 @@ +import 'package:flutter_html/flutter_html.dart'; + +class Marker { + final Content content; + + Style? style; + + Marker({ + this.content = Content.normal, + this.style, + }); +} + +class Content { + final String? replacementContent; + final bool _normal; + final bool display; + + const Content(this.replacementContent) + : _normal = false, + display = true; + const Content._normal() + : _normal = true, + display = true, + replacementContent = null; + const Content._none() + : _normal = false, + display = false, + replacementContent = null; + + static const Content none = Content._none(); + static const Content normal = Content._normal(); + + bool get isNormal => _normal; +} diff --git a/lib/src/style/size.dart b/lib/src/style/size.dart new file mode 100644 index 0000000000..1b73663793 --- /dev/null +++ b/lib/src/style/size.dart @@ -0,0 +1,18 @@ +import 'package:flutter_html/flutter_html.dart'; + +/// The [Width] class takes in a value and units, and defaults to px if no +/// units are provided. A helper constructor, [Width.auto] constructor is +/// provided for convenience. +class Width extends AutoOrLengthOrPercent { + Width(super.value, [super.unit = Unit.px]) + : assert(value >= 0, 'Width value must be non-negative'); + + Width.auto() : super(0, Unit.auto); +} + +class Height extends AutoOrLengthOrPercent { + Height(super.value, [super.unit = Unit.px]) + : assert(value >= 0, 'Height value must be non-negative'); + + Height.auto() : super(0, Unit.auto); +} diff --git a/lib/src/styled_element.dart b/lib/src/styled_element.dart index 9561d8f228..d5cc504c3c 100644 --- a/lib/src/styled_element.dart +++ b/lib/src/styled_element.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; import 'package:flutter_html/src/css_parser.dart'; import 'package:flutter_html/style.dart'; @@ -5,6 +7,7 @@ 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'; +import 'package:list_counter/list_counter.dart'; /// A [StyledElement] applies a style to all of its children. class StyledElement { @@ -14,6 +17,7 @@ class StyledElement { List children; Style style; final dom.Element? _node; + final ListQueue counters = ListQueue(); StyledElement({ this.name = "[[No name]]", @@ -22,16 +26,18 @@ class StyledElement { required this.children, required this.style, required dom.Element? node, - }) : this._node = node; + }) : _node = node; bool matchesSelector(String selector) => (_node != null && matches(_node!, selector)) || name == selector; + dom.Element? get internalNode => _node; + Map get attributes => _node?.attributes.map((key, value) { return MapEntry(key.toString(), value); }) ?? - Map(); + {}; dom.Element? get element => _node; @@ -39,16 +45,18 @@ class StyledElement { String toString() { String selfData = "[$name] ${children.length} ${elementClasses.isNotEmpty == true ? 'C:${elementClasses.toString()}' : ''}${elementId.isNotEmpty == true ? 'ID: $elementId' : ''}"; - children.forEach((child) { + for (var child in children) { selfData += ("\n${child.toString()}") .replaceAll(RegExp("^", multiLine: true), "-"); - }); + } return selfData; } } StyledElement parseStyledElement( - dom.Element element, List children) { + dom.Element element, + List children, +) { StyledElement styledElement = StyledElement( name: element.localName!, elementId: element.id, @@ -70,12 +78,12 @@ StyledElement parseStyledElement( continue italics; case "article": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; case "aside": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; bold: @@ -102,26 +110,26 @@ StyledElement parseStyledElement( //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, + margin: Margins.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, + margin: Margins.symmetric(horizontal: 40.0, vertical: 14.0), + display: Display.block, ); } break; case "body": styledElement.style = Style( - margin: EdgeInsets.all(8.0), - display: Display.BLOCK, + margin: Margins.all(8.0), + display: Display.block, ); break; case "center": styledElement.style = Style( alignment: Alignment.center, - display: Display.BLOCK, + display: Display.block, ); break; case "cite": @@ -134,8 +142,8 @@ StyledElement parseStyledElement( break; case "dd": styledElement.style = Style( - margin: EdgeInsets.only(left: 40.0), - display: Display.BLOCK, + margin: Margins.only(left: 40.0), + display: Display.block, ); break; strikeThrough: @@ -148,115 +156,120 @@ StyledElement parseStyledElement( continue italics; case "div": styledElement.style = Style( - margin: EdgeInsets.all(0), - display: Display.BLOCK, + margin: Margins.all(0), + display: Display.block, ); break; case "dl": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 14.0), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 14.0), + display: Display.block, ); break; case "dt": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; case "em": continue italics; case "figcaption": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; case "figure": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 14.0, horizontal: 40.0), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 14.0, horizontal: 40.0), + display: Display.block, ); break; case "footer": styledElement.style = Style( - display: Display.BLOCK, + 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, + 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, + fontSize: element.attributes['size'] != null + ? numberToFontSize(element.attributes['size']!) + : null, ); break; case "h1": styledElement.style = Style( - fontSize: FontSize.xxLarge, + fontSize: FontSize(2, Unit.em), fontWeight: FontWeight.bold, - margin: EdgeInsets.symmetric(vertical: 18.67), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 0.67, unit: Unit.em), + display: Display.block, ); break; case "h2": styledElement.style = Style( - fontSize: FontSize.xLarge, + fontSize: FontSize(1.5, Unit.em), fontWeight: FontWeight.bold, - margin: EdgeInsets.symmetric(vertical: 17.5), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 0.83, unit: Unit.em), + display: Display.block, ); break; case "h3": styledElement.style = Style( - fontSize: FontSize(16.38), + fontSize: FontSize(1.17, Unit.em), fontWeight: FontWeight.bold, - margin: EdgeInsets.symmetric(vertical: 16.5), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 1, unit: Unit.em), + display: Display.block, ); break; case "h4": styledElement.style = Style( - fontSize: FontSize.medium, fontWeight: FontWeight.bold, - margin: EdgeInsets.symmetric(vertical: 18.5), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 1.33, unit: Unit.em), + display: Display.block, ); break; case "h5": styledElement.style = Style( - fontSize: FontSize(11.62), + fontSize: FontSize(0.83, Unit.em), fontWeight: FontWeight.bold, - margin: EdgeInsets.symmetric(vertical: 19.25), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 1.67, unit: Unit.em), + display: Display.block, ); break; case "h6": styledElement.style = Style( - fontSize: FontSize(9.38), + fontSize: FontSize(0.67, Unit.em), fontWeight: FontWeight.bold, - margin: EdgeInsets.symmetric(vertical: 22), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 2.33, unit: Unit.em), + display: Display.block, ); break; case "header": styledElement.style = Style( - display: Display.BLOCK, + 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, + margin: Margins( + top: Margin(0.5, Unit.em), + bottom: Margin(0.5, Unit.em), + left: Margin.auto(), + right: Margin.auto(), + ), + border: Border.all(), + display: Display.block, ); break; case "html": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; italics: @@ -271,12 +284,12 @@ StyledElement parseStyledElement( continue monospace; case "li": styledElement.style = Style( - display: Display.LIST_ITEM, + display: Display.listItem, ); break; case "main": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; case "mark": @@ -287,47 +300,36 @@ StyledElement parseStyledElement( break; case "nav": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; case "noscript": styledElement.style = Style( - display: Display.BLOCK, + 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, - ); - } + styledElement.style = Style( + display: Display.block, + listStyleType: element.localName == "ol" + ? ListStyleType.decimal + : ListStyleType.disc, + padding: const EdgeInsets.only(left: 40), + ); break; case "p": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 14.0), - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 1, unit: Unit.em), + display: Display.block, ); break; case "pre": styledElement.style = Style( fontFamily: 'monospace', - margin: EdgeInsets.symmetric(vertical: 14.0), - whiteSpace: WhiteSpace.PRE, - display: Display.BLOCK, + margin: Margins.symmetric(vertical: 14.0), + whiteSpace: WhiteSpace.pre, + display: Display.block, ); break; case "q": @@ -342,7 +344,7 @@ StyledElement parseStyledElement( continue monospace; case "section": styledElement.style = Style( - display: Display.BLOCK, + display: Display.block, ); break; case "small": @@ -357,13 +359,13 @@ StyledElement parseStyledElement( case "sub": styledElement.style = Style( fontSize: FontSize.smaller, - verticalAlign: VerticalAlign.SUB, + verticalAlign: VerticalAlign.sub, ); break; case "sup": styledElement.style = Style( fontSize: FontSize.smaller, - verticalAlign: VerticalAlign.SUPER, + verticalAlign: VerticalAlign.sup, ); break; case "tt": @@ -409,4 +411,12 @@ FontSize numberToFontSize(String num) { return numberToFontSize((3 - relativeNum).toString()); } return FontSize.medium; -} \ No newline at end of file +} + +extension DeepCopy on ListQueue { + ListQueue deepCopy() { + return ListQueue.from(map((counter) { + return Counter(counter.name, counter.value); + })); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 389cb4fc6b..e79bba1008 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -38,7 +38,8 @@ class MultipleTapGestureDetector extends InheritedWidget { }) : super(key: key, child: child); static MultipleTapGestureDetector? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); + return context + .dependOnInheritedWidgetOfExactType(); } @override @@ -85,4 +86,4 @@ extension TextTransformUtil on String? { return this; } } -} \ No newline at end of file +} diff --git a/lib/style.dart b/lib/style.dart index 121319e918..086280a5bd 100644 --- a/lib/style.dart +++ b/lib/style.dart @@ -3,6 +3,14 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/src/css_parser.dart'; +import 'package:flutter_html/src/style/marker.dart'; + +//Export Style value-unit APIs +export 'package:flutter_html/src/style/margin.dart'; +export 'package:flutter_html/src/style/length.dart'; +export 'package:flutter_html/src/style/size.dart'; +export 'package:flutter_html/src/style/fontsize.dart'; +export 'package:flutter_html/src/style/lineheight.dart'; ///This class represents all the available CSS attributes ///for this package. @@ -19,6 +27,18 @@ class Style { /// Default: unspecified, Color? color; + /// CSS attribute "`counter-increment`" + /// + /// Inherited: no + /// Initial: none + Map? counterIncrement; + + /// CSS attribute "`counter-reset`" + /// + /// Inherited: no + /// Initial: none + Map? counterReset; + /// CSS attribute "`direction`" /// /// Inherited: yes, @@ -37,6 +57,12 @@ class Style { /// 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? fontFamilyFallback; + /// CSS attribute "`font-feature-settings`" /// /// Inherited: yes, @@ -64,8 +90,8 @@ class Style { /// CSS attribute "`height`" /// /// Inherited: no, - /// Default: Unspecified (null), - double? height; + /// Default: Height.auto(), + Height? height; /// CSS attribute "`letter-spacing`" /// @@ -73,10 +99,16 @@ class Style { /// Default: normal (0), double? letterSpacing; + /// CSS attribute "`list-style-image`" + /// + /// Inherited: yes, + /// Default: TODO + ListStyleImage? listStyleImage; + /// CSS attribute "`list-style-type`" /// /// Inherited: yes, - /// Default: ListStyleType.DISC + /// Default: ListStyleType.disc ListStyleType? listStyleType; /// CSS attribute "`list-style-position`" @@ -91,11 +123,17 @@ class Style { /// Default: EdgeInsets.zero EdgeInsets? padding; + /// CSS pseudo-element "`::marker`" + /// + /// Inherited: no, + /// Default: null + Marker? marker; + /// CSS attribute "`margin`" /// /// Inherited: no, /// Default: EdgeInsets.zero - EdgeInsets? margin; + Margins? margin; /// CSS attribute "`text-align`" /// @@ -151,8 +189,8 @@ class Style { /// CSS attribute "`width`" /// /// Inherited: no, - /// Default: unspecified (null) - double? width; + /// Default: Width.auto() + Width? width; /// CSS attribute "`word-spacing`" /// @@ -196,9 +234,12 @@ class Style { Style({ this.backgroundColor = Colors.transparent, this.color, + this.counterIncrement, + this.counterReset, this.direction, this.display, this.fontFamily, + this.fontFamilyFallback, this.fontFeatureSettings, this.fontSize, this.fontStyle, @@ -206,9 +247,11 @@ class Style { this.height, this.lineHeight, this.letterSpacing, + this.listStyleImage, this.listStyleType, this.listStylePosition, this.padding, + this.marker, this.margin, this.textAlign, this.textDecoration, @@ -229,23 +272,24 @@ class Style { this.textOverflow, this.textTransform = TextTransform.none, }) { - if (this.alignment == null && - (display == Display.BLOCK || display == Display.LIST_ITEM)) { - this.alignment = Alignment.centerLeft; + if (alignment == null && + (display == Display.block || display == Display.listItem)) { + alignment = Alignment.centerLeft; } } static Map 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 fromCss(String css, OnCssParseError? onCssParseError) { + 'h1': Style.fromTextStyle(theme.textTheme.displayLarge!), + 'h2': Style.fromTextStyle(theme.textTheme.displayMedium!), + 'h3': Style.fromTextStyle(theme.textTheme.displaySmall!), + 'h4': Style.fromTextStyle(theme.textTheme.headlineMedium!), + 'h5': Style.fromTextStyle(theme.textTheme.headlineSmall!), + 'h6': Style.fromTextStyle(theme.textTheme.titleLarge!), + 'body': Style.fromTextStyle(theme.textTheme.bodyMedium!), + }; + + static Map fromCss( + String css, OnCssParseError? onCssParseError) { final declarations = parseExternalCss(css, onCssParseError); Map styleMap = {}; declarations.forEach((key, value) { @@ -254,17 +298,20 @@ class Style { return styleMap; } - TextStyle generateTextStyle() { + TextStyle generateTextStyle(BuildContext context) { return TextStyle( backgroundColor: backgroundColor, - color: color, + color: Theme.of(context).brightness == Brightness.light + ? (color == Colors.white ? Colors.black : color) + : Colors.white, // TODO make this smarter, decoration: textDecoration, decorationColor: textDecorationColor, decorationStyle: textDecorationStyle, decorationThickness: textDecorationThickness, fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, fontFeatures: fontFeatureSettings, - fontSize: fontSize?.size, + fontSize: fontSize?.value, fontStyle: fontStyle, fontWeight: fontWeight, letterSpacing: letterSpacing, @@ -285,9 +332,12 @@ class Style { return copyWith( backgroundColor: other.backgroundColor, color: other.color, + counterIncrement: other.counterIncrement, + counterReset: other.counterReset, direction: other.direction, display: other.display, fontFamily: other.fontFamily, + fontFamilyFallback: other.fontFamilyFallback, fontFeatureSettings: other.fontFeatureSettings, fontSize: other.fontSize, fontStyle: other.fontStyle, @@ -295,12 +345,14 @@ class Style { height: other.height, lineHeight: other.lineHeight, letterSpacing: other.letterSpacing, + listStyleImage: other.listStyleImage, listStyleType: other.listStyleType, listStylePosition: other.listStylePosition, padding: other.padding, //TODO merge EdgeInsets margin: other.margin, - //TODO merge EdgeInsets + //TODO merge Margins + marker: other.marker, textAlign: other.textAlign, textDecoration: other.textDecoration, textDecorationColor: other.textDecorationColor, @@ -325,34 +377,39 @@ class Style { } 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; + FontSize? finalFontSize = FontSize.inherit(fontSize, child.fontSize); + + LineHeight? finalLineHeight = child.lineHeight != null + ? child.lineHeight?.units == "length" + ? LineHeight(child.lineHeight!.size! / + (finalFontSize == null ? 14 : finalFontSize.value) * + 1.2) + : child.lineHeight + : lineHeight; + return child.copyWith( - backgroundColor: child.backgroundColor != Colors.transparent ? - child.backgroundColor : backgroundColor, + backgroundColor: child.backgroundColor != Colors.transparent + ? child.backgroundColor + : backgroundColor, color: child.color ?? color, direction: child.direction ?? direction, - display: display == Display.NONE ? display : child.display, + 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, + listStyleImage: child.listStyleImage ?? listStyleImage, listStyleType: child.listStyleType ?? listStyleType, listStylePosition: child.listStylePosition ?? listStylePosition, textAlign: child.textAlign ?? textAlign, - textDecoration: TextDecoration.combine( - [child.textDecoration ?? TextDecoration.none, - textDecoration ?? TextDecoration.none]), + textDecoration: TextDecoration.combine([ + child.textDecoration ?? TextDecoration.none, + textDecoration ?? TextDecoration.none, + ]), textShadow: child.textShadow ?? textShadow, whiteSpace: child.whiteSpace ?? whiteSpace, wordSpacing: child.wordSpacing ?? wordSpacing, @@ -365,20 +422,25 @@ class Style { Style copyWith({ Color? backgroundColor, Color? color, + Map? counterIncrement, + Map? counterReset, TextDirection? direction, Display? display, String? fontFamily, + List? fontFamilyFallback, List? fontFeatureSettings, FontSize? fontSize, FontStyle? fontStyle, FontWeight? fontWeight, - double? height, + Height? height, LineHeight? lineHeight, double? letterSpacing, + ListStyleImage? listStyleImage, ListStyleType? listStyleType, ListStylePosition? listStylePosition, EdgeInsets? padding, - EdgeInsets? margin, + Margins? margin, + Marker? marker, TextAlign? textAlign, TextDecoration? textDecoration, Color? textDecorationColor, @@ -387,7 +449,7 @@ class Style { List? textShadow, VerticalAlign? verticalAlign, WhiteSpace? whiteSpace, - double? width, + Width? width, double? wordSpacing, String? before, String? after, @@ -402,9 +464,12 @@ class Style { return Style( backgroundColor: backgroundColor ?? this.backgroundColor, color: color ?? this.color, + counterIncrement: counterIncrement ?? this.counterIncrement, + counterReset: counterReset ?? this.counterReset, 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, @@ -412,10 +477,12 @@ class Style { height: height ?? this.height, lineHeight: lineHeight ?? this.lineHeight, letterSpacing: letterSpacing ?? this.letterSpacing, + listStyleImage: listStyleImage ?? this.listStyleImage, listStyleType: listStyleType ?? this.listStyleType, listStylePosition: listStylePosition ?? this.listStylePosition, padding: padding ?? this.padding, margin: margin ?? this.margin, + marker: marker ?? this.marker, textAlign: textAlign ?? this.textAlign, textDecoration: textDecoration ?? this.textDecoration, textDecorationColor: textDecorationColor ?? this.textDecorationColor, @@ -439,121 +506,170 @@ class Style { } 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.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; + backgroundColor = textStyle.backgroundColor; + color = textStyle.color; + textDecoration = textStyle.decoration; + textDecorationColor = textStyle.decorationColor; + textDecorationStyle = textStyle.decorationStyle; + textDecorationThickness = textStyle.decorationThickness; + fontFamily = textStyle.fontFamily; + fontFamilyFallback = textStyle.fontFamilyFallback; + fontFeatureSettings = textStyle.fontFeatures; + fontSize = + textStyle.fontSize != null ? FontSize(textStyle.fontSize!) : null; + fontStyle = textStyle.fontStyle; + fontWeight = textStyle.fontWeight; + letterSpacing = textStyle.letterSpacing; + textShadow = textStyle.shadows; + wordSpacing = textStyle.wordSpacing; + lineHeight = LineHeight(textStyle.height ?? 1.2); + textTransform = TextTransform.none; } -} -enum Display { - BLOCK, - INLINE, - INLINE_BLOCK, - LIST_ITEM, - NONE, -} - -class FontSize { - final double? size; - final String units; + /// Sets any dimensions set to rem or em to the computed size + void setRelativeValues(double remValue, double emValue) { + if (width?.unit == Unit.rem) { + width = Width(width!.value * remValue); + } else if (width?.unit == Unit.em) { + width = Width(width!.value * emValue); + } - const FontSize(this.size, {this.units = ""}); + if (height?.unit == Unit.rem) { + height = Height(height!.value * remValue); + } else if (height?.unit == Unit.em) { + height = Height(height!.value * emValue); + } - /// A percentage of the parent style's font size. - factory FontSize.percent(double percent) { - return FontSize(percent / -100.0, units: "%"); - } + if (fontSize?.unit == Unit.rem) { + fontSize = FontSize(fontSize!.value * remValue); + } else if (fontSize?.unit == Unit.em) { + fontSize = FontSize(fontSize!.value * emValue); + } - factory FontSize.em(double? em) { - return FontSize(em, units: "em"); - } + Margin? marginLeft; + Margin? marginTop; + Margin? marginRight; + Margin? marginBottom; - 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); -} + if (margin?.left?.unit == Unit.rem) { + marginLeft = Margin(margin!.left!.value * remValue); + } else if (margin?.left?.unit == Unit.em) { + marginLeft = Margin(margin!.left!.value * emValue); + } -class LineHeight { - final double? size; - final String units; + if (margin?.top?.unit == Unit.rem) { + marginTop = Margin(margin!.top!.value * remValue); + } else if (margin?.top?.unit == Unit.em) { + marginTop = Margin(margin!.top!.value * emValue); + } - const LineHeight(this.size, {this.units = ""}); + if (margin?.right?.unit == Unit.rem) { + marginRight = Margin(margin!.right!.value * remValue); + } else if (margin?.right?.unit == Unit.em) { + marginRight = Margin(margin!.right!.value * emValue); + } - factory LineHeight.percent(double percent) { - return LineHeight(percent / 100.0 * 1.2, units: "%"); - } + if (margin?.bottom?.unit == Unit.rem) { + marginBottom = Margin(margin!.bottom!.value * remValue); + } else if (margin?.bottom?.unit == Unit.em) { + marginBottom = Margin(margin!.bottom!.value * emValue); + } - factory LineHeight.em(double em) { - return LineHeight(em * 1.2, units: "em"); + margin = margin?.copyWith( + left: marginLeft, + top: marginTop, + right: marginRight, + bottom: marginBottom, + ); } +} - factory LineHeight.rem(double rem) { - return LineHeight(rem * 1.2, units: "rem"); - } +enum Display { + block, + inline, + inlineBlock, + listItem, + none, +} - factory LineHeight.number(double num) { - return LineHeight(num * 1.2, units: "number"); +enum ListStyleType { + arabicIndic('arabic-indic'), + armenian('armenian'), + lowerArmenian('lower-armenian'), + upperArmenian('upper-armenian'), + bengali('bengali'), + cambodian('cambodian'), + khmer('khmer'), + circle('circle'), + cjkDecimal('cjk-decimal'), + cjkEarthlyBranch('cjk-earthly-branch'), + cjkHeavenlyStem('cjk-heavenly-stem'), + cjkIdeographic('cjk-ideographic'), + decimal('decimal'), + decimalLeadingZero('decimal-leading-zero'), + devanagari('devanagari'), + disc('disc'), + disclosureClosed('disclosure-closed'), + disclosureOpen('disclosure-open'), + ethiopicNumeric('ethiopic-numeric'), + georgian('georgian'), + gujarati('gujarati'), + gurmukhi('gurmukhi'), + hebrew('hebrew'), + hiragana('hiragana'), + hiraganaIroha('hiragana-iroha'), + japaneseFormal('japanese-formal'), + japaneseInformal('japanese-informal'), + kannada('kannada'), + katakana('katakana'), + katakanaIroha('katakana-iroha'), + koreanHangulFormal('korean-hangul-formal'), + koreanHanjaInformal('korean-hanja-informal'), + koreanHanjaFormal('korean-hanja-formal'), + lao('lao'), + lowerAlpha('lower-alpha'), + lowerGreek('lower-greek'), + lowerLatin('lower-latin'), + lowerRoman('lower-roman'), + malayalam('malayalam'), + mongolian('mongolian'), + myanmar('myanmar'), + none('none'), + oriya('oriya'), + persian('persian'), + simpChineseFormal('simp-chinese-formal'), + simpChineseInformal('simp-chinese-informal'), + square('square'), + tamil('tamil'), + telugu('telugu'), + thai('thai'), + tibetan('tibetan'), + tradChineseFormal('trad-chinese-formal'), + tradChineseInformal('trad-chinese-informal'), + upperAlpha('upper-alpha'), + upperLatin('upper-latin'), + upperRoman('upper-roman'); + + final String counterStyle; + + const ListStyleType(this.counterStyle); + + factory ListStyleType.fromName(String name) { + return ListStyleType.values.firstWhere((value) { + return name == value.counterStyle; + }); } - - 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"); +class ListStyleImage { + final String uriText; - 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"); + const ListStyleImage(this.uriText); } enum ListStylePosition { - OUTSIDE, - INSIDE, + outside, + inside, } enum TextTransform { @@ -564,12 +680,12 @@ enum TextTransform { } enum VerticalAlign { - BASELINE, - SUB, - SUPER, + baseline, + sub, + sup, } enum WhiteSpace { - NORMAL, - PRE, + normal, + pre, } diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 0000000000..31db195878 --- /dev/null +++ b/melos.yaml @@ -0,0 +1,31 @@ +name: flutter_html +repository: https://github.com/sub6resources/flutter_html + +packages: + - packages/* + - . + - example + +command: + bootstrap: + usePubspecOverrides: true + runPubGetInParallel: false + version: + includeCommitId: true + +scripts: + analyze: + exec: flutter analyze . --fatal-infos + + test:selective_unit_test: + run: melos exec --dir-exists="test" --fail-fast -- flutter test --no-pub --coverage + description: Run Flutter tests for a specific package in this project. + select-package: + flutter: true + dir-exists: test + + test: + run: melos run test:selective_unit_test --no-select + description: Run all Flutter tests in this project. + + gen_coverage: melos exec -- "\$MELOS_ROOT_PATH/combine_coverage.sh" \ No newline at end of file diff --git a/packages/flutter_html_all/CHANGELOG.md b/packages/flutter_html_all/CHANGELOG.md index f81d3c94fd..9b53750143 100644 --- a/packages/flutter_html_all/CHANGELOG.md +++ b/packages/flutter_html_all/CHANGELOG.md @@ -1,2 +1,6 @@ +## 3.0.0-alpha.6 + + - Update a dependency to the latest release. + ## [3.0.0-alpha.2] - January 5, 2022: * Initial modularized flutter_html release; use flutter_html_all for full tag support or flutter_html for just the basics diff --git a/packages/flutter_html_all/analysis_options.yaml b/packages/flutter_html_all/analysis_options.yaml new file mode 100644 index 0000000000..f065cf1ea4 --- /dev/null +++ b/packages/flutter_html_all/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options \ No newline at end of file diff --git a/packages/flutter_html_all/example/example.md b/packages/flutter_html_all/example/example.md new file mode 100644 index 0000000000..aff9f75ad4 --- /dev/null +++ b/packages/flutter_html_all/example/example.md @@ -0,0 +1,3 @@ +# Example + +### Coming soon... \ No newline at end of file diff --git a/packages/flutter_html_all/lib/flutter_html_all.dart b/packages/flutter_html_all/lib/flutter_html_all.dart index 9fb8378795..2b34957522 100644 --- a/packages/flutter_html_all/lib/flutter_html_all.dart +++ b/packages/flutter_html_all/lib/flutter_html_all.dart @@ -1,3 +1,5 @@ +/// Package flutter_html_all is used to get access to all +/// of the extended features of the flutter_html package. library flutter_html_all; export 'package:flutter_html_audio/flutter_html_audio.dart'; diff --git a/packages/flutter_html_all/pubspec.yaml b/packages/flutter_html_all/pubspec.yaml index 9bea10a832..03c5fa53e5 100644 --- a/packages/flutter_html_all/pubspec.yaml +++ b/packages/flutter_html_all/pubspec.yaml @@ -1,38 +1,40 @@ name: flutter_html_all description: All optional flutter_html widgets, bundled into a single package. -version: 3.0.0-alpha.2 +version: 3.0.0-alpha.6 homepage: https://github.com/Sub6Resources/flutter_html +publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=3.1.0 <4.0.0" flutter: ">=2.2.0" dependencies: flutter: sdk: flutter - html: '>=0.15.0 <1.0.0' - flutter_html: '>=3.0.0-alpha.2 <4.0.0' - flutter_html_audio: '>=3.0.0-alpha.2 <4.0.0' - flutter_html_iframe: '>=3.0.0-alpha.2 <4.0.0' - flutter_html_math: '>=3.0.0-alpha.2 <4.0.0' - flutter_html_svg: '>=3.0.0-alpha.2 <4.0.0' - flutter_html_table: '>=3.0.0-alpha.2 <4.0.0' - flutter_html_video: '>=3.0.0-alpha.2 <4.0.0' -# flutter_html_audio: -# path: ../flutter_html_audio -# flutter_html_iframe: -# path: ../flutter_html_iframe -# flutter_html_math: -# path: ../flutter_html_math -# flutter_html_svg: -# path: ../flutter_html_svg -# flutter_html_table: -# path: ../flutter_html_table -# flutter_html_video: -# path: ../flutter_html_video + html: ">=0.15.0 <1.0.0" + # flutter_html: ^3.0.0-alpha.6 + # flutter_html_audio: ^3.0.0-alpha.4 + # flutter_html_iframe: ^3.0.0-alpha.4 + # flutter_html_math: ^3.0.0-alpha.4 + # flutter_html_svg: ^3.0.0-alpha.4 + # flutter_html_table: ^3.0.0-alpha.4 + # flutter_html_video: ^3.0.0-alpha.5 + flutter_html_audio: + path: ../flutter_html_audio + flutter_html_iframe: + path: ../flutter_html_iframe + flutter_html_math: + path: ../flutter_html_math + flutter_html_svg: + path: ../flutter_html_svg + flutter_html_table: + path: ../flutter_html_table + flutter_html_video: + path: ../flutter_html_video dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^2.0.1 flutter: diff --git a/packages/flutter_html_audio/CHANGELOG.md b/packages/flutter_html_audio/CHANGELOG.md index fe3f5ccafe..d0016e519d 100644 --- a/packages/flutter_html_audio/CHANGELOG.md +++ b/packages/flutter_html_audio/CHANGELOG.md @@ -1,2 +1,6 @@ +## 3.0.0-alpha.4 + + - **FIX**: Change CSSBoxWidget to CssBoxWidget. (a62449a7) + ## [3.0.0-alpha.2] - January 5, 2022: * Initial modularized flutter_html release; use flutter_html_audio if you need support for the `
Rowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspanDataDataRowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
DataData
Google
Rowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspan\nRowspanDataDataRowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
Rowspan
DataData
Google