diff --git a/example/lib/main.dart b/example/lib/main.dart index e0489a248c..eea3fc5cc1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -55,10 +55,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... @@ -297,7 +307,7 @@ class _MyHomePageState extends State { ? FlutterLogoStyle.horizontal : FlutterLogoStyle.markOnly, textColor: context.style.color!, - size: context.style.fontSize!.size! * 5, + size: context.style.fontSize!.value * 5, )), tagMatcher("table"): CustomRender.widget(widget: (context, buildChildren) => SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 79e23feab9..ffde7654f9 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: '>=2.17.0 <3.0.0' dependencies: flutter_html: diff --git a/lib/custom_render.dart b/lib/custom_render.dart index e421f3c931..27d15f13ff 100644 --- a/lib/custom_render.dart +++ b/lib/custom_render.dart @@ -6,6 +6,7 @@ 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); @@ -15,7 +16,8 @@ CustomRenderMatcher tagMatcher(String tag) => (context) { }; CustomRenderMatcher blockElementMatcher() => (context) { - return context.tree.style.display == Display.BLOCK && + return (context.tree.style.display == Display.BLOCK || + context.tree.style.display == Display.INLINE_BLOCK) && (context.tree.children.isNotEmpty || context.tree.element?.localName == "hr"); }; @@ -116,10 +118,6 @@ CustomRender blockElementRender({Style? style, List? children}) => 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 && @@ -131,117 +129,109 @@ CustomRender blockElementRender({Style? style, List? children}) => ); } return WidgetSpan( - child: ContainerSpan( - key: context.key, - newContext: context, - style: style ?? context.tree.style, - shrinkWrap: context.parser.shrinkWrap, - children: children ?? - context.tree.children - .expandIndexed((i, childTree) => [ - if (context.parser.shrinkWrap && - childTree.style.display == Display.BLOCK && - i > 0 && - context.tree.children[i - 1] is ReplacedElement) - TextSpan(text: "\n"), - context.parser.parseTree(context, childTree), - if (i != context.tree.children.length - 1 && - childTree.style.display == Display.BLOCK && - childTree.element?.localName != "html" && - childTree.element?.localName != "body") - TextSpan(text: "\n"), - ]) - .toList(), - )); + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: CssBoxWidget.withInlineSpanChildren( + key: context.key, + style: style ?? context.tree.style, + shrinkWrap: context.parser.shrinkWrap, + childIsReplaced: + REPLACED_EXTERNAL_ELEMENTS.contains(context.tree.name), + children: children ?? + context.tree.children + .expandIndexed((i, childTree) => [ + context.parser.parseTree(context, childTree), + //TODO can this newline be added in a different step? + if (i != context.tree.children.length - 1 && + childTree.style.display == Display.BLOCK && + childTree.element?.localName != "html" && + childTree.element?.localName != "body") + TextSpan(text: "\n"), + ]) + .toList(), + ), + ); }); CustomRender listElementRender( {Style? style, Widget? child, List? children}) => CustomRender.inlineSpan( - inlineSpan: (context, buildChildren) => WidgetSpan( - child: ContainerSpan( - key: context.key, - newContext: context, - style: style ?? context.tree.style, - shrinkWrap: context.parser.shrinkWrap, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - textDirection: - style?.direction ?? context.tree.style.direction, - children: [ - (style?.listStylePosition ?? - context.tree.style.listStylePosition) == - ListStylePosition.OUTSIDE - ? Padding( - padding: style?.padding?.nonNegative ?? - context.tree.style.padding?.nonNegative ?? - EdgeInsets.only( - left: (style?.direction ?? - context.tree.style.direction) != - TextDirection.rtl - ? 10.0 - : 0.0, - right: (style?.direction ?? - context.tree.style.direction) == - TextDirection.rtl - ? 10.0 - : 0.0), - child: style?.markerContent ?? - context.style.markerContent) - : Container(height: 0, width: 0), - Text("\u0020", - textAlign: TextAlign.right, - style: TextStyle(fontWeight: FontWeight.w400)), - Expanded( - child: Padding( - padding: (style?.listStylePosition ?? - context.tree.style.listStylePosition) == - ListStylePosition.INSIDE - ? EdgeInsets.only( - left: (style?.direction ?? - context.tree.style.direction) != - TextDirection.rtl - ? 10.0 - : 0.0, - right: (style?.direction ?? - context.tree.style.direction) == - TextDirection.rtl - ? 10.0 - : 0.0) - : EdgeInsets.zero, - child: StyledText( - textSpan: TextSpan( - children: _getListElementChildren( - style?.listStylePosition ?? - context.tree.style.listStylePosition, - buildChildren) - ..insertAll( - 0, - context.tree.style.listStylePosition == - ListStylePosition.INSIDE - ? [ - WidgetSpan( - alignment: - PlaceholderAlignment - .middle, - child: style?.markerContent ?? - context.style - .markerContent ?? - Container( - height: 0, width: 0)) - ] - : []), - style: style?.generateTextStyle() ?? - context.style.generateTextStyle(), - ), - style: style ?? context.style, - renderContext: context, - ))) - ], + inlineSpan: (context, buildChildren) => WidgetSpan( + child: CssBoxWidget( + key: context.key, + style: style ?? context.tree.style, + shrinkWrap: context.parser.shrinkWrap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + textDirection: style?.direction ?? context.tree.style.direction, + children: [ + (style?.listStylePosition ?? + context.tree.style.listStylePosition) == + ListStylePosition.OUTSIDE + ? Padding( + padding: style?.padding?.nonNegative ?? + context.tree.style.padding?.nonNegative ?? + EdgeInsets.only( + left: (style?.direction ?? + context.tree.style.direction) != + TextDirection.rtl + ? 10.0 + : 0.0, + right: (style?.direction ?? + context.tree.style.direction) == + TextDirection.rtl + ? 10.0 + : 0.0), + child: + style?.markerContent ?? context.style.markerContent) + : Container(height: 0, width: 0), + Text("\u0020", + textAlign: TextAlign.right, + style: TextStyle(fontWeight: FontWeight.w400)), + Expanded( + child: Padding( + padding: (style?.listStylePosition ?? + context.tree.style.listStylePosition) == + ListStylePosition.INSIDE + ? EdgeInsets.only( + left: (style?.direction ?? + context.tree.style.direction) != + TextDirection.rtl + ? 10.0 + : 0.0, + right: (style?.direction ?? + context.tree.style.direction) == + TextDirection.rtl + ? 10.0 + : 0.0) + : EdgeInsets.zero, + child: CssBoxWidget.withInlineSpanChildren( + 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 ?? context.style, + ), ), ), - )); + ], + ), + ), + ), + ); CustomRender replacedElementRender( {PlaceholderAlignment? alignment, @@ -482,14 +472,9 @@ CustomRender verticalAlignRender( key: context.key, offset: Offset( 0, verticalOffset ?? _getVerticalOffset(context.tree)), - child: StyledText( - textSpan: TextSpan( - style: style?.generateTextStyle() ?? - context.style.generateTextStyle(), - children: children ?? buildChildren.call(), - ), + child: CssBoxWidget.withInlineSpanChildren( + children: children ?? buildChildren.call(), style: context.style, - renderContext: context, ), ), )); @@ -512,19 +497,21 @@ CustomRender fallbackRender({Style? style, List? children}) => .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(), -}; +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(), + }; +} List _getListElementChildren( ListStylePosition? position, Function() buildChildren) { @@ -585,9 +572,9 @@ final _dataUriFormat = RegExp( double _getVerticalOffset(StyledElement tree) { switch (tree.style.verticalAlign) { case VerticalAlign.SUB: - return tree.style.fontSize!.size! / 2.5; + return tree.style.fontSize!.value / 2.5; case VerticalAlign.SUPER: - return tree.style.fontSize!.size! / -2.5; + return tree.style.fontSize!.value / -2.5; default: return 0; } diff --git a/lib/flutter_html.dart b/lib/flutter_html.dart index 3db68efb2b..ec60476178 100644 --- a/lib/flutter_html.dart +++ b/lib/flutter_html.dart @@ -18,6 +18,8 @@ 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'; @@ -177,24 +179,21 @@ class _HtmlState extends State { @override Widget build(BuildContext context) { - return Container( - width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width, - child: HtmlParser( - key: widget._anchorKey, - htmlData: documentElement, - onLinkTap: widget.onLinkTap, - onAnchorTap: widget.onAnchorTap, - onImageTap: widget.onImageTap, - onCssParseError: widget.onCssParseError, - onImageError: widget.onImageError, - shrinkWrap: widget.shrinkWrap, - selectable: false, - style: widget.style, - customRenders: {} - ..addAll(widget.customRenders) - ..addAll(defaultRenders), - tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList, - ), + return HtmlParser( + key: widget._anchorKey, + htmlData: documentElement, + onLinkTap: widget.onLinkTap, + onAnchorTap: widget.onAnchorTap, + onImageTap: widget.onImageTap, + onCssParseError: widget.onCssParseError, + onImageError: widget.onImageError, + shrinkWrap: widget.shrinkWrap, + selectable: false, + style: widget.style, + customRenders: {} + ..addAll(widget.customRenders) + ..addAll(generateDefaultRenders()), + tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList, ); } } @@ -306,7 +305,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. @@ -360,7 +361,7 @@ class _SelectableHtmlState extends State { style: widget.style, customRenders: {} ..addAll(widget.customRenders) - ..addAll(defaultRenders), + ..addAll(generateDefaultRenders()), tagsList: widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList, selectionControls: widget.selectionControls, diff --git a/lib/html_parser.dart b/lib/html_parser.dart index 2f66cc9a36..7ed4e959af 100644 --- a/lib/html_parser.dart +++ b/lib/html_parser.dart @@ -14,10 +14,10 @@ import 'package:html/parser.dart' as htmlparser; import 'package:numerus/numerus.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, @@ -62,15 +62,18 @@ class HtmlParser extends StatelessWidget { this.selectionControls, this.scrollPhysics, }) : this.internalOnAnchorTap = onAnchorTap != null - ? onAnchorTap - : key != null - ? _handleAnchorTap(key, onLinkTap) - : onLinkTap, + ? onAnchorTap + : key != null + ? _handleAnchorTap(key, onLinkTap) + : onLinkTap, super(key: key); + /// 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); + // Lexing Step StyledElement lexedTree = lexDomTree( htmlData, customRenders.keys.toList(), @@ -78,53 +81,33 @@ class HtmlParser extends StatelessWidget { 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); + + // Styling Step + StyledElement styledTree = + styleTree(lexedTree, htmlData, style, onCssParseError); + + // Processing Step + StyledElement processedTree = + processTree(styledTree, MediaQuery.of(context).devicePixelRatio); + + // Parsing Step InlineSpan parsedTree = parseTree( RenderContext( buildContext: context, parser: this, - tree: cleanedTree, - style: cleanedTree.style, + tree: processedTree, + style: processedTree.style, ), - cleanedTree, + processedTree, ); - // This is the final scaling that assumes any other StyledText instances are - // using textScaleFactor = 1.0 (which is the default). This ensures the correct - // scaling is used, but relies on https://github.com/flutter/flutter/pull/59711 - // to wrap everything when larger accessibility fonts are used. - if (selectable) { - return StyledText.selectable( - textSpan: parsedTree as TextSpan, - style: cleanedTree.style, - textScaleFactor: MediaQuery.of(context).textScaleFactor, - renderContext: RenderContext( - buildContext: context, - parser: this, - tree: cleanedTree, - style: cleanedTree.style, - ), - selectionControls: selectionControls, - scrollPhysics: scrollPhysics, - ); - } - return StyledText( - textSpan: parsedTree, - style: cleanedTree.style, - textScaleFactor: MediaQuery.of(context).textScaleFactor, - renderContext: RenderContext( - buildContext: context, - parser: this, - tree: cleanedTree, - style: cleanedTree.style, - ), + return CssBoxWidget.withInlineSpanChildren( + style: processedTree.style, + children: [parsedTree], + selectable: selectable, + scrollPhysics: scrollPhysics, + selectionControls: selectionControls, + shrinkWrap: shrinkWrap, ); } @@ -150,6 +133,7 @@ class HtmlParser extends StatelessWidget { name: "[Tree Root]", children: [], node: html, + //TODO(Sub6Resources): This seems difficult to customize style: Style.fromTextStyle(Theme.of(context).textTheme.bodyText2!), ); @@ -210,26 +194,34 @@ class HtmlParser extends StatelessWidget { 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.bodyText2!), + ), + )) { 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,7 +234,9 @@ class HtmlParser extends StatelessWidget { } } - static StyledElement _applyExternalCss(Map>> declarations, StyledElement tree) { + static StyledElement _applyExternalCss( + Map>> declarations, + StyledElement tree) { declarations.forEach((key, style) { try { if (tree.matchesSelector(key)) { @@ -256,7 +250,8 @@ class HtmlParser extends StatelessWidget { return tree; } - static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) { + static StyledElement _applyInlineStyles( + StyledElement tree, OnCssParseError? errorHandler) { if (tree.attributes.containsKey("style")) { final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler); if (newStyle != null) { @@ -270,7 +265,8 @@ class HtmlParser extends StatelessWidget { /// [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) { style.forEach((key, style) { try { if (tree.matchesSelector(key)) { @@ -285,7 +281,8 @@ class HtmlParser extends StatelessWidget { /// [_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) { + static StyledElement _cascadeStyles( + Map style, StyledElement tree) { tree.children.forEach((child) { child.style = tree.style.copyOnlyInherited(child.style); _cascadeStyles(style, child); @@ -294,17 +291,37 @@ class HtmlParser extends StatelessWidget { 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) { + Map>> declarations = + _getExternalCssDeclarations( + htmlData.getElementsByTagName("style"), onCssParseError); + + StyledElement? externalCssStyledTree; + if (declarations.isNotEmpty) { + externalCssStyledTree = _applyExternalCss(declarations, tree); + } + tree = _applyInlineStyles(externalCssStyledTree ?? tree, onCssParseError); + tree = _applyCustomStyles(style, tree); + tree = _cascadeStyles(style, tree); + return tree; + } + + /// [processTree] optimizes the [StyledElement] tree so all [BlockElement]s are /// on the first level, redundant levels are collapsed, empty elements are /// removed, and specialty elements are processed. - static StyledElement cleanTree(StyledElement tree) { + static StyledElement processTree( + StyledElement tree, double devicePixelRatio) { tree = _processInternalWhitespace(tree); tree = _processInlineWhitespace(tree); tree = _removeEmptyElements(tree); + + tree = _calculateRelativeValues(tree, devicePixelRatio); tree = _processListCharacters(tree); tree = _processBeforesAndAfters(tree); tree = _collapseMargins(tree); - tree = _processFontSize(tree); return tree; } @@ -325,23 +342,34 @@ class HtmlParser extends StatelessWidget { 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); + 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; + return customRenders[entry]! + .inlineSpan! + .call(newContext, buildChildren) as TextSpan; } if (customRenders[entry]?.inlineSpan != null) { - return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren); + return customRenders[entry]! + .inlineSpan! + .call(newContext, buildChildren); } return WidgetSpan( - child: ContainerSpan( - newContext: newContext, + child: CssBoxWidget( style: tree.style, shrinkWrap: newContext.parser.shrinkWrap, - child: customRenders[entry]!.widget!.call(newContext, buildChildren), + child: + customRenders[entry]!.widget!.call(newContext, buildChildren), + childIsReplaced: true, //TODO is this true? ), ); } @@ -349,10 +377,13 @@ class HtmlParser extends StatelessWidget { return WidgetSpan(child: Container(height: 0, width: 0)); } - static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => - (String? url, RenderContext context, Map attributes, dom.Element? element) { + 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; + final anchorContext = + AnchorKey.forId(key, url!.substring(1))?.currentContext; if (anchorContext != null) { Scrollable.ensureVisible(anchorContext); } @@ -396,24 +427,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 +466,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 +476,37 @@ 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)); + tree.children + .forEach((e) => _processInlineWhitespaceRecursive(e, keepLeadingSpace)); return tree; } @@ -503,14 +543,19 @@ class HtmlParser extends StatelessWidget { if (tree.style.listStylePosition == null) { tree.style.listStylePosition = ListStylePosition.OUTSIDE; } - if (tree.name == 'ol' && tree.style.listStyleType != null && tree.style.listStyleType!.type == "marker") { + 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) { + 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) { @@ -520,14 +565,22 @@ class HtmlParser extends StatelessWidget { } break; default: - olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + olStack.add(Context((tree.attributes['start'] != null + ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 + : 1) - + 1)); break; } - } else if (tree.style.display == Display.LIST_ITEM && tree.style.listStyleType != null && tree.style.listStyleType!.type == "widget") { + } 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") { + } 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) { + } else if (tree.style.display == Display.LIST_ITEM && + tree.style.listStyleType != null) { String marker = ""; switch (tree.style.listStyleType!) { case ListStyleType.NONE: @@ -543,7 +596,10 @@ class HtmlParser extends StatelessWidget { break; case ListStyleType.DECIMAL: if (olStack.isEmpty) { - olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + olStack.add(Context((tree.attributes['start'] != null + ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 + : 1) - + 1)); } olStack.last.data += 1; marker = '${olStack.last.data}.'; @@ -552,7 +608,10 @@ class HtmlParser extends StatelessWidget { case ListStyleType.LOWER_ALPHA: if (olStack.isEmpty) { olStack.add(Context('a')); - if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) { + 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) { @@ -568,7 +627,10 @@ class HtmlParser extends StatelessWidget { case ListStyleType.UPPER_ALPHA: if (olStack.isEmpty) { olStack.add(Context('a')); - if ((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start']!) : null) != null) { + 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) { @@ -582,18 +644,27 @@ class HtmlParser extends StatelessWidget { break; case ListStyleType.LOWER_ROMAN: if (olStack.isEmpty) { - olStack.add(Context((tree.attributes['start'] != null ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 : 1) - 1)); + 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() + "."; + 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.add(Context((tree.attributes['start'] != null + ? int.tryParse(tree.attributes['start'] ?? "") ?? 1 + : 1) - + 1)); } olStack.last.data += 1; if (olStack.last.data <= 0) { @@ -604,9 +675,9 @@ class HtmlParser extends StatelessWidget { break; } tree.style.markerContent = Text( - marker, - textAlign: TextAlign.right, - style: tree.style.generateTextStyle(), + marker, + textAlign: TextAlign.right, + style: tree.style.generateTextStyle(), ); } @@ -625,11 +696,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); @@ -637,7 +717,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. /// @@ -651,8 +731,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; } @@ -668,47 +749,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); } } @@ -716,24 +799,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); } } } @@ -752,18 +834,19 @@ 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 && @@ -787,20 +870,65 @@ 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; tree.children.forEach((child) { - if ((child.style.fontSize?.size ?? parentFontSize)! < 0) { - child.style.fontSize = - FontSize(parentFontSize! * -child.style.fontSize!.size!); + 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; + } } - _processFontSize(child); + // 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); }); - return tree; } } @@ -824,126 +952,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(); 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); 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..5bfffd82da --- /dev/null +++ b/lib/src/css_box_widget.dart @@ -0,0 +1,711 @@ +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 { + CssBoxWidget({ + this.key, + required this.child, + required this.style, + this.textDirection, + this.childIsReplaced = false, + this.shrinkWrap = false, + }) : super(key: key); + + /// Generates a CSSBoxWidget that contains a list of InlineSpan children. + CssBoxWidget.withInlineSpanChildren({ + this.key, + required List children, + required this.style, + this.textDirection, + this.childIsReplaced = false, + this.shrinkWrap = false, + bool selectable = false, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + }) : this.child = selectable + ? _generateSelectableWidgetChild( + children, + style, + selectionControls, + scrollPhysics, + ) + : _generateWidgetChild(children, style), + super(key: key); + + /// An optional anchor key to use in finding this box + final AnchorKey? key; + + /// The child to be rendered within the CSS Box. + final Widget child; + + /// 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) { + 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, + child: 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, + ), + ); + } + + /// Takes a list of InlineSpan children and generates a Text.rich Widget + /// containing those children. + static Widget _generateWidgetChild(List children, Style style) { + if (children.isEmpty) { + return Container(); + } + + return Text.rich( + TextSpan( + style: style.generateTextStyle(), + children: children, + ), + style: style.generateTextStyle(), + textAlign: style.textAlign, + textDirection: style.direction, + maxLines: style.maxLines, + overflow: style.textOverflow, + ); + } + + static Widget _generateSelectableWidgetChild( + List children, + Style style, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + ) { + if (children.isEmpty) { + return Container(); + } + + return SelectableText.rich( + TextSpan( + style: style.generateTextStyle(), + children: children, + ), + style: style.generateTextStyle(), + textAlign: style.textAlign, + textDirection: style.direction, + maxLines: style.maxLines, + selectionControls: selectionControls, + scrollPhysics: scrollPhysics, + ); + } + + /// 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.LIST_ITEM) && + !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 Widget child, + 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, children: [child]); + + /// 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.INLINE_BLOCK) { + 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; + + RenderBox? child = firstChild; + assert(child != null); + + // Calculate child size + final childConstraints = constraints.copyWith( + maxWidth: (this.width.unit != Unit.auto) + ? this.width.value + : containingBlockSize.width - + (this.margins.left?.value ?? 0) - + (this.margins.right?.value ?? 0), + maxHeight: (this.height.unit != Unit.auto) + ? this.height.value + : containingBlockSize.height - + (this.margins.top?.value ?? 0) - + (this.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); + + // 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.INLINE_BLOCK: + width = childSize.width + horizontalMargins; + height = childSize.height + verticalMargins; + break; + case Display.LIST_ITEM: + 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; + + RenderBox? child = firstChild; + while (child != null) { + 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.INLINE_BLOCK: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.LIST_ITEM: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.NONE: + //No offset + break; + } + childParentData.offset = Offset(leftOffset, topOffset); + + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + } + + 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); + } + + @override + void dispose() { + super.dispose(); + } +} + +extension Normalize on Dimension { + void normalize(double emValue) { + switch (this.unit) { + case Unit.em: + this.value *= emValue; + this.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 acc4724cc2..2d709187e9 100644 --- a/lib/src/css_parser.dart +++ b/lib/src/css_parser.dart @@ -13,53 +13,105 @@ Style declarationsToStyle(Map> declarations) { 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,135 +119,226 @@ 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 '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': @@ -207,14 +350,20 @@ Style declarationsToStyle(Map> declarations) { } } if (image != null) { - style.listStyleType = ExpressionMapping.expressionToListStyleType(image) ?? style.listStyleType; + style.listStyleType = + ExpressionMapping.expressionToListStyleType(image) ?? + style.listStyleType; } 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.listStyleType = ExpressionMapping.expressionToListStyleType( + value.first as css.UriTerm) ?? + style.listStyleType; } break; case 'list-style-position': @@ -230,54 +379,63 @@ Style declarationsToStyle(Map> declarations) { } 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 +460,65 @@ 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); @@ -349,7 +536,8 @@ Style declarationsToStyle(Map> declarations) { } break; case 'width': - style.width = ExpressionMapping.expressionToPaddingLength(value.first) ?? style.width; + style.width = + ExpressionMapping.expressionToWidth(value.first) ?? style.width; break; } } @@ -372,7 +560,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) { @@ -392,7 +581,8 @@ class DeclarationVisitor extends css.Visitor { late String _selector; late String _currentProperty; - Map>> getDeclarations(css.StyleSheet sheet) { + Map>> getDeclarations( + css.StyleSheet sheet) { sheet.topLevels.forEach((element) { if (element.span != null) { _selector = element.span!.text; @@ -400,13 +590,15 @@ class DeclarationVisitor extends css.Visitor { 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(new List.from(value)); } else { _result[_selector]![key] = new List.from(value); } }); } else { - _result[_selector] = new Map>.from(_properties); + _result[_selector] = + new Map>.from(_properties); } _properties.clear(); } @@ -433,8 +625,10 @@ class DeclarationVisitor extends css.Visitor { //Mapping functions class ExpressionMapping { - - 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 +703,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 +731,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(new RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? + 1.0; } else if (value is css.LiteralTerm) { switch (value.text) { case "thin": @@ -566,7 +773,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,7 +785,7 @@ class ExpressionMapping { static Display expressionToDisplay(css.Expression value) { if (value is css.LiteralTerm) { - switch(value.text) { + switch (value.text) { case 'block': return Display.BLOCK; case 'inline-block': @@ -594,16 +801,25 @@ class ExpressionMapping { 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 +835,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(new RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? + 16); } else if (value is css.LiteralTerm) { switch (value.text) { case "xx-small": @@ -651,7 +869,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 +902,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": @@ -712,7 +930,10 @@ 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(new RegExp(r'\s+(\d+\.\d+)\s+'), '')), + units: "length"); } return LineHeight.normal; } @@ -748,6 +969,64 @@ class ExpressionMapping { return null; } + 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); + } + } + + 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); + } + } + + 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) { double? left; double? right; @@ -787,14 +1066,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(new 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(new 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 +1118,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 +1139,14 @@ 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": @@ -887,20 +1196,37 @@ class ExpressionMapping { }); 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 as css.LiteralTerm) + .text + .replaceAll(nonNumberRegex, ''))!, + double.tryParse((offsetY as css.LiteralTerm) + .text + .replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) + ? double.tryParse((blurRadius as css.LiteralTerm) + .text + .replaceAll(nonNumberRegex, ''))! + : 0.0, )); } else { shadow.add(Shadow( - offset: Offset( - double.tryParse((offsetX as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!, - double.tryParse((offsetY as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))!), - blurRadius: (blurRadius is css.LiteralTerm) ? double.tryParse((blurRadius as css.LiteralTerm).text.replaceAll(nonNumberRegex, ''))! : 0.0, + 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, )); } } @@ -912,10 +1238,8 @@ class ExpressionMapping { 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)}' - ); + text = text.replaceAllMapped(RegExp(r"[a-f]|\d", caseSensitive: false), + (match) => '${match.group(0)}${match.group(0)}'); if (text.length > 6) { text = "0x" + text; } else { @@ -928,7 +1252,7 @@ class ExpressionMapping { 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(), @@ -955,10 +1279,13 @@ class ExpressionMapping { final hslValues = hslText.split(',').toList(); List parsedHsl = []; hslValues.forEach((element) { - if (element.contains("%") && double.tryParse(element.replaceAll("%", "")) != null) { + 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)); @@ -966,16 +1293,24 @@ class ExpressionMapping { } }); 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; } } diff --git a/lib/src/html_elements.dart b/lib/src/html_elements.dart index 4b096d8f5d..bf4e363024 100644 --- a/lib/src/html_elements.dart +++ b/lib/src/html_elements.dart @@ -129,7 +129,17 @@ const TABLE_CELL_ELEMENTS = ["th", "td"]; const TABLE_DEFINITION_ELEMENTS = ["col", "colgroup"]; -const EXTERNAL_ELEMENTS = ["audio", "iframe", "img", "math", "svg", "table", "video"]; +const EXTERNAL_ELEMENTS = [ + "audio", + "iframe", + "img", + "math", + "svg", + "table", + "video" +]; + +const REPLACED_EXTERNAL_ELEMENTS = ["iframe", "img", "video", "audio"]; const SELECTABLE_ELEMENTS = [ "br", diff --git a/lib/src/interactable_element.dart b/lib/src/interactable_element.dart index 2aab878a64..57a918fdbe 100644 --- a/lib/src/interactable_element.dart +++ b/lib/src/interactable_element.dart @@ -8,13 +8,13 @@ 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. @@ -23,20 +23,22 @@ enum Gesture { } 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..d366ce1fc3 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); } @@ -34,10 +35,10 @@ class TableSectionLayoutElement extends LayoutElement { 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) { @@ -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,75 @@ 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( + children: firstChild == null ? [] : [firstChild], + style: style, + ) + : Text("Details"), children: [ - StyledText( - textSpan: TextSpan( - style: style.generateTextStyle(), - children: getChildren(childrenList, context, elementList.isNotEmpty == true && elementList.first.localName == "summary" ? firstChild : null) - ), + CssBoxWidget.withInlineSpanChildren( + 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; } LayoutElement parseLayoutElement( - dom.Element element, - List children, + dom.Element element, + List children, ) { switch (element.localName) { case "details": @@ -190,10 +201,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..3234da0f1d 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() { @@ -58,7 +63,8 @@ class TextContentElement extends ReplacedElement { } 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; @@ -70,23 +76,29 @@ class RubyElement extends ReplacedElement { 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); } }); @@ -96,30 +108,31 @@ class RubyElement extends ReplacedElement { 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() + .copyWith(fontSize: rubySize), + ), + ), + ), + ), + ), + CssBoxWidget( + style: context.style, + child: node is TextContentElement + ? Text( + (node as TextContentElement).text?.trim() ?? "", + style: context.style.generateTextStyle(), + ) + : RichText(text: context.parser.parseTree(context, node!)), + ), ], ); widgets.add(widget); @@ -132,12 +145,14 @@ class RubyElement extends ReplacedElement { 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(), ), ); } @@ -153,7 +168,7 @@ ReplacedElement parseReplacedElement( text: "\n", style: Style(whiteSpace: WhiteSpace.PRE), element: element, - node: element + node: element, ); case "ruby": return RubyElement( @@ -161,6 +176,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..5d766c630a --- /dev/null +++ b/lib/src/style/fontsize.dart @@ -0,0 +1,39 @@ +//TODO implement dimensionality + +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(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 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 => this.value; +} diff --git a/lib/src/style/length.dart b/lib/src/style/length.dart new file mode 100644 index 0000000000..3fab69263d --- /dev/null +++ b/lib/src/style/length.dart @@ -0,0 +1,62 @@ +/// 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<_UnitType> 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/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..8434544c50 100644 --- a/lib/src/styled_element.dart +++ b/lib/src/styled_element.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/src/css_parser.dart'; +import 'package:flutter_html/src/style/margin.dart'; import 'package:flutter_html/style.dart'; import 'package:html/dom.dart' as dom; //TODO(Sub6Resources): don't use the internal code of the html package as it may change unexpectedly. @@ -48,7 +49,9 @@ class StyledElement { } StyledElement parseStyledElement( - dom.Element element, List children) { + dom.Element element, + List children, +) { StyledElement styledElement = StyledElement( name: element.localName!, elementId: element.id, @@ -102,19 +105,19 @@ 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), + 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), + margin: Margins.symmetric(horizontal: 40.0, vertical: 14.0), display: Display.BLOCK, ); } break; case "body": styledElement.style = Style( - margin: EdgeInsets.all(8.0), + margin: Margins.all(8.0), display: Display.BLOCK, ); break; @@ -134,7 +137,7 @@ StyledElement parseStyledElement( break; case "dd": styledElement.style = Style( - margin: EdgeInsets.only(left: 40.0), + margin: Margins.only(left: 40.0), display: Display.BLOCK, ); break; @@ -148,13 +151,13 @@ StyledElement parseStyledElement( continue italics; case "div": styledElement.style = Style( - margin: EdgeInsets.all(0), + margin: Margins.all(0), display: Display.BLOCK, ); break; case "dl": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 14.0), + margin: Margins.symmetric(vertical: 14.0), display: Display.BLOCK, ); break; @@ -172,7 +175,7 @@ StyledElement parseStyledElement( break; case "figure": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 14.0, horizontal: 40.0), + margin: Margins.symmetric(vertical: 14.0, horizontal: 40.0), display: Display.BLOCK, ); break; @@ -183,60 +186,62 @@ StyledElement parseStyledElement( 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), + 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), + 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), + 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), + 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), + 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), + margin: Margins.symmetric(vertical: 2.33, unit: Unit.em), display: Display.BLOCK, ); break; @@ -247,10 +252,13 @@ StyledElement parseStyledElement( break; case "hr": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 7.0), - width: double.infinity, - height: 1, - backgroundColor: Colors.black, + 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; @@ -318,14 +326,14 @@ StyledElement parseStyledElement( break; case "p": styledElement.style = Style( - margin: EdgeInsets.symmetric(vertical: 14.0), + 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), + margin: Margins.symmetric(vertical: 14.0), whiteSpace: WhiteSpace.PRE, display: Display.BLOCK, ); @@ -409,4 +417,4 @@ FontSize numberToFontSize(String num) { return numberToFontSize((3 - relativeNum).toString()); } return FontSize.medium; -} \ No newline at end of file +} 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 95bde15088..38d12ca136 100644 --- a/lib/style.dart +++ b/lib/style.dart @@ -4,6 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/src/css_parser.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. class Style { @@ -37,14 +44,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, @@ -72,8 +77,8 @@ class Style { /// CSS attribute "`height`" /// /// Inherited: no, - /// Default: Unspecified (null), - double? height; + /// Default: Height.auto(), + Height? height; /// CSS attribute "`letter-spacing`" /// @@ -103,7 +108,7 @@ class Style { /// /// Inherited: no, /// Default: EdgeInsets.zero - EdgeInsets? margin; + Margins? margin; /// CSS attribute "`text-align`" /// @@ -159,8 +164,8 @@ class Style { /// CSS attribute "`width`" /// /// Inherited: no, - /// Default: unspecified (null) - double? width; + /// Default: Width.auto() + Width? width; /// CSS attribute "`word-spacing`" /// @@ -245,16 +250,17 @@ class Style { } 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.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) { final declarations = parseExternalCss(css, onCssParseError); Map styleMap = {}; declarations.forEach((key, value) { @@ -274,7 +280,7 @@ class Style { fontFamily: fontFamily, fontFamilyFallback: fontFamilyFallback, fontFeatures: fontFeatureSettings, - fontSize: fontSize?.size, + fontSize: fontSize?.value, fontStyle: fontStyle, fontWeight: fontWeight, letterSpacing: letterSpacing, @@ -311,7 +317,7 @@ class Style { padding: other.padding, //TODO merge EdgeInsets margin: other.margin, - //TODO merge EdgeInsets + //TODO merge Margins textAlign: other.textAlign, textDecoration: other.textDecoration, textDecorationColor: other.textDecorationColor, @@ -336,18 +342,20 @@ 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, @@ -362,9 +370,10 @@ class Style { 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, @@ -385,13 +394,13 @@ class Style { FontSize? fontSize, FontStyle? fontStyle, FontWeight? fontWeight, - double? height, + Height? height, LineHeight? lineHeight, double? letterSpacing, ListStyleType? listStyleType, ListStylePosition? listStylePosition, EdgeInsets? padding, - EdgeInsets? margin, + Margins? margin, TextAlign? textAlign, TextDecoration? textDecoration, Color? textDecorationColor, @@ -400,7 +409,7 @@ class Style { List? textShadow, VerticalAlign? verticalAlign, WhiteSpace? whiteSpace, - double? width, + Width? width, double? wordSpacing, String? before, String? after, @@ -462,7 +471,8 @@ class Style { this.fontFamily = textStyle.fontFamily; this.fontFamilyFallback = textStyle.fontFamilyFallback; this.fontFeatureSettings = textStyle.fontFeatures; - this.fontSize = FontSize(textStyle.fontSize); + this.fontSize = + textStyle.fontSize != null ? FontSize(textStyle.fontSize!) : null; this.fontStyle = textStyle.fontStyle; this.fontWeight = textStyle.fontWeight; this.letterSpacing = textStyle.letterSpacing; @@ -471,75 +481,71 @@ class Style { this.lineHeight = LineHeight(textStyle.height ?? 1.2); this.textTransform = TextTransform.none; } -} - -enum Display { - BLOCK, - INLINE, - INLINE_BLOCK, - LIST_ITEM, - NONE, -} - -class FontSize { - final double? size; - final String units; - - const FontSize(this.size, {this.units = ""}); - /// A percentage of the parent style's font size. - factory FontSize.percent(double percent) { - return FontSize(percent / -100.0, units: "%"); - } + /// 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); + } - factory FontSize.em(double? em) { - return FontSize(em, units: "em"); - } + if (height?.unit == Unit.rem) { + height = Height(height!.value * remValue); + } else if (height?.unit == Unit.em) { + height = Height(height!.value * emValue); + } - 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 (fontSize?.unit == Unit.rem) { + fontSize = FontSize(fontSize!.value * remValue); + } else if (fontSize?.unit == Unit.em) { + fontSize = FontSize(fontSize!.value * emValue); + } -class LineHeight { - final double? size; - final String units; + Margin? marginLeft; + Margin? marginTop; + Margin? marginRight; + Margin? marginBottom; - const LineHeight(this.size, {this.units = ""}); + 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); + } - factory LineHeight.percent(double percent) { - return LineHeight(percent / 100.0 * 1.2, 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); + } - factory LineHeight.em(double em) { - return LineHeight(em * 1.2, units: "em"); - } + 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.rem(double rem) { - return LineHeight(rem * 1.2, units: "rem"); - } + 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.number(double num) { - return LineHeight(num * 1.2, units: "number"); + margin = margin?.copyWith( + left: marginLeft, + top: marginTop, + right: marginRight, + bottom: marginBottom, + ); } +} - static const normal = LineHeight(1.2); +enum Display { + BLOCK, + INLINE, + INLINE_BLOCK, + LIST_ITEM, + NONE, } class ListStyleType { @@ -549,9 +555,11 @@ class ListStyleType { const ListStyleType(this.text, {this.type = "marker", this.widget}); - factory ListStyleType.fromImage(String url) => ListStyleType(url, type: "image"); + factory ListStyleType.fromImage(String url) => + ListStyleType(url, type: "image"); - factory ListStyleType.fromWidget(Widget widget) => ListStyleType("", widget: widget, type: "widget"); + factory ListStyleType.fromWidget(Widget widget) => + ListStyleType("", widget: widget, type: "widget"); static const LOWER_ALPHA = ListStyleType("LOWER_ALPHA"); static const UPPER_ALPHA = ListStyleType("UPPER_ALPHA"); diff --git a/packages/flutter_html_audio/README.md b/packages/flutter_html_audio/README.md index 8c63614c36..81ac741006 100644 --- a/packages/flutter_html_audio/README.md +++ b/packages/flutter_html_audio/README.md @@ -1,6 +1,6 @@ # flutter_html_audio -Audio widget for flutter_html. +Audio extension for flutter_html. This package renders audio elements using the [`chewie_audio`](https://pub.dev/packages/chewie_audio) and the [`video_player`](https://pub.dev/packages/video_player) plugin. diff --git a/packages/flutter_html_audio/lib/flutter_html_audio.dart b/packages/flutter_html_audio/lib/flutter_html_audio.dart index 2484b8e45d..c542f12242 100644 --- a/packages/flutter_html_audio/lib/flutter_html_audio.dart +++ b/packages/flutter_html_audio/lib/flutter_html_audio.dart @@ -80,13 +80,14 @@ class _AudioWidgetState extends State { if (sources.isEmpty || sources.first == null) { return Container(height: 0, width: 0); } - return Container( + + return CssBoxWidget( key: widget.context.key, - width: widget.context.style.width ?? 300, - height: Theme.of(bContext).platform == TargetPlatform.android ? 48 : 75, + style: widget.context.style, child: ChewieAudio( controller: chewieAudioController!, ), + childIsReplaced: true, ); } } diff --git a/packages/flutter_html_iframe/lib/iframe_mobile.dart b/packages/flutter_html_iframe/lib/iframe_mobile.dart index a1c9bbb713..b35f7c7e3b 100644 --- a/packages/flutter_html_iframe/lib/iframe_mobile.dart +++ b/packages/flutter_html_iframe/lib/iframe_mobile.dart @@ -15,9 +15,9 @@ CustomRender iframeRender({NavigationDelegate? navigationDelegate}) => return Container( width: givenWidth ?? (givenHeight ?? 150) * 2, height: givenHeight ?? (givenWidth ?? 300) / 2, - child: ContainerSpan( + child: CssBoxWidget( style: context.style, - newContext: context, + childIsReplaced: true, child: WebView( initialUrl: context.tree.element?.attributes['src'], key: key, diff --git a/packages/flutter_html_iframe/lib/iframe_web.dart b/packages/flutter_html_iframe/lib/iframe_web.dart index fd776fe980..aaf81c23ff 100644 --- a/packages/flutter_html_iframe/lib/iframe_web.dart +++ b/packages/flutter_html_iframe/lib/iframe_web.dart @@ -24,27 +24,28 @@ CustomRender iframeRender({NavigationDelegate? navigationDelegate}) => ui.platformViewRegistry .registerViewFactory(createdViewId, (int viewId) => iframe); return Container( - width: double.tryParse( - context.tree.element?.attributes['width'] ?? "") ?? - (double.tryParse( - context.tree.element?.attributes['height'] ?? "") ?? - 150) * - 2, - height: double.tryParse( - context.tree.element?.attributes['height'] ?? "") ?? - (double.tryParse( - context.tree.element?.attributes['width'] ?? "") ?? - 300) / - 2, - child: ContainerSpan( - style: context.style, - newContext: context, - child: Directionality( - textDirection: TextDirection.ltr, - child: HtmlElementView( - viewType: createdViewId, - )), - )); + width: + double.tryParse(context.tree.element?.attributes['width'] ?? "") ?? + (double.tryParse( + context.tree.element?.attributes['height'] ?? "") ?? + 150) * + 2, + height: double.tryParse( + context.tree.element?.attributes['height'] ?? "") ?? + (double.tryParse(context.tree.element?.attributes['width'] ?? "") ?? + 300) / + 2, + child: CssBoxWidget( + style: context.style, + childIsReplaced: true, + child: Directionality( + textDirection: TextDirection.ltr, + child: HtmlElementView( + viewType: createdViewId, + ), + ), + ), + ); }); String getRandString(int len) { diff --git a/packages/flutter_html_svg/test/svg_image_matcher_source_matcher_test.dart b/packages/flutter_html_svg/test/svg_image_matcher_source_matcher_test.dart index ed136f0e18..eb31307d7a 100644 --- a/packages/flutter_html_svg/test/svg_image_matcher_source_matcher_test.dart +++ b/packages/flutter_html_svg/test/svg_image_matcher_source_matcher_test.dart @@ -6,19 +6,20 @@ import 'package:meta/meta.dart'; void main() { group("custom image data uri matcher", () { - CustomRenderMatcher matcher = svgDataUriMatcher(encoding: null, mime: 'image/svg+xml'); + CustomRenderMatcher matcher = + svgDataUriMatcher(encoding: null, mime: 'image/svg+xml'); testImgSrcMatcher( "matches an svg data uri with base64 encoding", matcher, imgSrc: - 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB2aWV3Qm94PSIwIDAgMzAgMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjE1IiBjeT0iMTAiIHI9IjEwIiBmaWxsPSJncmVlbiIvPgo8L3N2Zz4=', + 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB2aWV3Qm94PSIwIDAgMzAgMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjE1IiBjeT0iMTAiIHI9IjEwIiBmaWxsPSJncmVlbiIvPgo8L3N2Zz4=', shouldMatch: true, ); testImgSrcMatcher( "matches an svg data uri without specified encoding", matcher, imgSrc: - 'data:image/svg+xml,%3C?xml version="1.0" encoding="UTF-8"?%3E%3Csvg viewBox="0 0 30 20" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="15" cy="10" r="10" fill="green"/%3E%3C/svg%3E', + 'data:image/svg+xml,%3C?xml version="1.0" encoding="UTF-8"?%3E%3Csvg viewBox="0 0 30 20" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="15" cy="10" r="10" fill="green"/%3E%3C/svg%3E', shouldMatch: true, ); testImgSrcMatcher( @@ -31,7 +32,7 @@ void main() { "doesn't match non-base64 image data uri", matcher, imgSrc: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', shouldMatch: false, ); testImgSrcMatcher( @@ -69,11 +70,11 @@ String _fakeElement(String? src) { @isTest void testImgSrcMatcher( - String name, - CustomRenderMatcher matcher, { - required String? imgSrc, - required bool shouldMatch, - }) { + String name, + CustomRenderMatcher matcher, { + required String? imgSrc, + required bool shouldMatch, +}) { testWidgets(name, (WidgetTester tester) async { await tester.pumpWidget( TestApp( @@ -89,7 +90,8 @@ void testImgSrcMatcher( ), ), ); - await expectLater(find.text("Success"), shouldMatch ? findsOneWidget : findsNothing); + await expectLater( + find.text("Success"), shouldMatch ? findsOneWidget : findsNothing); }); } @@ -107,4 +109,4 @@ class TestApp extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/packages/flutter_html_table/lib/flutter_html_table.dart b/packages/flutter_html_table/lib/flutter_html_table.dart index c65387e9c2..8a207abbf3 100644 --- a/packages/flutter_html_table/lib/flutter_html_table.dart +++ b/packages/flutter_html_table/lib/flutter_html_table.dart @@ -9,19 +9,12 @@ import 'package:flutter_html/flutter_html.dart'; /// The CustomRender function that will render the HTML tag CustomRender tableRender() => CustomRender.widget(widget: (context, buildChildren) { - return Container( + return CssBoxWidget( key: context.key, - margin: context.style.margin?.nonNegative, - padding: context.style.padding?.nonNegative, - alignment: context.style.alignment, - decoration: BoxDecoration( - color: context.style.backgroundColor, - border: context.style.border, - ), - width: context.style.width, - height: context.style.height, + style: context.style, child: LayoutBuilder( - builder: (_, constraints) => _layoutCells(context, constraints)), + builder: (_, constraints) => _layoutCells(context, constraints), + ), ); }); @@ -110,24 +103,17 @@ Widget _layoutCells(RenderContext context, BoxConstraints constraints) { columnColspanOffset[columni].clamp(1, columnMax - columni - 1); } cells.add(GridPlacement( - child: Container( - width: child.style.width ?? double.infinity, - height: child.style.height, - padding: child.style.padding?.nonNegative ?? - row.style.padding?.nonNegative, - decoration: BoxDecoration( - color: child.style.backgroundColor ?? row.style.backgroundColor, - border: child.style.border ?? row.style.border, - ), + child: CssBoxWidget( + style: child.style + .merge(row.style), //TODO padding/decoration(color/border) child: SizedBox.expand( child: Container( alignment: child.style.alignment ?? context.style.alignment ?? Alignment.centerLeft, - child: StyledText( - textSpan: context.parser.parseTree(context, child), - style: child.style, - renderContext: context, + child: CssBoxWidget.withInlineSpanChildren( + children: [context.parser.parseTree(context, child)], + style: child.style, //TODO updated this. Does it work? ), ), ), diff --git a/packages/flutter_html_video/README.md b/packages/flutter_html_video/README.md index 62b7551e4b..a4d0644a4c 100644 --- a/packages/flutter_html_video/README.md +++ b/packages/flutter_html_video/README.md @@ -1,6 +1,6 @@ # flutter_html_video -Video widget for flutter_html. +Video extension for flutter_html. This package renders video elements using the [`chewie`](https://pub.dev/packages/chewie) and the [`video_player`](https://pub.dev/packages/video_player) plugin. diff --git a/pubspec.yaml b/pubspec.yaml index f47df5c0d3..6f1baadd9f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 3.0.0-alpha.5 homepage: https://github.com/Sub6Resources/flutter_html environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.17.0 <3.0.0' flutter: '>=2.2.0' dependencies: diff --git a/test/flutter_html_test.dart b/test/flutter_html_test.dart new file mode 100644 index 0000000000..9a5a752c70 --- /dev/null +++ b/test/flutter_html_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + "Check that widget does not fail on empty data", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "", + ), + ), + ); + expect(find.text('', findRichText: true), findsOneWidget); + }, + ); + + testWidgets( + "Check that selectable widget does not fail on empty data", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: SelectableHtml( + data: '', + ), + ), + ); + expect(find.text('', findRichText: true), findsOneWidget); + }, + ); + + testWidgets( + "Check that widget displays given text", + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "Text", + ), + ), + ); + expect(find.text('Text', findRichText: true), findsOneWidget); + }, + ); + + testWidgets('Check that a simple element is displayed', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "

Text

", + ), + ), + ); + expect(find.text('Text', findRichText: true), findsOneWidget); + }); + + testWidgets('Check that a simple element is hidden when tagsList does not contain it', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "

Text

", + tagsList: ['div'], //Anything but `p` + ), + ), + ); + expect(find.text('Text', findRichText: true), findsNothing); + }); + + testWidgets('Check that a simple element is displayed when it is included in tagsList', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "

Text

", + tagsList: ['html', 'body', 'p'], + ), + ), + ); + expect(find.text('Text', findRichText: true), findsOneWidget); + }); + + testWidgets('Check that a custom element is not displayed', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "Text", + ), + ), + ); + expect(find.text('Text', findRichText: true), findsNothing); + }); + + testWidgets('Check that a custom element is not displayed', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Html( + data: "Text", + tagsList: Html.tags..add('custom'), + ), + ), + ); + expect(find.text('Text', findRichText: true), findsOneWidget); + }); +} diff --git a/test/html_parser_test.dart b/test/html_parser_test.dart index c351ab2a36..3937c4a296 100644 --- a/test/html_parser_test.dart +++ b/test/html_parser_test.dart @@ -50,11 +50,11 @@ void testNewParser(BuildContext context) { shrinkWrap: false, selectable: true, style: {}, - customRenders: defaultRenders, + customRenders: generateDefaultRenders(), tagsList: Html.tags, selectionControls: null, scrollPhysics: null, - ) + ), ); print(tree.toString()); @@ -76,11 +76,11 @@ void testNewParser(BuildContext context) { shrinkWrap: false, selectable: true, style: {}, - customRenders: defaultRenders, + customRenders: generateDefaultRenders(), tagsList: Html.tags, selectionControls: null, scrollPhysics: null, - ) + ), ); print(tree.toString()); @@ -100,11 +100,11 @@ void testNewParser(BuildContext context) { shrinkWrap: false, selectable: true, style: {}, - customRenders: defaultRenders, + customRenders: generateDefaultRenders(), tagsList: Html.tags, selectionControls: null, scrollPhysics: null, - ) + ), ); print(tree.toString()); @@ -126,11 +126,11 @@ void testNewParser(BuildContext context) { shrinkWrap: false, selectable: true, style: {}, - customRenders: defaultRenders, + customRenders: generateDefaultRenders(), tagsList: Html.tags, selectionControls: null, scrollPhysics: null, - ) + ), ); print(tree.toString()); diff --git a/test/style/dimension_test.dart b/test/style/dimension_test.dart new file mode 100644 index 0000000000..cf239249f2 --- /dev/null +++ b/test/style/dimension_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_html/src/style/length.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const nonZeroNumber = 16.0; + +void main() { + test("Basic length unspecified units test", () { + final length = Length(nonZeroNumber); + expect(length.value, equals(nonZeroNumber)); + expect(length.unit, equals(Unit.px)); + }); + + test("Basic length-percent unspecified units test", () { + final lengthPercent = LengthOrPercent(nonZeroNumber); + expect(lengthPercent.value, equals(nonZeroNumber)); + expect(lengthPercent.unit, equals(Unit.px)); + }); + + test("Zero-length unspecified units test", () { + final length = Length(0); + expect(length.value, equals(0)); + expect(length.unit, equals(Unit.px)); + }); + + test("Zero-percent-length unspecified units test", () { + final lengthPercent = LengthOrPercent(0); + expect(lengthPercent.value, equals(0)); + expect(lengthPercent.unit, equals(Unit.px)); + }); + + test("Pass in invalid unit", () { + expect(() => Length(nonZeroNumber, Unit.percent), throwsAssertionError); + }); + + test("Pass in invalid unit with zero", () { + expect(() => Length(0, Unit.percent), throwsAssertionError); + }); + + test("Pass in a valid unit", () { + final lengthPercent = LengthOrPercent(nonZeroNumber, Unit.percent); + expect(lengthPercent.value, equals(nonZeroNumber)); + expect(lengthPercent.unit, equals(Unit.percent)); + }); +} \ No newline at end of file diff --git a/test/style/fontsize_test.dart b/test/style/fontsize_test.dart new file mode 100644 index 0000000000..e985d73706 --- /dev/null +++ b/test/style/fontsize_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_html/src/style/fontsize.dart'; +import 'package:flutter_html/src/style/length.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Check basic FontSize inheritance', () { + final FontSize parent = FontSize(16); + final FontSize? child = null; + + final result = FontSize.inherit(parent, child); + + expect(result?.value, equals(16)); + }); + + test('Check double null FontSize inheritance', () { + final FontSize? parent = null; + final FontSize? child = null; + + final result = FontSize.inherit(parent, child); + + expect(result?.value, equals(null)); + }); + + test('Check basic em inheritance', () { + final FontSize? parent = FontSize(16); + final FontSize? child = FontSize(1, Unit.em); + + final result = FontSize.inherit(parent, child); + + expect(result?.value, equals(16)); + }); + + test('Check factor em inheritance', () { + final FontSize? parent = FontSize(16); + final FontSize? child = FontSize(0.5, Unit.em); + + final result = FontSize.inherit(parent, child); + + expect(result?.value, equals(8)); + }); + + test('Check basic % inheritance', () { + final FontSize? parent = FontSize(16); + final FontSize? child = FontSize(100, Unit.percent); + + final result = FontSize.inherit(parent, child); + + expect(result?.value, equals(16)); + }); + + test('Check scaled % inheritance', () { + final FontSize? parent = FontSize(16); + final FontSize? child = FontSize(50, Unit.percent); + + final result = FontSize.inherit(parent, child); + + expect(result?.value, equals(8)); + }); +} \ No newline at end of file diff --git a/test/utils_test.dart b/test/utils_test.dart new file mode 100644 index 0000000000..c4cec61f93 --- /dev/null +++ b/test/utils_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html/src/css_parser.dart'; +import 'package:flutter_html/src/utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Tests the file lib/src/utils.dart + +void main() { + test('Tests that namedColors returns a valid color', () { + expect(ExpressionMapping.namedColorToColor('red'), equals(ExpressionMapping.stringToColor(namedColors['Red']!))); + expect(namedColors['Red'], equals('#FF0000')); + }); + + test('CustomBorderSide does not allow negative width', () { + expect(() => CustomBorderSide(width: -5), throwsAssertionError); + expect(CustomBorderSide(width: 0), TypeMatcher()); + expect(CustomBorderSide(width: 5), TypeMatcher()); + }); + + const originalString = 'Hello'; + const uppercaseString = 'HELLO'; + const lowercaseString = 'hello'; + + test('TextTransformUtil returns self if transform is null', () { + expect(originalString.transformed(null), equals(originalString)); + }); + + test('TextTransformUtil uppercases correctly', () { + expect(originalString.transformed(TextTransform.uppercase), equals(uppercaseString)); + }); + + test('TextTransformUtil lowercases correctly', () { + expect(originalString.transformed(TextTransform.lowercase), equals(lowercaseString)); + }); + + const originalLongString = 'Hello, world! pub.dev'; + const capitalizedLongString = 'Hello, World! Pub.Dev'; + + test('TextTransformUtil capitalizs correctly', () { + expect(originalLongString.transformed(TextTransform.capitalize), equals(capitalizedLongString)); + }); +} \ No newline at end of file