diff --git a/Dockerfile b/Dockerfile index ad51f3e..114e009 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,8 @@ RUN bun run build FROM rust:alpine AS backend-builder WORKDIR /app RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig git -RUN git clone --depth=1 https://github.com/typst/typst.git typst +RUN git clone https://github.com/typst/typst.git typst \ + && git -C typst checkout 44b3f78ed37fedea75e911dde2269ef86c45316f COPY server/Cargo.* server/ COPY server/src server/src WORKDIR /app/server diff --git a/README.md b/README.md index 3188818..c3e5ee6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul - **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode. - **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations. - **Document Sharing**: Invite collaborators by email with Editor or Viewer roles. Collaborators' uploaded fonts and images are available to the compiler. A dedicated "Shared with me" folder on the dashboard surfaces all documents others have shared with you. Manage and remove collaborators directly from the Share modal in the editor. -- **Public REST API**: Programmatically render Typst documents to PNG or PDF via `POST /v1/render`. Manage API keys from the Settings panel, with a live usage chart supporting 1-hour, 1-day, and 1-week views. Full API reference available at `/api-docs`. +- **Public REST API**: Programmatically render Typst documents to PNG or PDF via `POST /v1/render`. Compilation failures return a `422` with a JSON body detailing each Typst error, including its message and source line and column. Manage API keys from the Settings panel, with a live usage chart supporting 1-hour, 1-day, and 1-week views. Full API reference available at `/api-docs`. - **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel. - **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay. - **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents. diff --git a/package.json b/package.json index e60ec2c..0ed3c75 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,12 @@ "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.60.1", + "@sveltejs/kit": "^2.61.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", - "svelte": "^5.55.9", + "svelte": "^5.56.0", "svelte-check": "^4.4.8", "tailwindcss": "^4.3.0", "typescript": "^5.9.3", @@ -42,6 +42,6 @@ "highlight.js": "^11.11.1", "y-codemirror.next": "^0.3.5", "y-websocket": "^3.0.0", - "yjs": "^13.6.30" + "yjs": "^13.6.31" } } diff --git a/server/src/auth.rs b/server/src/auth.rs index 644c7dc..54dbc80 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -11,8 +11,6 @@ use crate::{ AppState, }; -const USER_FIELDS: &str = "id, username, email, password_hash, is_admin"; - use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, diff --git a/server/src/compiler.rs b/server/src/compiler.rs index 9f51849..372f314 100644 --- a/server/src/compiler.rs +++ b/server/src/compiler.rs @@ -5,7 +5,8 @@ use typst::diag::{SourceDiagnostic, Warned}; use typst::layout::{Frame, FrameItem}; use typst_layout::PagedDocument; use typst_pdf::{pdf, PdfOptions}; -use typst_render::render; +use typst_render::{render, RenderOptions}; +use typst_svg::SvgOptions; #[derive(Serialize, Clone)] pub struct DocumentStats { @@ -71,9 +72,14 @@ impl TypstCompiler { warnings: _, } => { let stats = extract_stats(&doc); - let svgs = doc.pages().iter().map(typst_svg::svg).collect(); + let options = SvgOptions::default(); + let svgs = doc + .pages() + .iter() + .map(|page| typst_svg::svg(page, &options)) + .collect(); let thumbnail = if let Some(page) = doc.pages().first() { - typst_svg::svg(page) + typst_svg::svg(page, &options) } else { String::new() }; @@ -83,15 +89,11 @@ impl TypstCompiler { output: Err(errors), warnings: _, } => { - use typst::World; + use typst::WorldExt; let diag = errors .into_iter() .map(|d| { - let range = d - .span - .id() - .and_then(|id| world.source(id).ok()) - .and_then(|s| s.range(d.span)); + let range = world.range(d.span); (d, range) }) .collect(); @@ -121,15 +123,11 @@ impl TypstCompiler { output: Err(errors), warnings: _, } => { - use typst::World; + use typst::WorldExt; Err(errors .into_iter() .map(|d| { - let range = d - .span - .id() - .and_then(|id| world.source(id).ok()) - .and_then(|s| s.range(d.span)); + let range = world.range(d.span); (d, range) }) .collect()) @@ -149,7 +147,11 @@ impl TypstCompiler { warnings: _, } => { if let Some(page) = doc.pages().first() { - let pixmap = render(page, 2.0); + let options = RenderOptions { + pixel_per_pt: 2.0, + ..RenderOptions::default() + }; + let pixmap = render(page, &options); if let Ok(encoded) = pixmap.encode_png() { return Ok(encoded); } @@ -160,15 +162,11 @@ impl TypstCompiler { output: Err(errors), warnings: _, } => { - use typst::World; + use typst::WorldExt; Err(errors .into_iter() .map(|d| { - let range = d - .span - .id() - .and_then(|id| world.source(id).ok()) - .and_then(|s| s.range(d.span)); + let range = world.range(d.span); (d, range) }) .collect()) diff --git a/server/src/public_api.rs b/server/src/public_api.rs index 7711260..fb28c21 100644 --- a/server/src/public_api.rs +++ b/server/src/public_api.rs @@ -5,7 +5,7 @@ use axum::{ Json, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sha2::{Sha256, Digest}; use std::collections::HashMap; use uuid::Uuid; @@ -25,6 +25,37 @@ pub struct InlineFile { pub data: String, // base64-encoded } +#[derive(Serialize)] +struct CompileErrorDetail { + message: String, + severity: String, + line: Option, + column: Option, +} + +#[derive(Serialize)] +struct CompileErrorResponse { + error: String, + details: Vec, +} + +fn line_and_column(code: &str, offset: usize) -> (usize, usize) { + let mut line = 1; + let mut column = 1; + for (index, character) in code.char_indices() { + if index >= offset { + break; + } + if character == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + (line, column) +} + fn compute_cache_key(format: &str, code: &str, files: &Option>) -> String { let mut hasher = Sha256::new(); hasher.update(format.as_bytes()); @@ -206,6 +237,48 @@ pub async fn render_handler( (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response() } - Err(_) => (StatusCode::UNPROCESSABLE_ENTITY, "Typst compilation failed. Check your code for errors.").into_response(), + Err(diagnostics) => { + let details: Vec = diagnostics + .into_iter() + .map(|(diagnostic, range)| { + let (line, column) = match range.as_ref() { + Some(range) => { + let (line, column) = line_and_column(&payload.code, range.start); + (Some(line), Some(column)) + } + None => (None, None), + }; + CompileErrorDetail { + message: diagnostic.message.to_string(), + severity: format!("{:?}", diagnostic.severity).to_lowercase(), + line, + column, + } + }) + .collect(); + + let summary = details + .iter() + .map(|detail| match (detail.line, detail.column) { + (Some(line), Some(column)) => { + format!("{} (line {}, column {})", detail.message, line, column) + } + _ => detail.message.clone(), + }) + .collect::>() + .join("; "); + + let error = if summary.is_empty() { + "Typst compilation failed.".to_string() + } else { + format!("Typst compilation failed: {}", summary) + }; + + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(CompileErrorResponse { error, details }), + ) + .into_response() + } } } diff --git a/src/routes/api-docs/+page.svelte b/src/routes/api-docs/+page.svelte index e136514..cf850e3 100644 --- a/src/routes/api-docs/+page.svelte +++ b/src/routes/api-docs/+page.svelte @@ -97,6 +97,18 @@ with open("output.png", "wb") as f: ] }`; + const compileErrorJson = `{ + "error": "Typst compilation failed: unknown variable: x (line 3, column 5)", + "details": [ + { + "message": "unknown variable: x", + "severity": "error", + "line": 3, + "column": 5 + } + ] +}`; + // Highlighted versions (derived so they update if baseUrl changes) let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value); let hCurlPdf = $derived(hljs.highlight(curlPdf, { language: 'bash' }).value); @@ -104,6 +116,7 @@ with open("output.png", "wb") as f: let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value); let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value); let hSchema = $derived(hljs.highlight(requestSchemaJson,{ language: 'json' }).value); + let hCompileErr = $derived(hljs.highlight(compileErrorJson, { language: 'json' }).value); async function copy(id: string, text: string) { await navigator.clipboard.writeText(text); @@ -321,6 +334,15 @@ with open("output.png", "wb") as f: +
+

