Typst Compile System Update

This commit is contained in:
2026-05-21 22:41:04 -04:00
parent a44babed9f
commit 997c0c7262
7 changed files with 60 additions and 173 deletions
+8 -8
View File
@@ -18,20 +18,20 @@
"@distube/yt-dlp": "^2.0.1",
"@google/generative-ai": "^0.24.1",
"@langchain/google-genai": "^1.0.3",
"bufferutil": "^4.0.9",
"bufferutil": "^4.1.0",
"chartjs-node-canvas": "^5.0.0",
"discord.js": "^14.22.1",
"distube": "^5.0.7",
"discord.js": "^14.26.4",
"distube": "^5.2.3",
"dotenv": "^16.6.1",
"ffmpeg-static": "^5.2.0",
"js-yaml": "^4.1.0",
"libsodium-wrappers": "^0.7.15",
"mongoose": "^9.0.0",
"ffmpeg-static": "^5.3.0",
"js-yaml": "^4.1.1",
"libsodium-wrappers": "^0.7.16",
"mongoose": "^9.6.2",
"ms": "^2.1.3",
"opusscript": "^0.1.1",
"sodium": "^3.0.2",
"sodium-native": "^4.3.3",
"utf-8-validate": "^6.0.5",
"utf-8-validate": "^6.0.6",
"zlib-sync": "^0.1.10"
},
"devDependencies": {
+3 -4
View File
@@ -2,7 +2,6 @@ import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
import QuestionScheduler from "../../libs/QuestionScheduler";
import AnswerGrader from "../../libs/AnswerGrader";
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"];
@@ -45,12 +44,12 @@ export default class DailyCommand extends BotCommand {
return interaction.editReply({ content: "You've already submitted an answer for today's question! Check back tomorrow." });
}
const imagePath = await client.typst.renderToImage(question.typst_source, {
const imageBuffer = await client.typst.renderToImage(question.typst_source, {
topic: question.topic,
difficulty: question.difficulty_rating
});
if (!imagePath) {
if (!imageBuffer) {
// Log error to logs channel
const logsChannelId = process.env.LOGS_CHANNEL_ID;
if (logsChannelId) {
@@ -89,7 +88,7 @@ export default class DailyCommand extends BotCommand {
.setFooter({ text: `Resets at 12 AM local time` })
.setTimestamp();
const attachment = new AttachmentBuilder(imagePath, { name: `daily_${safeSubject}.png` });
const attachment = new AttachmentBuilder(imageBuffer, { name: `daily_${safeSubject}.png` });
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
+3 -4
View File
@@ -2,7 +2,6 @@ import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
import QuestionScheduler from "../../libs/QuestionScheduler";
import AnswerGrader from "../../libs/AnswerGrader";
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"];
@@ -45,12 +44,12 @@ export default class WeeklyCommand extends BotCommand {
return interaction.editReply({ content: "You've already submitted an answer for this week's question! Check back next Sunday." });
}
const imagePath = await client.typst.renderToImage(question.typst_source, {
const imageBuffer = await client.typst.renderToImage(question.typst_source, {
topic: question.topic,
difficulty: question.difficulty_rating
});
if (!imagePath) {
if (!imageBuffer) {
// Log error to logs channel
const logsChannelId = process.env.LOGS_CHANNEL_ID;
if (logsChannelId) {
@@ -89,7 +88,7 @@ export default class WeeklyCommand extends BotCommand {
.setFooter({ text: `Resets at 12 AM Sunday` })
.setTimestamp();
const attachment = new AttachmentBuilder(imagePath, { name: `weekly_${safeSubject}.png` });
const attachment = new AttachmentBuilder(imageBuffer, { name: `weekly_${safeSubject}.png` });
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
-8
View File
@@ -11,14 +11,6 @@ export default async(Discord: any, client: BotClient) => {
client.emit("birthdayCheck");
}, ms('30m'));
// Clean up old typst files daily
setInterval(() => {
client.emit("typstCleanup");
}, ms('24h'));
// Run cleanup once on startup
client.emit("typstCleanup");
} catch (err) {
console.log(err);
} finally {
+1 -20
View File
@@ -1,20 +1 @@
import BotClient from "../../../libs/BotClient";
export default async(Discord: any, client: BotClient) => {
console.log("[TypstCleanup] Running cleanup for old typst files...");
// Delete files older than 1 day (after the question day passes)
const result = await client.typst.cleanupOldFiles(1);
if (result.deleted > 0) {
console.log(`[TypstCleanup] ✅ Successfully cleaned up ${result.deleted} old typst file(s)`);
}
if (result.errors > 0) {
console.error(`[TypstCleanup] ⚠️ Encountered ${result.errors} error(s) during cleanup`);
}
if (result.deleted === 0 && result.errors === 0) {
console.log("[TypstCleanup] No old files to clean up");
}
}
export default async() => {};
+40 -125
View File
@@ -1,12 +1,11 @@
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
// Default page settings prepended to all compile() calls so the API never falls back to A4.
// Users can override with their own #set page() — Typst's last rule wins.
const DEFAULT_PAGE = `#set page(width: auto, height: auto, margin: 8pt, fill: white)\n`;
export default class Typst {
messages: Map<string, { replyId: string; ownerId?: string }>;
private readonly apiUrl = "https://typ.sirblob.co/v1/render";
constructor() {
this.messages = new Map();
}
@@ -23,76 +22,36 @@ export default class Typst {
return null;
}
async compile(code: string, options?: { cleanup?: boolean }): Promise<{ buffers?: Buffer[]; error?: string; files?: string[]; typPath?: string }> {
const storageDir = path.resolve('./storage/typst');
await fs.promises.mkdir(storageDir, { recursive: true }).catch(() => {});
const uid = `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
const baseName = `typ_${uid}`;
const typPath = path.join(storageDir, `${baseName}.typ`);
const pngTemplate = path.join(storageDir, `${baseName}{p}.png`);
const pngSingle = path.join(storageDir, `${baseName}.png`);
const cleanup = options?.cleanup !== undefined ? options!.cleanup : true;
try {
await fs.promises.writeFile(typPath, code, 'utf8');
} catch (writeErr: any) {
return { error: `Failed to write typ file: ${String(writeErr)}` };
}
let compiledToTemplate = false;
try {
await execFileAsync('typst', ['compile', '--format', 'png', '--ppi', '300', typPath, pngTemplate], { timeout: 30000 });
compiledToTemplate = true;
} catch (multiErr: any) {
try {
await execFileAsync('typst', ['compile', '--format', 'png', '--ppi', '300', typPath, pngSingle], { timeout: 30000 });
compiledToTemplate = false;
} catch (singleErr: any) {
if (cleanup) await fs.promises.unlink(typPath).catch(() => {});
const msg = (multiErr && (multiErr.stderr || multiErr.message)) || (singleErr && (singleErr.stderr || singleErr.message)) || 'Unknown typst error';
return { error: `Typst compilation failed: ${String(msg)}` };
}
}
let pngFiles: string[] = [];
try {
const all = await fs.promises.readdir(storageDir);
if (compiledToTemplate) pngFiles = all.filter(f => f.startsWith(baseName) && f.endsWith('.png')).sort();
else if (all.includes(`${baseName}.png`)) pngFiles = [`${baseName}.png`];
} catch (readErr: any) {
if (cleanup) await fs.promises.unlink(typPath).catch(() => {});
return { error: `Failed to read output PNG files: ${String(readErr)}` };
}
if (pngFiles.length === 0) {
await fs.promises.unlink(typPath).catch(() => {});
return { error: 'Compilation finished but no PNG files were produced' };
}
const fullPaths = pngFiles.map(f => path.join(storageDir, f));
const buffers: Buffer[] = await Promise.all(fullPaths.map(async p => {
try { return await fs.promises.readFile(p); } catch (e) { return null as any; }
}));
const validBuffers = buffers.filter(Boolean) as Buffer[];
if (cleanup) {
await fs.promises.unlink(typPath).catch(() => {});
await Promise.all(fullPaths.map(f => fs.promises.unlink(f).catch(() => {})));
}
if (validBuffers.length === 0) return { error: 'No PNG buffers could be read from generated output' };
const result: { buffers?: Buffer[]; files?: string[]; typPath?: string } = { buffers: validBuffers };
if (!cleanup) {
result.files = fullPaths;
result.typPath = typPath;
}
return result;
private get apiKey(): string {
const key = process.env.TYPSTDRIVE_API_KEY;
if (!key) throw new Error("TYPSTDRIVE_API_KEY environment variable is missing.");
return key;
}
async renderToImage(typstCode: string, metadata?: { topic?: string; difficulty?: string }): Promise<string | null> {
async compile(code: string, _options?: { cleanup?: boolean }): Promise<{ buffers?: Buffer[]; error?: string }> {
try {
const response = await fetch(this.apiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ code: DEFAULT_PAGE + code, format: "png" }),
});
if (!response.ok) {
const text = await response.text().catch(() => response.statusText);
return { error: `TypstDrive API error ${response.status}: ${text}` };
}
const arrayBuffer = await response.arrayBuffer();
return { buffers: [Buffer.from(arrayBuffer)] };
} catch (error: any) {
return { error: `TypstDrive request failed: ${error?.message ?? String(error)}` };
}
}
async renderToImage(typstCode: string, metadata?: { topic?: string; difficulty?: string }): Promise<Buffer | null> {
const metadataHeader = metadata ? `
#align(center)[
#block(
@@ -100,7 +59,7 @@ export default class Typst {
inset: 10pt,
radius: 4pt,
[
#text(weight: "bold", size: 14pt)[Topic: ${metadata.topic || "N/A"}] \
#text(weight: "bold", size: 14pt)[Topic: ${metadata.topic || "N/A"}] \\
#text(size: 12pt, fill: rgb("#64748b"))[Difficulty: ${metadata.difficulty || "N/A"}]
]
)
@@ -109,8 +68,10 @@ export default class Typst {
#v(0.8em)
` : '';
// 500pt wide keeps equations readable; height: auto sizes to content.
// This #set page overrides the DEFAULT_PAGE prepended in compile().
const styledCode = `
#set page(width: 210mm, height: auto, margin: (x: 20pt, y: 25pt), fill: white)
#set page(width: 500pt, height: auto, margin: 12pt, fill: white)
#set text(size: 16pt, font: "New Computer Modern")
#set par(justify: true, leading: 0.65em)
#set block(spacing: 1.2em)
@@ -118,58 +79,12 @@ export default class Typst {
${metadataHeader}${typstCode}
`;
const result = await this.compile(styledCode, { cleanup: false });
if (result.error || !result.files || result.files.length === 0) {
const result = await this.compile(styledCode);
if (result.error || !result.buffers || result.buffers.length === 0) {
console.error("Typst render error:", result.error);
return null;
}
return result.files[0];
return result.buffers[0];
}
async cleanupOldFiles(daysOld: number = 1): Promise<{ deleted: number; errors: number }> {
const storageDir = path.resolve('./storage/typst');
let deleted = 0;
let errors = 0;
try {
await fs.promises.access(storageDir);
} catch {
console.log('[Typst] Storage directory does not exist, nothing to clean');
return { deleted: 0, errors: 0 };
}
try {
const files = await fs.promises.readdir(storageDir);
const now = Date.now();
const threshold = daysOld * 24 * 60 * 60 * 1000; // Convert days to milliseconds
for (const file of files) {
const filePath = path.join(storageDir, file);
try {
const stats = await fs.promises.stat(filePath);
const fileAge = now - stats.mtimeMs;
if (fileAge > threshold) {
await fs.promises.unlink(filePath);
deleted++;
console.log(`[Typst] Deleted old file: ${file}`);
}
} catch (err) {
console.error(`[Typst] Error processing file ${file}:`, err);
errors++;
}
}
console.log(`[Typst] Cleanup complete: ${deleted} files deleted, ${errors} errors`);
} catch (err) {
console.error('[Typst] Error during cleanup:', err);
errors++;
}
return { deleted, errors };
}
}
+1
View File
@@ -2,6 +2,7 @@
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,