UNPKG

tex2typst

Version:

JavaScript library for converting TeX code to Typst

671 lines (569 loc) 21.1 kB
import { array_includes } from "./generic"; import { shorthandMap } from "./typst-shorthands"; import { isalpha } from "./utils"; export enum TypstTokenType { NONE, SYMBOL, ELEMENT, LITERAL, TEXT, COMMENT, SPACE, CONTROL, NEWLINE } export class TypstToken { readonly type: TypstTokenType; value: string; constructor(type: TypstTokenType, content: string) { this.type = type; this.value = content; } eq(other: TypstToken): boolean { return this.type === other.type && this.value === other.value; } isOneOf(tokens: TypstToken[]): boolean { return array_includes(tokens, this); } public toNode(): TypstNode { return new TypstTerminal(this); } public toString(): string { switch (this.type) { case TypstTokenType.TEXT: return `"${this.value}"`; case TypstTokenType.COMMENT: return `//${this.value}`; default: return this.value; } } public static readonly NONE = new TypstToken(TypstTokenType.NONE, '#none'); public static readonly EMPTY = new TypstToken(TypstTokenType.ELEMENT, ''); public static readonly LEFT_BRACE = new TypstToken(TypstTokenType.ELEMENT, '{'); public static readonly RIGHT_BRACE = new TypstToken(TypstTokenType.ELEMENT, '}'); public static readonly LEFT_PAREN = new TypstToken(TypstTokenType.ELEMENT, '('); public static readonly RIGHT_PAREN = new TypstToken(TypstTokenType.ELEMENT, ')'); public static readonly LEFT_ANGLE = new TypstToken(TypstTokenType.SYMBOL, 'chevron.l'); public static readonly RIGHT_ANGLE = new TypstToken(TypstTokenType.SYMBOL, 'chevron.r'); public static readonly VERTICAL_BAR = new TypstToken(TypstTokenType.ELEMENT, '|'); public static readonly PLUS = new TypstToken(TypstTokenType.ELEMENT, '+'); public static readonly MINUS = new TypstToken(TypstTokenType.ELEMENT, '-'); public static readonly LR = new TypstToken(TypstTokenType.SYMBOL, 'lr'); public static readonly LEFT_DELIMITERS = [ TypstToken.LEFT_PAREN, new TypstToken(TypstTokenType.ELEMENT, '['), TypstToken.LEFT_BRACE, TypstToken.VERTICAL_BAR, TypstToken.LEFT_ANGLE, new TypstToken(TypstTokenType.SYMBOL, 'paren.l'), new TypstToken(TypstTokenType.SYMBOL, 'brace.l'), ]; public static readonly RIGHT_DELIMITERS = [ TypstToken.RIGHT_PAREN, new TypstToken(TypstTokenType.ELEMENT, ']'), TypstToken.RIGHT_BRACE, TypstToken.VERTICAL_BAR, TypstToken.RIGHT_ANGLE, new TypstToken(TypstTokenType.SYMBOL, 'paren.r'), new TypstToken(TypstTokenType.SYMBOL, 'brace.r'), ]; } export interface TypstSupsubData { base: TypstNode; sup: TypstNode | null; sub: TypstNode | null; } export interface TypstLeftRightData { body: TypstNode; left: TypstToken | null; right: TypstToken | null; } export interface TypstWriterEnvironment { insideFunctionDepth: number; } export interface TypstWriterOptions { nonStrict: boolean; preferShorthands: boolean; keepSpaces: boolean; inftyToOo: boolean; optimize: boolean; } export class TypstWriterError extends Error { node: TypstNode | TypstToken; constructor(message: string, node: TypstNode | TypstToken) { super(message); this.name = "TypstWriterError"; this.node = node; } } const SOFT_SPACE = new TypstToken(TypstTokenType.CONTROL, ' '); /** * fraction: `1/2`, `(x + y)/2`, `(1+x)/(1-x)` * group: `a + 1/3` * leftright: `(a + 1/3)`, `[a + 1/3)`, `lr(]sum_(x=1)^n])` * markupFunc: `#heading(level: 2)[something]`, `#text(fill: red)[some text and math $x + y$]` */ export type TypstNodeType = 'terminal' | 'group' | 'supsub' | 'funcCall' | 'fraction'| 'leftright' | 'matrixLike'| 'markupFunc'; export type TypstNamedParams = { [key: string]: TypstNode; }; export abstract class TypstNode { readonly type: TypstNodeType; head: TypstToken; // Some Typst functions accept additional options. e.g. mat() has option "delim", op() has option "limits" options?: TypstNamedParams; constructor(type: TypstNodeType, head: TypstToken | null) { this.type = type; this.head = head ? head : TypstToken.NONE; } // whether the node is over high so that if it's wrapped in braces, \left and \right should be used in its TeX form // e.g. 1/2 is over high, "2" is not. abstract isOverHigh(): boolean; abstract isLeftSpaceful(): boolean; abstract isRightSpaceful(): boolean; // Serialize a tree of TypstNode into a list of TypstToken abstract serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[]; public setOptions(options: TypstNamedParams) { this.options = options; } // Note that this is only shallow equality. public eq(other: TypstNode): boolean { return this.type === other.type && this.head.eq(other.head); } public toString(): string { throw new Error(`Unimplemented toString() in base class TypstNode`); } } export class TypstTerminal extends TypstNode { constructor(head: TypstToken) { super('terminal', head); } public isOverHigh(): boolean { return false; } public isLeftSpaceful(): boolean { switch (this.head.type) { case TypstTokenType.SPACE: case TypstTokenType.NEWLINE: return false; case TypstTokenType.TEXT: return true; case TypstTokenType.SYMBOL: case TypstTokenType.ELEMENT: { if(['(', '!', ',', ')', '}', ']'].includes(this.head.value)) { return false; } return true; } default: return true; } } public isRightSpaceful(): boolean { switch (this.head.type) { case TypstTokenType.SPACE: case TypstTokenType.NEWLINE: return false; case TypstTokenType.TEXT: return true; case TypstTokenType.SYMBOL: case TypstTokenType.ELEMENT: { return ['+', '=', ',', '\\/', 'dot', 'dot.op', 'arrow', 'arrow.r'].includes(this.head.value); } default: return false; } } public toString(): string { return this.head.toString(); } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { if (this.head.type === TypstTokenType.ELEMENT) { if (this.head.value === ',' && env.insideFunctionDepth > 0) { return [SOFT_SPACE, new TypstToken(TypstTokenType.SYMBOL, 'comma')]; } } else if (this.head.type === TypstTokenType.SYMBOL) { let symbol_name = this.head.value; if (options.preferShorthands) { if (shorthandMap.has(symbol_name)) { symbol_name = shorthandMap.get(symbol_name)!; } } if (options.inftyToOo && symbol_name === 'infinity') { symbol_name = 'oo'; } return [new TypstToken(TypstTokenType.SYMBOL, symbol_name)]; } else if (this.head.type === TypstTokenType.SPACE || this.head.type === TypstTokenType.NEWLINE) { const queue: TypstToken[] = []; for (const c of this.head.value) { if (c === ' ') { if (options.keepSpaces) { queue.push(new TypstToken(TypstTokenType.SPACE, c)); } } else if (c === '\n') { queue.push(new TypstToken(TypstTokenType.SYMBOL, c)); } else { throw new TypstWriterError(`Unexpected whitespace character: ${c}`, this); } } return queue; } return [this.head]; } } class TypstTokenQueue { private queue: TypstToken[] = []; public pushSoftSpace() { if (this.queue.length === 0) { return; } else if (this.queue.at(-1)!.eq(SOFT_SPACE)) { return; } else if (['(', '{', '['].includes(this.queue.at(-1)!.value)) { return; } this.queue.push(SOFT_SPACE); } public pushAll(tokens: TypstToken[]) { if (tokens.length == 0) { return; } else if (tokens[0].eq(SOFT_SPACE) && this.queue.length === 0) { this.queue.push(...tokens.slice(1)); } else { if ([')', '}', ']'].includes(tokens[0].value)) { while (this.queue.at(-1)?.eq(SOFT_SPACE)) { this.queue.pop(); } } this.queue.push(...tokens); } } public getQueue(): TypstToken[] { const res = Array.from(this.queue); while (res.at(-1)?.eq(SOFT_SPACE)) { res.pop(); } return res; } } export class TypstGroup extends TypstNode { public items: TypstNode[]; constructor(items: TypstNode[]) { super('group', TypstToken.NONE); this.items = items; } public isOverHigh(): boolean { return this.items.some((n) => n.isOverHigh()); } public isLeftSpaceful(): boolean { if (this.items.length === 0) { return false; } return this.items[0].isLeftSpaceful(); } public isRightSpaceful(): boolean { if (this.items.length === 0) { return false; } return this.items.at(-1)!.isRightSpaceful(); } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { if (this.items.length === 0) { return []; } const q = new TypstTokenQueue(); for(let i = 0; i < this.items.length; i++) { const n = this.items[i]; const tokens = n.serialize(env, options); if (n.isLeftSpaceful()) { q.pushSoftSpace(); } q.pushAll(tokens); if (n.isRightSpaceful()) { q.pushSoftSpace(); } } const queue = q.getQueue(); // "- a" -> "-a" // "+ a" -> "+a" if (queue.length > 0 && (queue[0].eq(TypstToken.MINUS) || queue[0].eq(TypstToken.PLUS))) { while(queue.length > 1 && queue[1].eq(SOFT_SPACE)) { queue.splice(1, 1); } } return queue; } } export class TypstSupsub extends TypstNode { public base: TypstNode; public sup: TypstNode | null; public sub: TypstNode | null; constructor(data: TypstSupsubData) { super('supsub', TypstToken.NONE); this.base = data.base; this.sup = data.sup; this.sub = data.sub; } public isOverHigh(): boolean { return this.base.isOverHigh(); } public isLeftSpaceful(): boolean { return true; } public isRightSpaceful(): boolean { return true; } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { const queue: TypstToken[] = []; let { base, sup, sub } = this; queue.push(...base.serialize(env, options)); const has_prime = (sup && sup.head.eq(new TypstToken(TypstTokenType.ELEMENT, "'"))); if (has_prime) { // Put prime symbol before '_'. Because $y_1'$ is not displayed properly in Typst (so far) // e.g. // y_1' -> y'_1 // y_{a_1}' -> y'_(a_1) queue.push(new TypstToken(TypstTokenType.ELEMENT, '\'')); } if (sub) { queue.push(new TypstToken(TypstTokenType.ELEMENT, '_')); queue.push(...sub.serialize(env, options)); } if (sup && !has_prime) { queue.push(new TypstToken(TypstTokenType.ELEMENT, '^')); queue.push(...sup.serialize(env, options)); } return queue; } } export class TypstFuncCall extends TypstNode { public args: TypstNode[]; constructor(head: TypstToken, args: TypstNode[]) { super('funcCall', head); this.args = args; } public isOverHigh(): boolean { if (this.head.value === 'frac') { return true; } return this.args.some((n) => n.isOverHigh()); } public isLeftSpaceful(): boolean { return true; } public isRightSpaceful(): boolean { return !['op', 'bold', 'dot'].includes(this.head.value); } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { const queue: TypstToken[] = []; const func_symbol: TypstToken = this.head; queue.push(func_symbol); env.insideFunctionDepth++; queue.push(TYPST_LEFT_PARENTHESIS); for (let i = 0; i < this.args.length; i++) { queue.push(...this.args[i].serialize(env, options)); if (i < this.args.length - 1) { queue.push(new TypstToken(TypstTokenType.ELEMENT, ',')); queue.push(SOFT_SPACE); } } if (this.options) { for (const [key, value] of Object.entries(this.options)) { queue.push(new TypstToken(TypstTokenType.LITERAL, `, ${key}: ${value.toString()}`)); } } queue.push(TYPST_RIGHT_PARENTHESIS); env.insideFunctionDepth--; return queue; } } export class TypstFraction extends TypstNode { public args: TypstNode[]; constructor(args: TypstNode[]) { super('fraction', TypstToken.NONE); this.args = args; } public isOverHigh(): boolean { return true; } public isLeftSpaceful(): boolean { return true; } public isRightSpaceful(): boolean { return true; } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { const queue: TypstToken[] = []; const [numerator, denominator] = this.args; queue.push(...numerator.serialize(env, options)); queue.push(new TypstToken(TypstTokenType.ELEMENT, '/')); queue.push(...denominator.serialize(env, options)); return queue; } } const TYPST_LEFT_PARENTHESIS: TypstToken = new TypstToken(TypstTokenType.ELEMENT, '('); const TYPST_RIGHT_PARENTHESIS: TypstToken = new TypstToken(TypstTokenType.ELEMENT, ')'); export class TypstLeftright extends TypstNode { public body: TypstNode; public left: TypstToken | null; public right: TypstToken | null; // head is either null or 'lr' constructor(head: TypstToken | null, data: TypstLeftRightData) { super('leftright', head); this.body = data.body; this.left = data.left; this.right = data.right; } public isOverHigh(): boolean { return this.body.isOverHigh(); } public isLeftSpaceful(): boolean { return true; } public isRightSpaceful(): boolean { return true; } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { const queue: TypstToken[] = []; const LR = new TypstToken(TypstTokenType.SYMBOL, 'lr'); const {left, right} = this; if (this.head.eq(LR)) { queue.push(LR); queue.push(TYPST_LEFT_PARENTHESIS); } if (left) { queue.push(left); // `lr(brace.l 1/3 brace.r)` instead of `lr(brace.l1/3 brace.r)` if (isalpha(left.value[0])) { queue.push(SOFT_SPACE); } } queue.push(...this.body.serialize(env, options)); if (right) { // `lr(brace.l 1/3 brace.r)` instead of `lr(brace.l 1/3brace.r)` if (isalpha(right.value[0])) { queue.push(SOFT_SPACE); } queue.push(right); } if (this.head.eq(LR)) { queue.push(TYPST_RIGHT_PARENTHESIS); } return queue; } } export class TypstMatrixLike extends TypstNode { public matrix: TypstNode[][]; // head is 'mat', 'cases' or null constructor(head: TypstToken | null, data: TypstNode[][]) { super('matrixLike', head); this.matrix = data; } public isOverHigh(): boolean { return true; } public isLeftSpaceful(): boolean { return true; } public isRightSpaceful(): boolean { return false; } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { const queue: TypstToken[] = []; let cell_sep: TypstToken; let row_sep: TypstToken; if (this.head.eq(TypstMatrixLike.MAT)) { cell_sep = new TypstToken(TypstTokenType.ELEMENT, ','); row_sep = new TypstToken(TypstTokenType.ELEMENT, ';'); } else if (this.head.eq(TypstMatrixLike.CASES)) { cell_sep = new TypstToken(TypstTokenType.ELEMENT, '&'); row_sep = new TypstToken(TypstTokenType.ELEMENT, ','); } else if (this.head.eq(TypstToken.NONE)){ // head is null cell_sep = new TypstToken(TypstTokenType.ELEMENT, '&'); row_sep = new TypstToken(TypstTokenType.SYMBOL, '\\'); } if (!this.head.eq(TypstToken.NONE)) { queue.push(this.head); env.insideFunctionDepth++; queue.push(TYPST_LEFT_PARENTHESIS); if (this.options) { for (const [key, value] of Object.entries(this.options)) { queue.push(new TypstToken(TypstTokenType.LITERAL, `${key}: ${value.toString()}, `)); } } } this.matrix.forEach((row, i) => { row.forEach((cell, j) => { queue.push(...cell.serialize(env, options)); if (j < row.length - 1) { if (cell_sep.value === '&') { queue.push(SOFT_SPACE); } queue.push(cell_sep); queue.push(SOFT_SPACE); } else { if (i < this.matrix.length - 1) { if (row_sep.value === '\\') { queue.push(SOFT_SPACE); } queue.push(row_sep); queue.push(SOFT_SPACE); } } }); }); if (!this.head.eq(TypstToken.NONE)) { queue.push(TYPST_RIGHT_PARENTHESIS); env.insideFunctionDepth--; } return queue; } static readonly MAT = new TypstToken(TypstTokenType.SYMBOL, 'mat'); static readonly CASES = new TypstToken(TypstTokenType.SYMBOL, 'cases'); } export class TypstMarkupFunc extends TypstNode { /* In idealized situations, for `#heading([some text and math $x + y$ example])`, fragments would be [TypstMarkup{"some text and math "}, TypstNode{"x + y"}, TypstMarkup{" example"}] At present, we haven't implemented anything about TypstMarkup. So only pattens like `#heading(level: 2)[$x+y$]`, `#text(fill: red)[$x + y$]` are supported. Therefore, fragments is always a list containing exactly 1 TypstNode in well-working situations. */ public fragments: TypstNode[]; constructor(head: TypstToken, fragments: TypstNode[]) { super('markupFunc', head); this.fragments = fragments; } public isOverHigh(): boolean { return this.fragments.some((n) => n.isOverHigh()); } public isLeftSpaceful(): boolean { return true; } public isRightSpaceful(): boolean { return true; } public serialize(env: TypstWriterEnvironment, options: TypstWriterOptions): TypstToken[] { const queue: TypstToken[] = []; queue.push(this.head); env.insideFunctionDepth++; queue.push(TYPST_LEFT_PARENTHESIS); if (this.options) { const entries = Object.entries(this.options); for (let i = 0; i < entries.length; i++) { const [key, value] = entries[i]; queue.push(new TypstToken(TypstTokenType.LITERAL, `${key}: ${value.toString()}`)); if (i < entries.length - 1) { queue.push(new TypstToken(TypstTokenType.ELEMENT, ',')); } } } queue.push(TYPST_RIGHT_PARENTHESIS); queue.push(new TypstToken(TypstTokenType.LITERAL, '[')); for (const frag of this.fragments) { queue.push(new TypstToken(TypstTokenType.LITERAL, '$')); queue.push(...frag.serialize(env, options)); queue.push(new TypstToken(TypstTokenType.LITERAL, '$')); } queue.push(new TypstToken(TypstTokenType.LITERAL, ']')); return queue; } }