Add HTML render format to API and update version to 1.4.8

This commit is contained in:
2026-06-14 13:08:33 -04:00
parent 68ffa14622
commit 9644361bad
6 changed files with 84 additions and 18 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "typstdrive", "name": "typstdrive",
"private": true, "private": true,
"version": "1.4.7", "version": "1.4.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "server" name = "server"
version = "1.4.7" version = "1.4.8"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -25,6 +25,7 @@ typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader
typst-pdf = { path = "../typst/crates/typst-pdf" } typst-pdf = { path = "../typst/crates/typst-pdf" }
typst-render = { path = "../typst/crates/typst-render" } typst-render = { path = "../typst/crates/typst-render" }
typst-svg = { path = "../typst/crates/typst-svg" } typst-svg = { path = "../typst/crates/typst-svg" }
typst-html = { path = "../typst/crates/typst-html" }
typst-layout = { path = "../typst/crates/typst-layout" } typst-layout = { path = "../typst/crates/typst-layout" }
yrs = "0.18.8" yrs = "0.18.8"
+46 -5
View File
@@ -3,6 +3,7 @@ use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use typst::diag::{SourceDiagnostic, Warned}; use typst::diag::{SourceDiagnostic, Warned};
use typst::layout::{Frame, FrameItem}; use typst::layout::{Frame, FrameItem};
use typst_html::HtmlDocument;
use typst_layout::PagedDocument; use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions}; use typst_pdf::{pdf, PdfOptions};
use typst_render::{render, RenderOptions}; use typst_render::{render, RenderOptions};
@@ -67,8 +68,8 @@ impl ProjectInput {
} }
} }
fn into_world(self) -> MemoryWorld { fn into_world(self, enable_html: bool) -> MemoryWorld {
MemoryWorld::new_project(self.entrypoint, self.files, self.packages) MemoryWorld::new_project(self.entrypoint, self.files, self.packages, enable_html)
} }
} }
@@ -86,7 +87,7 @@ impl TypstCompiler {
(Vec<String>, String, DocumentStats), (Vec<String>, String, DocumentStats),
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
> { > {
let world = input.into_world(); let world = input.into_world(false);
match typst::compile::<PagedDocument>(&world) { match typst::compile::<PagedDocument>(&world) {
Warned { Warned {
output: Ok(doc), output: Ok(doc),
@@ -127,7 +128,7 @@ impl TypstCompiler {
&self, &self,
input: ProjectInput, input: ProjectInput,
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> { ) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = input.into_world(); let world = input.into_world(false);
match typst::compile::<PagedDocument>(&world) { match typst::compile::<PagedDocument>(&world) {
Warned { Warned {
output: Ok(doc), output: Ok(doc),
@@ -159,7 +160,7 @@ impl TypstCompiler {
&self, &self,
input: ProjectInput, input: ProjectInput,
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> { ) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = input.into_world(); let world = input.into_world(false);
match typst::compile::<PagedDocument>(&world) { match typst::compile::<PagedDocument>(&world) {
Warned { Warned {
output: Ok(doc), output: Ok(doc),
@@ -192,4 +193,44 @@ impl TypstCompiler {
} }
} }
} }
pub fn export_html(
&self,
input: ProjectInput,
) -> Result<String, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
let world = input.into_world(true);
let document = match typst::compile::<HtmlDocument>(&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())
}
}
}
} }
+10 -3
View File
@@ -89,8 +89,8 @@ pub async fn render_handler(
None => return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Use: Authorization: Bearer <api-key>").into_response(), None => return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Use: Authorization: Bearer <api-key>").into_response(),
}; };
if payload.format != "png" && payload.format != "pdf" { if payload.format != "png" && payload.format != "pdf" && payload.format != "html" {
return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png' or 'pdf'").into_response(); return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png', 'pdf', or 'html'").into_response();
} }
if payload.code.trim().is_empty() { if payload.code.trim().is_empty() {
@@ -170,7 +170,11 @@ pub async fn render_handler(
// Check cache // Check cache
let cache_key = compute_cache_key(&payload.format, &payload.code, &payload.files); 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<u8>, String)>( if let Ok(Some((data, created_at))) = sqlx::query_as::<_, (Vec<u8>, String)>(
"SELECT data, created_at FROM api_render_cache WHERE content_hash = ? AND format = ?" "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() { let result = match payload.format.as_str() {
"pdf" => compiler.export_pdf(ProjectInput::single(payload.code.clone(), files_map)), "pdf" => compiler.export_pdf(ProjectInput::single(payload.code.clone(), files_map)),
"png" => compiler.export_png(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!(), _ => unreachable!(),
}; };
drop(compiler); drop(compiler);
+10 -1
View File
@@ -31,6 +31,7 @@ impl MemoryWorld {
entrypoint: String, entrypoint: String,
files: HashMap<String, Vec<u8>>, files: HashMap<String, Vec<u8>>,
local_packages: HashMap<String, HashMap<String, Vec<u8>>>, local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
enable_html: bool,
) -> Self { ) -> Self {
let main = FileId::new(RootedPath::new( let main = FileId::new(RootedPath::new(
VirtualRoot::Project, 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 { Self {
library: typst::utils::LazyHash::new(Library::builder().build()), library: typst::utils::LazyHash::new(library),
main, main,
files, files,
local_packages, local_packages,
+15 -7
View File
@@ -31,6 +31,12 @@
-d '{"code":"= My Report\\n\\nSome body text.","format":"pdf"}' \\ -d '{"code":"= My Report\\n\\nSome body text.","format":"pdf"}' \\
--output report.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', { let jsExample = $derived(`const response = await fetch('${baseUrl}/v1/render', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -87,12 +93,12 @@ with open("output.png", "wb") as f:
f.write(response.content)`); f.write(response.content)`);
const requestSchemaJson = `{ const requestSchemaJson = `{
"code": "string", // Typst markup (required) "code": "string", // Typst markup (required)
"format": "png" | "pdf", // Output format (required) "format": "png" | "pdf" | "html", // Output format (required)
"files": [ // Optional inline assets "files": [ // Optional inline assets
{ {
"name": "string", // Filename used in Typst code "name": "string", // Filename used in Typst code
"data": "string" // Base64-encoded file content "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) // Highlighted versions (derived so they update if baseUrl changes)
let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value); let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value);
let hCurlPdf = $derived(hljs.highlight(curlPdf, { 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 hJs = $derived(hljs.highlight(jsExample, { language: 'javascript' }).value);
let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value); let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value);
let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value); let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value);
@@ -290,7 +297,7 @@ with open("output.png", "wb") as f:
<code class="font-mono text-sm text-gray-800 dark:text-gray-200">/v1/render</code> <code class="font-mono text-sm text-gray-800 dark:text-gray-200">/v1/render</code>
</div> </div>
<p class="text-sm text-gray-600 dark:text-gray-400"> <p class="text-sm text-gray-600 dark:text-gray-400">
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. Results are cached for 1 hour — identical inputs return the cached result without recompiling.
</p> </p>
</div> </div>
@@ -330,7 +337,7 @@ with open("output.png", "wb") as f:
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Response</p> <p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Response</p>
<div class="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30 text-sm"> <div class="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30 text-sm">
<span class="font-mono text-xs font-bold text-green-700 dark:text-green-400">200 OK</span> <span class="font-mono text-xs font-bold text-green-700 dark:text-green-400">200 OK</span>
<span class="text-gray-600 dark:text-gray-400 ml-2">Binary body with <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">Content-Type: image/png</code> or <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">application/pdf</code></span> <span class="text-gray-600 dark:text-gray-400 ml-2">Response body with <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">Content-Type: image/png</code>, <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">application/pdf</code>, or <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">text/html</code></span>
</div> </div>
</div> </div>
@@ -360,6 +367,7 @@ with open("output.png", "wb") as f:
{#each [ {#each [
{ id: 'curl-png', label: 'cURL — render PNG', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPng, raw: curlPng }, { 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-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: '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: '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 }, { id: 'files', label: 'Python — with inline files', icon: 'mdi:file-image-outline', iconColor: 'text-purple-400', code: hFiles, raw: filesExample },