pub mod compile;
pub mod convert;
mod error_mapping;
pub mod helper;
mod overlay;
mod pdf_scan;
mod world;
#[doc(hidden)]
pub mod fuzz_utils {
pub use super::helper::inject_json;
}
use convert::mark_to_typst;
use quillmark_core::{
quill::build_transform_schema, session::SessionHandle, Backend, Diagnostic, OutputFormat,
Quill, QuillValue, RenderError, RenderOptions, RenderResult, RenderSession, Severity,
};
use std::any::Any;
use std::collections::HashMap;
#[derive(Debug)]
pub struct TypstBackend;
const SUPPORTED_FORMATS: &[OutputFormat] =
&[OutputFormat::Pdf, OutputFormat::Svg, OutputFormat::Png];
#[derive(Debug)]
pub struct TypstSession {
document: typst::layout::PagedDocument,
page_count: usize,
sig_placements: Vec<overlay::SigPlacement>,
}
impl TypstSession {
pub fn page_size_pt(&self, page: usize) -> Option<(f32, f32)> {
let frame = &self.document.pages.get(page)?.frame;
let size = frame.size();
Some((size.x.to_pt() as f32, size.y.to_pt() as f32))
}
pub fn render_rgba(&self, page: usize, scale: f32) -> Option<(u32, u32, Vec<u8>)> {
let p = self.document.pages.get(page)?;
let pixmap = typst_render::render(p, scale);
let width = pixmap.width();
let height = pixmap.height();
let mut rgba = Vec::with_capacity((width as usize) * (height as usize) * 4);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.push(c.red());
rgba.push(c.green());
rgba.push(c.blue());
rgba.push(c.alpha());
}
Some((width, height, rgba))
}
}
impl SessionHandle for TypstSession {
fn render(&self, opts: &RenderOptions) -> Result<RenderResult, RenderError> {
let format = opts.output_format.unwrap_or(OutputFormat::Pdf);
if !SUPPORTED_FORMATS.contains(&format) {
return Err(RenderError::FormatNotSupported {
diags: vec![Diagnostic::new(
Severity::Error,
format!("{:?} not supported by typst backend", format),
)
.with_code("backend::format_not_supported".to_string())
.with_hint(format!("Supported formats: {:?}", SUPPORTED_FORMATS))],
});
}
compile::render_document_pages(
&self.document,
opts.pages.as_deref(),
format,
opts.ppi,
&self.sig_placements,
opts.producer.as_deref(),
)
}
fn page_count(&self) -> usize {
self.page_count
}
fn as_any(&self) -> &dyn Any {
self
}
}
pub fn typst_session_of(session: &RenderSession) -> Option<&TypstSession> {
session.handle().as_any().downcast_ref::<TypstSession>()
}
impl Backend for TypstBackend {
fn id(&self) -> &'static str {
"typst"
}
fn supported_formats(&self) -> &'static [OutputFormat] {
SUPPORTED_FORMATS
}
fn supports_canvas(&self) -> bool {
true
}
fn open(
&self,
plate_content: &str,
source: &Quill,
json_data: &serde_json::Value,
) -> Result<RenderSession, RenderError> {
let fields = json_data.as_object().map_or_else(HashMap::new, |obj| {
obj.iter()
.map(|(key, value)| (key.clone(), QuillValue::from_json(value.clone())))
.collect::<HashMap<_, _>>()
});
let transformed_fields =
transform_markdown_fields(&fields, &build_transform_schema(source.config()));
let transformed_json = serde_json::Value::Object(
transformed_fields
.into_iter()
.map(|(key, value)| (key, value.into_json()))
.collect(),
);
let json_str =
serde_json::to_string(&transformed_json).unwrap_or_else(|_| "{}".to_string());
let document = compile::compile_to_document(source, plate_content, &json_str)?;
let page_count = document.pages.len();
let sig_placements = overlay::extract(&document)?;
let session = TypstSession {
document,
page_count,
sig_placements,
};
Ok(RenderSession::new(Box::new(session)))
}
}
impl Default for TypstBackend {
fn default() -> Self {
Self
}
}
fn is_markdown_field(field_schema: &serde_json::Value) -> bool {
field_schema
.get("contentMediaType")
.and_then(|v| v.as_str())
.map(|s| s == "text/markdown")
.unwrap_or(false)
}
fn is_markdown_array_field(field_schema: &serde_json::Value) -> bool {
field_schema
.get("type")
.and_then(|v| v.as_str())
.map(|s| s == "array")
.unwrap_or(false)
&& field_schema
.get("items")
.map(is_markdown_field)
.unwrap_or(false)
}
fn is_date_field(field_schema: &serde_json::Value) -> bool {
field_schema
.get("format")
.and_then(|v| v.as_str())
.map(|s| s == "date-time")
.unwrap_or(false)
}
fn content_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
properties
.iter()
.filter(|(_, fs)| is_markdown_field(fs) || is_markdown_array_field(fs))
.map(|(name, _)| name.clone())
.collect()
}
fn date_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
properties
.iter()
.filter(|(_, fs)| is_date_field(fs))
.map(|(name, _)| name.clone())
.collect()
}
fn convert_content_value(value: &QuillValue) -> Option<QuillValue> {
match value.as_json() {
serde_json::Value::String(s) => mark_to_typst(s)
.ok()
.map(|markup| QuillValue::from_json(serde_json::json!(markup))),
serde_json::Value::Array(arr) => {
let converted = arr
.iter()
.map(|elem| match elem.as_str() {
Some(s) => match mark_to_typst(s) {
Ok(markup) => serde_json::json!(markup),
Err(_) => elem.clone(),
},
None => elem.clone(),
})
.collect();
Some(QuillValue::from_json(serde_json::Value::Array(converted)))
}
_ => None,
}
}
fn transform_markdown_fields(
fields: &HashMap<String, QuillValue>,
schema: &QuillValue,
) -> HashMap<String, QuillValue> {
let mut result = fields.clone();
let schema_json = schema.as_json();
let properties_obj = match schema_json.get("properties").and_then(|v| v.as_object()) {
Some(obj) => obj,
None => return result,
};
let content_fields = content_field_names(properties_obj);
for field_name in &content_fields {
if let Some(value) = fields.get(field_name) {
if let Some(converted) = convert_content_value(value) {
result.insert(field_name.clone(), converted);
}
}
}
let date_fields = date_field_names(properties_obj);
if let Some(cards_value) = result.get("$cards") {
if let Some(cards_array) = cards_value.as_array() {
let transformed_cards = transform_cards_array(schema, cards_array);
result.insert(
"$cards".to_string(),
QuillValue::from_json(serde_json::Value::Array(transformed_cards)),
);
}
}
let mut card_content_fields = serde_json::Map::new();
let mut card_date_fields = serde_json::Map::new();
if let Some(defs) = schema_json.get("$defs").and_then(|v| v.as_object()) {
for (def_name, def_schema) in defs {
if let Some(card_kind) = def_name.strip_suffix("_card") {
let card_props = def_schema.get("properties").and_then(|v| v.as_object());
let card_fields = card_props.map(content_field_names).unwrap_or_default();
if !card_fields.is_empty() {
card_content_fields.insert(
card_kind.to_string(),
serde_json::Value::Array(
card_fields
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
let date_fields = card_props.map(date_field_names).unwrap_or_default();
if !date_fields.is_empty() {
card_date_fields.insert(
card_kind.to_string(),
serde_json::Value::Array(
date_fields
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
}
}
}
result.insert(
"__meta__".to_string(),
QuillValue::from_json(serde_json::json!({
"content_fields": content_fields,
"card_content_fields": card_content_fields,
"date_fields": date_fields,
"card_date_fields": card_date_fields,
})),
);
result
}
fn transform_cards_array(
document_schema: &QuillValue,
cards_array: &[serde_json::Value],
) -> Vec<serde_json::Value> {
let mut transformed_cards = Vec::new();
let defs = document_schema
.as_json()
.get("$defs")
.and_then(|v| v.as_object());
for card in cards_array {
if let Some(card_obj) = card.as_object() {
if let Some(card_kind) = card_obj.get("$kind").and_then(|v| v.as_str()) {
let def_name = format!("{}_card", card_kind);
if let Some(card_schema_json) = defs.and_then(|d| d.get(&def_name)) {
let mut card_fields: HashMap<String, QuillValue> = HashMap::new();
for (k, v) in card_obj {
card_fields.insert(k.clone(), QuillValue::from_json(v.clone()));
}
let transformed_card_fields = transform_markdown_fields(
&card_fields,
&QuillValue::from_json(card_schema_json.clone()),
);
let mut transformed_card_obj = serde_json::Map::new();
for (k, v) in transformed_card_fields {
transformed_card_obj.insert(k, v.into_json());
}
transformed_cards.push(serde_json::Value::Object(transformed_card_obj));
continue;
}
}
}
transformed_cards.push(card.clone());
}
transformed_cards
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_backend_info() {
let backend = TypstBackend;
assert_eq!(backend.id(), "typst");
assert!(backend.supported_formats().contains(&OutputFormat::Pdf));
assert!(backend.supported_formats().contains(&OutputFormat::Svg));
}
#[test]
fn test_is_markdown_field() {
let markdown_schema = json!({
"type": "string",
"contentMediaType": "text/markdown"
});
assert!(is_markdown_field(&markdown_schema));
let string_schema = json!({
"type": "string"
});
assert!(!is_markdown_field(&string_schema));
let other_media_type = json!({
"type": "string",
"contentMediaType": "text/plain"
});
assert!(!is_markdown_field(&other_media_type));
}
#[test]
fn test_is_markdown_array_field() {
let md_array = json!({
"type": "array",
"items": { "type": "string", "contentMediaType": "text/markdown" }
});
assert!(is_markdown_array_field(&md_array));
let string_array = json!({
"type": "array",
"items": { "type": "string" }
});
assert!(!is_markdown_array_field(&string_array));
let md_scalar = json!({ "type": "string", "contentMediaType": "text/markdown" });
assert!(!is_markdown_array_field(&md_scalar));
}
#[test]
fn test_transform_markdown_array_field() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": { "type": "string", "contentMediaType": "text/markdown" }
}
}
}));
let mut fields = HashMap::new();
fields.insert(
"sections".to_string(),
QuillValue::from_json(json!(["This is **bold** text.", "Plain line."])),
);
let result = transform_markdown_fields(&fields, &schema);
let sections = result.get("sections").unwrap().as_array().unwrap();
assert!(sections[0].as_str().unwrap().contains("#strong[bold]"));
assert!(sections[1].as_str().unwrap().contains("Plain line."));
let meta = result.get("__meta__").unwrap().as_json();
let content_fields = meta["content_fields"].as_array().unwrap();
assert!(content_fields.iter().any(|v| v == "sections"));
}
#[test]
fn test_is_date_field() {
let datetime_schema = json!({
"type": "string",
"format": "date-time"
});
assert!(is_date_field(&datetime_schema));
let no_format_schema = json!({ "type": "string" });
assert!(!is_date_field(&no_format_schema));
}
#[test]
fn test_transform_markdown_fields_basic() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"$body": { "type": "string", "contentMediaType": "text/markdown" }
}
}));
let mut fields = HashMap::new();
fields.insert(
"title".to_string(),
QuillValue::from_json(json!("My Title")),
);
fields.insert(
"$body".to_string(),
QuillValue::from_json(json!("This is **bold** text.")),
);
let result = transform_markdown_fields(&fields, &schema);
assert_eq!(result.get("title").unwrap().as_str(), Some("My Title"));
let body = result.get("$body").unwrap().as_str().unwrap();
assert!(body.contains("#strong[bold]"));
}
#[test]
fn test_transform_markdown_fields_no_markdown() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"count": { "type": "number" }
}
}));
let mut fields = HashMap::new();
fields.insert(
"title".to_string(),
QuillValue::from_json(json!("My Title")),
);
fields.insert("count".to_string(), QuillValue::from_json(json!(42)));
let result = transform_markdown_fields(&fields, &schema);
assert_eq!(result.get("title").unwrap().as_str(), Some("My Title"));
assert_eq!(result.get("count").unwrap().as_i64(), Some(42));
}
#[test]
fn test_transform_markdown_fields_wrapper() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"$body": { "type": "string", "contentMediaType": "text/markdown" }
}
}));
let mut fields = HashMap::new();
fields.insert(
"$body".to_string(),
QuillValue::from_json(json!("_italic_ text")),
);
let result = transform_markdown_fields(&fields, &schema);
let body = result.get("$body").unwrap().as_str().unwrap();
assert!(body.contains("#emph[italic]"));
}
#[test]
fn test_transform_markdown_fields_collects_top_level_date_metadata() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"issued": { "type": "string", "format": "date-time" },
"created_at": { "type": "string", "format": "date-time" }
}
}));
let mut fields = HashMap::new();
fields.insert(
"title".to_string(),
QuillValue::from_json(json!("My Title")),
);
let result = transform_markdown_fields(&fields, &schema);
let meta = result.get("__meta__").expect("missing __meta__").as_json();
let date_fields = meta["date_fields"].as_array().unwrap();
assert_eq!(date_fields.len(), 2);
assert!(date_fields.iter().any(|v| v == "issued"));
assert!(date_fields.iter().any(|v| v == "created_at"));
}
#[test]
fn test_transform_markdown_fields_collects_card_date_metadata() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {},
"$defs": {
"indorsement_card": {
"type": "object",
"properties": {
"signed_on": { "type": "string", "format": "date-time" },
"$body": { "type": "string", "contentMediaType": "text/markdown" }
}
}
}
}));
let fields = HashMap::new();
let result = transform_markdown_fields(&fields, &schema);
let meta = result.get("__meta__").expect("missing __meta__").as_json();
assert_eq!(meta["card_date_fields"]["indorsement"], json!(["signed_on"]));
}
}