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