diff --git a/lib/model/content.dart b/lib/model/content.dart index 72c8240133..fbd56b006e 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -341,8 +341,8 @@ class CodeBlockSpanNode extends ContentNode { } } -class MathBlockNode extends BlockContentNode { - const MathBlockNode({ +abstract class MathNode extends ContentNode { + const MathNode({ super.debugHtmlNode, required this.texSource, required this.nodes, @@ -369,8 +369,12 @@ class MathBlockNode extends BlockContentNode { } } -class KatexNode extends ContentNode { - const KatexNode({ +sealed class KatexNode extends ContentNode { + const KatexNode({super.debugHtmlNode}); +} + +class KatexSpanNode extends KatexNode { + const KatexSpanNode({ required this.styles, required this.text, required this.nodes, @@ -402,6 +406,71 @@ class KatexNode extends ContentNode { } } +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, + this.nodes = const [], + }); + + final double verticalOffsetEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('verticalOffsetEm', '$verticalOffsetEm')); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + +class KatexNegativeMarginNode extends KatexNode { + const KatexNegativeMarginNode({ + required this.marginRightEm, + required this.nodes, + super.debugHtmlNode, + }); + + final double marginRightEm; + final List nodes; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('marginRightEm', '$marginRightEm')); + } + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + +class MathBlockNode extends MathNode implements BlockContentNode { + const MathBlockNode({ + super.debugHtmlNode, + required super.texSource, + required super.nodes, + }); +} + class ImageNodeList extends BlockContentNode { const ImageNodeList(this.images, {super.debugHtmlNode}); @@ -863,26 +932,12 @@ class ImageEmojiNode extends EmojiNode { } } -class MathInlineNode extends InlineContentNode { +class MathInlineNode extends MathNode implements InlineContentNode { const MathInlineNode({ super.debugHtmlNode, - required this.texSource, - required this.nodes, + required super.texSource, + required super.nodes, }); - - final String texSource; - final List? nodes; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(StringProperty('texSource', texSource)); - } - - @override - List debugDescribeChildren() { - return nodes?.map((node) => node.toDiagnosticsNode()).toList() ?? const []; - } } class GlobalTimeNode extends InlineContentNode { diff --git a/lib/model/katex.dart b/lib/model/katex.dart index fc71025b59..4c71ba16b5 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,3 +1,5 @@ +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; import 'package:html/dom.dart' as dom; @@ -121,13 +123,29 @@ class _KatexParser { } List _parseChildSpans(dom.Element element) { - return List.unmodifiable(element.nodes.map((node) { - if (node case dom.Element(localName: 'span')) { - return _parseSpan(node); - } else { + var resultSpans = []; + for (final node in element.nodes.reversed) { + if (node is! dom.Element || node.localName != 'span') { throw KatexHtmlParseError(); } - })); + + final span = _parseSpan(node); + resultSpans.add(span); + + if (span is KatexSpanNode) { + final marginRightEm = span.styles.marginRightEm; + if (marginRightEm != null && marginRightEm.isNegative) { + final previousSpansReversed = + resultSpans.reversed.toList(growable: false); + resultSpans = []; + resultSpans.add(KatexNegativeMarginNode( + marginRightEm: marginRightEm, + nodes: previousSpansReversed)); + } + } + } + + return resultSpans.reversed.toList(growable: false); } static final _resetSizeClassRegExp = RegExp(r'^reset-size(\d\d?)$'); @@ -138,150 +156,230 @@ class _KatexParser { final spanClasses = List.unmodifiable(element.className.split(' ')); + if (element case dom.Element(localName: 'span', :final className) + when className.startsWith('vlist')) { + switch (element) { + case dom.Element( + localName: 'span', + className: 'vlist-t', + attributes: final attributesVlistT, + nodes: [ + dom.Element( + localName: 'span', + className: 'vlist-r', + attributes: final attributesVlistR, + nodes: [ + dom.Element( + localName: 'span', + className: 'vlist', + nodes: [ + dom.Element( + localName: 'span', + className: '', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') + && final pstrutSpan, + ..., + ]) && final innerSpan, + ]), + ]), + ]) + when !attributesVlistT.containsKey('style') && + !attributesVlistR.containsKey('style'): + // TODO vlist element should only have `height` style, which we ignore. + + var styles = _parseSpanInlineStyles(innerSpan)!; + final topEm = styles.topEm ?? 0; + + final pstrutStyles = _parseSpanInlineStyles(pstrutSpan)!; + final pstrutHeight = pstrutStyles.heightEm ?? 0; + + // TODO handle negative right-margin inline style on row nodes. + return KatexVlistNode(rows: [ + KatexVlistRowNode( + verticalOffsetEm: topEm + pstrutHeight, + nodes: _parseChildSpans(innerSpan)), + ]); + + case dom.Element( + localName: 'span', + className: 'vlist-t vlist-t2', + attributes: final attributesVlistT, + nodes: [ + dom.Element( + localName: 'span', + className: 'vlist-r', + attributes: final attributesVlistR, + nodes: [ + dom.Element( + localName: 'span', + className: 'vlist', + nodes: [...]) && final vlist1, + dom.Element(localName: 'span', className: 'vlist-s'), + ]), + dom.Element(localName: 'span', className: 'vlist-r', nodes: [ + dom.Element(localName: 'span', className: 'vlist', nodes: [ + dom.Element(localName: 'span', className: '', nodes: []), + ]) + ]), + ]) + when !attributesVlistT.containsKey('style') && + !attributesVlistR.containsKey('style'): + // TODO Ensure both should only have a `height` style. + + final rows = []; + + for (final innerSpan in vlist1.nodes) { + if (innerSpan case dom.Element( + localName: 'span', + className: '', + nodes: [ + dom.Element(localName: 'span', className: 'pstrut') && + final pstrutSpan, + ..., + ])) { + final styles = _parseSpanInlineStyles(innerSpan)!; + final topEm = styles.topEm ?? 0; + + final pstrutStyles = _parseSpanInlineStyles(pstrutSpan)!; + final pstrutHeight = pstrutStyles.heightEm ?? 0; + + // TODO handle negative right-margin inline style on row nodes. + rows.add(KatexVlistRowNode( + verticalOffsetEm: topEm + pstrutHeight, + nodes: _parseChildSpans(innerSpan))); + } + } + + return KatexVlistNode(rows: rows); + + default: + 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. // - // Each case in the switch blocks below is a separate CSS class definition + // Each case in the switch block below is a separate CSS class definition // in the same order as in katex.scss : // 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. - var styles = KatexSpanStyles(); + String? fontFamily; + double? fontSizeEm; + KatexSpanFontWeight? fontWeight; + KatexSpanFontStyle? fontStyle; + KatexSpanTextAlign? textAlign; var index = 0; while (index < spanClasses.length) { - var classFound = false; - - final spanClass = spanClasses[index]; + final spanClass = spanClasses[index++]; switch (spanClass) { case 'base': // .base { ... } // Do nothing, it has properties that don't need special handling. - classFound = true; + break; case 'strut': // .strut { ... } // Do nothing, it has properties that don't need special handling. - classFound = true; + break; case 'textbf': // .textbf { font-weight: bold; } - styles.fontWeight = KatexSpanFontWeight.bold; - classFound = true; + fontWeight = KatexSpanFontWeight.bold; case 'textit': // .textit { font-style: italic; } - styles.fontStyle = KatexSpanFontStyle.italic; - classFound = true; + fontStyle = KatexSpanFontStyle.italic; case 'textrm': // .textrm { font-family: KaTeX_Main; } - styles.fontFamily = 'KaTeX_Main'; - classFound = true; + fontFamily = 'KaTeX_Main'; - case 'textsf': - // .textsf { font-family: KaTeX_SansSerif; } - styles.fontFamily = 'KaTeX_SansSerif'; - classFound = true; + // case 'textsf': + // // .textsf { font-family: KaTeX_SansSerif; } + // This CSS rule has no effect, because the other `.textsf` rule below + // has the exact same list of declarations. Handle it there instead. case 'texttt': // .texttt { font-family: KaTeX_Typewriter; } - styles.fontFamily = 'KaTeX_Typewriter'; - classFound = true; + fontFamily = 'KaTeX_Typewriter'; case 'mathnormal': // .mathnormal { font-family: KaTeX_Math; font-style: italic; } - styles.fontFamily = 'KaTeX_Math'; - styles.fontStyle = KatexSpanFontStyle.italic; - classFound = true; + fontFamily = 'KaTeX_Math'; + fontStyle = KatexSpanFontStyle.italic; case 'mathit': // .mathit { font-family: KaTeX_Main; font-style: italic; } - styles.fontFamily = 'KaTeX_Main'; - styles.fontStyle = KatexSpanFontStyle.italic; - classFound = true; + fontFamily = 'KaTeX_Main'; + fontStyle = KatexSpanFontStyle.italic; case 'mathrm': // .mathrm { font-style: normal; } - styles.fontStyle = KatexSpanFontStyle.normal; - classFound = true; + fontStyle = KatexSpanFontStyle.normal; case 'mathbf': // .mathbf { font-family: KaTeX_Main; font-weight: bold; } - styles.fontFamily = 'KaTeX_Main'; - styles.fontWeight = KatexSpanFontWeight.bold; - classFound = true; + fontFamily = 'KaTeX_Main'; + fontWeight = KatexSpanFontWeight.bold; case 'boldsymbol': // .boldsymbol { font-family: KaTeX_Math; font-weight: bold; font-style: italic; } - styles.fontFamily = 'KaTeX_Math'; - styles.fontWeight = KatexSpanFontWeight.bold; - styles.fontStyle = KatexSpanFontStyle.italic; - classFound = true; + fontFamily = 'KaTeX_Math'; + fontWeight = KatexSpanFontWeight.bold; + fontStyle = KatexSpanFontStyle.italic; case 'amsrm': // .amsrm { font-family: KaTeX_AMS; } - styles.fontFamily = 'KaTeX_AMS'; - classFound = true; + fontFamily = 'KaTeX_AMS'; case 'mathbb': case 'textbb': // .mathbb, // .textbb { font-family: KaTeX_AMS; } - styles.fontFamily = 'KaTeX_AMS'; - classFound = true; + fontFamily = 'KaTeX_AMS'; case 'mathcal': // .mathcal { font-family: KaTeX_Caligraphic; } - styles.fontFamily = 'KaTeX_Caligraphic'; - classFound = true; + fontFamily = 'KaTeX_Caligraphic'; case 'mathfrak': case 'textfrak': // .mathfrak, // .textfrak { font-family: KaTeX_Fraktur; } - styles.fontFamily = 'KaTeX_Fraktur'; - classFound = true; + fontFamily = 'KaTeX_Fraktur'; case 'mathboldfrak': case 'textboldfrak': // .mathboldfrak, // .textboldfrak { font-family: KaTeX_Fraktur; font-weight: bold; } - styles.fontFamily = 'KaTeX_Fraktur'; - styles.fontWeight = KatexSpanFontWeight.bold; - classFound = true; + fontFamily = 'KaTeX_Fraktur'; + fontWeight = KatexSpanFontWeight.bold; case 'mathtt': // .mathtt { font-family: KaTeX_Typewriter; } - styles.fontFamily = 'KaTeX_Typewriter'; - classFound = true; + fontFamily = 'KaTeX_Typewriter'; case 'mathscr': case 'textscr': // .mathscr, // .textscr { font-family: KaTeX_Script; } - styles.fontFamily = 'KaTeX_Script'; - classFound = true; - } + fontFamily = 'KaTeX_Script'; - // We can't add the case for the next class (.mathsf, .textsf) in the - // above switch block, because there is already a case for .textsf above. - // So start a new block, to keep the order of the cases here same as the - // CSS class definitions in katex.scss . - switch (spanClass) { case 'mathsf': case 'textsf': // .mathsf, // .textsf { font-family: KaTeX_SansSerif; } - styles.fontFamily = 'KaTeX_SansSerif'; - classFound = true; + fontFamily = 'KaTeX_SansSerif'; case 'mathboldsf': case 'textboldsf': // .mathboldsf, // .textboldsf { font-family: KaTeX_SansSerif; font-weight: bold; } - styles.fontFamily = 'KaTeX_SansSerif'; - styles.fontWeight = KatexSpanFontWeight.bold; - classFound = true; + fontFamily = 'KaTeX_SansSerif'; + fontWeight = KatexSpanFontWeight.bold; case 'mathsfit': case 'mathitsf': @@ -289,15 +387,13 @@ class _KatexParser { // .mathsfit, // .mathitsf, // .textitsf { font-family: KaTeX_SansSerif; font-style: italic; } - styles.fontFamily = 'KaTeX_SansSerif'; - styles.fontStyle = KatexSpanFontStyle.italic; - classFound = true; + fontFamily = 'KaTeX_SansSerif'; + fontStyle = KatexSpanFontStyle.italic; case 'mainrm': // .mainrm { font-family: KaTeX_Main; font-style: normal; } - styles.fontFamily = 'KaTeX_Main'; - styles.fontStyle = KatexSpanFontStyle.normal; - classFound = true; + fontFamily = 'KaTeX_Main'; + fontStyle = KatexSpanFontStyle.normal; // TODO handle skipped class declarations between .mainrm and // .sizing . @@ -306,92 +402,69 @@ class _KatexParser { case 'fontsize-ensurer': // .sizing, // .fontsize-ensurer { ... } - if (index + 2 < spanClasses.length) { - final resetSizeClass = spanClasses[index + 1]; - final sizeClass = spanClasses[index + 2]; + if (index + 2 > spanClasses.length) throw KatexHtmlParseError(); + final resetSizeClass = spanClasses[index++]; + final sizeClass = spanClasses[index++]; - final resetSizeClassSuffix = _resetSizeClassRegExp.firstMatch(resetSizeClass)?.group(1); - final sizeClassSuffix = _sizeClassRegExp.firstMatch(sizeClass)?.group(1); + final resetSizeClassSuffix = _resetSizeClassRegExp.firstMatch(resetSizeClass)?.group(1); + if (resetSizeClassSuffix == null) throw KatexHtmlParseError(); + final sizeClassSuffix = _sizeClassRegExp.firstMatch(sizeClass)?.group(1); + if (sizeClassSuffix == null) throw KatexHtmlParseError(); - if (resetSizeClassSuffix != null && sizeClassSuffix != null) { - const sizes = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488]; + const sizes = [0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.2, 1.44, 1.728, 2.074, 2.488]; - final resetSizeIdx = int.parse(resetSizeClassSuffix, radix: 10); - final sizeIdx = int.parse(sizeClassSuffix, radix: 10); - - // These indexes start at 1. - if (resetSizeIdx <= sizes.length && sizeIdx <= sizes.length) { - styles.fontSizeEm = sizes[sizeIdx - 1] / sizes[resetSizeIdx - 1]; - index += 3; - continue; - } - } - } + final resetSizeIdx = int.parse(resetSizeClassSuffix, radix: 10); + final sizeIdx = int.parse(sizeClassSuffix, radix: 10); - throw KatexHtmlParseError(); + // These indexes start at 1. + if (resetSizeIdx > sizes.length) throw KatexHtmlParseError(); + if (sizeIdx > sizes.length) throw KatexHtmlParseError(); + fontSizeEm = sizes[sizeIdx - 1] / sizes[resetSizeIdx - 1]; case 'delimsizing': // .delimsizing { ... } - if (index + 1 < spanClasses.length) { - final nextClass = spanClasses[index + 1]; - switch (nextClass) { - case 'size1': - styles.fontFamily = 'KaTeX_Size1'; - case 'size2': - styles.fontFamily = 'KaTeX_Size2'; - case 'size3': - styles.fontFamily = 'KaTeX_Size3'; - case 'size4': - styles.fontFamily = 'KaTeX_Size4'; - - case 'mult': - // TODO handle nested spans with `.delim-size{1,4}` class. - break; - } - - if (styles.fontFamily == null) throw KatexHtmlParseError(); - - index += 2; - continue; - } - - throw KatexHtmlParseError(); + if (index + 1 > spanClasses.length) throw KatexHtmlParseError(); + fontFamily = switch (spanClasses[index++]) { + 'size1' => 'KaTeX_Size1', + 'size2' => 'KaTeX_Size2', + 'size3' => 'KaTeX_Size3', + 'size4' => 'KaTeX_Size4', + 'mult' => + // TODO handle nested spans with `.delim-size{1,4}` class. + throw KatexHtmlParseError(), + _ => throw KatexHtmlParseError(), + }; // TODO handle .nulldelimiter and .delimcenter . case 'op-symbol': // .op-symbol { ... } - if (index + 1 < spanClasses.length) { - final nextClass = spanClasses[index + 1]; - switch (nextClass) { - case 'small-op': - styles.fontFamily = 'KaTeX_Size1'; - case 'large-op': - styles.fontFamily = 'KaTeX_Size2'; - } - if (styles.fontFamily == null) throw KatexHtmlParseError(); - - index += 2; - continue; - } - - throw KatexHtmlParseError(); + if (index + 1 > spanClasses.length) throw KatexHtmlParseError(); + fontFamily = switch (spanClasses[index++]) { + 'small-op' => 'KaTeX_Size1', + 'large-op' => 'KaTeX_Size2', + _ => throw KatexHtmlParseError(), + }; // TODO handle more classes from katex.scss - } - // Ignore these classes because they don't have a CSS definition - // in katex.scss, but we encounter them in the generated HTML. - switch (spanClass) { case 'mord': case 'mopen': - classFound = true; - } - - if (!classFound) _logError('KaTeX: Unsupported CSS class: $spanClass'); + // Ignore these classes because they don't have a CSS definition + // in katex.scss, but we encounter them in the generated HTML. + break; - index++; + default: + _logError('KaTeX: Unsupported CSS class: $spanClass'); + } } + final styles = KatexSpanStyles( + fontFamily: fontFamily, + fontSizeEm: fontSizeEm, + fontWeight: fontWeight, + fontStyle: fontStyle, + textAlign: textAlign, + ); String? text; List? spans; @@ -402,11 +475,79 @@ class _KatexParser { } if (text == null && spans == null) throw KatexHtmlParseError(); - return KatexNode( - styles: styles, + final inlineStyles = _parseSpanInlineStyles(element); + + return KatexSpanNode( + styles: inlineStyles != null + ? styles.merge(inlineStyles) + : styles, text: text, nodes: spans); } + + KatexSpanStyles? _parseSpanInlineStyles(dom.Element element) { + if (element.attributes case {'style': final styleStr}) { + // `package:csslib` doesn't seem to have a way to parse inline styles: + // https://github.com/dart-lang/tools/issues/1173 + // So, workaround that by wrapping it in a universal declaration. + final stylesheet = css_parser.parse('*{$styleStr}'); + if (stylesheet.topLevels case [css_visitor.RuleSet() && final rule]) { + double? heightEm; + double? marginRightEm; + double? topEm; + double? verticalAlignEm; + + for (final declaration in rule.declarationGroup.declarations) { + if (declaration case css_visitor.Declaration( + :final property, + expression: css_visitor.Expressions( + expressions: [css_visitor.Expression() && final expression]), + )) { + switch (property) { + case 'height': + heightEm = _getEm(expression); + if (heightEm != null) continue; + + case 'margin-right': + marginRightEm = _getEm(expression); + if (marginRightEm != null) continue; + + case 'top': + topEm = _getEm(expression); + if (topEm != null) continue; + + case 'vertical-align': + verticalAlignEm = _getEm(expression); + if (verticalAlignEm != null) continue; + } + + // TODO handle more CSS properties + _logError('KaTeX: Unsupported CSS property: $property of ' + 'type ${expression.runtimeType}'); + } else { + throw KatexHtmlParseError(); + } + } + + return KatexSpanStyles( + heightEm: heightEm, + marginRightEm: marginRightEm, + topEm: topEm, + verticalAlignEm: verticalAlignEm, + ); + } else { + throw KatexHtmlParseError(); + } + } + return null; + } + + double? _getEm(css_visitor.Expression expression) { + if (expression is css_visitor.EmTerm && expression.value is num) { + return (expression.value as num).toDouble(); + } + return null; + } } enum KatexSpanFontWeight { @@ -424,14 +565,24 @@ enum KatexSpanTextAlign { right, } +@immutable class KatexSpanStyles { - String? fontFamily; - double? fontSizeEm; - KatexSpanFontWeight? fontWeight; - KatexSpanFontStyle? fontStyle; - KatexSpanTextAlign? textAlign; - - KatexSpanStyles({ + final double? heightEm; + final double? marginRightEm; + final double? topEm; + final double? verticalAlignEm; + + final String? fontFamily; + final double? fontSizeEm; + final KatexSpanFontWeight? fontWeight; + final KatexSpanFontStyle? fontStyle; + final KatexSpanTextAlign? textAlign; + + const KatexSpanStyles({ + this.heightEm, + this.marginRightEm, + this.topEm, + this.verticalAlignEm, this.fontFamily, this.fontSizeEm, this.fontWeight, @@ -442,6 +593,10 @@ class KatexSpanStyles { @override int get hashCode => Object.hash( 'KatexSpanStyles', + heightEm, + marginRightEm, + topEm, + verticalAlignEm, fontFamily, fontSizeEm, fontWeight, @@ -452,6 +607,10 @@ class KatexSpanStyles { @override bool operator ==(Object other) { return other is KatexSpanStyles && + other.heightEm == heightEm && + other.marginRightEm == marginRightEm && + other.topEm == topEm && + other.verticalAlignEm == verticalAlignEm && other.fontFamily == fontFamily && other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && @@ -462,6 +621,10 @@ class KatexSpanStyles { @override String toString() { final args = []; + if (heightEm != null) args.add('heightEm: $heightEm'); + if (marginRightEm != null) args.add('marginRightEm: $marginRightEm'); + if (topEm != null) args.add('topEm: $topEm'); + if (verticalAlignEm != null) args.add('verticalAlignEm: $verticalAlignEm'); if (fontFamily != null) args.add('fontFamily: $fontFamily'); if (fontSizeEm != null) args.add('fontSizeEm: $fontSizeEm'); if (fontWeight != null) args.add('fontWeight: $fontWeight'); @@ -469,6 +632,20 @@ class KatexSpanStyles { if (textAlign != null) args.add('textAlign: $textAlign'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } + + KatexSpanStyles merge(KatexSpanStyles other) { + return KatexSpanStyles( + heightEm: other.heightEm ?? heightEm, + marginRightEm: other.marginRightEm ?? marginRightEm, + topEm: other.topEm ?? topEm, + verticalAlignEm: other.verticalAlignEm ?? verticalAlignEm, + fontFamily: other.fontFamily ?? fontFamily, + fontSizeEm: other.fontSizeEm ?? fontSizeEm, + fontStyle: other.fontStyle ?? fontStyle, + fontWeight: other.fontWeight ?? fontWeight, + textAlign: other.textAlign ?? textAlign, + ); + } } class KatexHtmlParseError extends Error { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 4bf6f8adae..b7035dac7f 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -816,35 +816,39 @@ class MathBlock extends StatelessWidget { children: [TextSpan(text: node.texSource)]))); } - return _Katex(inline: false, nodes: nodes); + return _Katex( + inline: false, + textStyle: ContentTheme.of(context).textStylePlainParagraph, + 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); +TextStyle mkBaseKatexTextStyle(TextStyle style) { + assert(style.fontSize != null); + 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, + required this.textStyle, required this.nodes, }); final bool inline; + final TextStyle textStyle; final List nodes; @override Widget build(BuildContext context) { - Widget widget = Text.rich(TextSpan( - children: List.unmodifiable(nodes.map((e) { - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: _KatexSpan(e)); - })))); + Widget widget = _KatexNodeList(nodes: nodes); if (!inline) { widget = Center( @@ -856,35 +860,49 @@ class _Katex extends StatelessWidget { return Directionality( textDirection: TextDirection.ltr, child: DefaultTextStyle( - style: kBaseKatexTextStyle.copyWith( - color: ContentTheme.of(context).textStylePlainParagraph.color), + style: mkBaseKatexTextStyle(textStyle), child: widget)); } } +class _KatexNodeList extends StatelessWidget { + const _KatexNodeList({required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + return Text.rich(TextSpan( + children: List.unmodifiable(nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + }); + })))); + } +} + class _KatexSpan extends StatelessWidget { - const _KatexSpan(this.span); + const _KatexSpan(this.node); - final KatexNode span; + final KatexSpanNode node; @override Widget build(BuildContext context) { final em = DefaultTextStyle.of(context).style.fontSize!; Widget widget = const SizedBox.shrink(); - if (span.text != null) { - widget = Text(span.text!); - } else if (span.nodes != null && span.nodes!.isNotEmpty) { - widget = Text.rich(TextSpan( - children: List.unmodifiable(span.nodes!.map((e) { - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: _KatexSpan(e)); - })))); + if (node.text != null) { + widget = Text(node.text!); + } else if (node.nodes != null && node.nodes!.isNotEmpty) { + widget = _KatexNodeList(nodes: node.nodes!); } - final styles = span.styles; + final styles = node.styles; final fontFamily = styles.fontFamily; final fontSize = switch (styles.fontSizeEm) { @@ -934,7 +952,75 @@ class _KatexSpan extends StatelessWidget { textAlign: textAlign, child: widget); } - return widget; + + if (styles.verticalAlignEm != null) { + widget = Baseline( + baseline: styles.verticalAlignEm! * em, + baselineType: TextBaseline.alphabetic, + child: widget); + } + + return Container( + margin: styles.marginRightEm != null && !styles.marginRightEm!.isNegative + ? EdgeInsets.only(right: styles.marginRightEm! * em) + : null, + height: styles.heightEm != null + ? styles.heightEm! * em + : null, + child: widget, + ); + } +} + +class _KatexVlist extends StatelessWidget { + const _KatexVlist(this.node); + + final KatexVlistNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Stack(children: List.unmodifiable(node.rows.map((row) { + return Transform.translate( + offset: Offset(0, row.verticalOffsetEm * em), + child: RichText(text: TextSpan( + children: List.unmodifiable(row.nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + }); + }))))); + }))); + } +} + +class _KatexNegativeMargin extends StatelessWidget { + const _KatexNegativeMargin(this.node); + + final KatexNegativeMarginNode node; + + @override + Widget build(BuildContext context) { + final em = DefaultTextStyle.of(context).style.fontSize!; + + return Transform.translate( + offset: Offset(node.marginRightEm * em, 0), + child: Text.rich(TextSpan( + children: List.unmodifiable(node.nodes.map((e) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: switch (e) { + KatexSpanNode() => _KatexSpan(e), + KatexVlistNode() => _KatexVlist(e), + KatexNegativeMarginNode() => _KatexNegativeMargin(e), + }); + }))))); } } @@ -1256,7 +1342,7 @@ class _InlineContentBuilder { : WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, - child: _Katex(inline: true, nodes: nodes)); + child: _Katex(inline: true, textStyle: widget.style, nodes: nodes)); case GlobalTimeNode(): return WidgetSpan(alignment: PlaceholderAlignment.middle, diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 5ab60c8e7e..315baa5e4c 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -518,9 +518,9 @@ class ContentExample { ' \\lambda ' '

', MathInlineNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -529,7 +529,7 @@ class ContentExample { ]), ])); - static final mathBlock = ContentExample( + static const mathBlock = ContentExample( 'math block', "```math\n\\lambda\n```", expectedText: r'\lambda', @@ -538,9 +538,9 @@ class ContentExample { '\\lambda' '

', [MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -549,7 +549,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInParagraph = ContentExample( + static const mathBlocksMultipleInParagraph = ContentExample( 'math blocks, multiple in paragraph', '```math\na\n\nb\n```', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2001490 @@ -563,9 +563,9 @@ class ContentExample { 'b' '

', [ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -574,9 +574,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -586,7 +586,7 @@ class ContentExample { ]), ]); - static final mathBlockInQuote = ContentExample( + static const mathBlockInQuote = ContentExample( 'math block in quote', // There's sometimes a quirky extra `
\n` at the end of the `

` that // encloses the math block. In particular this happens when the math block @@ -602,9 +602,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: r'\lambda', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -614,7 +614,7 @@ class ContentExample { ]), ])]); - static final mathBlocksMultipleInQuote = ContentExample( + static const mathBlocksMultipleInQuote = ContentExample( 'math blocks, multiple in quote', "````quote\n```math\na\n\nb\n```\n````", // https://chat.zulip.org/#narrow/channel/7-test-here/topic/.E2.9C.94.20Rajesh/near/2029236 @@ -631,9 +631,9 @@ class ContentExample { '
\n

\n', [QuotationNode([ MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -642,9 +642,9 @@ class ContentExample { ]), ]), MathBlockNode(texSource: 'b', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.6944), text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -654,7 +654,7 @@ class ContentExample { ]), ])]); - static final mathBlockBetweenImages = ContentExample( + static const mathBlockBetweenImages = ContentExample( 'math block between images', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Greg/near/2035891 'https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg\n```math\na\n```\nhttps://upload.wikimedia.org/wikipedia/commons/thumb/7/71/Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg/1280px-Zaadpluizen_van_een_Clematis_texensis_%27Princess_Diana%27._18-07-2023_%28actm.%29_02.jpg', @@ -680,9 +680,9 @@ class ContentExample { originalHeight: null), ]), MathBlockNode(texSource: 'a', nodes: [ - KatexNode(styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode(styles: KatexSpanStyles(),text: null, nodes: []), - KatexNode( + KatexSpanNode(styles: KatexSpanStyles(), text: null, nodes: [ + KatexSpanNode(styles: KatexSpanStyles(heightEm: 0.4306),text: null, nodes: []), + KatexSpanNode( styles: KatexSpanStyles( fontFamily: 'KaTeX_Math', fontStyle: KatexSpanFontStyle.italic), @@ -702,7 +702,7 @@ class ContentExample { // The font sizes can be compared using the katex.css generated // from katex.scss : // https://unpkg.com/katex@0.16.21/dist/katex.css - static final mathBlockKatexSizing = ContentExample( + static const mathBlockKatexSizing = ContentExample( 'math block; KaTeX different sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2155476 '```math\n\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0\n```', @@ -727,51 +727,51 @@ class ContentExample { MathBlockNode( texSource: "\\Huge 1\n\\huge 2\n\\LARGE 3\n\\Large 4\n\\large 5\n\\normalsize 6\n\\small 7\n\\footnotesize 8\n\\scriptsize 9\n\\tiny 0", nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( - styles: KatexSpanStyles(), + KatexSpanNode( + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.488), // .reset-size6.size11 text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 2.074), // .reset-size6.size10 text: '2', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.728), // .reset-size6.size9 text: '3', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.44), // .reset-size6.size8 text: '4', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.2), // .reset-size6.size7 text: '5', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 1.0), // .reset-size6.size6 text: '6', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.9), // .reset-size6.size5 text: '7', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.8), // .reset-size6.size4 text: '8', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.7), // .reset-size6.size3 text: '9', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // .reset-size6.size1 text: '0', nodes: null), @@ -779,7 +779,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexNestedSizing = ContentExample( + static const mathBlockKatexNestedSizing = ContentExample( 'math block; KaTeX nested sizing', '```math\n\\tiny {1 \\Huge 2}\n```', '

' @@ -796,23 +796,23 @@ class ContentExample { MathBlockNode( texSource: '\\tiny {1 \\Huge 2}', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( - styles: KatexSpanStyles(), + KatexSpanNode( + styles: KatexSpanStyles(heightEm: 1.6034), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 0.5), // reset-size6 size1 text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '1', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontSizeEm: 4.976), // reset-size1 size11 text: '2', nodes: null), @@ -821,7 +821,7 @@ class ContentExample { ]), ]); - static final mathBlockKatexDelimSizing = ContentExample( + static const mathBlockKatexDelimSizing = ContentExample( 'math block; KaTeX delimiter sizing', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/2147135 '```math\n⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊\n```', @@ -841,50 +841,52 @@ class ContentExample { MathBlockNode( texSource: '⟨ \\big( \\Big[ \\bigg⌈ \\Bigg⌊', nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( - styles: KatexSpanStyles(), + KatexSpanNode( + styles: KatexSpanStyles( + heightEm: 3.0, + verticalAlignEm: -1.25), text: null, nodes: []), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: '⟨', nodes: null), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size1'), text: '(', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size2'), text: '[', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size3'), text: '⌈', nodes: null), ]), - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(), text: null, nodes: [ - KatexNode( + KatexSpanNode( styles: KatexSpanStyles(fontFamily: 'KaTeX_Size4'), text: '⌊', nodes: null), diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index a788225aac..d5445fb931 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -595,15 +595,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; + final baseNode = mathBlockNode.nodes!.single as KatexSpanNode; final nodes = baseNode.nodes!.skip(1); // Skip .strut node. - for (final katexNode in nodes) { - final fontSize = katexNode.styles.fontSizeEm! * kBaseKatexTextStyle.fontSize!; + for (var katexNode in nodes) { + katexNode = katexNode as KatexSpanNode; + final fontSize = katexNode.styles.fontSizeEm! * baseTextStyle.fontSize!; checkKatexText(tester, katexNode.text!, fontFamily: 'KaTeX_Main', fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontHeight: baseTextStyle.height!); } }); @@ -616,17 +621,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 { @@ -639,25 +648,28 @@ void main() { await prepareContent(tester, plainContent(content.html)); final mathBlockNode = content.expectedNodes.single as MathBlockNode; - final baseNode = mathBlockNode.nodes!.single; + 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; + 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) { - katexNode = katexNode.nodes!.single; // Skip empty .mord parent. + katexNode = katexNode as KatexSpanNode; + katexNode = katexNode.nodes!.single as KatexSpanNode; // Skip empty .mord parent. final fontFamily = katexNode.styles.fontFamily!; checkKatexText(tester, katexNode.text!, fontFamily: fontFamily, - fontSize: fontSize, - fontHeight: kBaseKatexTextStyle.height!); + fontSize: baseTextStyle.fontSize!, + fontHeight: baseTextStyle.height!); } }); });