Skip to content

KaTeX (2/n): Support horizontal and vertical offsets for spans #1452

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
49 changes: 49 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,55 @@ 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<KatexVlistRowNode> rows;

@override
List<DiagnosticsNode> 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 MathBlockNode extends MathNode implements BlockContentNode {
const MathBlockNode({
super.debugHtmlNode,
Expand Down
204 changes: 201 additions & 3 deletions lib/model/katex.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,122 @@ 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) {
Comment on lines +188 to +189
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the startsWith logic necessary? Seems like we could just skip to the equality check.

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()) {
Comment on lines +198 to +199
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: binary operator goes after newline, not before (so it doesn't get visually lost off at the ragged right edge of the code):

Suggested change
if (styles.filter(heightEm: false, verticalAlignEm: false) !=
KatexSpanStyles()) {
if (styles.filter(heightEm: false, verticalAlignEm: false)
!= KatexSpanStyles()) {

Comment on lines +198 to +199
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will allocate two fresh KatexSpanStyles objects, with all their fields, in order to check that the other fields of styles are all null.

We can eliminate one of those allocations easily by saying const KatexSpanStyles().

The other one is from filter. Let's leave that for now, I guess; it's probably not a big deal in practice.

(Definitely it's good to be doing this check, excluding unanticipated shapes of the tree.)

throw _KatexHtmlParseError();
}

return KatexStrutNode(
heightEm: heightEm,
verticalAlignEm: verticalAlignEm);
Comment on lines +203 to +205
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should get debugHtmlNode

} 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 = <KatexVlistRowNode>[];

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)!;
Comment on lines +261 to +266
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like these need the same check that happens at the other _parseSpanInlineStyles call site below, to rule out vertical-align.

final pstrutHeight = pstrutStyles.heightEm ?? 0;
Comment on lines +266 to +267
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we wouldn't notice if this span had additional inline style properties we weren't acting on. So it should get the same check we have in the .strut case above.


rows.add(KatexVlistRowNode(
verticalOffsetEm: topEm + pstrutHeight,
debugHtmlNode: kDebugMode ? innerSpan : null,
node: KatexSpanNode(
styles: styles,
text: null,
nodes: _parseChildSpans(otherSpans))));
} else {
throw _KatexHtmlParseError();
}
}

return KatexVlistNode(
rows: rows,
debugHtmlNode: kDebugMode ? vlistT : null,
);
} 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.
Expand All @@ -197,7 +310,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<String>.unmodifiable(element.className.split(' '));
final spanClasses = element.className != ''
? List<String>.unmodifiable(element.className.split(' '))
: const <String>[];
String? fontFamily;
double? fontSizeEm;
KatexSpanFontWeight? fontWeight;
Expand All @@ -214,8 +329,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; }
Expand Down Expand Up @@ -463,6 +579,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(
Expand All @@ -474,6 +594,28 @@ 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) {
if (marginRightEm < 0) throw _KatexHtmlParseError();
continue;
}

case 'margin-left':
marginLeftEm = _getEm(expression);
if (marginLeftEm != null) {
if (marginLeftEm < 0) throw _KatexHtmlParseError();
continue;
}
}

// TODO handle more CSS properties
Expand All @@ -488,6 +630,10 @@ class _KatexParser {

return KatexSpanStyles(
heightEm: heightEm,
topEm: topEm,
verticalAlignEm: verticalAlignEm,
marginRightEm: marginRightEm,
marginLeftEm: marginLeftEm,
);
} else {
throw _KatexHtmlParseError();
Expand Down Expand Up @@ -524,6 +670,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;
Expand All @@ -533,6 +685,10 @@ class KatexSpanStyles {

const KatexSpanStyles({
this.heightEm,
this.verticalAlignEm,
this.topEm,
this.marginRightEm,
this.marginLeftEm,
this.fontFamily,
this.fontSizeEm,
this.fontWeight,
Expand All @@ -544,6 +700,10 @@ class KatexSpanStyles {
int get hashCode => Object.hash(
'KatexSpanStyles',
heightEm,
verticalAlignEm,
topEm,
marginRightEm,
marginLeftEm,
fontFamily,
fontSizeEm,
fontWeight,
Expand All @@ -555,6 +715,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 &&
Expand All @@ -566,6 +730,10 @@ class KatexSpanStyles {
String toString() {
final args = <String>[];
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');
Expand All @@ -584,13 +752,43 @@ 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,
fontWeight: other.fontWeight ?? fontWeight,
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 {
Expand Down
Loading