Typst Compile System Update
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 +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() => {};
|
||||
|
||||
+41
-126
@@ -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 };
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user