UNPKG

tex2typst

Version:

JavaScript library for converting TeX code to Typst

397 lines (369 loc) 16.4 kB
import { TexNode, TypstNode, TypstPrimitiveValue, TypstSupsubData, TypstToken, TypstTokenType } from "./types"; import { shorthandMap } from "./typst-shorthands"; import { assert } from "./util"; function is_delimiter(c: TypstNode): boolean { return c.type === 'atom' && ['(', ')', '[', ']', '{', '}', '|', '⌊', '⌋', '⌈', '⌉'].includes(c.content); } const TYPST_LEFT_PARENTHESIS: TypstToken = new TypstToken(TypstTokenType.ELEMENT, '('); const TYPST_RIGHT_PARENTHESIS: TypstToken = new TypstToken(TypstTokenType.ELEMENT, ')'); const TYPST_COMMA: TypstToken = new TypstToken(TypstTokenType.ELEMENT, ','); const TYPST_NEWLINE: TypstToken = new TypstToken(TypstTokenType.SYMBOL, '\n'); function typst_primitive_to_string(value: TypstPrimitiveValue) { switch (typeof value) { case 'string': return `"${value}"`; case 'number': return (value as number).toString(); case 'boolean': return (value as boolean) ? '#true' : '#false'; default: assert(value instanceof TypstNode, 'Not a valid primitive value'); return (value as TypstNode).content; } } export class TypstWriterError extends Error { node: TexNode | TypstNode | TypstToken; constructor(message: string, node: TexNode | TypstNode | TypstToken) { super(message); this.name = "TypstWriterError"; this.node = node; } } export interface TypstWriterOptions { nonStrict: boolean; preferShorthands: boolean; keepSpaces: boolean; inftyToOo: boolean; } export class TypstWriter { private nonStrict: boolean; private preferShorthands: boolean; private keepSpaces: boolean; private inftyToOo: boolean; protected buffer: string = ""; protected queue: TypstToken[] = []; private insideFunctionDepth = 0; constructor(opt: TypstWriterOptions) { this.nonStrict = opt.nonStrict; this.preferShorthands = opt.preferShorthands; this.keepSpaces = opt.keepSpaces; this.inftyToOo = opt.inftyToOo; } private writeBuffer(token: TypstToken) { const str = token.toString(); if (str === '') { return; } // TODO: "C \frac{xy}{z}" should translate to "C (x y)/z" instead of "C(x y)/z" let no_need_space = false; // putting the first token in clause no_need_space ||= /[\(\[\|]$/.test(this.buffer) && /^\w/.test(str); // closing a clause no_need_space ||= /^[})\]\|]$/.test(str); // putting the opening '(' for a function no_need_space ||= /[^=]$/.test(this.buffer) && str === '('; // putting punctuation no_need_space ||= /^[_^,;!]$/.test(str); // putting a prime no_need_space ||= str === "'"; // leading sign. e.g. produce "+1" instead of " +1" no_need_space ||= /[\(\[{]\s*(-|\+)$/.test(this.buffer) || this.buffer === "-" || this.buffer === "+"; // new line no_need_space ||= str.startsWith('\n'); // buffer is empty no_need_space ||= this.buffer === ""; // str is starting with a space itself no_need_space ||= /^\s/.test(str); // "&=" instead of "& =" no_need_space ||= this.buffer.endsWith('&') && str === '='; // before or after a slash e.g. "a/b" instead of "a / b" no_need_space ||= this.buffer.endsWith('/') || str === '/'; // other cases no_need_space ||= /[\s_^{\(]$/.test(this.buffer); if (!no_need_space) { this.buffer += ' '; } this.buffer += str; } // Serialize a tree of TypstNode into a list of TypstToken public serialize(node: TypstNode) { switch (node.type) { case 'none': this.queue.push(new TypstToken(TypstTokenType.NONE, '#none')); break; case 'atom': { if (node.content === ',' && this.insideFunctionDepth > 0) { this.queue.push(new TypstToken(TypstTokenType.SYMBOL, 'comma')); } else { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, node.content)); } break; } case 'symbol': { let content = node.content; if(this.preferShorthands) { if (shorthandMap.has(content)) { content = shorthandMap.get(content)!; } } if (this.inftyToOo && content === 'infinity') { content = 'oo'; } this.queue.push(new TypstToken(TypstTokenType.SYMBOL, content)); break; } case 'text': this.queue.push(new TypstToken(TypstTokenType.TEXT, node.content)); break; case 'comment': this.queue.push(new TypstToken(TypstTokenType.COMMENT, node.content)); break; case 'whitespace': for (const c of node.content) { if (c === ' ') { if (this.keepSpaces) { this.queue.push(new TypstToken(TypstTokenType.SPACE, c)); } } else if (c === '\n') { this.queue.push(new TypstToken(TypstTokenType.SYMBOL, c)); } else { throw new TypstWriterError(`Unexpected whitespace character: ${c}`, node); } } break; case 'group': for (const item of node.args!) { this.serialize(item); } break; case 'supsub': { let { base, sup, sub } = node.data as TypstSupsubData; this.appendWithBracketsIfNeeded(base); let trailing_space_needed = false; const has_prime = (sup && sup.type === 'atom' && sup.content === '\''); 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} this.queue.push(new TypstToken(TypstTokenType.ELEMENT, '\'')); trailing_space_needed = false; } if (sub) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, '_')); trailing_space_needed = this.appendWithBracketsIfNeeded(sub); } if (sup && !has_prime) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, '^')); trailing_space_needed = this.appendWithBracketsIfNeeded(sup); } if (trailing_space_needed) { this.queue.push(new TypstToken(TypstTokenType.CONTROL, ' ')); } break; } case 'funcCall': { const func_symbol: TypstToken = new TypstToken(TypstTokenType.SYMBOL, node.content); this.queue.push(func_symbol); if (node.content !== 'lr') { this.insideFunctionDepth++; } this.queue.push(TYPST_LEFT_PARENTHESIS); for (let i = 0; i < node.args!.length; i++) { this.serialize(node.args![i]); if (i < node.args!.length - 1) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, ',')); } } if (node.options) { for (const [key, value] of Object.entries(node.options)) { const value_str = typst_primitive_to_string(value); this.queue.push(new TypstToken(TypstTokenType.SYMBOL, `, ${key}: ${value_str}`)); } } this.queue.push(TYPST_RIGHT_PARENTHESIS); if (node.content !== 'lr') { this.insideFunctionDepth--; } break; } case 'fraction': { const [numerator, denominator] = node.args!; if(numerator.type === 'group') { this.queue.push(TYPST_LEFT_PARENTHESIS); this.serialize(numerator); this.queue.push(TYPST_RIGHT_PARENTHESIS); } else { this.serialize(numerator); } this.queue.push(new TypstToken(TypstTokenType.ELEMENT, '/')); if(denominator.type === 'group') { this.queue.push(TYPST_LEFT_PARENTHESIS); this.serialize(denominator); this.queue.push(TYPST_RIGHT_PARENTHESIS); } else { this.serialize(denominator); } break; } case 'align': { const matrix = node.data as TypstNode[][]; matrix.forEach((row, i) => { row.forEach((cell, j) => { if (j > 0) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, '&')); } this.serialize(cell); }); if (i < matrix.length - 1) { this.queue.push(new TypstToken(TypstTokenType.SYMBOL, '\\')); } }); break; } case 'matrix': { const matrix = node.data as TypstNode[][]; this.queue.push(new TypstToken(TypstTokenType.SYMBOL, 'mat')); this.insideFunctionDepth++; this.queue.push(TYPST_LEFT_PARENTHESIS); if (node.options) { for (const [key, value] of Object.entries(node.options)) { const value_str = typst_primitive_to_string(value); this.queue.push(new TypstToken(TypstTokenType.SYMBOL, `${key}: ${value_str}, `)); } } matrix.forEach((row, i) => { row.forEach((cell, j) => { // There is a leading & in row // if (cell.type === 'ordgroup' && cell.args!.length === 0) { // this.queue.push(new TypstNode('atom', ',')); // return; // } // if (j == 0 && cell.type === 'newline' && cell.content === '\n') { // return; // } this.serialize(cell); // cell.args!.forEach((n) => this.append(n)); if (j < row.length - 1) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, ',')); } else { if (i < matrix.length - 1) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, ';')); } } }); }); this.queue.push(TYPST_RIGHT_PARENTHESIS); this.insideFunctionDepth--; break; } case 'cases': { const cases = node.data as TypstNode[][]; this.queue.push(new TypstToken(TypstTokenType.SYMBOL, 'cases')); this.insideFunctionDepth++; this.queue.push(TYPST_LEFT_PARENTHESIS); if (node.options) { for (const [key, value] of Object.entries(node.options)) { const value_str = typst_primitive_to_string(value); this.queue.push(new TypstToken(TypstTokenType.SYMBOL, `${key}: ${value_str}, `)); } } cases.forEach((row, i) => { row.forEach((cell, j) => { this.serialize(cell); if (j < row.length - 1) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, '&')); } else { if (i < cases.length - 1) { this.queue.push(new TypstToken(TypstTokenType.ELEMENT, ',')); } } }); }); this.queue.push(TYPST_RIGHT_PARENTHESIS); this.insideFunctionDepth--; break; } case 'unknown': { if (this.nonStrict) { this.queue.push(new TypstToken(TypstTokenType.SYMBOL, node.content)); } else { throw new TypstWriterError(`Unknown macro: ${node.content}`, node); } break; } default: throw new TypstWriterError(`Unimplemented node type to append: ${node.type}`, node); } } private appendWithBracketsIfNeeded(node: TypstNode): boolean { let need_to_wrap = ['group', 'supsub', 'empty'].includes(node.type); if (node.type === 'group') { if (node.args!.length === 0) { // e.g. TeX `P_{}` converts to Typst `P_()` need_to_wrap = true; } else { const first = node.args![0]; const last = node.args![node.args!.length - 1]; if (is_delimiter(first) && is_delimiter(last)) { need_to_wrap = false; } } } if (need_to_wrap) { this.queue.push(TYPST_LEFT_PARENTHESIS); this.serialize(node); this.queue.push(TYPST_RIGHT_PARENTHESIS); } else { this.serialize(node); } return !need_to_wrap; } protected flushQueue() { const SOFT_SPACE = new TypstToken(TypstTokenType.CONTROL, ' '); // delete soft spaces if they are not needed for(let i = 0; i < this.queue.length; i++) { let token = this.queue[i]; if (token.eq(SOFT_SPACE)) { if (i === this.queue.length - 1) { this.queue[i].value = ''; } else if (this.queue[i + 1].isOneOf([TYPST_RIGHT_PARENTHESIS, TYPST_COMMA, TYPST_NEWLINE])) { this.queue[i].value = ''; } } } this.queue.forEach((token) => { this.writeBuffer(token) }); this.queue = []; } public finalize(): string { this.flushQueue(); const smartFloorPass = function (input: string): string { // Use regex to replace all "floor.l xxx floor.r" with "floor(xxx)" let res = input.replace(/floor\.l\s*(.*?)\s*floor\.r/g, "floor($1)"); // Typst disallow "floor()" with empty argument, so add am empty string inside if it's empty. res = res.replace(/floor\(\)/g, 'floor("")'); return res; }; const smartCeilPass = function (input: string): string { // Use regex to replace all "ceil.l xxx ceil.r" with "ceil(xxx)" let res = input.replace(/ceil\.l\s*(.*?)\s*ceil\.r/g, "ceil($1)"); // Typst disallow "ceil()" with empty argument, so add an empty string inside if it's empty. res = res.replace(/ceil\(\)/g, 'ceil("")'); return res; } const smartRoundPass = function (input: string): string { // Use regex to replace all "floor.l xxx ceil.r" with "round(xxx)" let res = input.replace(/floor\.l\s*(.*?)\s*ceil\.r/g, "round($1)"); // Typst disallow "round()" with empty argument, so add an empty string inside if it's empty. res = res.replace(/round\(\)/g, 'round("")'); return res; } const all_passes = [smartFloorPass, smartCeilPass, smartRoundPass]; for (const pass of all_passes) { this.buffer = pass(this.buffer); } return this.buffer; } }