diff --git a/package.json b/package.json index 008f56e..d5ea08a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "typstdrive", "private": true, - "version": "1.4.7", + "version": "1.4.8", "type": "module", "scripts": { "dev": "vite dev --host", diff --git a/server/Cargo.toml b/server/Cargo.toml index 81695fd..07e011c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server" -version = "1.4.7" +version = "1.4.8" edition = "2021" [dependencies] @@ -25,6 +25,7 @@ typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader typst-pdf = { path = "../typst/crates/typst-pdf" } typst-render = { path = "../typst/crates/typst-render" } typst-svg = { path = "../typst/crates/typst-svg" } +typst-html = { path = "../typst/crates/typst-html" } typst-layout = { path = "../typst/crates/typst-layout" } yrs = "0.18.8" diff --git a/server/src/compiler.rs b/server/src/compiler.rs index f241732..3cddbb7 100644 --- a/server/src/compiler.rs +++ b/server/src/compiler.rs @@ -3,6 +3,7 @@ use serde::Serialize; use std::collections::HashMap; use typst::diag::{SourceDiagnostic, Warned}; use typst::layout::{Frame, FrameItem}; +use typst_html::HtmlDocument; use typst_layout::PagedDocument; use typst_pdf::{pdf, PdfOptions}; use typst_render::{render, RenderOptions}; @@ -67,8 +68,8 @@ impl ProjectInput { } } - fn into_world(self) -> MemoryWorld { - MemoryWorld::new_project(self.entrypoint, self.files, self.packages) + fn into_world(self, enable_html: bool) -> MemoryWorld { + MemoryWorld::new_project(self.entrypoint, self.files, self.packages, enable_html) } } @@ -86,7 +87,7 @@ impl TypstCompiler { (Vec, String, DocumentStats), Vec<(SourceDiagnostic, Option>)>, > { - let world = input.into_world(); + let world = input.into_world(false); match typst::compile::(&world) { Warned { output: Ok(doc), @@ -127,7 +128,7 @@ impl TypstCompiler { &self, input: ProjectInput, ) -> Result, Vec<(SourceDiagnostic, Option>)>> { - let world = input.into_world(); + let world = input.into_world(false); match typst::compile::(&world) { Warned { output: Ok(doc), @@ -159,7 +160,7 @@ impl TypstCompiler { &self, input: ProjectInput, ) -> Result, Vec<(SourceDiagnostic, Option>)>> { - let world = input.into_world(); + let world = input.into_world(false); match typst::compile::(&world) { Warned { output: Ok(doc), @@ -192,4 +193,44 @@ impl TypstCompiler { } } } + + pub fn export_html( + &self, + input: ProjectInput, + ) -> Result>)>> { + let world = input.into_world(true); + let document = match typst::compile::(&world) { + Warned { + output: Ok(document), + warnings: _, + } => document, + Warned { + output: Err(errors), + warnings: _, + } => { + use typst::WorldExt; + return Err(errors + .into_iter() + .map(|d| { + let range = world.range(d.span); + (d, range) + }) + .collect()); + } + }; + + match typst_html::html(&document) { + Ok(html) => Ok(html), + Err(errors) => { + use typst::WorldExt; + Err(errors + .into_iter() + .map(|d| { + let range = world.range(d.span); + (d, range) + }) + .collect()) + } + } + } } diff --git a/server/src/public_api.rs b/server/src/public_api.rs index 4097f17..f42cff1 100644 --- a/server/src/public_api.rs +++ b/server/src/public_api.rs @@ -89,8 +89,8 @@ pub async fn render_handler( None => return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Use: Authorization: Bearer ").into_response(), }; - if payload.format != "png" && payload.format != "pdf" { - return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png' or 'pdf'").into_response(); + if payload.format != "png" && payload.format != "pdf" && payload.format != "html" { + return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png', 'pdf', or 'html'").into_response(); } if payload.code.trim().is_empty() { @@ -170,7 +170,11 @@ pub async fn render_handler( // Check cache let cache_key = compute_cache_key(&payload.format, &payload.code, &payload.files); - let content_type: &'static str = if payload.format == "pdf" { "application/pdf" } else { "image/png" }; + let content_type: &'static str = match payload.format.as_str() { + "pdf" => "application/pdf", + "html" => "text/html; charset=utf-8", + _ => "image/png", + }; if let Ok(Some((data, created_at))) = sqlx::query_as::<_, (Vec, String)>( "SELECT data, created_at FROM api_render_cache WHERE content_hash = ? AND format = ?" @@ -216,6 +220,9 @@ pub async fn render_handler( let result = match payload.format.as_str() { "pdf" => compiler.export_pdf(ProjectInput::single(payload.code.clone(), files_map)), "png" => compiler.export_png(ProjectInput::single(payload.code.clone(), files_map)), + "html" => compiler + .export_html(ProjectInput::single(payload.code.clone(), files_map)) + .map(|html| html.into_bytes()), _ => unreachable!(), }; drop(compiler); diff --git a/server/src/world.rs b/server/src/world.rs index af182b5..704f1e6 100644 --- a/server/src/world.rs +++ b/server/src/world.rs @@ -31,6 +31,7 @@ impl MemoryWorld { entrypoint: String, files: HashMap>, local_packages: HashMap>>, + enable_html: bool, ) -> Self { let main = FileId::new(RootedPath::new( VirtualRoot::Project, @@ -62,8 +63,16 @@ impl MemoryWorld { } } + let library = if enable_html { + Library::builder() + .with_features([typst::Feature::Html].into_iter().collect()) + .build() + } else { + Library::builder().build() + }; + Self { - library: typst::utils::LazyHash::new(Library::builder().build()), + library: typst::utils::LazyHash::new(library), main, files, local_packages, diff --git a/src/routes/api-docs/+page.svelte b/src/routes/api-docs/+page.svelte index cf850e3..3383e52 100644 --- a/src/routes/api-docs/+page.svelte +++ b/src/routes/api-docs/+page.svelte @@ -31,6 +31,12 @@ -d '{"code":"= My Report\\n\\nSome body text.","format":"pdf"}' \\ --output report.pdf`); + let curlHtml = $derived(`curl -X POST ${baseUrl}/v1/render \\ + -H "Authorization: Bearer td_your_api_key_here" \\ + -H "Content-Type: application/json" \\ + -d '{"code":"= My Report\\n\\nSome body text.","format":"html"}' \\ + --output report.html`); + let jsExample = $derived(`const response = await fetch('${baseUrl}/v1/render', { method: 'POST', headers: { @@ -87,12 +93,12 @@ with open("output.png", "wb") as f: f.write(response.content)`); const requestSchemaJson = `{ - "code": "string", // Typst markup (required) - "format": "png" | "pdf", // Output format (required) - "files": [ // Optional inline assets + "code": "string", // Typst markup (required) + "format": "png" | "pdf" | "html", // Output format (required) + "files": [ // Optional inline assets { - "name": "string", // Filename used in Typst code - "data": "string" // Base64-encoded file content + "name": "string", // Filename used in Typst code + "data": "string" // Base64-encoded file content } ] }`; @@ -112,6 +118,7 @@ with open("output.png", "wb") as f: // 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); + let hCurlHtml = $derived(hljs.highlight(curlHtml, { language: 'bash' }).value); let hJs = $derived(hljs.highlight(jsExample, { language: 'javascript' }).value); let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value); let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value); @@ -290,7 +297,7 @@ with open("output.png", "wb") as f: /v1/render

- Compile Typst markup and return rendered binary output as PNG or PDF. + Compile Typst markup and return rendered output as PNG, PDF, or HTML. Results are cached for 1 hour — identical inputs return the cached result without recompiling.

@@ -330,7 +337,7 @@ with open("output.png", "wb") as f:

Response

200 OK - Binary body with Content-Type: image/png or application/pdf + Response body with Content-Type: image/png, application/pdf, or text/html
@@ -360,6 +367,7 @@ with open("output.png", "wb") as f: {#each [ { id: 'curl-png', label: 'cURL — render PNG', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPng, raw: curlPng }, { id: 'curl-pdf', label: 'cURL — render PDF', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPdf, raw: curlPdf }, + { id: 'curl-html', label: 'cURL — render HTML', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlHtml, raw: curlHtml }, { id: 'js', label: 'JavaScript / TypeScript', icon: 'mdi:language-javascript', iconColor: 'text-yellow-400', code: hJs, raw: jsExample }, { id: 'python', label: 'Python (httpx)', icon: 'mdi:language-python', iconColor: 'text-blue-400', code: hPython, raw: pythonExample}, { id: 'files', label: 'Python — with inline files', icon: 'mdi:file-image-outline', iconColor: 'text-purple-400', code: hFiles, raw: filesExample },