Skip to content

Split out KaTeX tests; simplify a bit; split KaTeX widgets from other content widgets #1729

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,11 @@ class CodeBlockSpanNode extends ContentNode {
}
}

/// A complete KaTeX math expression within Zulip content,
/// whether block or inline.
///
/// The content nodes that are descendants of this node
/// will all be of KaTeX-specific types, such as [KatexNode].
sealed class MathNode extends ContentNode {
const MathNode({
super.debugHtmlNode,
Expand Down Expand Up @@ -374,15 +379,20 @@ sealed class MathNode extends ContentNode {
}
}

/// A content node that expects a generic KaTeX context from its parent.
///
/// Each of these will have a [MathNode] as an ancestor.
sealed class KatexNode extends ContentNode {
const KatexNode({super.debugHtmlNode});
}

/// A generic KaTeX content node, corresponding to any span in KaTeX HTML
/// that we don't otherwise specially handle.
class KatexSpanNode extends KatexNode {
const KatexSpanNode({
required this.styles,
required this.text,
required this.nodes,
this.styles = const KatexSpanStyles(),
this.text,
this.nodes,
super.debugHtmlNode,
}) : assert((text != null) ^ (nodes != null));

Expand Down Expand Up @@ -411,6 +421,7 @@ class KatexSpanNode extends KatexNode {
}
}

/// A KaTeX strut, corresponding to a `span.strut` node in KaTeX HTML.
class KatexStrutNode extends KatexNode {
const KatexStrutNode({
required this.heightEm,
Expand All @@ -429,6 +440,12 @@ class KatexStrutNode extends KatexNode {
}
}

/// A KaTeX "vertical list", corresponding to a `span.vlist-t` in KaTeX HTML.
///
/// These nodes in KaTeX HTML have a very specific structure.
/// The children of these nodes in our tree correspond in the HTML to
/// certain great-grandchildren (certain `> .vlist-r > .vlist > span`)
/// of the `.vlist-t` node.
class KatexVlistNode extends KatexNode {
const KatexVlistNode({
required this.rows,
Expand All @@ -443,6 +460,11 @@ class KatexVlistNode extends KatexNode {
}
}

/// An element of a KaTeX "vertical list"; a child of a [KatexVlistNode].
///
/// These correspond to certain `.vlist-t > .vlist-r > .vlist > span` nodes
/// in KaTeX HTML. The [KatexVlistNode] parent in our tree
/// corresponds to the `.vlist-t` great-grandparent in the HTML.
class KatexVlistRowNode extends ContentNode {
const KatexVlistRowNode({
required this.verticalOffsetEm,
Expand All @@ -465,6 +487,11 @@ class KatexVlistRowNode extends ContentNode {
}
}

/// A KaTeX node corresponding to negative values for `margin-left`
/// or `margin-right` in the inline CSS style of a KaTeX HTML node.
///
/// The parser synthesizes these as additional nodes, not corresponding
/// directly to any node in the HTML.
class KatexNegativeMarginNode extends KatexNode {
const KatexNegativeMarginNode({
required this.leftOffsetEm,
Expand Down
3 changes: 0 additions & 3 deletions lib/model/katex.dart
Original file line number Diff line number Diff line change
Expand Up @@ -341,13 +341,10 @@ class _KatexParser {

KatexSpanNode child = KatexSpanNode(
styles: styles,
text: null,
nodes: _parseChildSpans(otherSpans));

if (marginLeftIsNegative) {
child = KatexSpanNode(
styles: KatexSpanStyles(),
text: null,
nodes: [KatexNegativeMarginNode(
leftOffsetEm: marginLeftEm!,
nodes: [child])]);
Expand Down
227 changes: 0 additions & 227 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'dart:async';

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
Expand All @@ -16,7 +15,6 @@ import '../model/binding.dart';
import '../model/content.dart';
import '../model/emoji.dart';
import '../model/internal_link.dart';
import '../model/katex.dart';
import '../model/presence.dart';
import 'actions.dart';
import 'code_block.dart';
Expand Down Expand Up @@ -834,231 +832,6 @@ class MathBlock extends StatelessWidget {
}
}

/// 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,
textBaseline: TextBaseline.alphabetic,
leadingDistribution: TextLeadingDistribution.even,
decoration: TextDecoration.none,
fontFamilyFallback: const []);
}

@visibleForTesting
class KatexWidget extends StatelessWidget {
const KatexWidget({
super.key,
required this.textStyle,
required this.nodes,
});

final TextStyle textStyle;
final List<KatexNode> nodes;

@override
Widget build(BuildContext context) {
Widget widget = _KatexNodeList(nodes: nodes);

return Directionality(
textDirection: TextDirection.ltr,
child: DefaultTextStyle(
style: mkBaseKatexTextStyle(textStyle).copyWith(
color: ContentTheme.of(context).textStylePlainParagraph.color),
child: widget));
}
}

class _KatexNodeList extends StatelessWidget {
const _KatexNodeList({required this.nodes});

final List<KatexNode> nodes;

@override
Widget build(BuildContext context) {
return Text.rich(TextSpan(
children: List.unmodifiable(nodes.map((e) {
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
// Work around a bug where text inside a WidgetSpan could be scaled
// multiple times incorrectly, if the system font scale is larger
// than 1x.
// See: https://github.com/flutter/flutter/issues/126962
child: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.noScaling),
child: switch (e) {
KatexSpanNode() => _KatexSpan(e),
KatexStrutNode() => _KatexStrut(e),
KatexVlistNode() => _KatexVlist(e),
KatexNegativeMarginNode() => _KatexNegativeMargin(e),
}));
}))));
}
}

class _KatexSpan extends StatelessWidget {
const _KatexSpan(this.node);

final KatexSpanNode node;

@override
Widget build(BuildContext context) {
var em = DefaultTextStyle.of(context).style.fontSize!;

Widget widget = const SizedBox.shrink();
if (node.text != null) {
widget = Text(node.text!);
} else if (node.nodes != null && node.nodes!.isNotEmpty) {
widget = _KatexNodeList(nodes: node.nodes!);
}

final styles = node.styles;

// Currently, we expect `top` to be only present with the
// vlist inner row span, and parser handles that explicitly.
assert(styles.topEm == null);

final fontFamily = styles.fontFamily;
final fontSize = switch (styles.fontSizeEm) {
double fontSizeEm => fontSizeEm * em,
null => null,
};
if (fontSize != null) em = fontSize;

final fontWeight = switch (styles.fontWeight) {
KatexSpanFontWeight.bold => FontWeight.bold,
null => null,
};
var fontStyle = switch (styles.fontStyle) {
KatexSpanFontStyle.normal => FontStyle.normal,
KatexSpanFontStyle.italic => FontStyle.italic,
null => null,
};

TextStyle? textStyle;
if (fontFamily != null ||
fontSize != null ||
fontWeight != null ||
fontStyle != null) {
// TODO(upstream) remove this workaround when upstream fixes the broken
// rendering of KaTeX_Math font with italic font style on Android:
// https://github.com/flutter/flutter/issues/167474
if (defaultTargetPlatform == TargetPlatform.android &&
fontFamily == 'KaTeX_Math') {
fontStyle = FontStyle.normal;
}

textStyle = TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: fontWeight,
fontStyle: fontStyle,
);
}
final textAlign = switch (styles.textAlign) {
KatexSpanTextAlign.left => TextAlign.left,
KatexSpanTextAlign.center => TextAlign.center,
KatexSpanTextAlign.right => TextAlign.right,
null => null,
};

if (textStyle != null || textAlign != null) {
widget = DefaultTextStyle.merge(
style: textStyle,
textAlign: textAlign,
child: widget);
}

widget = SizedBox(
height: styles.heightEm != null
? styles.heightEm! * em
: null,
child: widget);

final margin = switch ((styles.marginLeftEm, styles.marginRightEm)) {
(null, null) => null,
(null, final marginRightEm?) =>
EdgeInsets.only(right: marginRightEm * em),
(final marginLeftEm?, null) =>
EdgeInsets.only(left: marginLeftEm * em),
(final marginLeftEm?, final marginRightEm?) =>
EdgeInsets.only(left: marginLeftEm * em, right: marginRightEm * em),
};

if (margin != null) {
assert(margin.isNonNegative);
widget = Padding(padding: margin, child: widget);
}

return 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 Stack(children: List.unmodifiable(node.rows.map((row) {
return 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});

Expand Down
Loading