diff --git a/lib/model/katex.dart b/lib/model/katex.dart index 057f7076bc..c77757783b 100644 --- a/lib/model/katex.dart +++ b/lib/model/katex.dart @@ -1,7 +1,9 @@ +import 'dart:ui'; + +import 'package:convert/convert.dart'; import 'package:csslib/parser.dart' as css_parser; import 'package:csslib/visitor.dart' as css_visitor; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:html/dom.dart' as dom; import '../log.dart'; @@ -494,6 +496,7 @@ class _KatexParser { double? verticalAlignEm; double? marginRightEm; double? marginLeftEm; + KatexSpanColor? color; for (final declaration in rule.declarationGroup.declarations) { if (declaration case css_visitor.Declaration( @@ -523,6 +526,38 @@ class _KatexParser { if (marginLeftEm < 0) throw _KatexHtmlParseError(); continue; } + + case 'color': + // `package:csslib` parser emits a HexColorTerm for the `color` + // attribute. It automatically resolves the named CSS colors to + // their hex values. The `HexColorTerm.value` is the hex + // encoded in an integer in the same sequence as the input hex + // string. But it also allows some non-conformant CSS hex color + // notations, like #f, #ff, #fffff, #fffffff. + // See: + // https://drafts.csswg.org/css-color/#hex-notation. + // https://github.com/dart-lang/tools/blob/2a2a2d611/pkgs/csslib/lib/parser.dart#L2714-L2743 + // + // Also the generated integer value will be in 0xRRGGBBAA + // sequence (CSS notation), whereas `dart:ui` Color + // requires 0xAARRGGBB. + // + // So, we try to parse the value of `color` attribute ourselves + // only allowing conformant CSS hex color notations, mapping + // named CSS colors to their corresponding values, generating a + // typed result (KatexSpanColor(r, g, b, a)) to be used later + // while rendering. + + final valueStr = _getRawValue(expression); + if (valueStr != null) { + if (valueStr.startsWith('#')) { + color = parseCssHexColor(valueStr); + if (color != null) continue; + } + + color = _cssNamedColorsMap[valueStr]; + if (color != null) continue; + } } // TODO handle more CSS properties @@ -540,6 +575,7 @@ class _KatexParser { verticalAlignEm: verticalAlignEm, marginRightEm: marginRightEm, marginLeftEm: marginLeftEm, + color: color, ); } else { throw _KatexHtmlParseError(); @@ -556,6 +592,10 @@ class _KatexParser { } return null; } + + String? _getRawValue(css_visitor.Expression expression) { + return expression.span?.text; + } } enum KatexSpanFontWeight { @@ -573,6 +613,32 @@ enum KatexSpanTextAlign { right, } +class KatexSpanColor { + const KatexSpanColor(this.r, this.g, this.b, this.a); + + final int r; + final int g; + final int b; + final int a; + + @override + bool operator ==(Object other) { + return other is KatexSpanColor && + other.r == r && + other.g == g && + other.b == b && + other.a == a; + } + + @override + int get hashCode => Object.hash('KatexSpanColor', r, g, b, a); + + @override + String toString() { + return '${objectRuntimeType(this, 'KatexSpanColor')}($r, $g, $b, $a)'; + } +} + @immutable class KatexSpanStyles { final double? heightEm; @@ -587,6 +653,8 @@ class KatexSpanStyles { final KatexSpanFontStyle? fontStyle; final KatexSpanTextAlign? textAlign; + final KatexSpanColor? color; + const KatexSpanStyles({ this.heightEm, this.verticalAlignEm, @@ -597,6 +665,7 @@ class KatexSpanStyles { this.fontWeight, this.fontStyle, this.textAlign, + this.color, }); @override @@ -611,6 +680,7 @@ class KatexSpanStyles { fontWeight, fontStyle, textAlign, + color, ); @override @@ -624,7 +694,8 @@ class KatexSpanStyles { other.fontSizeEm == fontSizeEm && other.fontWeight == fontWeight && other.fontStyle == fontStyle && - other.textAlign == textAlign; + other.textAlign == textAlign && + other.color == color; } @override @@ -639,6 +710,7 @@ class KatexSpanStyles { if (fontWeight != null) args.add('fontWeight: $fontWeight'); if (fontStyle != null) args.add('fontStyle: $fontStyle'); if (textAlign != null) args.add('textAlign: $textAlign'); + if (color != null) args.add('color: $color'); return '${objectRuntimeType(this, 'KatexSpanStyles')}(${args.join(', ')})'; } @@ -660,6 +732,7 @@ class KatexSpanStyles { fontStyle: other.fontStyle ?? fontStyle, fontWeight: other.fontWeight ?? fontWeight, textAlign: other.textAlign ?? textAlign, + color: other.color ?? color, ); } @@ -688,6 +761,196 @@ class KatexSpanStyles { } } +final _hexColorRegExp = + RegExp(r'^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$'); + +/// Parses the CSS hex color notation. +/// +/// See: https://drafts.csswg.org/css-color/#hex-notation +KatexSpanColor? parseCssHexColor(String hexStr) { + final match = _hexColorRegExp.firstMatch(hexStr); + if (match == null) return null; + + String hexValue = match.group(1)!; + hexValue = hexValue.toLowerCase(); + switch (hexValue.length) { + case 3: + hexValue = '${hexValue[0]}${hexValue[0]}' + '${hexValue[1]}${hexValue[1]}' + '${hexValue[2]}${hexValue[2]}' + 'ff'; + case 4: + hexValue = '${hexValue[0]}${hexValue[0]}' + '${hexValue[1]}${hexValue[1]}' + '${hexValue[2]}${hexValue[2]}' + '${hexValue[3]}${hexValue[3]}'; + case 6: + hexValue += 'ff'; + } + + try { + final [r, g, b, a] = hex.decode(hexValue); + return KatexSpanColor(r, g, b, a); + } catch (_) { + return null; // TODO(log) + } +} + +// CSS named colors: https://drafts.csswg.org/css-color/#named-colors +// Map adapted from the following source file: +// https://github.com/w3c/csswg-drafts/blob/1942d0918/css-color-4/Overview.bs#L1562-L1859 +const _cssNamedColorsMap = { + 'transparent': KatexSpanColor(0, 0, 0, 0), // https://drafts.csswg.org/css-color/#transparent-color + 'aliceblue': KatexSpanColor(240, 248, 255, 255), + 'antiquewhite': KatexSpanColor(250, 235, 215, 255), + 'aqua': KatexSpanColor(0, 255, 255, 255), + 'aquamarine': KatexSpanColor(127, 255, 212, 255), + 'azure': KatexSpanColor(240, 255, 255, 255), + 'beige': KatexSpanColor(245, 245, 220, 255), + 'bisque': KatexSpanColor(255, 228, 196, 255), + 'black': KatexSpanColor(0, 0, 0, 255), + 'blanchedalmond': KatexSpanColor(255, 235, 205, 255), + 'blue': KatexSpanColor(0, 0, 255, 255), + 'blueviolet': KatexSpanColor(138, 43, 226, 255), + 'brown': KatexSpanColor(165, 42, 42, 255), + 'burlywood': KatexSpanColor(222, 184, 135, 255), + 'cadetblue': KatexSpanColor(95, 158, 160, 255), + 'chartreuse': KatexSpanColor(127, 255, 0, 255), + 'chocolate': KatexSpanColor(210, 105, 30, 255), + 'coral': KatexSpanColor(255, 127, 80, 255), + 'cornflowerblue': KatexSpanColor(100, 149, 237, 255), + 'cornsilk': KatexSpanColor(255, 248, 220, 255), + 'crimson': KatexSpanColor(220, 20, 60, 255), + 'cyan': KatexSpanColor(0, 255, 255, 255), + 'darkblue': KatexSpanColor(0, 0, 139, 255), + 'darkcyan': KatexSpanColor(0, 139, 139, 255), + 'darkgoldenrod': KatexSpanColor(184, 134, 11, 255), + 'darkgray': KatexSpanColor(169, 169, 169, 255), + 'darkgreen': KatexSpanColor(0, 100, 0, 255), + 'darkgrey': KatexSpanColor(169, 169, 169, 255), + 'darkkhaki': KatexSpanColor(189, 183, 107, 255), + 'darkmagenta': KatexSpanColor(139, 0, 139, 255), + 'darkolivegreen': KatexSpanColor(85, 107, 47, 255), + 'darkorange': KatexSpanColor(255, 140, 0, 255), + 'darkorchid': KatexSpanColor(153, 50, 204, 255), + 'darkred': KatexSpanColor(139, 0, 0, 255), + 'darksalmon': KatexSpanColor(233, 150, 122, 255), + 'darkseagreen': KatexSpanColor(143, 188, 143, 255), + 'darkslateblue': KatexSpanColor(72, 61, 139, 255), + 'darkslategray': KatexSpanColor(47, 79, 79, 255), + 'darkslategrey': KatexSpanColor(47, 79, 79, 255), + 'darkturquoise': KatexSpanColor(0, 206, 209, 255), + 'darkviolet': KatexSpanColor(148, 0, 211, 255), + 'deeppink': KatexSpanColor(255, 20, 147, 255), + 'deepskyblue': KatexSpanColor(0, 191, 255, 255), + 'dimgray': KatexSpanColor(105, 105, 105, 255), + 'dimgrey': KatexSpanColor(105, 105, 105, 255), + 'dodgerblue': KatexSpanColor(30, 144, 255, 255), + 'firebrick': KatexSpanColor(178, 34, 34, 255), + 'floralwhite': KatexSpanColor(255, 250, 240, 255), + 'forestgreen': KatexSpanColor(34, 139, 34, 255), + 'fuchsia': KatexSpanColor(255, 0, 255, 255), + 'gainsboro': KatexSpanColor(220, 220, 220, 255), + 'ghostwhite': KatexSpanColor(248, 248, 255, 255), + 'gold': KatexSpanColor(255, 215, 0, 255), + 'goldenrod': KatexSpanColor(218, 165, 32, 255), + 'gray': KatexSpanColor(128, 128, 128, 255), + 'green': KatexSpanColor(0, 128, 0, 255), + 'greenyellow': KatexSpanColor(173, 255, 47, 255), + 'grey': KatexSpanColor(128, 128, 128, 255), + 'honeydew': KatexSpanColor(240, 255, 240, 255), + 'hotpink': KatexSpanColor(255, 105, 180, 255), + 'indianred': KatexSpanColor(205, 92, 92, 255), + 'indigo': KatexSpanColor(75, 0, 130, 255), + 'ivory': KatexSpanColor(255, 255, 240, 255), + 'khaki': KatexSpanColor(240, 230, 140, 255), + 'lavender': KatexSpanColor(230, 230, 250, 255), + 'lavenderblush': KatexSpanColor(255, 240, 245, 255), + 'lawngreen': KatexSpanColor(124, 252, 0, 255), + 'lemonchiffon': KatexSpanColor(255, 250, 205, 255), + 'lightblue': KatexSpanColor(173, 216, 230, 255), + 'lightcoral': KatexSpanColor(240, 128, 128, 255), + 'lightcyan': KatexSpanColor(224, 255, 255, 255), + 'lightgoldenrodyellow': KatexSpanColor(250, 250, 210, 255), + 'lightgray': KatexSpanColor(211, 211, 211, 255), + 'lightgreen': KatexSpanColor(144, 238, 144, 255), + 'lightgrey': KatexSpanColor(211, 211, 211, 255), + 'lightpink': KatexSpanColor(255, 182, 193, 255), + 'lightsalmon': KatexSpanColor(255, 160, 122, 255), + 'lightseagreen': KatexSpanColor(32, 178, 170, 255), + 'lightskyblue': KatexSpanColor(135, 206, 250, 255), + 'lightslategray': KatexSpanColor(119, 136, 153, 255), + 'lightslategrey': KatexSpanColor(119, 136, 153, 255), + 'lightsteelblue': KatexSpanColor(176, 196, 222, 255), + 'lightyellow': KatexSpanColor(255, 255, 224, 255), + 'lime': KatexSpanColor(0, 255, 0, 255), + 'limegreen': KatexSpanColor(50, 205, 50, 255), + 'linen': KatexSpanColor(250, 240, 230, 255), + 'magenta': KatexSpanColor(255, 0, 255, 255), + 'maroon': KatexSpanColor(128, 0, 0, 255), + 'mediumaquamarine': KatexSpanColor(102, 205, 170, 255), + 'mediumblue': KatexSpanColor(0, 0, 205, 255), + 'mediumorchid': KatexSpanColor(186, 85, 211, 255), + 'mediumpurple': KatexSpanColor(147, 112, 219, 255), + 'mediumseagreen': KatexSpanColor(60, 179, 113, 255), + 'mediumslateblue': KatexSpanColor(123, 104, 238, 255), + 'mediumspringgreen': KatexSpanColor(0, 250, 154, 255), + 'mediumturquoise': KatexSpanColor(72, 209, 204, 255), + 'mediumvioletred': KatexSpanColor(199, 21, 133, 255), + 'midnightblue': KatexSpanColor(25, 25, 112, 255), + 'mintcream': KatexSpanColor(245, 255, 250, 255), + 'mistyrose': KatexSpanColor(255, 228, 225, 255), + 'moccasin': KatexSpanColor(255, 228, 181, 255), + 'navajowhite': KatexSpanColor(255, 222, 173, 255), + 'navy': KatexSpanColor(0, 0, 128, 255), + 'oldlace': KatexSpanColor(253, 245, 230, 255), + 'olive': KatexSpanColor(128, 128, 0, 255), + 'olivedrab': KatexSpanColor(107, 142, 35, 255), + 'orange': KatexSpanColor(255, 165, 0, 255), + 'orangered': KatexSpanColor(255, 69, 0, 255), + 'orchid': KatexSpanColor(218, 112, 214, 255), + 'palegoldenrod': KatexSpanColor(238, 232, 170, 255), + 'palegreen': KatexSpanColor(152, 251, 152, 255), + 'paleturquoise': KatexSpanColor(175, 238, 238, 255), + 'palevioletred': KatexSpanColor(219, 112, 147, 255), + 'papayawhip': KatexSpanColor(255, 239, 213, 255), + 'peachpuff': KatexSpanColor(255, 218, 185, 255), + 'peru': KatexSpanColor(205, 133, 63, 255), + 'pink': KatexSpanColor(255, 192, 203, 255), + 'plum': KatexSpanColor(221, 160, 221, 255), + 'powderblue': KatexSpanColor(176, 224, 230, 255), + 'purple': KatexSpanColor(128, 0, 128, 255), + 'rebeccapurple': KatexSpanColor(102, 51, 153, 255), + 'red': KatexSpanColor(255, 0, 0, 255), + 'rosybrown': KatexSpanColor(188, 143, 143, 255), + 'royalblue': KatexSpanColor(65, 105, 225, 255), + 'saddlebrown': KatexSpanColor(139, 69, 19, 255), + 'salmon': KatexSpanColor(250, 128, 114, 255), + 'sandybrown': KatexSpanColor(244, 164, 96, 255), + 'seagreen': KatexSpanColor(46, 139, 87, 255), + 'seashell': KatexSpanColor(255, 245, 238, 255), + 'sienna': KatexSpanColor(160, 82, 45, 255), + 'silver': KatexSpanColor(192, 192, 192, 255), + 'skyblue': KatexSpanColor(135, 206, 235, 255), + 'slateblue': KatexSpanColor(106, 90, 205, 255), + 'slategray': KatexSpanColor(112, 128, 144, 255), + 'slategrey': KatexSpanColor(112, 128, 144, 255), + 'snow': KatexSpanColor(255, 250, 250, 255), + 'springgreen': KatexSpanColor(0, 255, 127, 255), + 'steelblue': KatexSpanColor(70, 130, 180, 255), + 'tan': KatexSpanColor(210, 180, 140, 255), + 'teal': KatexSpanColor(0, 128, 128, 255), + 'thistle': KatexSpanColor(216, 191, 216, 255), + 'tomato': KatexSpanColor(255, 99, 71, 255), + 'turquoise': KatexSpanColor(64, 224, 208, 255), + 'violet': KatexSpanColor(238, 130, 238, 255), + 'wheat': KatexSpanColor(245, 222, 179, 255), + 'white': KatexSpanColor(255, 255, 255, 255), + 'whitesmoke': KatexSpanColor(245, 245, 245, 255), + 'yellow': KatexSpanColor(255, 255, 0, 255), + 'yellowgreen': KatexSpanColor(154, 205, 50, 255), +}; + class _KatexHtmlParseError extends Error { final String? message; diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 52ab7008b8..dec4c0619a 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -940,12 +940,14 @@ class _KatexSpan extends StatelessWidget { KatexSpanFontStyle.italic => FontStyle.italic, null => null, }; + final color = styles.color; TextStyle? textStyle; if (fontFamily != null || fontSize != null || fontWeight != null || - fontStyle != null) { + fontStyle != null || + color != 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 @@ -959,6 +961,9 @@ class _KatexSpan extends StatelessWidget { fontSize: fontSize, fontWeight: fontWeight, fontStyle: fontStyle, + color: color != null + ? Color.fromARGB(color.a, color.r, color.g, color.b) + : null, ); } final textAlign = switch (styles.textAlign) { diff --git a/test/model/content_test.dart b/test/model/content_test.dart index c19e1c02a5..5793717650 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -943,6 +943,72 @@ class ContentExample { ]), ]); + static const mathBlockKatexColoredText = ContentExample( + 'math block; KaTeX colored text', + '```math\n\\color{#f00} 0\n\n\\textcolor{red} 1\n\n\\red 2\n```', + '