Compilation errors

+
+ 422 Unprocessable Entity + JSON body describing every Typst error. error is a readable summary; details lists each diagnostic with its message, severity, and source line and column. +
+
{@html hCompileErr}
+
+

Account files available automatically

Files uploaded to your TypstDrive account are available by filename inside your Typst code. Pass additional files inline via the files array to supplement or override them.

@@ -399,7 +421,7 @@ with open("output.png", "wb") as f: {#each [ { code: '400', name: 'Bad Request', desc: 'Invalid format value, empty code, or malformed JSON body.' }, { code: '401', name: 'Unauthorized', desc: 'Missing or invalid Authorization header, or unknown API key.' }, - { code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. Fix the markup and retry.' }, + { code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. The JSON body lists each error message with its source line and column.' }, { code: '429', name: 'Too Many Requests', desc: 'Rate limit exceeded. Wait for the current 60-second window to reset.' }, { code: '500', name: 'Internal Server Error', desc: 'Unexpected server error. Try again after a short delay.' }, ] as err} @@ -414,7 +436,7 @@ with open("output.png", "wb") as f:

Error body

-

Error responses return plain text describing the issue — no JSON envelope.

+

Compilation failures (422) return a JSON body with an error summary and a details array. All other errors return plain text describing the issue.

{/if} diff --git a/typst b/typst index de6f400..44b3f78 160000 --- a/typst +++ b/typst @@ -1 +1 @@ -Subproject commit de6f400976f9bf6ab8b923d13a068722959d0070 +Subproject commit 44b3f78ed37fedea75e911dde2269ef86c45316f