mod compile;
pub mod emit;
mod error_mapping;
mod helper;
mod overlay;
mod world;
use std::borrow::Cow;
use quillmark_core::{
quill::{build_transform_schema, QUILLMARK_INLINE_KEY, CONTENT_MEDIA_TYPE},
session::SessionHandle,
Backend, ChangeSet, ContentHit, Diagnostic, LiveSession, OutputFormat, Quill, RenderError,
RenderOptions, RenderResult, RenderedRegion, Severity,
};
#[derive(Debug)]
pub struct TypstBackend;
const SUPPORTED_FORMATS: &[OutputFormat] =
&[OutputFormat::Pdf, OutputFormat::Svg, OutputFormat::Png];
struct TypstSession {
world: world::QuillWorld,
document: typst_layout::PagedDocument,
page_count: usize,
field_placements: Vec<overlay::FieldPlacement>,
schema_meta: SchemaMeta,
windows: Vec<overlay::FieldWindow>,
scalar_windows: Vec<overlay::FieldWindow>,
helper_source: typst::syntax::Source,
page_hashes: Vec<u128>,
compile_warnings: Vec<Diagnostic>,
}
pub(crate) fn page_hashes(document: &typst_layout::PagedDocument) -> Vec<u128> {
use std::hash::{Hash, Hasher};
use typst::layout::FrameItem;
fn hash_text<H: Hasher>(text: &typst::text::TextItem, state: &mut H) {
text.font.hash(state);
text.size.hash(state);
text.fill.hash(state);
text.stroke.hash(state);
text.lang.hash(state);
text.region.hash(state);
text.text.hash(state);
for g in &text.glyphs {
g.id.hash(state);
g.x_advance.hash(state);
g.x_offset.hash(state);
g.y_advance.hash(state);
g.y_offset.hash(state);
g.range.hash(state);
}
}
fn walk<H: Hasher>(frame: &typst::layout::Frame, state: &mut H) {
frame.size().hash(state);
for (pos, item) in frame.items() {
match item {
FrameItem::Tag(_) => {}
FrameItem::Group(g) => {
pos.hash(state);
g.transform.hash(state);
g.clip.hash(state);
walk(&g.frame, state);
}
FrameItem::Text(text) => {
pos.hash(state);
hash_text(text, state);
}
FrameItem::Shape(shape, _span) => {
pos.hash(state);
shape.hash(state);
}
FrameItem::Image(image, size, _span) => {
pos.hash(state);
image.hash(state);
size.hash(state);
}
FrameItem::Link(dest, size) => {
pos.hash(state);
dest.hash(state);
size.hash(state);
}
}
}
}
struct VisiblePage<'a>(&'a typst_layout::Page);
impl Hash for VisiblePage<'_> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.fill.hash(state);
self.0.numbering.hash(state);
self.0.number.hash(state);
walk(&self.0.frame, state);
}
}
document
.pages()
.iter()
.map(|page| typst::utils::hash128(&VisiblePage(page)))
.collect()
}
fn transformed_data<'a>(
meta: &SchemaMeta,
json_data: &'a serde_json::Value,
) -> Result<Cow<'a, serde_json::Value>, RenderError> {
match json_data.as_object() {
Some(obj) => {
validate_date_fields(meta, obj)?;
Ok(Cow::Borrowed(json_data))
}
None => {
Ok(Cow::Owned(serde_json::Value::Object(serde_json::Map::new())))
}
}
}
fn validate_date_fields(
meta: &SchemaMeta,
obj: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), RenderError> {
fn bad_date(field: &str, value: &str) -> RenderError {
RenderError::from_diag(
Diagnostic::new(
Severity::Error,
format!("invalid date in field {field:?}: {value:?} is not a recognized date"),
)
.with_code("backend::invalid_date".to_string()),
)
}
fn check(
names: &[String],
dict: &serde_json::Map<String, serde_json::Value>,
prefix: &str,
is_datetime: bool,
) -> Result<(), RenderError> {
for key in names {
if let Some(serde_json::Value::String(s)) = dict.get(key) {
let ok = if is_datetime {
quillmark_core::quill::parse_datetime(s).is_some()
} else {
quillmark_core::quill::parse_date(s).is_some()
};
if !s.is_empty() && !ok {
return Err(bad_date(&format!("{prefix}{key}"), s));
}
}
}
Ok(())
}
check(&meta.date_fields, obj, "", false)?;
check(&meta.datetime_fields, obj, "", true)?;
if let Some(cards) = obj.get("$cards").and_then(|v| v.as_array()) {
for card in cards {
let Some(card_obj) = card.as_object() else {
continue;
};
let Some(kind) = card_obj.get("$kind").and_then(|v| v.as_str()) else {
continue;
};
let prefix = format!("$cards.{kind}.");
let dates = helper::card_names(&meta.card_date_fields, kind);
let datetimes = helper::card_names(&meta.card_datetime_fields, kind);
check(&dates, card_obj, &prefix, false)?;
check(&datetimes, card_obj, &prefix, true)?;
}
}
Ok(())
}
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::from_diag(
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.field_placements,
opts.producer.as_deref(),
)
}
fn page_count(&self) -> usize {
self.page_count
}
fn apply(&mut self, json_data: &serde_json::Value) -> Result<ChangeSet, RenderError> {
let data = transformed_data(&self.schema_meta, json_data)?;
let mut windows = self
.world
.inject_helper_package(data.as_ref(), &self.schema_meta)
.map_err(|e| engine_err("typst::emit", e.to_string()))?;
windows.extend(self.scalar_windows.iter().cloned());
let (document, compile_warnings) = compile::compile_document(&self.world)?;
let helper_source = helper_source(&self.world)?;
let field_placements = overlay::extract(&document)?;
let new_hashes = page_hashes(&document);
let dirty_pages = (0..new_hashes.len())
.filter(|&i| self.page_hashes.get(i) != Some(&new_hashes[i]))
.collect();
self.document = document;
self.field_placements = field_placements;
self.windows = windows;
self.helper_source = helper_source;
self.page_count = new_hashes.len();
self.page_hashes = new_hashes;
self.compile_warnings = compile_warnings;
Ok(ChangeSet {
page_count: self.page_count,
dirty_pages,
})
}
fn warnings(&self) -> &[Diagnostic] {
&self.compile_warnings
}
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))
}
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,
&typst_render::RenderOptions {
pixel_per_pt: typst::utils::Scalar::new(scale as f64),
..Default::default()
},
);
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))
}
fn regions(&self) -> Vec<quillmark_core::RenderedRegion> {
let mut regions = self.widget_regions();
regions.extend(overlay::scan_content_regions(
&self.document,
&self.world,
&self.helper_source,
&self.windows,
));
regions
}
fn field_at(&self, page: usize, x: f32, y: f32) -> Option<String> {
self.widget_regions()
.into_iter()
.rev()
.find(|r| r.contains(page, x, y))
.map(|r| r.field)
.or_else(|| {
overlay::field_at(
&self.document,
&self.world,
&self.helper_source,
&self.windows,
page,
x,
y,
)
})
}
fn position_at(&self, page: usize, x: f32, y: f32) -> Option<ContentHit> {
overlay::position_at(
&self.document,
&self.world,
&self.helper_source,
&self.windows,
page,
x,
y,
)
}
fn locate(&self, field: &str, pos: usize) -> Option<RenderedRegion> {
overlay::locate(
&self.document,
&self.world,
&self.helper_source,
&self.windows,
field,
pos,
)
}
}
impl TypstSession {
fn widget_regions(&self) -> Vec<quillmark_core::RenderedRegion> {
overlay::build_field_specs(&self.document, &self.field_placements)
.map(|specs| quillmark_pdf::regions_of(&specs))
.unwrap_or_default()
}
}
fn helper_source(world: &world::QuillWorld) -> Result<typst::syntax::Source, RenderError> {
use typst::World as _;
world
.source(world::QuillWorld::helper_fid("lib.typ"))
.map_err(|e| {
engine_err(
"typst::helper_source",
format!("helper lib.typ unreadable: {e}"),
)
})
}
impl Backend for TypstBackend {
fn id(&self) -> &'static str {
"typst"
}
fn supported_formats(&self) -> &'static [OutputFormat] {
SUPPORTED_FORMATS
}
fn open(
&self,
source: &Quill,
json_data: &serde_json::Value,
) -> Result<LiveSession, RenderError> {
let plate_content = read_plate(source)?;
let transform_schema = build_transform_schema(source.config());
let schema_meta = SchemaMeta::from_schema_json(transform_schema.as_json());
let data = transformed_data(&schema_meta, json_data)?;
let (world, mut windows) =
world::QuillWorld::new_with_data(source, &plate_content, data.as_ref(), &schema_meta)
.map_err(
|e| {
RenderError::from_diag(
Diagnostic::new(
Severity::Error,
format!("Failed to create Typst compilation environment: {}", e),
)
.with_code("typst::world_creation".to_string())
.with_source(e.as_ref()),
)
},
)?;
let scalar_windows: Vec<overlay::FieldWindow> = {
use typst::World as _;
let main_id = world.main();
world
.source(main_id)
.ok()
.map(|src| {
overlay::scalar_windows(&src, &schema_meta.fields)
.into_iter()
.map(|(path, range)| overlay::FieldWindow {
path,
file: main_id,
range,
segments: Vec::new(),
})
.collect()
})
.unwrap_or_default()
};
windows.extend(scalar_windows.iter().cloned());
let (document, compile_warnings) = compile::compile_document(&world)?;
let helper_src = helper_source(&world)?;
let page_count = document.pages().len();
let field_placements = overlay::extract(&document)?;
let hashes = page_hashes(&document);
let session = TypstSession {
world,
document,
page_count,
field_placements,
schema_meta,
windows,
scalar_windows,
helper_source: helper_src,
page_hashes: hashes,
compile_warnings,
};
Ok(LiveSession::new(Box::new(session)))
}
}
impl Default for TypstBackend {
fn default() -> Self {
Self
}
}
fn read_plate(source: &Quill) -> Result<String, RenderError> {
let plate_file = source
.config()
.backend_config
.get("plate_file")
.and_then(|v| v.as_str());
let Some(plate_file) = plate_file else {
return Ok(String::new());
};
let bytes = source.files().get_file(plate_file).ok_or_else(|| {
engine_err(
"typst::plate_missing",
format!("plate file '{plate_file}' not found in the quill's file tree"),
)
})?;
String::from_utf8(bytes.to_vec()).map_err(|e| {
engine_err(
"typst::invalid_utf8",
format!("plate file '{plate_file}' is not valid UTF-8: {e}"),
)
})
}
fn engine_err(code: &str, message: impl Into<String>) -> RenderError {
RenderError::from_diag(
Diagnostic::new(Severity::Error, message.into()).with_code(code.to_string()),
)
}
fn is_richtext_field(field_schema: &serde_json::Value) -> bool {
field_schema
.get("contentMediaType")
.and_then(|v| v.as_str())
.map(|s| s == CONTENT_MEDIA_TYPE)
.unwrap_or(false)
}
fn is_richtext_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_richtext_field)
.unwrap_or(false)
}
fn is_inline_richtext_field(field_schema: &serde_json::Value) -> bool {
fn marked_inline(fs: &serde_json::Value) -> bool {
fs.get(QUILLMARK_INLINE_KEY).and_then(|v| v.as_bool()) == Some(true)
}
(is_richtext_field(field_schema) && marked_inline(field_schema))
|| (is_richtext_array_field(field_schema)
&& field_schema
.get("items")
.map(marked_inline)
.unwrap_or(false))
}
fn is_date_field(field_schema: &serde_json::Value) -> bool {
has_format(field_schema, "date")
}
fn is_datetime_field(field_schema: &serde_json::Value) -> bool {
has_format(field_schema, "date-time")
}
fn has_format(field_schema: &serde_json::Value, format: &str) -> bool {
field_schema.get("format").and_then(|v| v.as_str()) == Some(format)
}
fn field_names_where(
properties: &serde_json::Map<String, serde_json::Value>,
predicate: impl Fn(&serde_json::Value) -> bool,
) -> Vec<String> {
properties
.iter()
.filter(|(_, fs)| predicate(fs))
.map(|(name, _)| name.clone())
.collect()
}
fn content_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
field_names_where(properties, |fs| {
is_richtext_field(fs) || is_richtext_array_field(fs)
})
}
fn inline_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
field_names_where(properties, is_inline_richtext_field)
}
fn date_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
field_names_where(properties, is_date_field)
}
fn datetime_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
field_names_where(properties, is_datetime_field)
}
fn array_field_names(properties: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
field_names_where(properties, |fs| {
fs.get("type").and_then(|v| v.as_str()) == Some("array")
})
}
#[derive(Default)]
pub(crate) struct SchemaMeta {
pub(crate) content_fields: Vec<String>,
pub(crate) date_fields: Vec<String>,
pub(crate) datetime_fields: Vec<String>,
pub(crate) array_fields: Vec<String>,
pub(crate) inline_fields: Vec<String>,
pub(crate) card_content_fields: serde_json::Map<String, serde_json::Value>,
pub(crate) card_date_fields: serde_json::Map<String, serde_json::Value>,
pub(crate) card_datetime_fields: serde_json::Map<String, serde_json::Value>,
pub(crate) card_field_names: serde_json::Map<String, serde_json::Value>,
pub(crate) card_array_fields: serde_json::Map<String, serde_json::Value>,
pub(crate) card_inline_fields: serde_json::Map<String, serde_json::Value>,
pub(crate) fields: Vec<String>,
}
impl SchemaMeta {
pub(crate) fn from_schema_json(schema_json: &serde_json::Value) -> Self {
let Some(properties_obj) = schema_json.get("properties").and_then(|v| v.as_object()) else {
return Self::default();
};
let content_fields = content_field_names(properties_obj);
let date_fields = date_field_names(properties_obj);
let datetime_fields = datetime_field_names(properties_obj);
let array_fields = array_field_names(properties_obj);
let inline_fields = inline_field_names(properties_obj);
let fields = properties_obj.keys().cloned().collect();
let mut card_content_fields = serde_json::Map::new();
let mut card_date_fields = serde_json::Map::new();
let mut card_datetime_fields = serde_json::Map::new();
let mut card_field_names = serde_json::Map::new();
let mut card_array_fields = serde_json::Map::new();
let mut card_inline_fields = serde_json::Map::new();
fn insert_names(
table: &mut serde_json::Map<String, serde_json::Value>,
kind: &str,
names: Vec<String>,
) {
if !names.is_empty() {
table.insert(kind.to_string(), names.into());
}
}
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());
if let Some(props) = card_props {
card_field_names.insert(
card_kind.to_string(),
props.keys().cloned().collect::<Vec<String>>().into(),
);
}
insert_names(
&mut card_content_fields,
card_kind,
card_props.map(content_field_names).unwrap_or_default(),
);
insert_names(
&mut card_date_fields,
card_kind,
card_props.map(date_field_names).unwrap_or_default(),
);
insert_names(
&mut card_datetime_fields,
card_kind,
card_props.map(datetime_field_names).unwrap_or_default(),
);
insert_names(
&mut card_array_fields,
card_kind,
card_props.map(array_field_names).unwrap_or_default(),
);
insert_names(
&mut card_inline_fields,
card_kind,
card_props.map(inline_field_names).unwrap_or_default(),
);
}
}
}
Self {
content_fields,
date_fields,
datetime_fields,
array_fields,
inline_fields,
card_content_fields,
card_date_fields,
card_datetime_fields,
card_field_names,
card_array_fields,
card_inline_fields,
fields,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quillmark_core::QuillValue;
use serde_json::json;
use std::collections::HashMap;
fn content(markdown: &str) -> serde_json::Value {
let rt = quillmark_content::import::from_markdown(markdown).expect("import");
quillmark_content::serial::to_canonical_value(&rt)
}
#[test]
fn page_hashes_ignore_span_shift_when_ink_is_identical() {
use quillmark_core::FileTreeNode;
const PLATE: &str = r#"#import "@local/quillmark-helper:0.1.0": data
#set page(width: 300pt, height: 200pt, margin: 20pt)
#set text(size: 11pt)
#data.body
"#;
let quill_with = |extra_field: bool| {
let mut yaml = String::from(
"quill:\n name: shift\n version: 0.1.0\n backend: typst\n description: span shift probe\ntypst:\n plate_file: plate.typ\nmain:\n fields:\n body:\n type: richtext\n description: body\n",
);
if extra_field {
yaml.push_str(
" zz_unused:\n type: string\n description: never placed\n",
);
}
let mut files = HashMap::new();
files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File {
contents: yaml.into_bytes(),
},
);
files.insert(
"plate.typ".to_string(),
FileTreeNode::File {
contents: PLATE.as_bytes().to_vec(),
},
);
Quill::from_tree(FileTreeNode::Directory { files }).expect("quill")
};
let json =
serde_json::json!({ "body": content("A **markdown** body with real ink to lay out.") });
let hashes_of = |quill: &Quill| {
let plate_content = read_plate(quill).expect("plate");
let transform_schema = build_transform_schema(quill.config());
let schema_meta = SchemaMeta::from_schema_json(transform_schema.as_json());
let data = transformed_data(&schema_meta, &json).expect("data");
let (world, _windows) =
world::QuillWorld::new_with_data(quill, &plate_content, data.as_ref(), &schema_meta)
.expect("world");
let (document, _warnings) = compile::compile_document(&world).expect("compile");
page_hashes(&document)
};
assert_eq!(
hashes_of(&quill_with(false)),
hashes_of(&quill_with(true)),
"identical ink must fingerprint identically across a whole-file span shift"
);
}
#[test]
fn line_anchored_paragraph_text_stays_literal() {
use quillmark_core::FileTreeNode;
use typst::foundations::{NativeElement, Selector};
use typst::introspection::Introspector;
use typst::model::{EnumElem, HeadingElem, ListElem, TermsElem};
const PLATE: &str = r#"#import "@local/quillmark-helper:0.1.0": data
#set page(width: 300pt, height: 400pt, margin: 20pt)
#set text(size: 11pt)
#data.body
"#;
let quill = || {
let yaml = "quill:\n name: anchor\n version: 0.1.0\n backend: typst\n description: line-anchor guard\ntypst:\n plate_file: plate.typ\nmain:\n fields:\n body:\n type: richtext\n description: body\n";
let mut files = HashMap::new();
files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File { contents: yaml.as_bytes().to_vec() },
);
files.insert(
"plate.typ".to_string(),
FileTreeNode::File { contents: PLATE.as_bytes().to_vec() },
);
Quill::from_tree(FileTreeNode::Directory { files }).expect("quill")
};
use quillmark_content::model::{Line, LineKind, Content};
let para = |_: usize| Line { kind: LineKind::Para, containers: vec![], continues: false };
let mut rt = Content {
text: "= Heading\n- bullet\n+ numbered\n1. dotted\n/ term: desc".to_string(),
lines: (0..5).map(para).collect(),
marks: vec![],
islands: vec![],
};
rt.normalize();
assert_eq!(rt.validate(), Ok(()), "content invariants");
let q = quill();
let json =
serde_json::json!({ "body": quillmark_content::serial::to_canonical_value(&rt) });
let plate_content = read_plate(&q).expect("plate");
let transform_schema = build_transform_schema(q.config());
let schema_meta = SchemaMeta::from_schema_json(transform_schema.as_json());
let data = transformed_data(&schema_meta, &json).expect("data");
let (world, _w) =
world::QuillWorld::new_with_data(&q, &plate_content, data.as_ref(), &schema_meta)
.expect("world");
let (document, _warn) = compile::compile_document(&world).expect("compile");
let intro = document.introspector();
let count = |e| intro.query(&Selector::Elem(e, None)).len();
assert_eq!(count(<HeadingElem as NativeElement>::ELEM), 0, "no heading");
assert_eq!(count(<ListElem as NativeElement>::ELEM), 0, "no bullet list");
assert_eq!(count(<EnumElem as NativeElement>::ELEM), 0, "no enum");
assert_eq!(count(<TermsElem as NativeElement>::ELEM), 0, "no term list");
}
#[test]
fn inline_field_in_par_emits_no_parbreak_warning() {
use quillmark_core::FileTreeNode;
const PLATE: &str = r#"#import "@local/quillmark-helper:0.1.0": data
#set page(width: 300pt, height: 200pt, margin: 20pt)
#set text(size: 11pt)
#par(data.subject)
"#;
let quill = |inline: bool| {
let inline_line = if inline { " inline: true\n" } else { "" };
let yaml = format!(
"quill:\n name: parbreak\n version: 0.1.0\n backend: typst\n description: parbreak probe\ntypst:\n plate_file: plate.typ\nmain:\n fields:\n subject:\n type: richtext\n{inline_line} description: subject\n",
);
let mut files = HashMap::new();
files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File { contents: yaml.into_bytes() },
);
files.insert(
"plate.typ".to_string(),
FileTreeNode::File { contents: PLATE.as_bytes().to_vec() },
);
Quill::from_tree(FileTreeNode::Directory { files }).expect("quill")
};
let warnings_for = |inline: bool| {
let q = quill(inline);
let json = serde_json::json!({ "subject": content("A subject line") });
let plate_content = read_plate(&q).expect("plate");
let transform_schema = build_transform_schema(q.config());
let schema_meta = SchemaMeta::from_schema_json(transform_schema.as_json());
let data = transformed_data(&schema_meta, &json).expect("data");
let (world, _w) =
world::QuillWorld::new_with_data(&q, &plate_content, data.as_ref(), &schema_meta)
.expect("world");
let (_doc, warnings) = compile::compile_document(&world).expect("compile");
warnings
};
let has_parbreak =
|ws: &[Diagnostic]| ws.iter().any(|d| d.message.contains("parbreak"));
assert!(
has_parbreak(&warnings_for(false)),
"block richtext in par() should emit the parbreak warning"
);
assert!(
!has_parbreak(&warnings_for(true)),
"inline richtext in par() must emit no parbreak warning"
);
}
#[test]
fn plate_uses_plaintext_projection_as_string() {
use quillmark_core::FileTreeNode;
const PLATE: &str = r#"#import "@local/quillmark-helper:0.1.0": data, plaintext
#set page(width: 300pt, height: 200pt, margin: 20pt)
#set text(size: 11pt)
#assert(type(plaintext("subject")) == str, message: "plaintext must be a str")
#assert(plaintext("subject").starts-with("Hello"), message: "string op works")
#assert(plaintext("missing") == "", message: "absent field defaults to empty string")
#upper(plaintext("subject"))
"#;
let quill = || {
let yaml = "quill:\n name: plaintext\n version: 0.1.0\n backend: typst\n description: plaintext probe\ntypst:\n plate_file: plate.typ\nmain:\n fields:\n subject:\n type: richtext\n description: subject\n";
let mut files = HashMap::new();
files.insert(
"Quill.yaml".to_string(),
FileTreeNode::File { contents: yaml.as_bytes().to_vec() },
);
files.insert(
"plate.typ".to_string(),
FileTreeNode::File { contents: PLATE.as_bytes().to_vec() },
);
Quill::from_tree(FileTreeNode::Directory { files }).expect("quill")
};
let q = quill();
let json = serde_json::json!({ "subject": content("Hello **bold** world") });
let plate_content = read_plate(&q).expect("plate");
let transform_schema = build_transform_schema(q.config());
let schema_meta = SchemaMeta::from_schema_json(transform_schema.as_json());
let data = transformed_data(&schema_meta, &json).expect("data");
let (world, _w) =
world::QuillWorld::new_with_data(&q, &plate_content, data.as_ref(), &schema_meta)
.expect("world");
let (_doc, _warn) = compile::compile_document(&world).expect("compile");
}
#[test]
fn test_is_richtext_field() {
let richtext_schema = json!({
"type": "object",
"contentMediaType": CONTENT_MEDIA_TYPE
});
assert!(is_richtext_field(&richtext_schema));
let string_schema = json!({ "type": "string" });
assert!(!is_richtext_field(&string_schema));
let old_media_type = json!({ "type": "string", "contentMediaType": "text/markdown" });
assert!(!is_richtext_field(&old_media_type));
}
#[test]
fn test_is_richtext_array_field() {
let rt_array = json!({
"type": "array",
"items": { "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE }
});
assert!(is_richtext_array_field(&rt_array));
let string_array = json!({
"type": "array",
"items": { "type": "string" }
});
assert!(!is_richtext_array_field(&string_array));
let rt_scalar = json!({ "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE });
assert!(!is_richtext_array_field(&rt_scalar));
}
#[test]
fn schema_meta_classifies_richtext_content_fields() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"intro": { "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE },
"sections": {
"type": "array",
"items": { "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE }
}
}
}));
let meta = SchemaMeta::from_schema_json(schema.as_json());
assert!(meta.content_fields.contains(&"intro".to_string()));
assert!(meta.content_fields.contains(&"sections".to_string()));
assert!(!meta.content_fields.contains(&"title".to_string()));
}
#[test]
fn schema_meta_array_fields_distinguish_scalar_from_array() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"subject": { "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE },
"sections": {
"type": "array",
"items": { "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE }
},
"signature_block": {
"type": "array",
"items": { "type": "string" }
}
},
"$defs": {
"indorsement_card": {
"type": "object",
"properties": {
"$body": { "type": "object", "contentMediaType": CONTENT_MEDIA_TYPE },
"refs": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
}));
let meta = SchemaMeta::from_schema_json(schema.as_json());
assert!(meta.array_fields.contains(&"sections".to_string()));
assert!(meta.array_fields.contains(&"signature_block".to_string()));
assert!(!meta.array_fields.contains(&"subject".to_string()));
let card_arrays = meta.card_array_fields.get("indorsement").unwrap();
assert_eq!(card_arrays, &serde_json::json!(["refs"]));
}
#[test]
fn schema_meta_collects_date_and_datetime_fields() {
let schema = QuillValue::from_json(json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"issued": { "type": "string", "format": "date" },
"created_at": { "type": "string", "format": "date-time" }
},
"$defs": {
"indorsement_card": {
"type": "object",
"properties": {
"signed_on": { "type": "string", "format": "date" },
"filed_at": { "type": "string", "format": "date-time" }
}
}
}
}));
let meta = SchemaMeta::from_schema_json(schema.as_json());
assert!(meta.date_fields.contains(&"issued".to_string()));
assert!(!meta.date_fields.contains(&"created_at".to_string()));
assert!(meta.datetime_fields.contains(&"created_at".to_string()));
assert_eq!(meta.card_date_fields["indorsement"], json!(["signed_on"]));
assert_eq!(meta.card_datetime_fields["indorsement"], json!(["filed_at"]));
}
}