import 'dart:collection'; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:csslib/parser.dart' as cssparser; import 'package:csslib/visitor.dart' as css; import 'package:flutter/material.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as htmlparser; import 'package:list_counter/list_counter.dart'; import 'package:sonnat/core/html/custom_render.dart'; import 'package:sonnat/core/html/flutter_html.dart'; import 'package:sonnat/core/html/src/anchor.dart'; import 'package:sonnat/core/html/src/css_box_widget.dart'; import 'package:sonnat/core/html/src/css_parser.dart'; import 'package:sonnat/core/html/src/html_elements.dart'; import 'package:sonnat/core/html/src/layout_element.dart'; import 'package:sonnat/core/html/src/style/fontsize.dart'; import 'package:sonnat/core/html/src/style/length.dart'; import 'package:sonnat/core/html/src/style/margin.dart'; import 'package:sonnat/core/html/src/style/marker.dart'; import 'package:sonnat/core/html/src/utils.dart'; import 'package:sonnat/core/html/style.dart'; typedef OnTap = void Function( String? url, RenderContext context, Map attributes, dom.Element? element, ); typedef OnCssParseError = String? Function( String css, List errors, ); class HtmlParser extends StatelessWidget { final dom.Element htmlData; final OnTap? onLinkTap; final OnTap? onAnchorTap; final OnTap? onImageTap; final OnCssParseError? onCssParseError; final ImageErrorListener? onImageError; final bool shrinkWrap; final bool selectable; final Map style; final Map customRenders; final List tagsList; final OnTap? internalOnAnchorTap; final Html? root; final TextSelectionControls? selectionControls; final ScrollPhysics? scrollPhysics; final Map cachedImageSizes = {}; HtmlParser({ required super.key, required this.htmlData, required this.onLinkTap, required this.onAnchorTap, required this.onImageTap, required this.onCssParseError, required this.onImageError, required this.shrinkWrap, required this.selectable, required this.style, required this.customRenders, required this.tagsList, this.root, this.selectionControls, this.scrollPhysics, }) : internalOnAnchorTap = onAnchorTap ?? (key != null ? _handleAnchorTap(key, onLinkTap) : onLinkTap); @override Widget build(BuildContext context) { // Lexing Step StyledElement lexedTree = lexDomTree( htmlData, customRenders.keys.toList(), tagsList, context, this, ); // Styling Step StyledElement styledTree = styleTree(lexedTree, htmlData, style, onCssParseError); // Processing Step StyledElement processedTree = processTree(styledTree, MediaQuery.of(context).devicePixelRatio); // Parsing Step InlineSpan parsedTree = parseTree( RenderContext( buildContext: context, parser: this, tree: processedTree, style: processedTree.style, ), processedTree, ); return CssBoxWidget.withInlineSpanChildren( style: processedTree.style, children: [parsedTree], selectable: selectable, scrollPhysics: scrollPhysics, selectionControls: selectionControls, shrinkWrap: shrinkWrap, ); } /// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library. static dom.Element parseHTML(String data) { return htmlparser.parse(data).documentElement!; } /// [parseCss] converts a string of CSS to a CSS stylesheet using the dart `csslib` library. static css.StyleSheet parseCss(String data) { return cssparser.parse(data); } /// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s. static StyledElement lexDomTree( dom.Element html, List customRenderMatchers, List tagsList, BuildContext context, HtmlParser parser, ) { StyledElement tree = StyledElement( name: '[Tree Root]', children: [], node: html, style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!), ); for (var node in html.nodes) { tree.children.add(_recursiveLexer( node, customRenderMatchers, tagsList, context, parser, )); } return tree; } /// [_recursiveLexer] is the recursive worker function for [lexDomTree]. /// /// It runs the parse functions of every type of /// element and returns a [StyledElement] tree representing the element. static StyledElement _recursiveLexer( dom.Node node, List customRenderMatchers, List tagsList, BuildContext context, HtmlParser parser, ) { List children = []; for (var childNode in node.nodes) { children.add(_recursiveLexer( childNode, customRenderMatchers, tagsList, context, parser, )); } if (node is dom.Element) { if (!tagsList.contains(node.localName)) { return EmptyContentElement(); } if (HtmlElements.styledElements.contains(node.localName)) { return parseStyledElement(node, children); } if (HtmlElements.interactableElements.contains(node.localName)) { return parseInteractableElement(node, children); } if (HtmlElements.replacedElements.contains(node.localName)) { return parseReplacedElement(node, children); } if (HtmlElements.layoutElements.contains(node.localName)) { return parseLayoutElement(node, children); } if (HtmlElements.tableCellElements.contains(node.localName)) { return parseTableCellElement(node, children); } if (HtmlElements.tableDefinitionElements.contains(node.localName)) { return parseTableDefinitionElement(node, children); } else { final StyledElement tree = parseStyledElement(node, children); for (final entry in customRenderMatchers) { if (entry.call( RenderContext( buildContext: context, parser: parser, tree: tree, style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!), ), )) { return tree; } } return EmptyContentElement(); } } else if (node is dom.Text) { return TextContentElement( text: node.text, style: Style(), element: node.parent, node: node, ); } else { return EmptyContentElement(); } } static Map>> _getExternalCssDeclarations( List styles, OnCssParseError? errorHandler) { String fullCss = ''; for (final e in styles) { fullCss = fullCss + e.innerHtml; } if (fullCss.isNotEmpty) { final declarations = parseExternalCss(fullCss, errorHandler); return declarations; } else { return {}; } } static StyledElement _applyExternalCss( Map>> declarations, StyledElement tree) { declarations.forEach((key, style) { try { if (tree.matchesSelector(key)) { tree.style = tree.style.merge(declarationsToStyle(style)); } } catch (_) {} }); for (var element in tree.children) { _applyExternalCss(declarations, element); } return tree; } static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) { if (tree.attributes.containsKey('style')) { final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler); if (newStyle != null) { tree.style = tree.style.merge(newStyle); } } for (var element in tree.children) { _applyInlineStyles(element, errorHandler); } return tree; } /// [applyCustomStyles] applies the [Style] objects passed into the [Html] /// widget onto the [StyledElement] tree, no cascading of styles is done at this point. static StyledElement _applyCustomStyles(Map style, StyledElement tree) { style.forEach((key, style) { try { if (tree.matchesSelector(key)) { tree.style = tree.style.merge(style); } } catch (_) {} }); for (var element in tree.children) { _applyCustomStyles(style, element); } return tree; } /// [_cascadeStyles] cascades all of the inherited styles down the tree, applying them to each /// child that doesn't specify a different style. static StyledElement _cascadeStyles(Map style, StyledElement tree) { for (var child in tree.children) { child.style = tree.style.copyOnlyInherited(child.style); _cascadeStyles(style, child); } return tree; } /// [styleTree] takes the lexed [StyleElement] tree and applies external, /// inline, and custom CSS/Flutter styles, and then cascades the styles down the tree. static StyledElement styleTree( StyledElement tree, dom.Element htmlData, Map 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 processTree(StyledElement tree, double devicePixelRatio) { tree = _processInternalWhitespace(tree); tree = _processInlineWhitespace(tree); tree = _removeEmptyElements(tree); tree = _calculateRelativeValues(tree, devicePixelRatio); tree = _preprocessListMarkers(tree); tree = _processCounters(tree); tree = _processListMarkers(tree); tree = _processBeforesAndAfters(tree); tree = _collapseMargins(tree); return tree; } /// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree. /// /// [parseTree] is responsible for handling the [customRenders] parameter and /// deciding what different `Style.display` options look like as Widgets. InlineSpan parseTree(RenderContext context, StyledElement tree) { // Merge this element's style into the context so that children // inherit the correct style RenderContext newContext = RenderContext( buildContext: context.buildContext, parser: this, tree: tree, style: context.style.copyOnlyInherited(tree.style), key: AnchorKey.of(key, tree), ); for (final entry in customRenders.keys) { if (entry.call(newContext)) { List buildChildren() => tree.children.map((tree) => parseTree(newContext, tree)).toList(); if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) { List selectableBuildChildren() => tree.children.map((tree) => parseTree(newContext, tree) as TextSpan).toList(); return (customRenders[entry] as SelectableCustomRender).textSpan.call(newContext, selectableBuildChildren); } if (newContext.parser.selectable) { return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren) as TextSpan; } if (customRenders[entry]?.inlineSpan != null) { return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren); } return WidgetSpan( child: CssBoxWidget( style: tree.style, shrinkWrap: newContext.parser.shrinkWrap, childIsReplaced: true, child: customRenders[entry]!.widget!.call(newContext, buildChildren), ), ); } } return const WidgetSpan(child: SizedBox(height: 0, width: 0)); } static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => (url, context, attributes, element) { if (url?.startsWith('#') == true) { final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext; if (anchorContext != null) { Scrollable.ensureVisible(anchorContext); } return; } onLinkTap?.call(url, context, attributes, element); }; /// [processWhitespace] removes unnecessary whitespace from the StyledElement tree. /// /// The criteria for determining which whitespace is replaceable is outlined /// at https://www.w3.org/TR/css-text-3/ /// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33 static StyledElement _processInternalWhitespace(StyledElement tree) { if ((tree.style.whiteSpace ?? WhiteSpace.normal) == WhiteSpace.pre) { // Preserve this whitespace } else if (tree is TextContentElement) { tree.text = _removeUnnecessaryWhitespace(tree.text!); } else { tree.children.forEach(_processInternalWhitespace); } return tree; } /// [_processInlineWhitespace] is responsible for removing redundant whitespace /// between and among inline elements. It does so by creating a boolean [Context] /// and passing it to the [_processInlineWhitespaceRecursive] function. static StyledElement _processInlineWhitespace(StyledElement tree) { tree = _processInlineWhitespaceRecursive(tree, Context(false)); return tree; } /// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different /// inline elements, and replaces any instance of two or more spaces with a single space, according /// to the w3's HTML whitespace processing specification linked to above. static StyledElement _processInlineWhitespaceRecursive( StyledElement tree, Context keepLeadingSpace, ) { if (tree is TextContentElement) { /// initialize indices to negative numbers to make conditionals a little easier int textIndex = -1; int elementIndex = -1; /// initialize parent after to a whitespace to account for elements that are /// the last child in the list of elements String parentAfterText = ' '; /// find the index of the text in the current tree if ((tree.element?.nodes.length ?? 0) >= 1) { textIndex = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1; } /// get the parent nodes dom.NodeList? parentNodes = tree.element?.parent?.nodes; /// find the index of the tree itself in the parent nodes if ((parentNodes?.length ?? 0) >= 1) { elementIndex = parentNodes?.indexWhere((element) => element == tree.element) ?? -1; } /// if the tree is any node except the last node in the node list and the /// next node in the node list is a text node, then get its text. Otherwise /// the next node will be a [dom.Element], so keep unwrapping that until /// we get the underlying text node, and finally get its text. if (elementIndex < (parentNodes?.length ?? 1) - 1 && parentNodes?[elementIndex + 1] is dom.Text) { parentAfterText = parentNodes?[elementIndex + 1].text ?? ' '; } else if (elementIndex < (parentNodes?.length ?? 1) - 1) { var parentAfter = parentNodes?[elementIndex + 1]; while (parentAfter is dom.Element) { if (parentAfter.nodes.isNotEmpty) { parentAfter = parentAfter.nodes.first; } else { break; } } parentAfterText = parentAfter?.text ?? ' '; } /// If the text is the first element in the current tree node list, it /// starts with a whitespace, it isn't a line break, either the /// whitespace is unnecessary or it is a block element, and either it is /// first element in the parent node list or the previous element /// in the parent node list ends with a whitespace, delete it. /// /// We should also delete the whitespace at any point in the node list /// if the previous element is a
because that tag makes the element /// act like a block element. if (textIndex < 1 && tree.text!.startsWith(' ') && tree.element?.localName != 'br' && (!keepLeadingSpace.data || tree.style.display == Display.block) && (elementIndex < 1 || (elementIndex >= 1 && parentNodes?[elementIndex - 1] is dom.Text && parentNodes![elementIndex - 1].text!.endsWith(' ')))) { tree.text = tree.text!.replaceFirst(' ', ''); } else if (textIndex >= 1 && tree.text!.startsWith(' ') && tree.element?.nodes[textIndex - 1] is dom.Element && (tree.element?.nodes[textIndex - 1] as dom.Element).localName == 'br') { tree.text = tree.text!.replaceFirst(' ', ''); } /// If the text is the last element in the current tree node list, it isn't /// a line break, and the next text node starts with a whitespace, /// update the [Context] to signify to that next text node whether it should /// keep its whitespace. This is based on whether the current text ends with a /// whitespace. if (textIndex == (tree.element?.nodes.length ?? 1) - 1 && tree.element?.localName != 'br' && parentAfterText.startsWith(' ')) { keepLeadingSpace.data = !tree.text!.endsWith(' '); } } for (var element in tree.children) { _processInlineWhitespaceRecursive(element, keepLeadingSpace); } return tree; } /// [removeUnnecessaryWhitespace] removes "unnecessary" white space from the given String. /// /// The steps for removing this whitespace are as follows: /// (1) Remove any whitespace immediately preceding or following a newline. /// (2) Replace all newlines with a space /// (3) Replace all tabs with a space /// (4) Replace any instances of two or more spaces with a single space. static String _removeUnnecessaryWhitespace(String text) { return text .replaceAll(RegExp('\\ *(?=\n)'), '\n') .replaceAll(RegExp('(?:\n)\\ *'), '\n') .replaceAll('\n', ' ') .replaceAll('\t', ' ') .replaceAll(RegExp(' {2,}'), ' '); } /// [preprocessListMarkers] adds marker pseudo elements to the front of all list /// items. static StyledElement _preprocessListMarkers(StyledElement tree) { tree.style.listStylePosition ??= ListStylePosition.outside; if (tree.style.display == Display.listItem) { // Add the marker pseudo-element if it doesn't exist tree.style.marker ??= Marker( content: Content.normal, style: tree.style, ); // Inherit styles from originating widget tree.style.marker!.style = tree.style.copyOnlyInherited(tree.style.marker!.style ?? Style()); // Add the implicit counter-increment on `list-item` if it isn't set // explicitly already tree.style.counterIncrement ??= {}; if (!tree.style.counterIncrement!.containsKey('list-item')) { tree.style.counterIncrement!['list-item'] = 1; } } // Add the counters to ol and ul types. if (tree.name == 'ol' || tree.name == 'ul') { tree.style.counterReset ??= {}; if (!tree.style.counterReset!.containsKey('list-item')) { tree.style.counterReset!['list-item'] = 0; } } for (var child in tree.children) { _preprocessListMarkers(child); } return tree; } /// [_processListCounters] adds the appropriate counter values to each /// StyledElement on the tree. static StyledElement _processCounters(StyledElement tree, [ListQueue? counters]) { // Add the counters for the current scope. tree.counters.addAll(counters?.deepCopy() ?? []); // Create any new counters if (tree.style.counterReset != null) { tree.style.counterReset!.forEach((counterName, initialValue) { tree.counters.add(Counter(counterName, initialValue ?? 0)); }); } // Increment any counters that are to be incremented if (tree.style.counterIncrement != null) { tree.style.counterIncrement!.forEach((counterName, increment) { tree.counters .lastWhereOrNull( (counter) => counter.name == counterName, ) ?.increment(increment ?? 1); // If we didn't newly create the counter, increment the counter in the old copy as well. if (tree.style.counterReset == null || !tree.style.counterReset!.containsKey(counterName)) { counters ?.lastWhereOrNull( (counter) => counter.name == counterName, ) ?.increment(increment ?? 1); } }); } for (var element in tree.children) { _processCounters(element, tree.counters); } return tree; } static StyledElement _processListMarkers(StyledElement tree) { if (tree.style.display == Display.listItem) { final listStyleType = tree.style.listStyleType ?? ListStyleType.decimal; final counterStyle = CounterStyleRegistry.lookup( listStyleType.counterStyle, ); String counterContent; if (tree.style.marker?.content.isNormal ?? true) { counterContent = counterStyle.generateMarkerContent( tree.counters.lastOrNull?.value ?? 0, ); } else if (!(tree.style.marker?.content.display ?? true)) { counterContent = ''; } else { counterContent = tree.style.marker?.content.replacementContent ?? counterStyle.generateMarkerContent( tree.counters.lastOrNull?.value ?? 0, ); } tree.style.marker = Marker(content: Content(counterContent), style: tree.style.marker?.style); } for (var child in tree.children) { _processListMarkers(child); } return tree; } /// [_processBeforesAndAfters] adds text content to the beginning and end of /// the list of the trees children according to the `before` and `after` Style /// properties. static StyledElement _processBeforesAndAfters(StyledElement tree) { if (tree.style.before != null) { tree.children.insert( 0, TextContentElement( text: tree.style.before, style: tree.style.copyWith(beforeAfterNull: true, display: Display.inline), ), ); } if (tree.style.after != null) { tree.children.add(TextContentElement( text: tree.style.after, style: tree.style.copyWith(beforeAfterNull: true, display: Display.inline), )); } tree.children.forEach(_processBeforesAndAfters); return tree; } /// [collapseMargins] follows the specifications at https://www.w3.org/TR/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. /// /// Paraphrased from the CSS specification: /// Margins are collapsed if both belong to vertically-adjacent box edges, i.e form one of the following pairs: /// (1) Top margin of a box and top margin of its first in-flow child /// (2) Bottom margin of a box and top margin of its next in-flow following sibling /// (3) Bottom margin of a last in-flow child and bottom margin of its parent (if the parent's height is not explicit) /// (4) Top and Bottom margins of a box with a height of zero or no in-flow children. static StyledElement _collapseMargins(StyledElement tree) { //Short circuit if we've reached a leaf of the tree if (tree.children.isEmpty) { // Handle case (4) from above. if (tree.style.height?.value == 0 && tree.style.height?.unit != Unit.auto) { tree.style.margin = tree.style.margin?.collapse() ?? Margins.zero; } return tree; } //Collapsing should be depth-first. tree.children.forEach(_collapseMargins); //The root boxes do not collapse. if (tree.name == '[Tree Root]' || tree.name == 'html') { return tree; } // Handle case (1) from above. // Top margins cannot collapse if the element has padding if ((tree.style.padding?.top ?? 0) == 0) { final parentTop = tree.style.margin?.top?.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 = Margins.only(top: newOuterMarginTop); } else { 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 = Margins.zero; } else { tree.children.first.style.margin = 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?.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 = Margins.only(bottom: newOuterMarginBottom); } else { tree.style.margin = tree.style.margin!.copyWithEdge(bottom: newOuterMarginBottom); } // And remove the child's margin if (tree.children.last.style.margin == null) { tree.children.last.style.margin = Margins.zero; } else { tree.children.last.style.margin = tree.children.last.style.margin!.copyWithEdge(bottom: 0); } } // Handle case (2) from above. if (tree.children.length > 1) { for (int i = 1; i < tree.children.length; i++) { final previousSiblingBottom = tree.children[i - 1].style.margin?.bottom?.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 = Margins.only(bottom: newInternalMargin); } else { tree.children[i - 1].style.margin = tree.children[i - 1].style.margin!.copyWithEdge(bottom: newInternalMargin); } if (tree.children[i].style.margin == null) { tree.children[i].style.margin = Margins.only(top: newInternalMargin); } else { tree.children[i].style.margin = tree.children[i].style.margin!.copyWithEdge(top: newInternalMargin); } } } return tree; } /// [removeEmptyElements] recursively removes empty elements. /// /// An empty element is any [EmptyContentElement], any empty [TextContentElement], /// or any block-level [TextContentElement] that contains only whitespace and doesn't follow /// a block element or a line break. static StyledElement _removeEmptyElements(StyledElement tree) { List toRemove = []; bool lastChildBlock = true; tree.children.forEachIndexed((index, child) { if (child is EmptyContentElement || child is EmptyLayoutElement) { toRemove.add(child); } else if (child is TextContentElement && ((tree.name == 'body' && (index == 0 || index + 1 == tree.children.length || tree.children[index - 1].style.display == Display.block || tree.children[index + 1].style.display == Display.block)) || tree.name == 'ul') && child.text!.replaceAll(' ', '').isEmpty) { toRemove.add(child); } else if (child is TextContentElement && child.text!.isEmpty && child.style.whiteSpace != WhiteSpace.pre) { toRemove.add(child); } else if (child is TextContentElement && child.style.whiteSpace != WhiteSpace.pre && tree.style.display == Display.block && child.text!.isEmpty && lastChildBlock) { toRemove.add(child); } else if (child.style.display == Display.none) { toRemove.add(child); } else { _removeEmptyElements(child); } // This is used above to check if the previous element is a block element or a line break. lastChildBlock = (child.style.display == Display.block || child.style.display == Display.listItem || (child is TextContentElement && child.text == '\n')); }); tree.children.removeWhere((element) => toRemove.contains(element)); return tree; } /// [_calculateRelativeValues] converts rem values to px sizes and then /// applies relative calculations static StyledElement _calculateRelativeValues(StyledElement tree, double devicePixelRatio) { double remSize = (tree.style.fontSize?.value ?? FontSize.medium.value); //If the root element has a rem-based fontSize, then give it the default // font size times the set rem value. if (tree.style.fontSize?.unit == Unit.rem) { tree.style.fontSize = FontSize(FontSize.medium.value * remSize); } _applyRelativeValuesRecursive(tree, remSize, devicePixelRatio); tree.style.setRelativeValues(remSize, remSize / devicePixelRatio); return tree; } /// This is the recursive worker function for [_calculateRelativeValues] static void _applyRelativeValuesRecursive(StyledElement tree, double remFontSize, double devicePixelRatio) { //When we get to this point, there should be a valid fontSize at every level. assert(tree.style.fontSize != null); final parentFontSize = tree.style.fontSize!.value; for (var child in tree.children) { if (child.style.fontSize == null) { child.style.fontSize = FontSize(parentFontSize); } else { switch (child.style.fontSize!.unit) { case Unit.em: child.style.fontSize = FontSize(parentFontSize * child.style.fontSize!.value); break; case Unit.percent: child.style.fontSize = FontSize(parentFontSize * (child.style.fontSize!.value / 100.0)); break; case Unit.rem: child.style.fontSize = FontSize(remFontSize * child.style.fontSize!.value); break; case Unit.px: case Unit.auto: //Ignore break; } } // Note: it is necessary to scale down the emSize by the factor of // devicePixelRatio since Flutter seems to calculates font sizes using // physical pixels, but margins/padding using logical pixels. final emSize = child.style.fontSize!.value / devicePixelRatio; tree.style.setRelativeValues(remFontSize, emSize); _applyRelativeValuesRecursive(child, remFontSize, devicePixelRatio); } } } extension IterateLetters on String { String nextLetter() { String s = toLowerCase(); if (s == 'z') { return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa } else { var lastChar = s.substring(s.length - 1); var sub = s.substring(0, s.length - 1); if (lastChar == 'z') { // If a string of length > 1 ends in Z/z, // increment the string (excluding the last Z/z) recursively, // and append A/a (depending on casing) to it return '${sub.nextLetter()}a'; } else { // (take till last char) append with (increment last char) return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1); } } } } class RenderContext { final BuildContext buildContext; final HtmlParser parser; final StyledElement tree; final Style style; final AnchorKey? key; RenderContext({ required this.buildContext, required this.parser, required this.tree, required this.style, this.key, }); }