feat: render LaTeX, custom HTML and diagrams in the transcript (#19)
This commit was merged in pull request #19.
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
import { CLOSING_FENCE, OPENING_FENCE, mapOutsideCodeSpans, readCodeSpan, repairCodeBlocks } from "./code";
|
||||
import { sanitizeInlineHtml } from "./html";
|
||||
import { looksLikeMath, renderLatex } from "./latex";
|
||||
|
||||
/**
|
||||
* The rendering pass for model output.
|
||||
*
|
||||
* `markdown.ts` builds the transcript, but an answer is not markdown that the
|
||||
* renderer can draw as written. Models write LaTeX, raw HTML, and diagrams made
|
||||
* of spaces, and Vicinae draws none of those: it has no maths block, block level
|
||||
* HTML is reduced to its text, and a paragraph wraps. Each of those is turned
|
||||
* into something the renderer already knows how to draw here, in one place, on
|
||||
* the way to the pane.
|
||||
*
|
||||
* The order is the whole design:
|
||||
*
|
||||
* 1. {@link repairCodeBlocks} runs first, because it decides where the fences
|
||||
* are. Everything after this pass is fence aware and skips code.
|
||||
* 2. Maths runs next, so a formula becomes inline markup before the HTML pass
|
||||
* looks at markup at all.
|
||||
* 3. HTML runs last, so the tags the maths pass adds are checked by the same
|
||||
* allowlist as the ones the model wrote. It is a second opinion on this
|
||||
* module's own output, and it costs nothing to take.
|
||||
*
|
||||
* Nothing here reads preferences or touches Vicinae, so the whole pipeline is
|
||||
* unit testable and the clipboard copy in `markdown.ts` keeps the source text
|
||||
* rather than the rendering.
|
||||
*/
|
||||
|
||||
/** Fence labels that mean "this body is maths". */
|
||||
const MATH_LANGUAGES = new Set(["math", "maths", "latex", "tex", "stex", "katex"]);
|
||||
|
||||
interface Segment {
|
||||
/** Fenced code, which every pass here leaves alone unless it is maths. */
|
||||
fence: boolean;
|
||||
/** Lower-cased language on the opening fence, empty when there is none. */
|
||||
language: string;
|
||||
/** For a fence, the body lines only. For text, the lines themselves. */
|
||||
lines: string[];
|
||||
/** A fence exactly as written, so it can be copied through untouched. */
|
||||
raw?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a document into text and fences.
|
||||
*
|
||||
* Both passes need the same answer to "is this code", and this is the one place
|
||||
* that decides. A fence with no closing marker runs to the end of the document,
|
||||
* which is how the parser reads it too.
|
||||
*/
|
||||
function splitSegments(markdown: string): Segment[] {
|
||||
const lines = markdown.split("\n");
|
||||
const segments: Segment[] = [];
|
||||
|
||||
let text: string[] = [];
|
||||
const flushText = () => {
|
||||
if (text.length > 0) segments.push({ fence: false, language: "", lines: text });
|
||||
text = [];
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const opener = OPENING_FENCE.exec(lines[index]);
|
||||
|
||||
if (!opener) {
|
||||
text.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushText();
|
||||
|
||||
const raw = [lines[index]];
|
||||
index += 1;
|
||||
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
||||
raw.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
raw.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
fence: true,
|
||||
language: opener[3].toLowerCase(),
|
||||
lines: raw.slice(1, raw.length - (CLOSING_FENCE.test(raw[raw.length - 1]) ? 1 : 0)),
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
flushText();
|
||||
return segments;
|
||||
}
|
||||
|
||||
const DISPLAY_MARKERS = ["$$", "\\["];
|
||||
|
||||
const DISPLAY_CLOSERS: Record<string, string> = { $$: "$$", "\\[": "\\]" };
|
||||
|
||||
interface DisplayOpen {
|
||||
marker: string;
|
||||
/** The formula on this line, whether or not the marker closed it here. */
|
||||
body: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a display maths opener, if this line has one.
|
||||
*
|
||||
* A marker is only read as a block opener when it is the first thing on the
|
||||
* line, so `where $$x$$ is` stays a sentence. What follows the marker is the
|
||||
* formula whether or not the closer is on the same line, because a long formula
|
||||
* written across lines puts its first terms beside the opening `$$`.
|
||||
*/
|
||||
function openDisplayMath(line: string): DisplayOpen | undefined {
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
for (const marker of DISPLAY_MARKERS) {
|
||||
if (!trimmed.startsWith(marker)) continue;
|
||||
|
||||
const closer = DISPLAY_CLOSERS[marker];
|
||||
const inner = trimmed.slice(marker.length).trim();
|
||||
|
||||
if (inner.length >= closer.length && inner.endsWith(closer)) {
|
||||
return { marker, body: inner.slice(0, inner.length - closer.length), closed: true };
|
||||
}
|
||||
|
||||
return { marker, body: inner, closed: false };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The part of a line before the closer, or `undefined` when the line has none. */
|
||||
function closeDisplayMath(line: string, marker: string): string | undefined {
|
||||
const closer = DISPLAY_CLOSERS[marker];
|
||||
const trimmed = line.trimEnd();
|
||||
return trimmed.endsWith(closer) ? trimmed.slice(0, trimmed.length - closer.length) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline maths, in the order the delimiters have to be tried.
|
||||
*
|
||||
* `$$` has to be read before `$`, and both have to be read after the backslash
|
||||
* forms, which are unambiguous. The single `$` pattern is the fussy one and the
|
||||
* guard around it is deliberate: an opening `$` cannot be followed by a space
|
||||
* and a closing `$` cannot be preceded by one, which is what keeps `$5 and $10`
|
||||
* and `cd $A; echo $B` out of the renderer.
|
||||
*/
|
||||
const INLINE_MATH: Array<{ pattern: RegExp; guarded: boolean }> = [
|
||||
{ pattern: /\\\((.+?)\\\)/g, guarded: false },
|
||||
{ pattern: /\\\[(.+?)\\\]/g, guarded: false },
|
||||
{ pattern: /\$\$(.+?)\$\$/g, guarded: false },
|
||||
{ pattern: /(?<![$\\])\$(?![\s$])((?:\\.|[^\n$\\])+?)(?<!\s)\$(?!\$|\d)/g, guarded: true },
|
||||
];
|
||||
|
||||
function convertInlineMath(text: string): string {
|
||||
return mapOutsideCodeSpans(text, (chunk) => {
|
||||
let out = chunk;
|
||||
|
||||
for (const { pattern, guarded } of INLINE_MATH) {
|
||||
out = out.replace(pattern, (match: string, body: string) =>
|
||||
!guarded || looksLikeMath(body) ? renderLatex(body) : match,
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The line with every code span blanked out, at the same length.
|
||||
*
|
||||
* Used to ask whether a display marker is really at the start of the line. The
|
||||
* replacement is a non-space character on purpose: spaces would be trimmed away
|
||||
* again and a marker hidden behind a code span would look like a block opener.
|
||||
*/
|
||||
function maskCodeSpans(line: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < line.length) {
|
||||
if (line[index] !== "`") {
|
||||
out += line[index];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = readCodeSpan(line, index);
|
||||
out += "\u0000".repeat(span.raw.length);
|
||||
index = span.end;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the maths in a run of text.
|
||||
*
|
||||
* A display formula is pulled onto its own paragraph with a blank line either
|
||||
* side, which is the only way to keep it out of the sentence around it and out
|
||||
* of the paragraph that follows.
|
||||
*/
|
||||
function renderMathLines(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const display = openDisplayMath(line);
|
||||
|
||||
// A marker inside a code span is being shown, not used, and it is the
|
||||
// opening one that decides whether this is a block at all.
|
||||
if (!display || !openDisplayMath(maskCodeSpans(line))) {
|
||||
out.push(convertInlineMath(line));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (display.closed) {
|
||||
const formula = renderLatex(display.body, { display: true });
|
||||
if (formula.length > 0) out.push("", formula, "");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = [display.body];
|
||||
let cursor = index + 1;
|
||||
let closed = false;
|
||||
|
||||
while (cursor < lines.length) {
|
||||
const rest = closeDisplayMath(lines[cursor], display.marker);
|
||||
if (rest !== undefined) {
|
||||
body.push(rest);
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
body.push(lines[cursor]);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
// A marker with no closer is only a formula if what it swallowed looks like
|
||||
// one. A stray `$$5 per unit` would otherwise pull the rest of the answer
|
||||
// into a single paragraph, which is a lot of damage for a typo.
|
||||
if (!closed && !looksLikeMath(body.join("\n"))) {
|
||||
out.push(convertInlineMath(line));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const formula = renderLatex(body.join("\n"), { display: true });
|
||||
if (formula.length > 0) out.push("", formula, "");
|
||||
|
||||
// An unclosed display marker swallows the rest of the run, which is what the
|
||||
// formula the model started to write actually covers.
|
||||
index = closed ? cursor + 1 : lines.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function mathPass(segments: Segment[]): Segment[] {
|
||||
return segments.map((segment) => {
|
||||
if (segment.fence) {
|
||||
if (!MATH_LANGUAGES.has(segment.language)) return segment;
|
||||
|
||||
const formula = renderLatex(segment.lines.join("\n"), { display: true });
|
||||
return { fence: false, language: "", lines: formula.length > 0 ? ["", formula, ""] : [] };
|
||||
}
|
||||
|
||||
return { fence: false, language: "", lines: renderMathLines(segment.lines) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a parser reads as an HTML *block* when a line starts with one.
|
||||
*
|
||||
* This is CommonMark's type 6 list. An HTML block is not a rendering of its own
|
||||
* in Vicinae: `processHtmlNodes` in `markdown-model.cpp` walks it, pulls out
|
||||
* every `<img>` as a block image, and keeps the trimmed text of everything else
|
||||
* with the markup thrown away. So `<table><tr><td>a</td><td>b</td></tr></table>`
|
||||
* arrives as the word `ab`, and a `<div>` full of styled spans arrives as one
|
||||
* run-on sentence.
|
||||
*/
|
||||
const HTML_BLOCK_TAGS = new Set([
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"base",
|
||||
"basefont",
|
||||
"blockquote",
|
||||
"body",
|
||||
"caption",
|
||||
"center",
|
||||
"col",
|
||||
"colgroup",
|
||||
"dd",
|
||||
"details",
|
||||
"dialog",
|
||||
"dir",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"frame",
|
||||
"frameset",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"head",
|
||||
"header",
|
||||
"hr",
|
||||
"html",
|
||||
"iframe",
|
||||
"legend",
|
||||
"li",
|
||||
"link",
|
||||
"main",
|
||||
"menu",
|
||||
"menuitem",
|
||||
"nav",
|
||||
"noframes",
|
||||
"ol",
|
||||
"optgroup",
|
||||
"option",
|
||||
"p",
|
||||
"param",
|
||||
"search",
|
||||
"section",
|
||||
"summary",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"title",
|
||||
"tr",
|
||||
"track",
|
||||
"ul",
|
||||
]);
|
||||
|
||||
/** Tags that never need a closing tag, which the balance check has to skip. */
|
||||
const VOID_TAGS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
/** Whether this line would open an HTML block rather than continue a paragraph. */
|
||||
function startsHtmlBlock(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
const name = /^<([a-zA-Z][a-zA-Z0-9-]*)[\s/>]/.exec(trimmed)?.[1]?.toLowerCase();
|
||||
if (name && HTML_BLOCK_TAGS.has(name)) return true;
|
||||
|
||||
// A single complete tag with nothing but whitespace after it opens a block
|
||||
// too, which is the rule that catches a standalone `<img>` or `<span>`.
|
||||
return /^<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^<>]*)?\/?>\s*$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every tag in this run of HTML is closed in the right order.
|
||||
*
|
||||
* Only well-formed markup is handed to the rich text engine, because markup that
|
||||
* does not balance is markup nothing can be relied on to draw, and the text the
|
||||
* block path keeps is the better of two broken outcomes.
|
||||
*/
|
||||
function isBalancedHtml(text: string): boolean {
|
||||
const stack: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^<>]*?(\/?)>/g)) {
|
||||
const name = match[2].toLowerCase();
|
||||
if (match[3] === "/" || VOID_TAGS.has(name)) continue;
|
||||
|
||||
if (match[1] === "/") {
|
||||
if (stack.pop() !== name) return false;
|
||||
} else {
|
||||
stack.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a paragraph should be handed to the engine as inline HTML.
|
||||
*
|
||||
* The `<img>` exception is deliberate: an image inside an HTML block is the one
|
||||
* thing that path does better than this one, because it becomes a block image
|
||||
* the renderer actually loads.
|
||||
*/
|
||||
function shouldUnblock(paragraph: string[]): boolean {
|
||||
if (paragraph.length === 0) return false;
|
||||
if (!startsHtmlBlock(paragraph[0])) return false;
|
||||
|
||||
const text = paragraph.join("\n");
|
||||
if (/<img\b/i.test(text)) return false;
|
||||
|
||||
return isBalancedHtml(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a block of HTML into the paragraph the engine can see.
|
||||
*
|
||||
* Wrapping it in a `<span>` and collapsing it onto one line is what makes the
|
||||
* difference: a line that begins with `<span>` is a paragraph rather than an
|
||||
* HTML block, because `span` is not on that list, and a paragraph keeps its raw
|
||||
* HTML (`CMARK_NODE_HTML_INLINE` is passed through untouched). The same markup
|
||||
* therefore reaches the rich text engine, which is the only component in the
|
||||
* chain that can draw a table or a styled panel at all.
|
||||
*
|
||||
* Lines are joined because a continuation line starting with a block tag would
|
||||
* open a new HTML block of its own, which is how a multi-line HTML table loses
|
||||
* its markup today.
|
||||
*/
|
||||
function unblockHtml(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
let end = index;
|
||||
while (end < lines.length && lines[end].trim().length > 0) end += 1;
|
||||
|
||||
const paragraph = lines.slice(index, end);
|
||||
if (shouldUnblock(paragraph)) {
|
||||
out.push(`<span>${paragraph.map((line) => line.trim()).join(" ")}</span>`);
|
||||
} else {
|
||||
out.push(...paragraph);
|
||||
}
|
||||
|
||||
if (end === index) {
|
||||
out.push(lines[end]);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
index = end;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function htmlPass(segments: Segment[]): Segment[] {
|
||||
return segments.map((segment) => {
|
||||
if (segment.fence) return segment;
|
||||
|
||||
const unblocked = unblockHtml(segment.lines);
|
||||
return {
|
||||
fence: false,
|
||||
language: "",
|
||||
lines: sanitizeInlineHtml(unblocked.join("\n")).split("\n"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function flatten(segments: Segment[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const segment of segments) lines.push(...(segment.fence ? (segment.raw ?? []) : segment.lines));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maths and markup only, for text a person wrote.
|
||||
*
|
||||
* A question goes through this rather than the full pass: a person pastes LaTeX
|
||||
* and writes HTML, but their typing is not the thing the code-fence repair
|
||||
* exists for, so a question is left as it was typed wherever the repair would
|
||||
* have changed it.
|
||||
*/
|
||||
export function renderInlineContent(markdown: string): string {
|
||||
return flatten(htmlPass(mathPass(splitSegments(markdown))));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full rendering pass, from what the model wrote to what the pane draws.
|
||||
*
|
||||
* Takes one message's text, not a whole transcript: the transcript adds its own
|
||||
* markdown around each message (a blockquote for a question, a rule between
|
||||
* exchanges) and none of that should be read as model output.
|
||||
*/
|
||||
export function renderModelOutput(markdown: string): string {
|
||||
return renderInlineContent(repairCodeBlocks(markdown));
|
||||
}
|
||||
Reference in New Issue
Block a user