tex2typst
Version:
JavaScript library for converting TeX code to Typst
1,062 lines (995 loc) • 46.1 kB
text/typescript
import { TexNode, TexToken, TexTokenType, TexFuncCall, TexGroup, TexSupSub,
TexText, TexBeginEnd, TexLeftRight, TexTerminal} from "./tex-types";
import type { Tex2TypstOptions, Typst2TexOptions } from "./exposed-types";
import { TypstFraction, TypstFuncCall, TypstGroup, TypstLeftright, TypstMarkupFunc, TypstMatrixLike, TypstNode, TypstSupsub, TypstTerminal } from "./typst-types";
import { TypstNamedParams } from "./typst-types";
import { TypstSupsubData } from "./typst-types";
import { TypstToken } from "./typst-types";
import { TypstTokenType } from "./typst-types";
import { symbolMap, reverseSymbolMap } from "./map";
import { array_includes, array_intersperse, array_split } from "./generic";
import { assert } from "./utils";
import { TEX_BINARY_COMMANDS, TEX_UNARY_COMMANDS } from "./tex-tokenizer";
export class ConverterError extends Error {
node: TexNode | TypstNode | TexToken | TypstToken | null;
constructor(message: string, node: TexNode | TypstNode | TexToken | TypstToken | null = null) {
super(message);
this.name = "ConverterError";
this.node = node;
}
}
const TYPST_NONE = TypstToken.NONE.toNode();
// native textual operators in Typst
const TYPST_INTRINSIC_OP = [
'dim',
'id',
'im',
'mod',
'Pr',
'sech',
'csch',
// 'sgn
];
function _tex_token_str_to_typst(token: string): string | null {
if (/^[a-zA-Z0-9]$/.test(token)) {
return token;
} else if (token === '/') {
return '\\/';
} else if (['\\\\', '\\{', '\\}', '\\%'].includes(token)) {
return token.substring(1);
} else if (['\\$', '\\#', '\\&', '\\_'].includes(token)) {
return token;
} else if (token.startsWith('\\')) {
const symbol = token.slice(1);
if (symbolMap.has(symbol)) {
return symbolMap.get(symbol)!;
} else {
// Fall back to the original macro.
// This works for \alpha, \beta, \gamma, etc.
// If this.nonStrict is true, this also works for all unknown macros.
return null;
}
}
return token;
}
function tex_token_to_typst(token: TexToken, options: Tex2TypstOptions): TypstToken {
let token_type: TypstTokenType;
switch (token.type) {
case TexTokenType.EMPTY:
return TypstToken.NONE;
case TexTokenType.COMMAND:
token_type = TypstTokenType.SYMBOL;
break;
case TexTokenType.ELEMENT:
token_type = TypstTokenType.ELEMENT;
break;
case TexTokenType.LITERAL:
// This happens, for example, node={type: 'literal', content: 'myop'} as in `\operatorname{myop}`
token_type = TypstTokenType.LITERAL;
break;
case TexTokenType.COMMENT:
token_type = TypstTokenType.COMMENT;
break;
case TexTokenType.SPACE:
token_type = TypstTokenType.SPACE;
break;
case TexTokenType.NEWLINE:
token_type = TypstTokenType.NEWLINE;
break;
case TexTokenType.CONTROL: {
if (token.value === '\\\\') {
// \\ -> \
return new TypstToken(TypstTokenType.CONTROL, '\\');
} else if (token.value === '\\!') {
// \! -> #h(-math.thin.amount)
return new TypstToken(TypstTokenType.SYMBOL, '#h(-math.thin.amount)');
} else if (token.value === '~') {
// ~ -> space.nobreak
const typst_symbol = symbolMap.get('~')!;
return new TypstToken(TypstTokenType.SYMBOL, typst_symbol);
} else if (symbolMap.has(token.value.substring(1))) {
// node.content is one of \, \: \;
const typst_symbol = symbolMap.get(token.value.substring(1))!;
return new TypstToken(TypstTokenType.SYMBOL, typst_symbol);
} else {
throw new Error(`Unknown control sequence: ${token.value}`);
}
}
default:
throw Error(`Unknown token type: ${token.type}`);
}
const typst_str = _tex_token_str_to_typst(token.value);
if (typst_str === null) {
if (options.nonStrict) {
return new TypstToken(token_type, token.value.substring(1));
} else {
throw new ConverterError(`Unknown token: ${token.value}`, token);
}
}
return new TypstToken(token_type, typst_str);
}
// \overset{X}{Y} -> limits(Y)^X
// and with special case \overset{\text{def}}{=} -> eq.def
function convert_overset(node: TexFuncCall, options: Tex2TypstOptions): TypstNode {
const [sup, base] = node.args;
if (options.optimize) {
// \overset{\text{def}}{=} or \overset{def}{=} are considered as eq.def
if (["\\overset{\\text{def}}{=}", "\\overset{d e f}{=}"].includes(node.toString())) {
return new TypstToken(TypstTokenType.SYMBOL, 'eq.def').toNode();
}
}
const limits_call = new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'limits'),
[convert_tex_node_to_typst(base, options)]
);
return new TypstSupsub({
base: limits_call,
sup: convert_tex_node_to_typst(sup, options),
sub: null,
});
}
// \underset{X}{Y} -> limits(Y)_X
function convert_underset(node: TexFuncCall, options: Tex2TypstOptions): TypstNode {
const [sub, base] = node.args;
const limits_call = new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'limits'),
[convert_tex_node_to_typst(base, options)]
);
return new TypstSupsub({
base: limits_call,
sub: convert_tex_node_to_typst(sub, options),
sup: null,
});
}
function convert_tex_array_align_literal(alignLiteral: string): TypstNamedParams {
const np: TypstNamedParams = {};
const alignMap: Record<string, string> = { l: '#left', c: '#center', r: '#right' };
const chars = Array.from(alignLiteral);
const vlinePositions: number[] = [];
let columnIndex = 0;
for (const c of chars) {
if (c === '|') {
vlinePositions.push(columnIndex);
} else if (c === 'l' || c === 'c' || c === 'r') {
columnIndex++;
}
}
if (vlinePositions.length > 0) {
let augment_str: string;
if (vlinePositions.length === 1) {
augment_str = `#${vlinePositions[0]}`;
} else {
augment_str = `#(vline: (${vlinePositions.join(', ')}))`;
}
np['augment'] = new TypstToken(TypstTokenType.LITERAL, augment_str).toNode();
}
const alignments = chars
.map(c => alignMap[c])
.filter((x) => x !== undefined)
.map(s => new TypstToken(TypstTokenType.LITERAL, s!).toNode());
if (alignments.length > 0) {
const all_same = alignments.every(item => item.eq(alignments[0]));
np['align'] = all_same ? alignments[0] : new TypstToken(TypstTokenType.LITERAL, '#center').toNode();
}
return np;
}
const TYPST_LEFT_PARENTHESIS: TypstToken = new TypstToken(TypstTokenType.ELEMENT, '(');
const TYPST_RIGHT_PARENTHESIS: TypstToken = new TypstToken(TypstTokenType.ELEMENT, ')');
function appendWithBracketsIfNeeded(node: TypstNode): TypstNode {
let need_to_wrap = ['group', 'supsub', 'matrixLike', 'fraction','empty'].includes(node.type);
if (need_to_wrap) {
return new TypstLeftright(null, {
left: TYPST_LEFT_PARENTHESIS,
right: TYPST_RIGHT_PARENTHESIS,
body: node,
});
} else {
return node;
}
}
export function convert_tex_node_to_typst(abstractNode: TexNode, options: Tex2TypstOptions): TypstNode {
switch (abstractNode.type) {
case 'terminal': {
const node = abstractNode as TexTerminal;
return tex_token_to_typst(node.head, options).toNode();
}
case 'text': {
const node = abstractNode as TexText;
return new TypstToken(TypstTokenType.TEXT, node.head.value).toNode();
}
case 'ordgroup':
const node = abstractNode as TexGroup;
return new TypstGroup(
node.items.map((n) => convert_tex_node_to_typst(n, options))
);
case 'supsub': {
const node = abstractNode as TexSupSub;
let { base, sup, sub } = node;
// special hook for overbrace
// \overbrace{X}^{Y} -> overbrace(X, Y)
if (base && base.type === 'funcCall' && base.head.value === '\\overbrace' && sup) {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'overbrace'),
[convert_tex_node_to_typst((base as TexFuncCall).args[0], options), convert_tex_node_to_typst(sup, options)]
);
} else if (base && base.type === 'funcCall' && base.head.value === '\\underbrace' && sub) {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'underbrace'),
[convert_tex_node_to_typst((base as TexFuncCall).args[0], options), convert_tex_node_to_typst(sub, options)]
);
}
const data: TypstSupsubData = {
base: convert_tex_node_to_typst(base, options),
sup: sup? convert_tex_node_to_typst(sup, options) : null,
sub: sub? convert_tex_node_to_typst(sub, options) : null,
};
if (data.sup) {
data.sup = appendWithBracketsIfNeeded(data.sup);
}
if (data.sub) {
data.sub = appendWithBracketsIfNeeded(data.sub);
}
return new TypstSupsub(data);
}
case 'leftright': {
const node = abstractNode as TexLeftRight;
const { left, right } = node;
const typ_body = convert_tex_node_to_typst(node.body, options);
if (options.optimize) {
// optimization off: "lr(bar.v.double a + 1/2 bar.v.double)"
// optimization on : "norm(a + 1/2)"
if (left !== null && right !== null) {
const typ_left = tex_token_to_typst(left, options);
const typ_right = tex_token_to_typst(right, options);
if (left.value === '\\|' && right.value === '\\|') {
return new TypstFuncCall(new TypstToken(TypstTokenType.SYMBOL, 'norm'), [typ_body]);
}
// These pairs will be handled by Typst compiler by default. No need to add lr()
if ([
"[]", "()", "\\{\\}",
"\\lfloor\\rfloor",
"\\lceil\\rceil",
"\\lfloor\\rceil",
].includes(left.value + right.value)) {
return new TypstGroup([typ_left.toNode(), typ_body, typ_right.toNode()]);
}
}
}
// "\left\{ a + \frac{1}{3} \right." -> "lr(\{ a + 1/3)"
// "\left. a + \frac{1}{3} \right\}" -> "lr( a + 1/3 \})"
// Note that: In lr(), if one side of delimiter doesn't present (i.e. derived from "\\left." or "\\right."),
// "(", ")", "{", "[", should be escaped with "\" to be the other side of delimiter.
// Simple "lr({ a+1/3)" doesn't compile in Typst.
const escape_curly_or_paren = function(s: TypstToken): TypstToken {
if (["(", ")", "{", "["].includes(s.value)) {
return new TypstToken(TypstTokenType.ELEMENT, "\\" + s.value);
} else {
return s;
}
};
let typ_left = left? tex_token_to_typst(left, options) : null;
let typ_right = right? tex_token_to_typst(right, options) : null;
// Convert < and > delimiters to chevron.l and chevron.r
if (typ_left && typ_left.value === '<') {
typ_left = new TypstToken(TypstTokenType.SYMBOL, 'chevron.l');
}
if (typ_right && typ_right.value === '>') {
typ_right = new TypstToken(TypstTokenType.SYMBOL, 'chevron.r');
}
if (typ_left === null && typ_right !== null) { // left.
typ_right = escape_curly_or_paren(typ_right);
}
if (typ_right === null && typ_left !== null) { // right.
typ_left = escape_curly_or_paren(typ_left);
}
return new TypstLeftright(
TypstToken.LR,
{ body: typ_body, left: typ_left, right: typ_right }
);
}
case 'funcCall': {
const node = abstractNode as TexFuncCall;
const arg0 = convert_tex_node_to_typst(node.args[0], options);
// \sqrt[3]{x} -> root(3, x)
if (node.head.value === '\\sqrt' && node.data) {
const data = convert_tex_node_to_typst(node.data, options); // the number of times to take the root
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'root'),
[data, arg0]
);
}
// \mathbf{a} -> upright(bold(a))
if (node.head.value === '\\mathbf') {
const inner: TypstNode = new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'bold'),
[arg0]
);
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'upright'),
[inner]
);
}
// \overrightarrow{AB} -> arrow(A B)
if (node.head.value === '\\overrightarrow') {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'arrow'),
[arg0]
);
}
// \overleftarrow{AB} -> accent(A B, arrow.l)
if (node.head.value === '\\overleftarrow') {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'accent'),
[arg0, new TypstToken(TypstTokenType.SYMBOL, 'arrow.l').toNode()]
);
}
// \operatorname{opname} -> op("opname")
if (node.head.value === '\\operatorname' || node.head.value === '\\operatorname*') {
// arg0 must be of type 'literal' in this situation
if (options.optimize) {
if (TYPST_INTRINSIC_OP.includes(arg0.head.value)) {
return new TypstToken(TypstTokenType.SYMBOL, arg0.head.value).toNode();
}
}
const op_call = new TypstFuncCall(new TypstToken(TypstTokenType.SYMBOL, 'op'), [new TypstToken(TypstTokenType.TEXT, arg0.head.value).toNode()]);
if (node.head.value === '\\operatorname*') {
op_call.setOptions({ limits: new TypstToken(TypstTokenType.LITERAL, '#true').toNode() });
}
return op_call;
}
// \textcolor{red}{2y} -> #text(fill: red)[$2y$]
if (node.head.value === '\\textcolor') {
const res = new TypstMarkupFunc(
new TypstToken(TypstTokenType.SYMBOL, `#text`),
[convert_tex_node_to_typst(node.args[1], options)]
);
res.setOptions({ fill: arg0 });
return res;
}
// \substack{a \\ b} -> a \ b
// as in translation from \sum_{\substack{a \\ b}} to sum_(a \ b)
if (node.head.value === '\\substack') {
return arg0;
}
// \displaylines{...} -> ...
if (node.head.value === '\\displaylines') {
return arg0;
}
// \mathinner{...} -> ...
if (node.head.value === '\\mathinner') {
return arg0;
}
// \mathrel{X} -> class("relation", X)
if (node.head.value === '\\mathrel') {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'class'),
[new TypstToken(TypstTokenType.TEXT, 'relation').toNode(), arg0]
);
}
// \mathbin{X} -> class("binary", X)
if (node.head.value === '\\mathbin') {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'class'),
[new TypstToken(TypstTokenType.TEXT, 'binary').toNode(), arg0]
);
}
// \mathop{X} -> class("large", X)
if (node.head.value === '\\mathop') {
return new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'class'),
[new TypstToken(TypstTokenType.TEXT, 'large').toNode(), arg0]
);
}
// \not X -> X.not
if (node.head.value === '\\not') {
const sym = convert_tex_node_to_typst(node.args[0], options);
assert(sym.type === "terminal");
if(sym.head.type === TypstTokenType.SYMBOL) {
return new TypstToken(TypstTokenType.SYMBOL, sym.head.value + '.not').toNode();
} else {
switch(sym.head.value) {
case '=':
return new TypstToken(TypstTokenType.SYMBOL, 'eq.not').toNode();
default:
throw new Error(`Not supported: \\not ${sym.head.value}`);
}
}
}
// \pmod y -> (mod y)
if (node.head.value === '\\pmod') {
const g = new TypstGroup([new TypstToken(TypstTokenType.SYMBOL, 'mod').toNode(), arg0]);
return new TypstLeftright(
null,
{ body: g, left: TypstToken.LEFT_PAREN, right: TypstToken.RIGHT_PAREN }
);
}
if (node.head.value === '\\overset') {
return convert_overset(node, options);
}
if (node.head.value === '\\underset') {
return convert_underset(node, options);
}
// The braket package
if(['\\bra', '\\ket', '\\braket', '\\set', '\\Bra', '\\Ket', '\\Braket', '\\Set'].includes(node.head.value)) {
function process_vertical_bar(n: TypstNode, once: boolean): TypstNode {
const mid_bar = new TypstFuncCall(
new TypstToken(TypstTokenType.SYMBOL, 'mid'),
[TypstToken.VERTICAL_BAR.toNode()]
);
if (n.type === 'terminal' && n.head.eq(TypstToken.VERTICAL_BAR)) {
return mid_bar;
} else if (n.type === 'group') {
const group = n as TypstGroup;
for (let i = 0; i < group.items.length; i++) {
if (group.items[i].type === 'terminal' && group.items[i].head.eq(TypstToken.VERTICAL_BAR)) {
group.items[i] = mid_bar;
if (once) {
break;
}
}
}
return group;
} else {
return n;
}
}
switch(node.head.value) {
case '\\bra':
// \bra{x} -> chevron.l x|
return new TypstLeftright(
null,
{ body: arg0, left: TypstToken.LEFT_ANGLE, right: TypstToken.VERTICAL_BAR }
);
case '\\ket':
// \ket{x} -> |x chevron.r
return new TypstLeftright(
null,
{ body: arg0, left: TypstToken.VERTICAL_BAR, right: TypstToken.RIGHT_ANGLE }
);
case '\\braket':
// \braket{x} -> chevron.l x chevron.r
return new TypstLeftright(
null,
{ body: arg0, left: TypstToken.LEFT_ANGLE, right: TypstToken.RIGHT_ANGLE }
);
case '\\set':
// \set{a, b, c} -> {a, b, c}
return new TypstLeftright(
null,
{ body: arg0, left: TypstToken.LEFT_BRACE, right: TypstToken.RIGHT_BRACE }
);
case '\\Bra':
// \Bra{x | \frac{1}{3}} -> lr(chevron.l x | 1/3 |)
return new TypstLeftright(
TypstToken.LR,
{ body: arg0, left: TypstToken.LEFT_ANGLE, right: TypstToken.VERTICAL_BAR }
);
case '\\Ket':
// \Ket{x | \frac{1}{3}} -> lr(|x | 1/3 chevron.r)
return new TypstLeftright(
TypstToken.LR,
{ body: arg0, left: TypstToken.VERTICAL_BAR, right: TypstToken.RIGHT_ANGLE }
);
case '\\Braket':
// \Braket{x | \frac{1}{3}} -> lr(chevron.l x mid(|) 1/3 chevron.r)
// In \Bracket, all vertical lines will expand.
return new TypstLeftright(
TypstToken.LR,
{ body: process_vertical_bar(arg0, false), left: TypstToken.LEFT_ANGLE, right: TypstToken.RIGHT_ANGLE }
);
case '\\Set':
// \Set{x | \frac{1}{3}} -> lr({x mid(|) 1/3})
// In \Set, the first vertical will expand.
return new TypstLeftright(
TypstToken.LR,
{ body: process_vertical_bar(arg0, true), left: TypstToken.LEFT_BRACE, right: TypstToken.RIGHT_BRACE }
);
default:
// unreachable
}
}
// \frac{a}{b} -> a / b
if (node.head.value === '\\frac') {
if (options.fracToSlash) {
return new TypstFraction(node.args.map((n) => convert_tex_node_to_typst(n, options)).map(appendWithBracketsIfNeeded));
}
}
if(options.optimize) {
// \mathbb{R} -> RR
if (node.head.value === '\\mathbb' && /^\\mathbb{[A-Z]}$/.test(node.toString())) {
return new TypstToken(TypstTokenType.SYMBOL, arg0.head.value.repeat(2)).toNode();
}
// \mathrm{d} -> dif
if (node.head.value === '\\mathrm' && node.toString() === '\\mathrm{d}') {
return new TypstToken(TypstTokenType.SYMBOL, 'dif').toNode();
}
}
// generic case
return new TypstFuncCall(
tex_token_to_typst(node.head, options),
node.args.map((n) => convert_tex_node_to_typst(n, options))
);
}
case 'beginend': {
const node = abstractNode as TexBeginEnd;
const matrix = node.matrix.map((row) => row.map((n) => convert_tex_node_to_typst(n, options)));
if (node.head.value.startsWith('align')) {
// align, align*, alignat, alignat*, aligned, etc.
return new TypstMatrixLike(null, matrix);
}
if (node.head.value === 'cases') {
return new TypstMatrixLike(TypstMatrixLike.CASES, matrix);
}
if (node.head.value === 'subarray') {
if (node.data) {
const align_node = node.data;
switch (align_node.head.value) {
case 'r':
matrix.forEach(row => row.push(TypstToken.EMPTY.toNode()));
break;
case 'l':
matrix.forEach(row => row.unshift(TypstToken.EMPTY.toNode()));
break;
default:
break;
}
}
return new TypstMatrixLike(null, matrix);
}
if (node.head.value === 'array') {
const np: TypstNamedParams = { 'delim': TYPST_NONE };
assert(node.data !== null && node.head.type === TexTokenType.LITERAL);
const np_new = convert_tex_array_align_literal(node.data!.head.value);
Object.assign(np, np_new);
const res = new TypstMatrixLike(TypstMatrixLike.MAT, matrix);
res.setOptions(np);
return res;
}
if (node.head.value.endsWith('matrix')) {
const res = new TypstMatrixLike(TypstMatrixLike.MAT, matrix);
let delim: TypstToken;
switch (node.head.value) {
case 'matrix':
delim = TypstToken.NONE;
break;
case 'pmatrix':
// delim = new TypstToken(TypstTokenType.TEXT, '(');
// break;
return res; // typst mat use delim:"(" by default
case 'bmatrix':
delim = new TypstToken(TypstTokenType.TEXT, '[');
break;
case 'Bmatrix':
delim = new TypstToken(TypstTokenType.TEXT, '{');
break;
case 'vmatrix':
delim = new TypstToken(TypstTokenType.TEXT, '|');
break;
case 'Vmatrix': {
delim = new TypstToken(TypstTokenType.SYMBOL, 'bar.v.double');
break;
}
default:
throw new ConverterError(`Unimplemented beginend: ${node.head}`, node);
}
res.setOptions({ 'delim': delim.toNode()});
return res;
}
throw new ConverterError(`Unimplemented beginend: ${node.head}`, node);
}
default:
throw new ConverterError(`Unimplemented node type: ${abstractNode.type}`, abstractNode);
}
}
/*
const TYPST_UNARY_FUNCTIONS: string[] = [
'sqrt',
'bold',
'arrow',
'upright',
'lr',
'op',
'macron',
'dot',
'dot.double',
'hat',
'tilde',
'overline',
'underline',
'bb',
'cal',
'frak',
'floor',
'ceil',
'norm',
'limits',
'#h',
];
const TYPST_BINARY_FUNCTIONS: string[] = [
'frac',
'root',
'overbrace',
'underbrace',
];
*/
function typst_token_to_tex(token: TypstToken): TexToken {
switch (token.type) {
case TypstTokenType.NONE:
// e.g. Typst `#none^2` is converted to TeX `^2`
return TexToken.EMPTY;
case TypstTokenType.SYMBOL: {
const _typst_symbol_to_tex = function(symbol: string): string {
switch(symbol) {
case 'eq':
return '=';
case 'plus':
return '+';
case 'minus':
return '-';
case 'percent':
return '%';
default: {
if (reverseSymbolMap.has(symbol)) {
return '\\' + reverseSymbolMap.get(symbol);
} else {
return '\\' + symbol;
}
}
}
}
if (token.value.endsWith('.not')) {
const sym = _typst_symbol_to_tex(token.value.slice(0, -4));
return new TexToken(TexTokenType.COMMAND, sym.startsWith('\\') ? `\\not${sym}` : `\\not ${sym}`);
}
return new TexToken(TexTokenType.COMMAND, _typst_symbol_to_tex(token.value));
}
case TypstTokenType.ELEMENT: {
let value: string;
if (['{', '}', '%'].includes(token.value)) {
value = '\\' + token.value;
} else {
value = token.value;
}
return new TexToken(TexTokenType.ELEMENT, value);
}
case TypstTokenType.LITERAL:
return new TexToken(TexTokenType.LITERAL, token.value);
case TypstTokenType.TEXT:
return new TexToken(TexTokenType.LITERAL, token.value);
case TypstTokenType.COMMENT:
return new TexToken(TexTokenType.COMMENT, token.value);
case TypstTokenType.SPACE:
return new TexToken(TexTokenType.SPACE, token.value);
case TypstTokenType.CONTROL: {
let value: string;
switch(token.value) {
case '\\':
value = '\\\\';
break;
case '&':
value = '&';
break;
default:
throw new Error(`[typst_token_to_tex]Unimplemented control sequence: ${token.value}`);
}
return new TexToken(TexTokenType.CONTROL, value);
}
case TypstTokenType.NEWLINE:
return new TexToken(TexTokenType.NEWLINE, token.value);
default:
throw new Error(`Unimplemented token type: ${token.type}`);
}
}
const TEX_NODE_COMMA = new TexToken(TexTokenType.ELEMENT, ',').toNode();
export function convert_typst_node_to_tex(abstractNode: TypstNode, options: Typst2TexOptions): TexNode {
const convert_node = (node: TypstNode) => convert_typst_node_to_tex(node, options);
switch (abstractNode.type) {
case 'terminal': {
const node = abstractNode as TypstTerminal;
if (node.head.type === TypstTokenType.SYMBOL) {
// special hook for eq.def
if (node.head.value === 'eq.def') {
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\overset'), [
new TexText(new TexToken(TexTokenType.LITERAL, 'def')),
new TexToken(TexTokenType.ELEMENT, '=').toNode()
]);
}
// special hook for comma
if(node.head.value === 'comma') {
return new TexToken(TexTokenType.ELEMENT, ',').toNode();
}
// special hook for dif
if(node.head.value === 'dif') {
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\mathrm'), [new TexToken(TexTokenType.ELEMENT, 'd').toNode()]);
}
// special hook for hyph and hyph.minus
if(node.head.value === 'hyph' || node.head.value === 'hyph.minus') {
return new TexText(new TexToken(TexTokenType.LITERAL, '-'));
}
// special hook for mathbb{R} <-- RR
if(/^([A-Z])\1$/.test(node.head.value)) {
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\mathbb'), [
new TexToken(TexTokenType.ELEMENT, node.head.value[0]).toNode()
]);
}
}
if (node.head.type === TypstTokenType.TEXT) {
return new TexText(new TexToken(TexTokenType.LITERAL, node.head.value));
}
return typst_token_to_tex(node.head).toNode();
}
case 'group': {
const node = abstractNode as TypstGroup;
const args = node.items.map(convert_node);
const alignment_char = new TexToken(TexTokenType.CONTROL, '&').toNode();
const newline_char = new TexToken(TexTokenType.CONTROL, '\\\\').toNode();
if (array_includes(args, alignment_char)) {
// wrap the whole math formula with \begin{aligned} and \end{aligned}
const rows = array_split(args, newline_char);
const matrix: TexNode[][] = [];
for(const row of rows) {
const cells = array_split(row, alignment_char);
matrix.push(cells.map(cell => new TexGroup(cell)));
}
return new TexBeginEnd(new TexToken(TexTokenType.LITERAL, 'aligned'), matrix);
}
return new TexGroup(args);
}
case 'leftright': {
const node = abstractNode as TypstLeftright;
const body = convert_node(node.body);
let left = node.left? typst_token_to_tex(node.left) : new TexToken(TexTokenType.ELEMENT, '.');
let right = node.right? typst_token_to_tex(node.right) : new TexToken(TexTokenType.ELEMENT, '.');
// const is_over_high = node.isOverHigh();
// const left_delim = is_over_high ? '\\left(' : '(';
// const right_delim = is_over_high ? '\\right)' : ')';
if (node.isOverHigh()) {
left.value = '\\left' + left.value;
right.value = '\\right' + right.value;
}
// TODO: should be TeXLeftRight(...)
// But currently writer will output `\left |` while people commonly prefer `\left|`.
return new TexGroup([left.toNode(), body, right.toNode()]);
}
case 'funcCall': {
const node = abstractNode as TypstFuncCall;
switch (node.head.value) {
// special hook for norm
// `\| a \|` <- `norm(a)`
// `\left\| a + \frac{1}{3} \right\|` <- `norm(a + 1/3)`
case 'norm': {
const arg0 = node.args[0];
const body = convert_node(arg0);
if (node.isOverHigh()) {
return new TexLeftRight({
body: body,
left: new TexToken(TexTokenType.COMMAND, "\\|"),
right: new TexToken(TexTokenType.COMMAND, "\\|")
});
} else {
return body;
}
}
// special hook for floor, ceil
// `\lfloor a \rfloor` <- `floor(a)`
// `\lceil a \rceil` <- `ceil(a)`
// `\left\lfloor a \right\rfloor` <- `floor(a)`
// `\left\lceil a \right\rceil` <- `ceil(a)`
case 'floor':
case 'ceil': {
const left = "\\l" + node.head.value;
const right = "\\r" + node.head.value;
const arg0 = node.args[0];
const body = convert_node(arg0);
const left_node = new TexToken(TexTokenType.COMMAND, left);
const right_node = new TexToken(TexTokenType.COMMAND, right);
if (node.isOverHigh()) {
return new TexLeftRight({
body: body,
left: left_node,
right: right_node
});
} else {
return new TexGroup([left_node.toNode(), body, right_node.toNode()]);
}
}
// special hook for root
case 'root': {
const [degree, radicand] = node.args;
const data = convert_node(degree);
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\sqrt'), [convert_node(radicand)], data);
}
// special hook for overbrace and underbrace
case 'overbrace':
case 'underbrace': {
const [body, label] = node.args;
const base = new TexFuncCall(typst_token_to_tex(node.head), [convert_node(body)]);
const script = convert_node(label);
const data = node.head.value === 'overbrace' ? { base, sup: script, sub: null } : { base, sub: script, sup: null };
return new TexSupSub(data);
}
// special hook for vec
// "vec(a, b, c)" -> "\begin{pmatrix}a\\ b\\ c\end{pmatrix}"
case 'vec': {
const tex_matrix = node.args.map(arg => [convert_node(arg)]);
return new TexBeginEnd(new TexToken(TexTokenType.LITERAL, 'pmatrix'), tex_matrix);
}
// special hook for op
case 'op': {
const arg0 = node.args[0];
assert(arg0.head.type === TypstTokenType.TEXT);
return new TexFuncCall(typst_token_to_tex(node.head), [new TexToken(TexTokenType.LITERAL, arg0.head.value).toNode()]);
}
case 'class': {
const arg0 = node.args[0];
assert(arg0.head.type === TypstTokenType.TEXT);
let command: string;
switch (arg0.head.value) {
// \mathrel{X} <- class("relation", X)
case 'relation':
command = '\\mathrel';
break;
// \mathbin{X} <- class("binary", X)
case 'binary':
command = '\\mathbin';
break;
// \mathop{X} <- class("large", X)
case 'large':
command = '\\mathop';
break;
default:
throw new Error(`Unimplemented class: ${arg0.head.value}`);
}
return new TexFuncCall(
new TexToken(TexTokenType.COMMAND, command),
[convert_node(node.args[1])]
);
}
// display(...) -> \displaystyle ... \textstyle
// The postprocessor will remove \textstyle if it is the end of the math code
case 'display': {
const arg0 = node.args[0];
const group = new TexGroup([
TexToken.COMMAND_DISPLAYSTYLE.toNode(),
convert_node(arg0),
]);
if (!options.blockMathMode) {
group.items.push(TexToken.COMMAND_TEXTSTYLE.toNode());
}
return group;
}
// inline(...) -> \textstyle ... \displaystyle
// The postprocessor will remove \displaystyle if it is the end of the math code
case 'inline': {
const arg0 = node.args[0];
const group = new TexGroup([
TexToken.COMMAND_TEXTSTYLE.toNode(),
convert_node(arg0),
]);
if (options.blockMathMode) {
group.items.push(TexToken.COMMAND_DISPLAYSTYLE.toNode());
}
return group;
}
// general case
default: {
const func_name_tex = typst_token_to_tex(node.head);
const is_known_func = TEX_UNARY_COMMANDS.includes(func_name_tex.value.substring(1))
|| TEX_BINARY_COMMANDS.includes(func_name_tex.value.substring(1));
if (func_name_tex.value.length > 0 && is_known_func) {
return new TexFuncCall(func_name_tex, node.args.map(convert_node));
} else {
return new TexGroup([
typst_token_to_tex(node.head).toNode(),
new TexToken(TexTokenType.ELEMENT, '(').toNode(),
...array_intersperse(node.args.map(convert_node), TEX_NODE_COMMA),
new TexToken(TexTokenType.ELEMENT, ')').toNode()
]);
}
}
}
}
case 'markupFunc': {
const node = abstractNode as TypstMarkupFunc;
switch (node.head.value) {
case '#text': {
// `\textcolor{red}{2y}` <- `#text(fill: red)[$2 y$]`
if (node.options && node.options['fill']) {
const color = node.options['fill'];
return new TexFuncCall(
new TexToken(TexTokenType.COMMAND, '\\textcolor'),
[convert_node(color), convert_node(node.fragments[0])]
)
}
}
case '#heading':
default:
throw new Error(`Unimplemented markup function: ${node.head.value}`);
}
}
case 'supsub': {
const node = abstractNode as TypstSupsub;
const { base, sup, sub } = node;
const sup_tex = sup? convert_node(sup) : null;
const sub_tex = sub? convert_node(sub) : null;
// special hook for limits
// `limits(+)^a` -> `\overset{a}{+}`
// `limits(+)_a` -> `\underset{a}{+}`
// `limits(+)_a^b` -> `\overset{b}{\underset{a}{+}}`
if (base.head.eq(new TypstToken(TypstTokenType.SYMBOL, 'limits'))) {
const limits = base as TypstFuncCall;
const body_in_limits = convert_node(limits.args[0]);
if (sup_tex !== null && sub_tex === null) {
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\overset'), [sup_tex, body_in_limits]);
} else if (sup_tex === null && sub_tex !== null) {
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\underset'), [sub_tex, body_in_limits]);
} else {
const underset_call = new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\underset'), [sub_tex!, body_in_limits]);
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\overset'), [sup_tex!, underset_call]);
}
}
const base_tex = convert_node(base);
const res = new TexSupSub({
base: base_tex,
sup: sup_tex,
sub: sub_tex
});
return res;
}
case 'matrixLike': {
const node = abstractNode as TypstMatrixLike;
const tex_matrix = node.matrix.map(row => row.map(convert_node));
if (node.head.eq(TypstMatrixLike.MAT)) {
let env_type = 'pmatrix'; // typst mat use delim:"(" by default
if (node.options) {
if ('delim' in node.options) {
const delim = node.options.delim;
switch (delim.head.value) {
case '#none':
env_type = 'matrix';
break;
case '[':
case ']':
env_type = 'bmatrix';
break;
case '(':
case ')':
env_type = 'pmatrix';
break;
case '{':
case '}':
env_type = 'Bmatrix';
break;
case '|':
env_type = 'vmatrix';
break;
case 'bar':
case 'bar.v':
env_type = 'vmatrix';
break;
case 'bar.v.double':
env_type = 'Vmatrix';
break;
default:
throw new Error(`Unexpected delimiter ${delim.head}`);
}
}
}
return new TexBeginEnd(new TexToken(TexTokenType.LITERAL, env_type), tex_matrix);
} else if (node.head.eq(TypstMatrixLike.CASES)) {
return new TexBeginEnd(new TexToken(TexTokenType.LITERAL, 'cases'), tex_matrix);
} else {
throw new Error(`Unexpected matrix type ${node.head}`);
}
}
case 'fraction': {
const node = abstractNode as TypstFraction;
const [numerator, denominator] = node.args;
const num_tex = convert_node(numerator);
const den_tex = convert_node(denominator);
return new TexFuncCall(new TexToken(TexTokenType.COMMAND, '\\frac'), [num_tex, den_tex]);
}
default:
throw new Error('[convert_typst_node_to_tex] Unimplemented type: ' + abstractNode.type);
}
}