Sonnat Project
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.

876 lines
32 KiB

  1. import 'dart:collection';
  2. import 'dart:math';
  3. import 'package:collection/collection.dart';
  4. import 'package:csslib/parser.dart' as cssparser;
  5. import 'package:csslib/visitor.dart' as css;
  6. import 'package:flutter/material.dart';
  7. import 'package:html/dom.dart' as dom;
  8. import 'package:html/parser.dart' as htmlparser;
  9. import 'package:list_counter/list_counter.dart';
  10. import 'package:sonnat/core/html/custom_render.dart';
  11. import 'package:sonnat/core/html/flutter_html.dart';
  12. import 'package:sonnat/core/html/src/anchor.dart';
  13. import 'package:sonnat/core/html/src/css_box_widget.dart';
  14. import 'package:sonnat/core/html/src/css_parser.dart';
  15. import 'package:sonnat/core/html/src/html_elements.dart';
  16. import 'package:sonnat/core/html/src/layout_element.dart';
  17. import 'package:sonnat/core/html/src/style/fontsize.dart';
  18. import 'package:sonnat/core/html/src/style/length.dart';
  19. import 'package:sonnat/core/html/src/style/margin.dart';
  20. import 'package:sonnat/core/html/src/style/marker.dart';
  21. import 'package:sonnat/core/html/src/utils.dart';
  22. import 'package:sonnat/core/html/style.dart';
  23. typedef OnTap = void Function(
  24. String? url,
  25. RenderContext context,
  26. Map<String, String> attributes,
  27. dom.Element? element,
  28. );
  29. typedef OnCssParseError = String? Function(
  30. String css,
  31. List<cssparser.Message> errors,
  32. );
  33. class HtmlParser extends StatelessWidget {
  34. final dom.Element htmlData;
  35. final OnTap? onLinkTap;
  36. final OnTap? onAnchorTap;
  37. final OnTap? onImageTap;
  38. final OnCssParseError? onCssParseError;
  39. final ImageErrorListener? onImageError;
  40. final bool shrinkWrap;
  41. final bool selectable;
  42. final Map<String, Style> style;
  43. final Map<CustomRenderMatcher, CustomRender> customRenders;
  44. final List<String> tagsList;
  45. final OnTap? internalOnAnchorTap;
  46. final Html? root;
  47. final TextSelectionControls? selectionControls;
  48. final ScrollPhysics? scrollPhysics;
  49. final Map<String, Size> cachedImageSizes = {};
  50. HtmlParser({
  51. required super.key,
  52. required this.htmlData,
  53. required this.onLinkTap,
  54. required this.onAnchorTap,
  55. required this.onImageTap,
  56. required this.onCssParseError,
  57. required this.onImageError,
  58. required this.shrinkWrap,
  59. required this.selectable,
  60. required this.style,
  61. required this.customRenders,
  62. required this.tagsList,
  63. this.root,
  64. this.selectionControls,
  65. this.scrollPhysics,
  66. }) : internalOnAnchorTap = onAnchorTap ?? (key != null ? _handleAnchorTap(key, onLinkTap) : onLinkTap);
  67. @override
  68. Widget build(BuildContext context) {
  69. // Lexing Step
  70. StyledElement lexedTree = lexDomTree(
  71. htmlData,
  72. customRenders.keys.toList(),
  73. tagsList,
  74. context,
  75. this,
  76. );
  77. // Styling Step
  78. StyledElement styledTree = styleTree(lexedTree, htmlData, style, onCssParseError);
  79. // Processing Step
  80. StyledElement processedTree = processTree(styledTree, MediaQuery.of(context).devicePixelRatio);
  81. // Parsing Step
  82. InlineSpan parsedTree = parseTree(
  83. RenderContext(
  84. buildContext: context,
  85. parser: this,
  86. tree: processedTree,
  87. style: processedTree.style,
  88. ),
  89. processedTree,
  90. );
  91. return CssBoxWidget.withInlineSpanChildren(
  92. style: processedTree.style,
  93. children: [parsedTree],
  94. selectable: selectable,
  95. scrollPhysics: scrollPhysics,
  96. selectionControls: selectionControls,
  97. shrinkWrap: shrinkWrap,
  98. );
  99. }
  100. /// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library.
  101. static dom.Element parseHTML(String data) {
  102. return htmlparser.parse(data).documentElement!;
  103. }
  104. /// [parseCss] converts a string of CSS to a CSS stylesheet using the dart `csslib` library.
  105. static css.StyleSheet parseCss(String data) {
  106. return cssparser.parse(data);
  107. }
  108. /// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s.
  109. static StyledElement lexDomTree(
  110. dom.Element html,
  111. List<CustomRenderMatcher> customRenderMatchers,
  112. List<String> tagsList,
  113. BuildContext context,
  114. HtmlParser parser,
  115. ) {
  116. StyledElement tree = StyledElement(
  117. name: '[Tree Root]',
  118. children: <StyledElement>[],
  119. node: html,
  120. style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!),
  121. );
  122. for (var node in html.nodes) {
  123. tree.children.add(_recursiveLexer(
  124. node,
  125. customRenderMatchers,
  126. tagsList,
  127. context,
  128. parser,
  129. ));
  130. }
  131. return tree;
  132. }
  133. /// [_recursiveLexer] is the recursive worker function for [lexDomTree].
  134. ///
  135. /// It runs the parse functions of every type of
  136. /// element and returns a [StyledElement] tree representing the element.
  137. static StyledElement _recursiveLexer(
  138. dom.Node node,
  139. List<CustomRenderMatcher> customRenderMatchers,
  140. List<String> tagsList,
  141. BuildContext context,
  142. HtmlParser parser,
  143. ) {
  144. List<StyledElement> children = <StyledElement>[];
  145. for (var childNode in node.nodes) {
  146. children.add(_recursiveLexer(
  147. childNode,
  148. customRenderMatchers,
  149. tagsList,
  150. context,
  151. parser,
  152. ));
  153. }
  154. if (node is dom.Element) {
  155. if (!tagsList.contains(node.localName)) {
  156. return EmptyContentElement();
  157. }
  158. if (HtmlElements.styledElements.contains(node.localName)) {
  159. return parseStyledElement(node, children);
  160. }
  161. if (HtmlElements.interactableElements.contains(node.localName)) {
  162. return parseInteractableElement(node, children);
  163. }
  164. if (HtmlElements.replacedElements.contains(node.localName)) {
  165. return parseReplacedElement(node, children);
  166. }
  167. if (HtmlElements.layoutElements.contains(node.localName)) {
  168. return parseLayoutElement(node, children);
  169. }
  170. if (HtmlElements.tableCellElements.contains(node.localName)) {
  171. return parseTableCellElement(node, children);
  172. }
  173. if (HtmlElements.tableDefinitionElements.contains(node.localName)) {
  174. return parseTableDefinitionElement(node, children);
  175. } else {
  176. final StyledElement tree = parseStyledElement(node, children);
  177. for (final entry in customRenderMatchers) {
  178. if (entry.call(
  179. RenderContext(
  180. buildContext: context,
  181. parser: parser,
  182. tree: tree,
  183. style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!),
  184. ),
  185. )) {
  186. return tree;
  187. }
  188. }
  189. return EmptyContentElement();
  190. }
  191. } else if (node is dom.Text) {
  192. return TextContentElement(
  193. text: node.text,
  194. style: Style(),
  195. element: node.parent,
  196. node: node,
  197. );
  198. } else {
  199. return EmptyContentElement();
  200. }
  201. }
  202. static Map<String, Map<String, List<css.Expression>>> _getExternalCssDeclarations(
  203. List<dom.Element> styles, OnCssParseError? errorHandler) {
  204. String fullCss = '';
  205. for (final e in styles) {
  206. fullCss = fullCss + e.innerHtml;
  207. }
  208. if (fullCss.isNotEmpty) {
  209. final declarations = parseExternalCss(fullCss, errorHandler);
  210. return declarations;
  211. } else {
  212. return {};
  213. }
  214. }
  215. static StyledElement _applyExternalCss(
  216. Map<String, Map<String, List<css.Expression>>> declarations, StyledElement tree) {
  217. declarations.forEach((key, style) {
  218. try {
  219. if (tree.matchesSelector(key)) {
  220. tree.style = tree.style.merge(declarationsToStyle(style));
  221. }
  222. } catch (_) {}
  223. });
  224. for (var element in tree.children) {
  225. _applyExternalCss(declarations, element);
  226. }
  227. return tree;
  228. }
  229. static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) {
  230. if (tree.attributes.containsKey('style')) {
  231. final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler);
  232. if (newStyle != null) {
  233. tree.style = tree.style.merge(newStyle);
  234. }
  235. }
  236. for (var element in tree.children) {
  237. _applyInlineStyles(element, errorHandler);
  238. }
  239. return tree;
  240. }
  241. /// [applyCustomStyles] applies the [Style] objects passed into the [Html]
  242. /// widget onto the [StyledElement] tree, no cascading of styles is done at this point.
  243. static StyledElement _applyCustomStyles(Map<String, Style> style, StyledElement tree) {
  244. style.forEach((key, style) {
  245. try {
  246. if (tree.matchesSelector(key)) {
  247. tree.style = tree.style.merge(style);
  248. }
  249. } catch (_) {}
  250. });
  251. for (var element in tree.children) {
  252. _applyCustomStyles(style, element);
  253. }
  254. return tree;
  255. }
  256. /// [_cascadeStyles] cascades all of the inherited styles down the tree, applying them to each
  257. /// child that doesn't specify a different style.
  258. static StyledElement _cascadeStyles(Map<String, Style> style, StyledElement tree) {
  259. for (var child in tree.children) {
  260. child.style = tree.style.copyOnlyInherited(child.style);
  261. _cascadeStyles(style, child);
  262. }
  263. return tree;
  264. }
  265. /// [styleTree] takes the lexed [StyleElement] tree and applies external,
  266. /// inline, and custom CSS/Flutter styles, and then cascades the styles down the tree.
  267. static StyledElement styleTree(
  268. StyledElement tree, dom.Element htmlData, Map<String, Style> style, OnCssParseError? onCssParseError) {
  269. Map<String, Map<String, List<css.Expression>>> declarations =
  270. _getExternalCssDeclarations(htmlData.getElementsByTagName('style'), onCssParseError);
  271. StyledElement? externalCssStyledTree;
  272. if (declarations.isNotEmpty) {
  273. externalCssStyledTree = _applyExternalCss(declarations, tree);
  274. }
  275. tree = _applyInlineStyles(externalCssStyledTree ?? tree, onCssParseError);
  276. tree = _applyCustomStyles(style, tree);
  277. tree = _cascadeStyles(style, tree);
  278. return tree;
  279. }
  280. /// [processTree] optimizes the [StyledElement] tree so all [BlockElement]s are
  281. /// on the first level, redundant levels are collapsed, empty elements are
  282. /// removed, and specialty elements are processed.
  283. static StyledElement processTree(StyledElement tree, double devicePixelRatio) {
  284. tree = _processInternalWhitespace(tree);
  285. tree = _processInlineWhitespace(tree);
  286. tree = _removeEmptyElements(tree);
  287. tree = _calculateRelativeValues(tree, devicePixelRatio);
  288. tree = _preprocessListMarkers(tree);
  289. tree = _processCounters(tree);
  290. tree = _processListMarkers(tree);
  291. tree = _processBeforesAndAfters(tree);
  292. tree = _collapseMargins(tree);
  293. return tree;
  294. }
  295. /// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree.
  296. ///
  297. /// [parseTree] is responsible for handling the [customRenders] parameter and
  298. /// deciding what different `Style.display` options look like as Widgets.
  299. InlineSpan parseTree(RenderContext context, StyledElement tree) {
  300. // Merge this element's style into the context so that children
  301. // inherit the correct style
  302. RenderContext newContext = RenderContext(
  303. buildContext: context.buildContext,
  304. parser: this,
  305. tree: tree,
  306. style: context.style.copyOnlyInherited(tree.style),
  307. key: AnchorKey.of(key, tree),
  308. );
  309. for (final entry in customRenders.keys) {
  310. if (entry.call(newContext)) {
  311. List<InlineSpan> buildChildren() => tree.children.map((tree) => parseTree(newContext, tree)).toList();
  312. if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) {
  313. List<TextSpan> selectableBuildChildren() =>
  314. tree.children.map((tree) => parseTree(newContext, tree) as TextSpan).toList();
  315. return (customRenders[entry] as SelectableCustomRender).textSpan.call(newContext, selectableBuildChildren);
  316. }
  317. if (newContext.parser.selectable) {
  318. return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren) as TextSpan;
  319. }
  320. if (customRenders[entry]?.inlineSpan != null) {
  321. return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren);
  322. }
  323. return WidgetSpan(
  324. child: CssBoxWidget(
  325. style: tree.style,
  326. shrinkWrap: newContext.parser.shrinkWrap,
  327. childIsReplaced: true,
  328. child: customRenders[entry]!.widget!.call(newContext, buildChildren),
  329. ),
  330. );
  331. }
  332. }
  333. return const WidgetSpan(child: SizedBox(height: 0, width: 0));
  334. }
  335. static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => (url, context, attributes, element) {
  336. if (url?.startsWith('#') == true) {
  337. final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext;
  338. if (anchorContext != null) {
  339. Scrollable.ensureVisible(anchorContext);
  340. }
  341. return;
  342. }
  343. onLinkTap?.call(url, context, attributes, element);
  344. };
  345. /// [processWhitespace] removes unnecessary whitespace from the StyledElement tree.
  346. ///
  347. /// The criteria for determining which whitespace is replaceable is outlined
  348. /// at https://www.w3.org/TR/css-text-3/
  349. /// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33
  350. static StyledElement _processInternalWhitespace(StyledElement tree) {
  351. if ((tree.style.whiteSpace ?? WhiteSpace.normal) == WhiteSpace.pre) {
  352. // Preserve this whitespace
  353. } else if (tree is TextContentElement) {
  354. tree.text = _removeUnnecessaryWhitespace(tree.text!);
  355. } else {
  356. tree.children.forEach(_processInternalWhitespace);
  357. }
  358. return tree;
  359. }
  360. /// [_processInlineWhitespace] is responsible for removing redundant whitespace
  361. /// between and among inline elements. It does so by creating a boolean [Context]
  362. /// and passing it to the [_processInlineWhitespaceRecursive] function.
  363. static StyledElement _processInlineWhitespace(StyledElement tree) {
  364. tree = _processInlineWhitespaceRecursive(tree, Context(false));
  365. return tree;
  366. }
  367. /// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different
  368. /// inline elements, and replaces any instance of two or more spaces with a single space, according
  369. /// to the w3's HTML whitespace processing specification linked to above.
  370. static StyledElement _processInlineWhitespaceRecursive(
  371. StyledElement tree,
  372. Context<bool> keepLeadingSpace,
  373. ) {
  374. if (tree is TextContentElement) {
  375. /// initialize indices to negative numbers to make conditionals a little easier
  376. int textIndex = -1;
  377. int elementIndex = -1;
  378. /// initialize parent after to a whitespace to account for elements that are
  379. /// the last child in the list of elements
  380. String parentAfterText = ' ';
  381. /// find the index of the text in the current tree
  382. if ((tree.element?.nodes.length ?? 0) >= 1) {
  383. textIndex = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1;
  384. }
  385. /// get the parent nodes
  386. dom.NodeList? parentNodes = tree.element?.parent?.nodes;
  387. /// find the index of the tree itself in the parent nodes
  388. if ((parentNodes?.length ?? 0) >= 1) {
  389. elementIndex = parentNodes?.indexWhere((element) => element == tree.element) ?? -1;
  390. }
  391. /// if the tree is any node except the last node in the node list and the
  392. /// next node in the node list is a text node, then get its text. Otherwise
  393. /// the next node will be a [dom.Element], so keep unwrapping that until
  394. /// we get the underlying text node, and finally get its text.
  395. if (elementIndex < (parentNodes?.length ?? 1) - 1 && parentNodes?[elementIndex + 1] is dom.Text) {
  396. parentAfterText = parentNodes?[elementIndex + 1].text ?? ' ';
  397. } else if (elementIndex < (parentNodes?.length ?? 1) - 1) {
  398. var parentAfter = parentNodes?[elementIndex + 1];
  399. while (parentAfter is dom.Element) {
  400. if (parentAfter.nodes.isNotEmpty) {
  401. parentAfter = parentAfter.nodes.first;
  402. } else {
  403. break;
  404. }
  405. }
  406. parentAfterText = parentAfter?.text ?? ' ';
  407. }
  408. /// If the text is the first element in the current tree node list, it
  409. /// starts with a whitespace, it isn't a line break, either the
  410. /// whitespace is unnecessary or it is a block element, and either it is
  411. /// first element in the parent node list or the previous element
  412. /// in the parent node list ends with a whitespace, delete it.
  413. ///
  414. /// We should also delete the whitespace at any point in the node list
  415. /// if the previous element is a <br> because that tag makes the element
  416. /// act like a block element.
  417. if (textIndex < 1 &&
  418. tree.text!.startsWith(' ') &&
  419. tree.element?.localName != 'br' &&
  420. (!keepLeadingSpace.data || tree.style.display == Display.block) &&
  421. (elementIndex < 1 ||
  422. (elementIndex >= 1 &&
  423. parentNodes?[elementIndex - 1] is dom.Text &&
  424. parentNodes![elementIndex - 1].text!.endsWith(' ')))) {
  425. tree.text = tree.text!.replaceFirst(' ', '');
  426. } else if (textIndex >= 1 &&
  427. tree.text!.startsWith(' ') &&
  428. tree.element?.nodes[textIndex - 1] is dom.Element &&
  429. (tree.element?.nodes[textIndex - 1] as dom.Element).localName == 'br') {
  430. tree.text = tree.text!.replaceFirst(' ', '');
  431. }
  432. /// If the text is the last element in the current tree node list, it isn't
  433. /// a line break, and the next text node starts with a whitespace,
  434. /// update the [Context] to signify to that next text node whether it should
  435. /// keep its whitespace. This is based on whether the current text ends with a
  436. /// whitespace.
  437. if (textIndex == (tree.element?.nodes.length ?? 1) - 1 &&
  438. tree.element?.localName != 'br' &&
  439. parentAfterText.startsWith(' ')) {
  440. keepLeadingSpace.data = !tree.text!.endsWith(' ');
  441. }
  442. }
  443. for (var element in tree.children) {
  444. _processInlineWhitespaceRecursive(element, keepLeadingSpace);
  445. }
  446. return tree;
  447. }
  448. /// [removeUnnecessaryWhitespace] removes "unnecessary" white space from the given String.
  449. ///
  450. /// The steps for removing this whitespace are as follows:
  451. /// (1) Remove any whitespace immediately preceding or following a newline.
  452. /// (2) Replace all newlines with a space
  453. /// (3) Replace all tabs with a space
  454. /// (4) Replace any instances of two or more spaces with a single space.
  455. static String _removeUnnecessaryWhitespace(String text) {
  456. return text
  457. .replaceAll(RegExp('\\ *(?=\n)'), '\n')
  458. .replaceAll(RegExp('(?:\n)\\ *'), '\n')
  459. .replaceAll('\n', ' ')
  460. .replaceAll('\t', ' ')
  461. .replaceAll(RegExp(' {2,}'), ' ');
  462. }
  463. /// [preprocessListMarkers] adds marker pseudo elements to the front of all list
  464. /// items.
  465. static StyledElement _preprocessListMarkers(StyledElement tree) {
  466. tree.style.listStylePosition ??= ListStylePosition.outside;
  467. if (tree.style.display == Display.listItem) {
  468. // Add the marker pseudo-element if it doesn't exist
  469. tree.style.marker ??= Marker(
  470. content: Content.normal,
  471. style: tree.style,
  472. );
  473. // Inherit styles from originating widget
  474. tree.style.marker!.style = tree.style.copyOnlyInherited(tree.style.marker!.style ?? Style());
  475. // Add the implicit counter-increment on `list-item` if it isn't set
  476. // explicitly already
  477. tree.style.counterIncrement ??= {};
  478. if (!tree.style.counterIncrement!.containsKey('list-item')) {
  479. tree.style.counterIncrement!['list-item'] = 1;
  480. }
  481. }
  482. // Add the counters to ol and ul types.
  483. if (tree.name == 'ol' || tree.name == 'ul') {
  484. tree.style.counterReset ??= {};
  485. if (!tree.style.counterReset!.containsKey('list-item')) {
  486. tree.style.counterReset!['list-item'] = 0;
  487. }
  488. }
  489. for (var child in tree.children) {
  490. _preprocessListMarkers(child);
  491. }
  492. return tree;
  493. }
  494. /// [_processListCounters] adds the appropriate counter values to each
  495. /// StyledElement on the tree.
  496. static StyledElement _processCounters(StyledElement tree, [ListQueue<Counter>? counters]) {
  497. // Add the counters for the current scope.
  498. tree.counters.addAll(counters?.deepCopy() ?? []);
  499. // Create any new counters
  500. if (tree.style.counterReset != null) {
  501. tree.style.counterReset!.forEach((counterName, initialValue) {
  502. tree.counters.add(Counter(counterName, initialValue ?? 0));
  503. });
  504. }
  505. // Increment any counters that are to be incremented
  506. if (tree.style.counterIncrement != null) {
  507. tree.style.counterIncrement!.forEach((counterName, increment) {
  508. tree.counters
  509. .lastWhereOrNull(
  510. (counter) => counter.name == counterName,
  511. )
  512. ?.increment(increment ?? 1);
  513. // If we didn't newly create the counter, increment the counter in the old copy as well.
  514. if (tree.style.counterReset == null || !tree.style.counterReset!.containsKey(counterName)) {
  515. counters
  516. ?.lastWhereOrNull(
  517. (counter) => counter.name == counterName,
  518. )
  519. ?.increment(increment ?? 1);
  520. }
  521. });
  522. }
  523. for (var element in tree.children) {
  524. _processCounters(element, tree.counters);
  525. }
  526. return tree;
  527. }
  528. static StyledElement _processListMarkers(StyledElement tree) {
  529. if (tree.style.display == Display.listItem) {
  530. final listStyleType = tree.style.listStyleType ?? ListStyleType.decimal;
  531. final counterStyle = CounterStyleRegistry.lookup(
  532. listStyleType.counterStyle,
  533. );
  534. String counterContent;
  535. if (tree.style.marker?.content.isNormal ?? true) {
  536. counterContent = counterStyle.generateMarkerContent(
  537. tree.counters.lastOrNull?.value ?? 0,
  538. );
  539. } else if (!(tree.style.marker?.content.display ?? true)) {
  540. counterContent = '';
  541. } else {
  542. counterContent = tree.style.marker?.content.replacementContent ??
  543. counterStyle.generateMarkerContent(
  544. tree.counters.lastOrNull?.value ?? 0,
  545. );
  546. }
  547. tree.style.marker = Marker(content: Content(counterContent), style: tree.style.marker?.style);
  548. }
  549. for (var child in tree.children) {
  550. _processListMarkers(child);
  551. }
  552. return tree;
  553. }
  554. /// [_processBeforesAndAfters] adds text content to the beginning and end of
  555. /// the list of the trees children according to the `before` and `after` Style
  556. /// properties.
  557. static StyledElement _processBeforesAndAfters(StyledElement tree) {
  558. if (tree.style.before != null) {
  559. tree.children.insert(
  560. 0,
  561. TextContentElement(
  562. text: tree.style.before,
  563. style: tree.style.copyWith(beforeAfterNull: true, display: Display.inline),
  564. ),
  565. );
  566. }
  567. if (tree.style.after != null) {
  568. tree.children.add(TextContentElement(
  569. text: tree.style.after,
  570. style: tree.style.copyWith(beforeAfterNull: true, display: Display.inline),
  571. ));
  572. }
  573. tree.children.forEach(_processBeforesAndAfters);
  574. return tree;
  575. }
  576. /// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS22/box.html#collapsing-margins
  577. /// for collapsing margins of block-level boxes. This prevents the doubling of margins between
  578. /// boxes, and makes for a more correct rendering of the html content.
  579. ///
  580. /// Paraphrased from the CSS specification:
  581. /// Margins are collapsed if both belong to vertically-adjacent box edges, i.e form one of the following pairs:
  582. /// (1) Top margin of a box and top margin of its first in-flow child
  583. /// (2) Bottom margin of a box and top margin of its next in-flow following sibling
  584. /// (3) Bottom margin of a last in-flow child and bottom margin of its parent (if the parent's height is not explicit)
  585. /// (4) Top and Bottom margins of a box with a height of zero or no in-flow children.
  586. static StyledElement _collapseMargins(StyledElement tree) {
  587. //Short circuit if we've reached a leaf of the tree
  588. if (tree.children.isEmpty) {
  589. // Handle case (4) from above.
  590. if (tree.style.height?.value == 0 && tree.style.height?.unit != Unit.auto) {
  591. tree.style.margin = tree.style.margin?.collapse() ?? Margins.zero;
  592. }
  593. return tree;
  594. }
  595. //Collapsing should be depth-first.
  596. tree.children.forEach(_collapseMargins);
  597. //The root boxes do not collapse.
  598. if (tree.name == '[Tree Root]' || tree.name == 'html') {
  599. return tree;
  600. }
  601. // Handle case (1) from above.
  602. // Top margins cannot collapse if the element has padding
  603. if ((tree.style.padding?.top ?? 0) == 0) {
  604. final parentTop = tree.style.margin?.top?.value ?? 0;
  605. final firstChildTop = tree.children.first.style.margin?.top?.value ?? 0;
  606. final newOuterMarginTop = max(parentTop, firstChildTop);
  607. // Set the parent's margin
  608. if (tree.style.margin == null) {
  609. tree.style.margin = Margins.only(top: newOuterMarginTop);
  610. } else {
  611. tree.style.margin = tree.style.margin!.copyWithEdge(top: newOuterMarginTop);
  612. }
  613. // And remove the child's margin
  614. if (tree.children.first.style.margin == null) {
  615. tree.children.first.style.margin = Margins.zero;
  616. } else {
  617. tree.children.first.style.margin = tree.children.first.style.margin!.copyWithEdge(top: 0);
  618. }
  619. }
  620. // Handle case (3) from above.
  621. // Bottom margins cannot collapse if the element has padding
  622. if ((tree.style.padding?.bottom ?? 0) == 0) {
  623. final parentBottom = tree.style.margin?.bottom?.value ?? 0;
  624. final lastChildBottom = tree.children.last.style.margin?.bottom?.value ?? 0;
  625. final newOuterMarginBottom = max(parentBottom, lastChildBottom);
  626. // Set the parent's margin
  627. if (tree.style.margin == null) {
  628. tree.style.margin = Margins.only(bottom: newOuterMarginBottom);
  629. } else {
  630. tree.style.margin = tree.style.margin!.copyWithEdge(bottom: newOuterMarginBottom);
  631. }
  632. // And remove the child's margin
  633. if (tree.children.last.style.margin == null) {
  634. tree.children.last.style.margin = Margins.zero;
  635. } else {
  636. tree.children.last.style.margin = tree.children.last.style.margin!.copyWithEdge(bottom: 0);
  637. }
  638. }
  639. // Handle case (2) from above.
  640. if (tree.children.length > 1) {
  641. for (int i = 1; i < tree.children.length; i++) {
  642. final previousSiblingBottom = tree.children[i - 1].style.margin?.bottom?.value ?? 0;
  643. final thisTop = tree.children[i].style.margin?.top?.value ?? 0;
  644. final newInternalMargin = max(previousSiblingBottom, thisTop);
  645. if (tree.children[i - 1].style.margin == null) {
  646. tree.children[i - 1].style.margin = Margins.only(bottom: newInternalMargin);
  647. } else {
  648. tree.children[i - 1].style.margin =
  649. tree.children[i - 1].style.margin!.copyWithEdge(bottom: newInternalMargin);
  650. }
  651. if (tree.children[i].style.margin == null) {
  652. tree.children[i].style.margin = Margins.only(top: newInternalMargin);
  653. } else {
  654. tree.children[i].style.margin = tree.children[i].style.margin!.copyWithEdge(top: newInternalMargin);
  655. }
  656. }
  657. }
  658. return tree;
  659. }
  660. /// [removeEmptyElements] recursively removes empty elements.
  661. ///
  662. /// An empty element is any [EmptyContentElement], any empty [TextContentElement],
  663. /// or any block-level [TextContentElement] that contains only whitespace and doesn't follow
  664. /// a block element or a line break.
  665. static StyledElement _removeEmptyElements(StyledElement tree) {
  666. List<StyledElement> toRemove = <StyledElement>[];
  667. bool lastChildBlock = true;
  668. tree.children.forEachIndexed((index, child) {
  669. if (child is EmptyContentElement || child is EmptyLayoutElement) {
  670. toRemove.add(child);
  671. } else if (child is TextContentElement &&
  672. ((tree.name == 'body' &&
  673. (index == 0 ||
  674. index + 1 == tree.children.length ||
  675. tree.children[index - 1].style.display == Display.block ||
  676. tree.children[index + 1].style.display == Display.block)) ||
  677. tree.name == 'ul') &&
  678. child.text!.replaceAll(' ', '').isEmpty) {
  679. toRemove.add(child);
  680. } else if (child is TextContentElement && child.text!.isEmpty && child.style.whiteSpace != WhiteSpace.pre) {
  681. toRemove.add(child);
  682. } else if (child is TextContentElement &&
  683. child.style.whiteSpace != WhiteSpace.pre &&
  684. tree.style.display == Display.block &&
  685. child.text!.isEmpty &&
  686. lastChildBlock) {
  687. toRemove.add(child);
  688. } else if (child.style.display == Display.none) {
  689. toRemove.add(child);
  690. } else {
  691. _removeEmptyElements(child);
  692. }
  693. // This is used above to check if the previous element is a block element or a line break.
  694. lastChildBlock = (child.style.display == Display.block ||
  695. child.style.display == Display.listItem ||
  696. (child is TextContentElement && child.text == '\n'));
  697. });
  698. tree.children.removeWhere((element) => toRemove.contains(element));
  699. return tree;
  700. }
  701. /// [_calculateRelativeValues] converts rem values to px sizes and then
  702. /// applies relative calculations
  703. static StyledElement _calculateRelativeValues(StyledElement tree, double devicePixelRatio) {
  704. double remSize = (tree.style.fontSize?.value ?? FontSize.medium.value);
  705. //If the root element has a rem-based fontSize, then give it the default
  706. // font size times the set rem value.
  707. if (tree.style.fontSize?.unit == Unit.rem) {
  708. tree.style.fontSize = FontSize(FontSize.medium.value * remSize);
  709. }
  710. _applyRelativeValuesRecursive(tree, remSize, devicePixelRatio);
  711. tree.style.setRelativeValues(remSize, remSize / devicePixelRatio);
  712. return tree;
  713. }
  714. /// This is the recursive worker function for [_calculateRelativeValues]
  715. static void _applyRelativeValuesRecursive(StyledElement tree, double remFontSize, double devicePixelRatio) {
  716. //When we get to this point, there should be a valid fontSize at every level.
  717. assert(tree.style.fontSize != null);
  718. final parentFontSize = tree.style.fontSize!.value;
  719. for (var child in tree.children) {
  720. if (child.style.fontSize == null) {
  721. child.style.fontSize = FontSize(parentFontSize);
  722. } else {
  723. switch (child.style.fontSize!.unit) {
  724. case Unit.em:
  725. child.style.fontSize = FontSize(parentFontSize * child.style.fontSize!.value);
  726. break;
  727. case Unit.percent:
  728. child.style.fontSize = FontSize(parentFontSize * (child.style.fontSize!.value / 100.0));
  729. break;
  730. case Unit.rem:
  731. child.style.fontSize = FontSize(remFontSize * child.style.fontSize!.value);
  732. break;
  733. case Unit.px:
  734. case Unit.auto:
  735. //Ignore
  736. break;
  737. }
  738. }
  739. // Note: it is necessary to scale down the emSize by the factor of
  740. // devicePixelRatio since Flutter seems to calculates font sizes using
  741. // physical pixels, but margins/padding using logical pixels.
  742. final emSize = child.style.fontSize!.value / devicePixelRatio;
  743. tree.style.setRelativeValues(remFontSize, emSize);
  744. _applyRelativeValuesRecursive(child, remFontSize, devicePixelRatio);
  745. }
  746. }
  747. }
  748. extension IterateLetters on String {
  749. String nextLetter() {
  750. String s = toLowerCase();
  751. if (s == 'z') {
  752. return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa
  753. } else {
  754. var lastChar = s.substring(s.length - 1);
  755. var sub = s.substring(0, s.length - 1);
  756. if (lastChar == 'z') {
  757. // If a string of length > 1 ends in Z/z,
  758. // increment the string (excluding the last Z/z) recursively,
  759. // and append A/a (depending on casing) to it
  760. return '${sub.nextLetter()}a';
  761. } else {
  762. // (take till last char) append with (increment last char)
  763. return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1);
  764. }
  765. }
  766. }
  767. }
  768. class RenderContext {
  769. final BuildContext buildContext;
  770. final HtmlParser parser;
  771. final StyledElement tree;
  772. final Style style;
  773. final AnchorKey? key;
  774. RenderContext({
  775. required this.buildContext,
  776. required this.parser,
  777. required this.tree,
  778. required this.style,
  779. this.key,
  780. });
  781. }