Typst Compile System Update
This commit is contained in:
+8
-8
@@ -18,20 +18,20 @@
|
|||||||
"@distube/yt-dlp": "^2.0.1",
|
"@distube/yt-dlp": "^2.0.1",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@langchain/google-genai": "^1.0.3",
|
"@langchain/google-genai": "^1.0.3",
|
||||||
"bufferutil": "^4.0.9",
|
"bufferutil": "^4.1.0",
|
||||||
"chartjs-node-canvas": "^5.0.0",
|
"chartjs-node-canvas": "^5.0.0",
|
||||||
"discord.js": "^14.22.1",
|
"discord.js": "^14.26.4",
|
||||||
"distube": "^5.0.7",
|
"distube": "^5.2.3",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.1",
|
||||||
"libsodium-wrappers": "^0.7.15",
|
"libsodium-wrappers": "^0.7.16",
|
||||||
"mongoose": "^9.0.0",
|
"mongoose": "^9.6.2",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"opusscript": "^0.1.1",
|
"opusscript": "^0.1.1",
|
||||||
"sodium": "^3.0.2",
|
"sodium": "^3.0.2",
|
||||||
"sodium-native": "^4.3.3",
|
"sodium-native": "^4.3.3",
|
||||||
"utf-8-validate": "^6.0.5",
|
"utf-8-validate": "^6.0.6",
|
||||||
"zlib-sync": "^0.1.10"
|
"zlib-sync": "^0.1.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import BotCommand from "../../libs/BotCommand";
|
|||||||
import BotClient from "../../libs/BotClient";
|
import BotClient from "../../libs/BotClient";
|
||||||
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
|
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
|
||||||
import QuestionScheduler from "../../libs/QuestionScheduler";
|
import QuestionScheduler from "../../libs/QuestionScheduler";
|
||||||
import AnswerGrader from "../../libs/AnswerGrader";
|
|
||||||
|
|
||||||
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"];
|
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." });
|
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,
|
topic: question.topic,
|
||||||
difficulty: question.difficulty_rating
|
difficulty: question.difficulty_rating
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!imagePath) {
|
if (!imageBuffer) {
|
||||||
// Log error to logs channel
|
// Log error to logs channel
|
||||||
const logsChannelId = process.env.LOGS_CHANNEL_ID;
|
const logsChannelId = process.env.LOGS_CHANNEL_ID;
|
||||||
if (logsChannelId) {
|
if (logsChannelId) {
|
||||||
@@ -89,7 +88,7 @@ export default class DailyCommand extends BotCommand {
|
|||||||
.setFooter({ text: `Resets at 12 AM local time` })
|
.setFooter({ text: `Resets at 12 AM local time` })
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
const attachment = new AttachmentBuilder(imagePath, { name: `daily_${safeSubject}.png` });
|
const attachment = new AttachmentBuilder(imageBuffer, { name: `daily_${safeSubject}.png` });
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import BotCommand from "../../libs/BotCommand";
|
|||||||
import BotClient from "../../libs/BotClient";
|
import BotClient from "../../libs/BotClient";
|
||||||
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
|
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
|
||||||
import QuestionScheduler from "../../libs/QuestionScheduler";
|
import QuestionScheduler from "../../libs/QuestionScheduler";
|
||||||
import AnswerGrader from "../../libs/AnswerGrader";
|
|
||||||
|
|
||||||
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"];
|
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." });
|
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,
|
topic: question.topic,
|
||||||
difficulty: question.difficulty_rating
|
difficulty: question.difficulty_rating
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!imagePath) {
|
if (!imageBuffer) {
|
||||||
// Log error to logs channel
|
// Log error to logs channel
|
||||||
const logsChannelId = process.env.LOGS_CHANNEL_ID;
|
const logsChannelId = process.env.LOGS_CHANNEL_ID;
|
||||||
if (logsChannelId) {
|
if (logsChannelId) {
|
||||||
@@ -89,7 +88,7 @@ export default class WeeklyCommand extends BotCommand {
|
|||||||
.setFooter({ text: `Resets at 12 AM Sunday` })
|
.setFooter({ text: `Resets at 12 AM Sunday` })
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
const attachment = new AttachmentBuilder(imagePath, { name: `weekly_${safeSubject}.png` });
|
const attachment = new AttachmentBuilder(imageBuffer, { name: `weekly_${safeSubject}.png` });
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>()
|
const row = new ActionRowBuilder<ButtonBuilder>()
|
||||||
.addComponents(
|
.addComponents(
|
||||||
|
|||||||
@@ -11,14 +11,6 @@ export default async(Discord: any, client: BotClient) => {
|
|||||||
client.emit("birthdayCheck");
|
client.emit("birthdayCheck");
|
||||||
}, ms('30m'));
|
}, ms('30m'));
|
||||||
|
|
||||||
// Clean up old typst files daily
|
|
||||||
setInterval(() => {
|
|
||||||
client.emit("typstCleanup");
|
|
||||||
}, ms('24h'));
|
|
||||||
|
|
||||||
// Run cleanup once on startup
|
|
||||||
client.emit("typstCleanup");
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,20 +1 @@
|
|||||||
import BotClient from "../../../libs/BotClient";
|
export default async() => {};
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+40
-125
@@ -1,12 +1,11 @@
|
|||||||
import fs from 'fs';
|
// Default page settings prepended to all compile() calls so the API never falls back to A4.
|
||||||
import path from 'path';
|
// Users can override with their own #set page() — Typst's last rule wins.
|
||||||
import { execFile } from 'child_process';
|
const DEFAULT_PAGE = `#set page(width: auto, height: auto, margin: 8pt, fill: white)\n`;
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
export default class Typst {
|
export default class Typst {
|
||||||
messages: Map<string, { replyId: string; ownerId?: string }>;
|
messages: Map<string, { replyId: string; ownerId?: string }>;
|
||||||
|
private readonly apiUrl = "https://typ.sirblob.co/v1/render";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.messages = new Map();
|
this.messages = new Map();
|
||||||
}
|
}
|
||||||
@@ -23,76 +22,36 @@ export default class Typst {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async compile(code: string, options?: { cleanup?: boolean }): Promise<{ buffers?: Buffer[]; error?: string; files?: string[]; typPath?: string }> {
|
private get apiKey(): string {
|
||||||
const storageDir = path.resolve('./storage/typst');
|
const key = process.env.TYPSTDRIVE_API_KEY;
|
||||||
await fs.promises.mkdir(storageDir, { recursive: true }).catch(() => {});
|
if (!key) throw new Error("TYPSTDRIVE_API_KEY environment variable is missing.");
|
||||||
|
return key;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ? `
|
const metadataHeader = metadata ? `
|
||||||
#align(center)[
|
#align(center)[
|
||||||
#block(
|
#block(
|
||||||
@@ -100,7 +59,7 @@ export default class Typst {
|
|||||||
inset: 10pt,
|
inset: 10pt,
|
||||||
radius: 4pt,
|
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"}]
|
#text(size: 12pt, fill: rgb("#64748b"))[Difficulty: ${metadata.difficulty || "N/A"}]
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -109,8 +68,10 @@ export default class Typst {
|
|||||||
#v(0.8em)
|
#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 = `
|
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 text(size: 16pt, font: "New Computer Modern")
|
||||||
#set par(justify: true, leading: 0.65em)
|
#set par(justify: true, leading: 0.65em)
|
||||||
#set block(spacing: 1.2em)
|
#set block(spacing: 1.2em)
|
||||||
@@ -118,58 +79,12 @@ export default class Typst {
|
|||||||
${metadataHeader}${typstCode}
|
${metadataHeader}${typstCode}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await this.compile(styledCode, { cleanup: false });
|
const result = await this.compile(styledCode);
|
||||||
|
if (result.error || !result.buffers || result.buffers.length === 0) {
|
||||||
if (result.error || !result.files || result.files.length === 0) {
|
|
||||||
console.error("Typst render error:", result.error);
|
console.error("Typst render error:", result.error);
|
||||||
return null;
|
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2016",
|
"target": "es2016",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user