' + '' + '0\\color{#f00} 0' + '\n\n' + '' + '1\\textcolor{red} 1' + '\n\n' + '' + '2\\red 2' + '

', [ + MathBlockNode( + texSource: '\\color{#f00} 0', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(255, 0, 0, 255)), + text: '0', + nodes: null), + ]), + ]), + MathBlockNode( + texSource: '\\textcolor{red} 1', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(255, 0, 0, 255)), + text: '1', + nodes: null), + ]), + ]), + MathBlockNode( + texSource: '\\red 2', + nodes: [ + KatexSpanNode( + styles: KatexSpanStyles(), + text: null, + nodes: [ + KatexStrutNode(heightEm: 0.6444, verticalAlignEm: null), + KatexSpanNode( + styles: KatexSpanStyles(color: KatexSpanColor(223, 0, 48, 255)), + text: '2', + nodes: null), + ]), + ]), + ]); + static const imageSingle = ContentExample( 'single image', // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103 @@ -2033,6 +2099,7 @@ void main() async { testParseExample(ContentExample.mathBlockKatexNestedSizing); testParseExample(ContentExample.mathBlockKatexDelimSizing); testParseExample(ContentExample.mathBlockKatexSpace); + testParseExample(ContentExample.mathBlockKatexColoredText); testParseExample(ContentExample.imageSingle); testParseExample(ContentExample.imageSingleNoDimensions); diff --git a/test/model/katex_test.dart b/test/model/katex_test.dart new file mode 100644 index 0000000000..22b0b01235 --- /dev/null +++ b/test/model/katex_test.dart @@ -0,0 +1,50 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/model/katex.dart'; + +void main() { + group('parseCssHexColor', () { + const testCases = [ + ('#c0c0c0ff', KatexSpanColor(192, 192, 192, 255)), + ('#f00ba4', KatexSpanColor(240, 11, 164, 255)), + ('#cafe', KatexSpanColor(204, 170, 255, 238)), + + ('#ffffffff', KatexSpanColor(255, 255, 255, 255)), + ('#ffffff', KatexSpanColor(255, 255, 255, 255)), + ('#ffff', KatexSpanColor(255, 255, 255, 255)), + ('#fff', KatexSpanColor(255, 255, 255, 255)), + ('#00ffffff', KatexSpanColor(0, 255, 255, 255)), + ('#00ffff', KatexSpanColor(0, 255, 255, 255)), + ('#0fff', KatexSpanColor(0, 255, 255, 255)), + ('#0ff', KatexSpanColor(0, 255, 255, 255)), + ('#ff00ffff', KatexSpanColor(255, 0, 255, 255)), + ('#ff00ff', KatexSpanColor(255, 0, 255, 255)), + ('#f0ff', KatexSpanColor(255, 0, 255, 255)), + ('#f0f', KatexSpanColor(255, 0, 255, 255)), + ('#ffff00ff', KatexSpanColor(255, 255, 0, 255)), + ('#ffff00', KatexSpanColor(255, 255, 0, 255)), + ('#ff0f', KatexSpanColor(255, 255, 0, 255)), + ('#ff0', KatexSpanColor(255, 255, 0, 255)), + ('#ffffff00', KatexSpanColor(255, 255, 255, 0)), + ('#fff0', KatexSpanColor(255, 255, 255, 0)), + + ('#FF00FFFF', KatexSpanColor(255, 0, 255, 255)), + ('#FF00FF', KatexSpanColor(255, 0, 255, 255)), + + ('#ff00FFff', KatexSpanColor(255, 0, 255, 255)), + ('#ff00FF', KatexSpanColor(255, 0, 255, 255)), + + ('#F', null), + ('#FF', null), + ('#FFFFF', null), + ('#FFFFFFF', null), + ('FFF', null), + ]; + + for (final testCase in testCases) { + test(testCase.$1, () { + check(parseCssHexColor(testCase.$1)).equals(testCase.$2); + }); + } + }); +} diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f6366a9215..66403fb70f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -634,6 +634,23 @@ void main() { }); } }); + + testWidgets('displays KaTeX content with colored text', (tester) async { + addTearDown(testBinding.reset); + final globalSettings = testBinding.globalStore.settings; + await globalSettings.setBool(BoolGlobalSetting.renderKatex, true); + check(globalSettings).getBool(BoolGlobalSetting.renderKatex).isTrue(); + + final content = ContentExample.mathBlockKatexColoredText; + await prepareContent(tester, plainContent(content.html)); + + check(mergedStyleOf(tester, '0')).isNotNull() + .color.equals(Color(0xFFFF0000)); + check(mergedStyleOf(tester, '1')).isNotNull() + .color.equals(Color(0xFFFF0000)); + check(mergedStyleOf(tester, '2')).isNotNull() + .color.equals(Color(0xFFDF0030)); + }); }); /// Make a [TargetFontSizeFinder] to pass to [checkFontSizeRatio],