diff --git a/lib/model/content.dart b/lib/model/content.dart index e4273f1b3a..e49cb93246 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -411,6 +411,77 @@ class KatexSpanNode extends KatexNode { } } +class KatexStrutNode extends KatexNode { + const KatexStrutNode({ + required this.heightEm, + required this.verticalAlignEm, + super.debugHtmlNode, + }); + + final double heightEm; + final double? verticalAlignEm; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('heightEm', heightEm)); + properties.add(DoubleProperty('verticalAlignEm', verticalAlignEm)); + } +} + +class KatexVlistNode extends KatexNode { + const KatexVlistNode({ + required this.rows, + super.debugHtmlNode, + }); + + final List rows; + + @override + List debugDescribeChildren() { + return rows.map((row) => row.toDiagnosticsNode()).toList(); + } +} + +class KatexVlistRowNode extends ContentNode { + const KatexVlistRowNode({ + required this.verticalOffsetEm, + required this.node, + super.debugHtmlNode, + }); + + final double verticalOffsetEm; + final KatexSpanNode node; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('verticalOffsetEm', verticalOffsetEm)); + } +} + +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.leftOffsetEm, + required this.nodes, + super.debugHtmlNode, + }) : assert(leftOffsetEm < 0); + + final double leftOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('leftOffsetEm', leftOffsetEm)); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + class MathBlockNode extends MathNode implements BlockContentNode { const MathBlockNode({ super.debugHtmlNode, diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 922546c676..ed41b440f9 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:csslib/parser.dart' as css_parser; import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; @@ -160,6 +161,8 @@ class _KatexParser { final unsupportedCssClasses = []; final unsupportedInlineCssProperties = []; + final List _ancestorClasses = []; + List parseKatexHtml(dom.Element element) { assert(element.localName == 'span'); assert(element.className == 'katex-html'); @@ -167,16 +170,44 @@ class _KatexParser { } List _parseChildSpans(List nodes) { - return List.unmodifiable(nodes.map((node) { - if (node case dom.Element(localName: 'span')) { - return _parseSpan(node); - } else { + var resultSpans = QueueList(); + for (final node in nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { throw _KatexHtmlParseError( node is dom.Element ? 'unsupported html node: ${node.localName}' : 'unsupported html node'); } - })); + + _ancestorClasses.add(node.className); + final span = _parseSpan(node); + assert(_ancestorClasses.removeLast() == node.className); + + if (span is KatexSpanNode) { + final marginRightEm = span.styles.marginRightEm; + if (marginRightEm != null && marginRightEm.isNegative) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: marginRightEm, + nodes: previousSpans)); + } + } + + resultSpans.addFirst(span); + + if (span is KatexSpanNode) { + final marginLeftEm = span.styles.marginLeftEm; + if (marginLeftEm != null && marginLeftEm.isNegative) { + final previousSpans = resultSpans; + resultSpans = QueueList(); + resultSpans.addFirst(KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: previousSpans)); + } + } + } + return resultSpans; } static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); @@ -185,9 +216,151 @@ class _KatexParser { KatexNode _parseSpan(dom.Element element) { // TODO maybe check if the sequence of ancestors matter for spans. + if (element.className.startsWith('strut')) { + if (element.className == 'strut' && element.nodes.isEmpty) { + final styles = _parseSpanInlineStyles(element); + if (styles == null) throw _KatexHtmlParseError(); + + final heightEm = styles.heightEm; + if (heightEm == null) throw _KatexHtmlParseError(); + final verticalAlignEm = styles.verticalAlignEm; + + // Ensure only `height` and `vertical-align` inline styles are present. + if (styles.filter(heightEm: false, verticalAlignEm: false) != + KatexSpanStyles()) { + throw _KatexHtmlParseError(); + } + + return KatexStrutNode( + heightEm: heightEm, + verticalAlignEm: verticalAlignEm); + } else { + throw _KatexHtmlParseError(); + } + } + + if (element.className.startsWith('vlist')) { + if (element case dom.Element( + localName: 'span', + className: 'vlist-t' || 'vlist-t vlist-t2', + nodes: [...], + ) && final vlistT) { + if (vlistT.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + final hasTwoVlistR = vlistT.className == 'vlist-t vlist-t2'; + if (!hasTwoVlistR && vlistT.nodes.length != 1) throw _KatexHtmlParseError(); + + if (hasTwoVlistR) { + if (vlistT.nodes case [ + _, + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]), + ]), + ]) { + // Do nothing. + } else { + throw _KatexHtmlParseError(); + } + } + + if (vlistT.nodes.first + case dom.Element(localName: 'span', className: 'vlist-r') && + final vlistR) { + if (vlistR.attributes.containsKey('style')) throw _KatexHtmlParseError(); + + if (vlistR.nodes.first + case dom.Element(localName: 'span', className: 'vlist') && + final vlist) { + final rows = []; + + for (final innerSpan in vlist.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ...final otherSpans, + ], + )) { + if (innerSpan.className != '') { + throw _KatexHtmlParseError('unexpected CSS class for ' + 'vlist inner span: ${innerSpan.className}'); + } + + var styles = _parseSpanInlineStyles(innerSpan)!; + final topEm = styles.topEm ?? 0; + + styles = styles.filter(topEm: false); + + final pstrutStyles = _parseSpanInlineStyles(pstrutSpan)!; + final pstrutHeight = pstrutStyles.heightEm ?? 0; + + KatexSpanNode innerSpanNode = KatexSpanNode( + styles: styles, + text: null, + nodes: _parseChildSpans(otherSpans)); + + final marginRightEm = styles.marginRightEm; + final marginLeftEm = styles.marginLeftEm; + if (marginRightEm != null && marginRightEm.isNegative) { + throw _KatexHtmlParseError(); + } + if (marginLeftEm != null && marginLeftEm.isNegative) { + innerSpanNode = KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexNegativeMarginNode( + leftOffsetEm: marginLeftEm, + nodes: [innerSpanNode]), + ]); + } + + rows.add(KatexVlistRowNode( + verticalOffsetEm: topEm + pstrutHeight, + debugHtmlNode: kDebugMode ? innerSpan : null, + node: innerSpanNode)); + } else { + throw _KatexHtmlParseError(); + } + } + + final result = KatexVlistNode( + rows: rows, + debugHtmlNode: kDebugMode ? vlistT : null, + ); + + if (_ancestorClasses.any( + (classes) => classes.split(' ').contains('op-limits'), + )) { + return KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.center), + text: null, + nodes: [result]); + } + + return result; + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } else { + throw _KatexHtmlParseError(); + } + } + final debugHtmlNode = kDebugMode ? element : null; final inlineStyles = _parseSpanInlineStyles(element); + if (inlineStyles != null) { + // We expect `vertical-align` inline style to be only present on a + // `strut` span, for which we emit `KatexStrutNode` separately. + if (inlineStyles.verticalAlignEm != null) throw _KatexHtmlParseError(); + } // Aggregate the CSS styles that apply, in the same order as the CSS // classes specified for this span, mimicking the behaviour on web. @@ -197,7 +370,9 @@ class _KatexParser { // https://github.com/KaTeX/KaTeX/blob/2fe1941b/src/styles/katex.scss // A copy of class definition (where possible) is accompanied in a comment // with each case statement to keep track of updates. - final spanClasses = List.unmodifiable(element.className.split(' ')); + final spanClasses = element.className != '' + ? List.unmodifiable(element.className.split(' ')) + : const []; String? fontFamily; double? fontSizeEm; KatexSpanFontWeight? fontWeight; @@ -214,8 +389,9 @@ class _KatexParser { case 'strut': // .strut { ... } - // Do nothing, it has properties that don't need special handling. - break; + // We expect the 'strut' class to be the only class in a span, + // in which case we handle it separately and emit `KatexStrutNode`. + throw _KatexHtmlParseError(); case 'textbf': // .textbf { font-weight: bold; } @@ -398,6 +574,10 @@ class _KatexParser { _ => throw _KatexHtmlParseError(), }; + case 'op-limits': + // .op-limits > .vlist-t { text-align: center; } + // We handle this above while parsing the vlist. + // TODO handle more classes from katex.scss case 'mord': @@ -463,6 +643,10 @@ class _KatexParser { final stylesheet = css_parser.parse('*{$styleStr}'); if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { double? heightEm; + double? verticalAlignEm; + double? topEm; + double? marginRightEm; + double? marginLeftEm; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -474,6 +658,22 @@ class _KatexParser { case 'height': heightEm = _getEm(expression); if (heightEm != null) continue; + + case 'vertical-align': + verticalAlignEm = _getEm(expression); + if (verticalAlignEm != null) continue; + + case 'top': + topEm = _getEm(expression); + if (topEm != null) continue; + + case 'margin-right': + marginRightEm = _getEm(expression); + if (marginRightEm != null) continue; + + case 'margin-left': + marginLeftEm = _getEm(expression); + if (marginLeftEm != null) continue; } // TODO handle more CSS properties @@ -488,6 +688,10 @@ class _KatexParser { return KatexSpanStyles( heightEm: heightEm, + topEm: topEm, + verticalAlignEm: verticalAlignEm, + marginRightEm: marginRightEm, + marginLeftEm: marginLeftEm, ); } else { throw _KatexHtmlParseError(); @@ -524,6 +728,12 @@ enum KatexSpanTextAlign { @immutable class KatexSpanStyles { final double? heightEm; + final double? verticalAlignEm; + + final double? topEm; + + final double? marginRightEm; + final double? marginLeftEm; final String? fontFamily; final double? fontSizeEm; @@ -533,6 +743,10 @@ class KatexSpanStyles { const KatexSpanStyles({ this.heightEm, + this.verticalAlignEm, + this.topEm, + this.marginRightEm, + this.marginLeftEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -544,6 +758,10 @@ class KatexSpanStyles { int get hashCode => Object.hash( 'KatexSpanStyles', heightEm, + verticalAlignEm, + topEm, + marginRightEm, + marginLeftEm, fontFamily, fontSizeEm, fontWeight, @@ -555,6 +773,10 @@ class KatexSpanStyles { bool operator ==(Object other) { return other is KatexSpanStyles && other.heightEm == heightEm && + other.verticalAlignEm == verticalAlignEm && + other.topEm == topEm && + other.marginRightEm == marginRightEm && + other.marginLeftEm == marginLeftEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -566,6 +788,10 @@ class KatexSpanStyles { String toString() { final args = []; if (heightEm != null) args.add('heightEm: $heightEm'); + if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); + if (topEm != null) args.add('topEm: $topEm'); + if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); + if (marginLeftEm != null) args.add('marginLeftEm: $marginLeftEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -584,6 +810,10 @@ class KatexSpanStyles { KatexSpanStyles merge(KatexSpanStyles other) { return KatexSpanStyles( heightEm: other.heightEm ?? heightEm, + verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, + topEm: other.topEm ?? topEm, + marginRightEm: other.marginRightEm ?? marginRightEm, + marginLeftEm: other.marginLeftEm ?? marginLeftEm, fontFamily: other.fontFamily ?? fontFamily, fontSizeEm: other.fontSizeEm ?? fontSizeEm, fontStyle: other.fontStyle ?? fontStyle, @@ -591,6 +821,32 @@ class KatexSpanStyles { textAlign: other.textAlign ?? textAlign, ); } + + KatexSpanStyles filter({ + bool heightEm = true, + bool verticalAlignEm = true, + bool topEm = true, + bool marginRightEm = true, + bool marginLeftEm = true, + bool fontFamily = true, + bool fontSizeEm = true, + bool fontWeight = true, + bool fontStyle = true, + bool textAlign = true, + }) { + return KatexSpanStyles( + heightEm: heightEm ? this.heightEm : null, + verticalAlignEm: verticalAlignEm ? this.verticalAlignEm : null, + topEm: topEm ? this.topEm : null, + marginRightEm: marginRightEm ? this.marginRightEm : null, + marginLeftEm: marginLeftEm ? this.marginLeftEm : null, + fontFamily: fontFamily ? this.fontFamily : null, + fontSizeEm: fontSizeEm ? this.fontSizeEm : null, + fontWeight: fontWeight ? this.fontWeight : null, + fontStyle: fontStyle ? this.fontStyle : null, + textAlign: textAlign ? this.textAlign : null, + ); + } } class _KatexHtmlParseError extends Error { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index b49fdb4d9c..752fe9e071 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -22,6 +22,7 @@ import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; +import 'katex.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; @@ -820,42 +821,52 @@ class MathBlock extends StatelessWidget { children: [TextSpan(text: node.texSource)]))); } - return _Katex(inline: false, nodes: nodes); + return Center( + child: SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: Katex( + textStyle: ContentTheme.of(context).textStylePlainParagraph, + textAlign: TextAlign.center, + nodes: nodes))); } } -// Base text style from .katex class in katex.scss : -// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 -const kBaseKatexTextStyle = TextStyle( - fontSize: kBaseFontSize * 1.21, - fontFamily: 'KaTeX_Main', - height: 1.2); +/// Creates a base text style for rendering KaTeX content. +/// +/// This applies the CSS styles defined in .katex class in katex.scss : +/// https://github.com/KaTeX/KaTeX/blob/613c3da8/src/styles/katex.scss#L13-L15 +/// +/// Requires the [style.fontSize] to be non-null. +TextStyle mkBaseKatexTextStyle(TextStyle style) { + return style.copyWith( + fontSize: style.fontSize! * 1.21, + fontFamily: 'KaTeX_Main', + height: 1.2, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal); +} -class _Katex extends StatelessWidget { - const _Katex({ - required this.inline, +class Katex extends StatelessWidget { + const Katex({ + super.key, + required this.textStyle, + this.textAlign, required this.nodes, }); - final bool inline; + final TextStyle textStyle; + final TextAlign? textAlign; final List nodes; @override Widget build(BuildContext context) { Widget widget = _KatexNodeList(nodes: nodes); - if (!inline) { - widget = Center( - child: SingleChildScrollViewWithScrollbar( - scrollDirection: Axis.horizontal, - child: widget)); - } - return Directionality( textDirection: TextDirection.ltr, - child: DefaultTextStyle( - style: kBaseKatexTextStyle.copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), + child: DefaultTextStyle.merge( + style: mkBaseKatexTextStyle(textStyle), + textAlign: textAlign, child: widget)); } } @@ -872,9 +883,14 @@ class _KatexNodeList extends StatelessWidget { return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: switch (e) { - KatexSpanNode() => _KatexSpan(e), - }); + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.noScaling), + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexStrutNode() => _KatexStrut(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + })); })))); } } @@ -946,14 +962,103 @@ class _KatexSpan extends StatelessWidget { child: widget); } - return SizedBox( + final marginRight = switch (styles.marginRightEm) { + double marginRightEm when marginRightEm >= 0 => marginRightEm * em, + _ => null, + }; + final marginLeft = switch (styles.marginLeftEm) { + double marginLeftEm when marginLeftEm >= 0 => marginLeftEm * em, + _ => null, + }; + + EdgeInsets? margin; + if (marginRight != null || marginLeft != null) { + margin = EdgeInsets.zero; + if (marginRight != null) { + margin += EdgeInsets.only(right: marginRight); + } + if (marginLeft != null) { + margin += EdgeInsets.only(left: marginLeft); + } + } + + return Container( + margin: margin, height: styles.heightEm != null ? styles.heightEm! * (fontSize ?? em) : null, + transform: styles.topEm != null + ? Matrix4.translationValues(0, styles.topEm! * em, 0) + : null, child: widget); } } +class _KatexStrut extends StatelessWidget { + const _KatexStrut(this.node); + + final KatexStrutNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + final verticalAlignEm = node.verticalAlignEm; + if (verticalAlignEm == null) { + return SizedBox(height: node.heightEm * em); + } + + return SizedBox( + height: node.heightEm * em, + child: Baseline( + baseline: (verticalAlignEm + node.heightEm) * em, + baselineType: TextBaseline.alphabetic, + child: const Text('')), + ); + } +} + +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + textBaseline: TextBaseline.alphabetic, + children: List.unmodifiable(node.rows.map((row) { + return SizedBox( + height: 0, + child: OverflowBox( + maxHeight: double.infinity, + child: Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: _KatexSpan(row.node), + ))); + })))); + } +} + +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return NegativeLeftOffset( + leftOffset: node.leftOffsetEm * em, + child: _KatexNodeList(nodes: node.nodes)); + } +} + class WebsitePreview extends StatelessWidget { const WebsitePreview({super.key, required this.node}); @@ -1272,7 +1377,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(inline: true, nodes: nodes)); + child: Katex(textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/lib/widgets/katex.dart b/lib/widgets/katex.dart new file mode 100644 index 0000000000..9b89270c8b --- /dev/null +++ b/lib/widgets/katex.dart @@ -0,0 +1,199 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +class NegativeLeftOffset extends SingleChildRenderObjectWidget { + NegativeLeftOffset({super.key, required this.leftOffset, super.child}) + : assert(leftOffset.isNegative), + _padding = EdgeInsets.only(left: leftOffset); + + final double leftOffset; + final EdgeInsetsGeometry _padding; + + @override + RenderNegativePadding createRenderObject(BuildContext context) { + return RenderNegativePadding( + padding: _padding, + textDirection: Directionality.maybeOf(context)); + } + + @override + void updateRenderObject( + BuildContext context, + RenderNegativePadding renderObject, + ) { + renderObject + ..padding = _padding + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', _padding)); + } +} + +// Like [RenderPadding] but only supports negative values. +// TODO(upstream): give Padding an option to accept negative padding (at cost of hit-testing not working) +class RenderNegativePadding extends RenderShiftedBox { + RenderNegativePadding({ + required EdgeInsetsGeometry padding, + TextDirection? textDirection, + RenderBox? child, + }) : assert(!padding.isNonNegative), + _textDirection = textDirection, + _padding = padding, + super(child); + + EdgeInsets? _resolvedPaddingCache; + EdgeInsets get _resolvedPadding { + final EdgeInsets returnValue = _resolvedPaddingCache ??= padding.resolve(textDirection); + return returnValue; + } + + void _markNeedResolution() { + _resolvedPaddingCache = null; + markNeedsLayout(); + } + + /// The amount to pad the child in each dimension. + /// + /// If this is set to an [EdgeInsetsDirectional] object, then [textDirection] + /// must not be null. + EdgeInsetsGeometry get padding => _padding; + EdgeInsetsGeometry _padding; + set padding(EdgeInsetsGeometry value) { + assert(!value.isNonNegative); + if (_padding == value) { + return; + } + _padding = value; + _markNeedResolution(); + } + + /// The text direction with which to resolve [padding]. + /// + /// This may be changed to null, but only after the [padding] has been changed + /// to a value that does not depend on the direction. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + _markNeedResolution(); + } + + @override + double computeMinIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicWidth(math.max(0.0, height - padding.vertical)) + + padding.horizontal; + } + return padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMinIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final EdgeInsets padding = _resolvedPadding; + if (child != null) { + // Relies on double.infinity absorption. + return child!.getMaxIntrinsicHeight(math.max(0.0, width - padding.horizontal)) + + padding.vertical; + } + return padding.vertical; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + return constraints.constrain(Size(padding.horizontal, padding.vertical)); + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain( + Size(padding.horizontal + childSize.width, padding.vertical + childSize.height), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final EdgeInsets padding = _resolvedPadding; + final BoxConstraints innerConstraints = constraints.deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(innerConstraints, baseline)) + padding.top; + return result.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final EdgeInsets padding = _resolvedPadding; + if (child == null) { + size = constraints.constrain(Size(padding.horizontal, padding.vertical)); + return; + } + final BoxConstraints innerConstraints = constraints.deflate(padding); + child!.layout(innerConstraints, parentUsesSize: true); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset(padding.left, padding.top); + size = constraints.constrain( + Size(padding.horizontal + child!.size.width, padding.vertical + child!.size.height), + ); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + super.debugPaintSize(context, offset); + assert(() { + final Rect outerRect = offset & size; + debugPaintPadding( + context.canvas, + outerRect, + child != null ? _resolvedPaddingCache!.deflateRect(outerRect) : null, + ); + return true; + }()); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + } +} diff --git a/test/model/content_test.dart b/test/model/content_test.dart index c90ac54b33..308023f46f 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -519,7 +519,7 @@ class ContentExample { '

', MathInlineNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -539,7 +539,7 @@ class ContentExample { '

', [MathBlockNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -564,7 +564,7 @@ class ContentExample { '

', [ MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -575,7 +575,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'b', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -603,7 +603,7 @@ class ContentExample { [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -632,7 +632,7 @@ class ContentExample { [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -643,7 +643,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'b', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexStrutNode(heightEm: 0.6944, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -681,7 +681,7 @@ class ContentExample { ]), MathBlockNode(texSource: 'a', nodes: [ KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306),text: null, nodes: []), + KatexStrutNode(heightEm: 0.4306, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', @@ -731,10 +731,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 1.6034), - text: null, - nodes: []), + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', @@ -800,10 +797,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 1.6034), - text: null, - nodes: []), + KatexStrutNode(heightEm: 1.6034, verticalAlignEm: null), KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, @@ -845,10 +839,7 @@ class ContentExample { styles: KatexSpanStyles(), text: null, nodes: [ - KatexSpanNode( - styles: KatexSpanStyles(heightEm: 3.0), - text: null, - nodes: []), + KatexStrutNode(heightEm: 3, verticalAlignEm: -1.25), KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', @@ -893,6 +884,522 @@ class ContentExample { ]), ]); + static const mathBlockKatexVertical1 = ContentExample( + 'math block katex vertical 1', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176734 + '```math\na\'\n```', + '

' + '' + 'a' + 'a'' + '

', + [ + MathBlockNode( + texSource: 'a\'', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.8019, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'a', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: '′', + nodes: null), + ]), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical2 = ContentExample( + 'math block katex vertical 2', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176735 + '```math\nx_n\n```', + '

' + '' + 'xn' + 'x_n' + '

', + [ + MathBlockNode( + texSource: 'x_n', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.5806, verticalAlignEm: -0.15), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'x', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.55 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles( + marginLeftEm: 0, + marginRightEm: 0.05), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'n', + nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical3 = ContentExample( + 'math block katex vertical 3', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176737 + '```math\ne^x\n```', + '

' + '' + 'ex' + 'e^x' + '

', + [ + MathBlockNode( + texSource: 'e^x', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.7144, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'e', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'x', + nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical4 = ContentExample( + 'math block katex vertical 4', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176738 + '```math\n_u^o\n```', + '

' + '' + 'uo' + '_u^o' + '

', + [ + MathBlockNode( + texSource: "_u^o", + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.9614, verticalAlignEm: -0.247), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: []), + KatexSpanNode( + styles: KatexSpanStyles(textAlign: KatexSpanTextAlign.left), + text: null, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.453 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'u', + nodes: null), + ]), + ])), + KatexVlistRowNode( + verticalOffsetEm: -3.113 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: 0.05), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontSizeEm: 0.7), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'o', + nodes: null), + ]), + ])), + ]), + ]), + ]), + ]), + ]), + ]); + + static const mathBlockKatexVertical5 = ContentExample( + 'math block katex vertical 5', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2176739 + '```math\na\\raisebox{0.25em}{\$b\$}c\n```', + '

' + '' + 'abc' + 'a\\raisebox{0.25em}{\$b\$}c' + '

', + [ + MathBlockNode( + texSource: 'a\\raisebox{0.25em}{\$b\$}c', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.9444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'a', + nodes: null), + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -3.25 + 3, + node: KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'b', + nodes: null), + ]), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Math', + fontStyle: KatexSpanFontStyle.italic), + text: 'c', + nodes: null), + ]), + ]), + ]); + + static const mathBlockKatexNegativeMargins = ContentExample( + 'math block katex negative margins', + '```math\n\\KaTeX\n```', + '

' + '' + 'KaTeX' + '\\KaTeX' + '

', + [ + MathBlockNode( + texSource: '\\KaTeX', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.8988, verticalAlignEm: -0.2155), + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(fontFamily: 'KaTeX_Main'), + text: 'K', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.17), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.17, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.905 + 2.7, + node: KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main', + fontSizeEm: 0.7), + text: 'A', + nodes: null), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.15), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.15, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main'), + text: 'T', + nodes: null), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.1667), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.1667, + nodes: [ + KatexVlistNode( + rows: [ + KatexVlistRowNode( + verticalOffsetEm: -2.7845 + 3, + node: KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main'), + text: 'E', + nodes: null), + ])), + ]), + KatexSpanNode( + styles: KatexSpanStyles(marginRightEm: -0.125), + text: null, + nodes: []), + KatexNegativeMarginNode( + leftOffsetEm: -0.125, + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles( + fontFamily: 'KaTeX_Main'), + text: 'X', + nodes: null), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -1963,10 +2470,13 @@ void main() async { testParseExample(ContentExample.mathBlockBetweenImages); testParseExample(ContentExample.mathBlockKatexSizing); testParseExample(ContentExample.mathBlockKatexNestedSizing); - // TODO: Re-enable this test after adding support for parsing - // `vertical-align` in inline styles. Currently it fails - // because `strut` span has `vertical-align`. - testParseExample(ContentExample.mathBlockKatexDelimSizing, skip: true); + testParseExample(ContentExample.mathBlockKatexDelimSizing); + testParseExample(ContentExample.mathBlockKatexVertical1); + testParseExample(ContentExample.mathBlockKatexVertical2); + testParseExample(ContentExample.mathBlockKatexVertical3); + testParseExample(ContentExample.mathBlockKatexVertical4); + testParseExample(ContentExample.mathBlockKatexVertical5); + testParseExample(ContentExample.mathBlockKatexNegativeMargins); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 754410fddc..f963e478c9 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -595,16 +596,20 @@ void main() { final content = ContentExample.mathBlockKatexSizing; await prepareContent(tester, plainContent(content.html)); + final context = tester.element(find.byType(MathBlock)); + final baseTextStyle = + mkBaseKatexTextStyle(ContentTheme.of(context).textStylePlainParagraph); + final mathBlockNode = content.expectedNodes.single as MathBlockNode; final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; final nodes = baseNode.nodes!.skip(1); // Skip .strut node. for (var katexNode in nodes) { katexNode = katexNode as KatexSpanNode; - final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; + final fontSize = katexNode.styles.fontSizeEm! * baseTextStyle.fontSize!; checkKatexText(tester, katexNode.text!, fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); } }); @@ -617,17 +622,21 @@ void main() { final content = ContentExample.mathBlockKatexNestedSizing; await prepareContent(tester, plainContent(content.html)); - var fontSize = 0.5 * kBaseKatexTextStyle.fontSize!; + final context = tester.element(find.byType(MathBlock)); + final baseTextStyle = + mkBaseKatexTextStyle(ContentTheme.of(context).textStylePlainParagraph); + + var fontSize = 0.5 * baseTextStyle.fontSize!; checkKatexText(tester, '1', fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); fontSize = 4.976 * fontSize; checkKatexText(tester, '2', fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); }); testWidgets('displays KaTeX content with different delimiter sizing', (tester) async { @@ -643,13 +652,15 @@ void main() { final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; var nodes = baseNode.nodes!.skip(1); // Skip .strut node. - final fontSize = kBaseKatexTextStyle.fontSize!; + final context = tester.element(find.byType(MathBlock)); + final baseTextStyle = + mkBaseKatexTextStyle(ContentTheme.of(context).textStylePlainParagraph); final firstNode = nodes.first as KatexSpanNode; checkKatexText(tester, firstNode.text!, fontFamily: 'KaTeX_Main', - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontSize: baseTextStyle.fontSize!, + fontHeight: baseTextStyle.height!); nodes = nodes.skip(1); for (var katexNode in nodes) { @@ -658,12 +669,70 @@ void main() { final fontFamily = katexNode.styles.fontFamily!; checkKatexText(tester, katexNode.text!, fontFamily: fontFamily, - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontSize: baseTextStyle.fontSize!, + fontHeight: baseTextStyle.height!); + } + }); + + group('characters render at specific offsets with specific size: ', () { + final testCases = [ + (ContentExample.mathBlockKatexVertical1, [ + ('a', Offset(0.0, 4.36), Size(10.88, 25.0)), + ('′', Offset(10.87, 0.26), Size(3.96, 17.0)) + ]), + (ContentExample.mathBlockKatexVertical2, [ + ('x', Offset(0.0, 4.36), Size(11.76, 25.0)), + ('n', Offset(11.76, 12.79), Size(8.63, 17.0)) + ]), + (ContentExample.mathBlockKatexVertical3, [ + ('e', Offset(0.0, 4.36), Size(9.58, 25.0)), + ('x', Offset(9.58, 1.20), Size(8.23, 17.0)) + ]), + (ContentExample.mathBlockKatexVertical4, [ + ('u', Offset(0.0, 14.82), Size(8.23, 17.0)), + ('o', Offset(0.0, 1.24), Size(6.98, 17.0)) + ]), + (ContentExample.mathBlockKatexVertical5, [ + ('a', Offset(0.0, 4.24), Size(10.88, 25.0)), + ('b', Offset(10.87, -0.57), Size(8.82, 25.0)), + ('c', Offset(19.69, 4.24), Size(8.9, 25.0)) + ]), + (ContentExample.mathBlockKatexNegativeMargins, [ + ('K', Offset(0.0, 7.75), Size(16.0, 25.0)), + ('A', Offset(12.50, 9.97), Size(10.79, 17.0)), + ('T', Offset(20.20, 8.48), Size(14.85, 25.0)), + ('E', Offset(31.62, 13.64), Size(14.0, 25.0)), + ('X', Offset(43.05, 8.96), Size(15.42, 25.0)), + ]), + ]; + + for (final testCase in testCases) { + testWidgets(testCase.$1.description, (tester) async { + await _loadKatexFonts(); + + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + await prepareContent(tester, plainContent(testCase.$1.html)); + + final baseRect = tester.getRect(find.byType(Katex)); + + for (final characterData in testCase.$2) { + final character = characterData.$1; + final topLeftOffset = characterData.$2; + final size = characterData.$3; + + final rect = tester.getRect(find.text(character)); + check(rect.topLeft - baseRect.topLeft) + .within(distance: 0.05, from: topLeftOffset); + check(rect.size) + .within(distance: 0.05, from: size); + } + }); } - }, skip: true); // TODO: Re-enable this test after adding support for parsing - // `vertical-align` in inline styles. Currently it fails - // because `strut` span has `vertical-align`. + }); }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio], @@ -1412,3 +1481,48 @@ void main() { }); }); } + +Future _loadKatexFonts() async { + const fonts = { + 'KaTeX_AMS': ['KaTeX_AMS-Regular.ttf'], + 'KaTeX_Caligraphic': [ + 'KaTeX_Caligraphic-Regular.ttf', + 'KaTeX_Caligraphic-Bold.ttf', + ], + 'KaTeX_Fraktur': [ + 'KaTeX_Fraktur-Regular.ttf', + 'KaTeX_Fraktur-Bold.ttf', + ], + 'KaTeX_Main': [ + 'KaTeX_Main-Regular.ttf', + 'KaTeX_Main-Bold.ttf', + 'KaTeX_Main-Italic.ttf', + 'KaTeX_Main-BoldItalic.ttf', + ], + 'KaTeX_Math': [ + 'KaTeX_Math-Italic.ttf', + 'KaTeX_Math-BoldItalic.ttf', + ], + 'KaTeX_SansSerif': [ + 'KaTeX_SansSerif-Regular.ttf', + 'KaTeX_SansSerif-Bold.ttf', + 'KaTeX_SansSerif-Italic.ttf', + ], + 'KaTeX_Script': ['KaTeX_Script-Regular.ttf'], + 'KaTeX_Size1': ['KaTeX_Size1-Regular.ttf'], + 'KaTeX_Size2': ['KaTeX_Size2-Regular.ttf'], + 'KaTeX_Size3': ['KaTeX_Size3-Regular.ttf'], + 'KaTeX_Size4': ['KaTeX_Size4-Regular.ttf'], + 'KaTeX_Typewriter': ['KaTeX_Typewriter-Regular.ttf'], + }; + for (final entry in fonts.entries) { + final fontFamily = entry.key; + final fontFiles = entry.value; + + final fontLoader = FontLoader(fontFamily); + for (final fontFile in fontFiles) { + fontLoader.addFont(rootBundle.load('assets/KaTeX/$fontFile')); + } + await fontLoader.load(); + } +}