use std::collections::HashMap;
use std::ops::Range;
use typst::layout::{Frame, FrameItem, Point, Transform};
use typst::syntax::ast::{self, AstNode};
use typst::syntax::{DiagSpan, DiagSpanKind, FileId, LinkedNode, Source, Span, SyntaxKind};
use typst::World;
use typst_layout::PagedDocument;
use quillmark_core::{ContentHit, HitGranularity, RenderedRegion};
use crate::emit::SegmentMap;
use crate::world::QuillWorld;
#[derive(Debug, Clone)]
pub(crate) struct FieldWindow {
pub path: String,
pub file: FileId,
pub range: Range<usize>,
pub segments: Vec<SegmentMap>,
}
#[derive(Clone, Copy)]
struct Aabb {
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
}
impl Aabb {
fn of(corners: [Point; 4], ts: Transform) -> Self {
let mut b = Self {
min_x: f64::INFINITY,
min_y: f64::INFINITY,
max_x: f64::NEG_INFINITY,
max_y: f64::NEG_INFINITY,
};
for c in corners {
let p = c.transform(ts);
let (x, y) = (p.x.to_pt(), p.y.to_pt());
b.min_x = b.min_x.min(x);
b.min_y = b.min_y.min(y);
b.max_x = b.max_x.max(x);
b.max_y = b.max_y.max(y);
}
b
}
fn union(&mut self, o: Aabb) {
self.min_x = self.min_x.min(o.min_x);
self.min_y = self.min_y.min(o.min_y);
self.max_x = self.max_x.max(o.max_x);
self.max_y = self.max_y.max(o.max_y);
}
fn contains(&self, x: f64, y: f64) -> bool {
self.min_x <= x && x <= self.max_x && self.min_y <= y && y <= self.max_y
}
}
fn pdf_rect(b: &Aabb, page_h: f64) -> [f32; 4] {
[
b.min_x as f32,
(page_h - b.max_y) as f32,
b.max_x as f32,
(page_h - b.min_y) as f32,
]
}
struct Hit {
page: usize,
class: HitClass,
rect: Option<Aabb>,
}
#[derive(Clone, Copy)]
enum HitClass {
Boxable { key: (usize, Option<usize>) },
Transparent { window: usize },
Anonymous,
Foreign,
}
impl HitClass {
fn window(self) -> Option<usize> {
match self {
HitClass::Boxable { key } => Some(key.0),
HitClass::Transparent { window } => Some(window),
HitClass::Anonymous | HitClass::Foreign => None,
}
}
}
fn hit_class(
resolved: Option<(usize, Option<usize>)>,
detached: bool,
windows: &[FieldWindow],
) -> HitClass {
match resolved {
None if detached => HitClass::Anonymous,
None => HitClass::Foreign,
Some((w, Some(s))) => HitClass::Boxable { key: (w, Some(s)) },
Some((w, None)) if windows[w].segments.is_empty() => HitClass::Boxable { key: (w, None) },
Some((w, None)) => HitClass::Transparent { window: w },
}
}
struct Classifier<'a> {
world: &'a QuillWorld,
helper: &'a Source,
windows: &'a [FieldWindow],
memo: HashMap<Span, Option<(usize, Option<usize>)>>,
}
impl<'a> Classifier<'a> {
fn new(world: &'a QuillWorld, helper: &'a Source, windows: &'a [FieldWindow]) -> Self {
Self {
world,
helper,
windows,
memo: HashMap::new(),
}
}
fn resolve_range(&self, span: Span) -> Option<(FileId, Range<usize>)> {
match DiagSpan::from(span).get() {
DiagSpanKind::Detached => None,
DiagSpanKind::Number { id, num, sub_range } => {
let range = if id == self.helper.id() {
self.helper.range(num, sub_range)
} else {
self.world
.source(id)
.ok()
.and_then(|s| s.range(num, sub_range))
};
range.map(|r| (id, r))
}
DiagSpanKind::Range { id, range } => Some((id, range)),
}
}
fn classify_seg(&mut self, span: Span) -> Option<(usize, Option<usize>)> {
if let Some(&c) = self.memo.get(&span) {
return c;
}
let c = self.resolve_range(span).and_then(|(file, range)| {
self.windows
.iter()
.position(|win| {
win.file == file && win.range.start <= range.start && range.end <= win.range.end
})
.map(|i| (i, self.seg_of(i, &range)))
});
self.memo.insert(span, c);
c
}
fn seg_of(&self, window: usize, range: &Range<usize>) -> Option<usize> {
let segs = &self.windows[window].segments;
let i = segs.partition_point(|s| s.gen.start <= range.start);
(i > 0 && segs[i - 1].gen.end >= range.end).then(|| i - 1)
}
}
fn collect_page_hits(frame: &Frame, page: usize, cls: &mut Classifier, out: &mut Vec<Hit>) {
walk_items(frame, Transform::identity(), page, &mut |page, span, _offset, aabb| {
let class = hit_class(cls.classify_seg(span), span.is_detached(), cls.windows);
let rect = class.window().is_some().then(aabb);
out.push(Hit { page, class, rect });
});
}
type ItemVisitor<'a> = dyn FnMut(usize, Span, u16, &dyn Fn() -> Aabb) + 'a;
fn walk_items(frame: &Frame, ts: Transform, page: usize, visit: &mut ItemVisitor) {
for (pos, item) in frame.items() {
match item {
FrameItem::Group(group) => {
let ts = ts
.pre_concat(Transform::translate(pos.x, pos.y))
.pre_concat(group.transform);
walk_items(&group.frame, ts, page, visit);
}
FrameItem::Text(text) => {
let bb = text.bbox();
let mut cursor = Point::zero();
for glyph in &text.glyphs {
let advance =
Point::new(glyph.x_advance.at(text.size), glyph.y_advance.at(text.size));
let offset =
Point::new(glyph.x_offset.at(text.size), glyph.y_offset.at(text.size));
let lo = Point::new(cursor.x + offset.x, cursor.y + bb.min.y);
let hi = Point::new(cursor.x + offset.x + advance.x, cursor.y + bb.max.y);
let p = *pos;
visit(page, glyph.span.0, glyph.span.1, &|| item_aabb(p, lo, hi, ts));
cursor += advance;
}
}
FrameItem::Shape(shape, span) => {
let bb = shape.geometry.bbox(shape.stroke.as_ref());
let p = *pos;
visit(page, *span, 0, &|| item_aabb(p, bb.min, bb.max, ts));
}
FrameItem::Image(_, size, span) => {
let sz = size.to_point();
let p = *pos;
visit(page, *span, 0, &|| item_aabb(p, Point::zero(), sz, ts));
}
_ => {}
}
}
}
fn item_aabb(pos: Point, lo: Point, hi: Point, ts: Transform) -> Aabb {
Aabb::of(
[
Point::new(pos.x + lo.x, pos.y + lo.y),
Point::new(pos.x + hi.x, pos.y + lo.y),
Point::new(pos.x + lo.x, pos.y + hi.y),
Point::new(pos.x + hi.x, pos.y + hi.y),
],
ts,
)
}
#[derive(Clone, Copy, PartialEq)]
enum Run {
NotSeen,
Suspended {
last_page: usize,
},
Done,
}
pub(crate) fn scan(
doc: &PagedDocument,
world: &QuillWorld,
helper: &Source,
windows: &[FieldWindow],
) -> Vec<RenderedRegion> {
if windows.is_empty() {
return Vec::new();
}
let mut cls = Classifier::new(world, helper, windows);
let mut hits = Vec::new();
for (page, p) in doc.pages().iter().enumerate() {
collect_page_hits(&p.frame, page, &mut cls, &mut hits);
}
let keys = flatten_keys(windows);
let boxes = run_scan_machine(&keys, &hits);
let mut out: Vec<(RenderedRegion, usize)> = Vec::new();
for (ki, &(wi, seg)) in keys.iter().enumerate() {
let window = &windows[wi];
let span = seg.map(|s| {
let c = &window.segments[s].content;
[c.start, c.end]
});
for (page, b) in &boxes[ki] {
let Some(page_h) = doc.pages().get(*page).map(|p| p.frame.size().y.to_pt()) else {
continue;
};
out.push((
RenderedRegion {
field: window.path.clone(),
page: *page,
rect: pdf_rect(b, page_h),
span,
},
ki,
));
}
}
out.sort_by(|(a, ai), (b, bi)| (a.page, &a.field, *ai).cmp(&(b.page, &b.field, *bi)));
out.into_iter().map(|(r, _)| r).collect()
}
fn flatten_keys(windows: &[FieldWindow]) -> Vec<(usize, Option<usize>)> {
let mut keys = Vec::new();
for (wi, w) in windows.iter().enumerate() {
if w.segments.is_empty() {
keys.push((wi, None));
} else {
keys.extend((0..w.segments.len()).map(|s| (wi, Some(s))));
}
}
keys
}
fn run_scan_machine(keys: &[(usize, Option<usize>)], hits: &[Hit]) -> Vec<Vec<(usize, Aabb)>> {
let key_index: HashMap<(usize, Option<usize>), usize> =
keys.iter().enumerate().map(|(i, k)| (*k, i)).collect();
let mut state = vec![Run::NotSeen; keys.len()];
let mut boxes: Vec<Vec<(usize, Aabb)>> = vec![Vec::new(); keys.len()];
let mut current: Option<(usize, usize)> = None;
for hit in hits {
match hit.class {
HitClass::Boxable { key } => {
let ki = key_index[&key];
if current.map(|(c, _)| c) == Some(ki) {
accrue(&mut boxes[ki], hit);
current = Some((ki, hit.page));
} else {
if let Some((c, last_page)) = current.take() {
state[c] = Run::Suspended { last_page };
}
match state[ki] {
Run::NotSeen => {
accrue(&mut boxes[ki], hit);
current = Some((ki, hit.page));
}
Run::Suspended { last_page } if hit.page == last_page + 1 => {
accrue(&mut boxes[ki], hit);
current = Some((ki, hit.page));
}
Run::Suspended { .. } => state[ki] = Run::Done,
Run::Done => {}
}
}
}
HitClass::Transparent { window } => match current {
Some((c, _)) if keys[c].0 == window => {}
_ => {
if let Some((c, last_page)) = current.take() {
state[c] = Run::Suspended { last_page };
}
}
},
HitClass::Anonymous => {}
HitClass::Foreign => {
if let Some((c, last_page)) = current.take() {
state[c] = Run::Suspended { last_page };
}
}
}
}
boxes
}
fn accrue(boxes: &mut Vec<(usize, Aabb)>, hit: &Hit) {
let rect = hit.rect.expect("classified hits carry a box");
match boxes.last_mut() {
Some((page, b)) if *page == hit.page => b.union(rect),
_ => boxes.push((hit.page, rect)),
}
}
pub(crate) fn field_at(
doc: &PagedDocument,
world: &QuillWorld,
helper: &Source,
windows: &[FieldWindow],
page: usize,
x: f32,
y: f32,
) -> Option<String> {
if windows.is_empty() {
return None;
}
let frame = &doc.pages().get(page)?.frame;
let page_h = frame.size().y.to_pt();
let (x, y) = (x as f64, page_h - y as f64);
let mut cls = Classifier::new(world, helper, windows);
let mut hits = Vec::new();
collect_page_hits(frame, page, &mut cls, &mut hits);
hits.iter()
.rev()
.find(|h| h.rect.is_some_and(|r| r.contains(x, y)))
.and_then(|h| h.class.window())
.map(|w| windows[w].path.clone())
}
struct GlyphHit {
page: usize,
rect: Aabb,
node: Range<usize>,
offset: u16,
window: usize,
seg: Option<usize>,
}
fn walk_glyphs(
frame: &Frame,
page: usize,
cls: &mut Classifier,
only: Option<(usize, Option<usize>)>,
out: &mut Vec<GlyphHit>,
) {
walk_items(frame, Transform::identity(), page, &mut |page, span, offset, aabb| {
let Some((w, seg)) = cls.classify_seg(span) else {
return;
};
if only.is_some_and(|t| t != (w, seg)) {
return;
}
if let Some((_, node)) = cls.resolve_range(span) {
out.push(GlyphHit {
page,
rect: aabb(),
node,
offset,
window: w,
seg,
});
}
});
}
pub(crate) fn position_at(
doc: &PagedDocument,
world: &QuillWorld,
helper: &Source,
windows: &[FieldWindow],
page: usize,
x: f32,
y: f32,
) -> Option<ContentHit> {
if windows.is_empty() {
return None;
}
let frame = &doc.pages().get(page)?.frame;
let page_h = frame.size().y.to_pt();
let (px, py) = (x as f64, page_h - y as f64);
let mut cls = Classifier::new(world, helper, windows);
let mut hits = Vec::new();
walk_glyphs(frame, page, &mut cls, None, &mut hits);
let hit = hits
.iter()
.rev()
.find(|g| g.seg.is_some() && g.rect.contains(px, py))?;
let window = &windows[hit.window];
let segmap = &window.segments[hit.seg?];
let (pos, granularity) = invert_hit(helper, segmap, &hit.node, hit.offset);
Some(ContentHit {
field: window.path.clone(),
pos,
granularity: Some(granularity),
})
}
fn invert_hit(
helper: &Source,
segmap: &SegmentMap,
node: &Range<usize>,
offset: u16,
) -> (usize, HitGranularity) {
let Some((content_r, gen_r, ctx)) = segmap
.runs
.iter()
.find(|(_, g, _)| g.start <= node.start && node.end <= g.end)
else {
return (segmap.content.start, HitGranularity::Segment);
};
let abs = (node.start + offset as usize)
.min(gen_r.end.saturating_sub(1))
.max(gen_r.start);
let gen_text = &helper.text()[gen_r.clone()];
let pos = content_r.start + crate::emit::invert_gen_offset(gen_text, *ctx, abs - gen_r.start);
(pos, HitGranularity::Cluster)
}
pub(crate) fn locate(
doc: &PagedDocument,
world: &QuillWorld,
helper: &Source,
windows: &[FieldWindow],
field: &str,
pos: usize,
) -> Option<RenderedRegion> {
if windows.is_empty() {
return None;
}
let (wi, window) = windows
.iter()
.enumerate()
.find(|(_, w)| w.path == field && !w.segments.is_empty())?;
let seg_idx = window
.segments
.iter()
.position(|s| s.content.start <= pos && pos <= s.content.end)?;
let target_gen = forward_pos(helper, &window.segments[seg_idx], pos);
let mut cls = Classifier::new(world, helper, windows);
let mut hits = Vec::new();
for (page, p) in doc.pages().iter().enumerate() {
walk_glyphs(&p.frame, page, &mut cls, Some((wi, Some(seg_idx))), &mut hits);
}
let g = hits
.iter()
.min_by_key(|g| {
let covers = g.node.start <= target_gen && target_gen < g.node.end;
let caret = g.node.start + g.offset as usize;
(
!covers,
caret > target_gen,
(caret as isize - target_gen as isize).unsigned_abs(),
)
})?;
let page_h = doc.pages().get(g.page)?.frame.size().y.to_pt();
Some(RenderedRegion {
field: field.to_string(),
page: g.page,
rect: pdf_rect(&g.rect, page_h),
span: Some([pos, pos]),
})
}
fn forward_pos(helper: &Source, segmap: &SegmentMap, pos: usize) -> usize {
match segmap
.runs
.iter()
.find(|(c, _, _)| c.start <= pos && pos < c.end)
{
Some((content_r, gen_r, ctx)) => {
let gen_text = &helper.text()[gen_r.clone()];
gen_r.start + crate::emit::forward_content_offset(gen_text, *ctx, pos - content_r.start)
}
None => segmap.gen.start,
}
}
pub(crate) fn scalar_windows(source: &Source, fields: &[String]) -> Vec<(String, Range<usize>)> {
let mut anchors: Vec<(String, Range<usize>, Range<usize>)> = Vec::new();
collect_anchors(&LinkedNode::new(source.root()), fields, &mut anchors);
let mut out: Vec<(String, Range<usize>)> = anchors
.iter()
.map(|(path, chain, _)| (path.clone(), chain.clone()))
.collect();
for (path, chain, wide) in &anchors {
if wide == chain {
continue;
}
let inside = anchors
.iter()
.filter(|(_, c, _)| wide.start <= c.start && c.end <= wide.end)
.count();
if inside == 1 {
out.push((path.clone(), wide.clone()));
}
}
out
}
fn collect_anchors(
node: &LinkedNode,
fields: &[String],
out: &mut Vec<(String, Range<usize>, Range<usize>)>,
) {
if let Some((path, anchor)) = data_access(node, fields) {
let mut chain = anchor.clone();
while let Some(parent) = chain.parent() {
match parent.kind() {
SyntaxKind::FieldAccess | SyntaxKind::FuncCall => chain = parent.clone(),
_ => break,
}
}
let mut wide = chain.clone();
while let Some(parent) = wide.parent() {
match parent.kind() {
SyntaxKind::FieldAccess
| SyntaxKind::FuncCall
| SyntaxKind::Args
| SyntaxKind::Named
| SyntaxKind::Spread
| SyntaxKind::Parenthesized
| SyntaxKind::Unary
| SyntaxKind::Binary => wide = parent.clone(),
_ => break,
}
}
out.push((path, chain.range(), wide.range()));
}
for child in node.children() {
collect_anchors(&child, fields, out);
}
}
fn data_access<'a>(node: &LinkedNode<'a>, fields: &[String]) -> Option<(String, LinkedNode<'a>)> {
if node.kind() != SyntaxKind::FieldAccess {
return None;
}
let access = node.cast::<ast::FieldAccess>()?;
let ast::Expr::Ident(target) = access.target() else {
return None;
};
if target.as_str() != "data" {
return None;
}
let field = access.field();
if fields.iter().any(|f| f == field.as_str()) {
return Some((field.as_str().to_string(), node.clone()));
}
if field.as_str() == "at" {
let parent = node.parent()?;
let call = parent.cast::<ast::FuncCall>()?;
let ast::Expr::FieldAccess(callee) = call.callee() else {
return None;
};
if callee.to_untyped() != node.get() {
return None;
}
let first = call.args().items().find_map(|arg| match arg {
ast::Arg::Pos(ast::Expr::Str(s)) => Some(s.get().to_string()),
_ => None,
})?;
if fields.contains(&first) {
return Some((first, parent.clone()));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compile::compile_document;
use crate::world::QuillWorld;
use quillmark_core::{FileTreeNode, Quill};
use std::collections::HashMap as Map;
use typst::World;
fn quill(yaml: &str, plate: &str) -> Quill {
let mut files = Map::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("load quill")
}
#[test]
fn block_output_spans_resolve_into_the_helper_file() {
const YAML: &str = r#"
quill:
name: span_probe
version: 0.1.0
backend: typst
description: helper-file span resolution probe
typst:
plate_file: plate.typ
main:
fields:
intro:
type: richtext
description: a probe field
"#;
const PLATE: &str = r#"
#import "@local/quillmark-helper:0.1.0": data
#set page(width: 400pt, height: 400pt, margin: 40pt)
#data.intro
"#;
let q = quill(YAML, PLATE);
let plate = crate::read_plate(&q).expect("plate");
let schema = quillmark_core::quill::build_transform_schema(q.config());
let meta = crate::SchemaMeta::from_schema_json(schema.as_json());
let rt = quillmark_content::import::from_markdown("A probe paragraph, PROBETOKEN.")
.expect("import");
let data =
serde_json::json!({ "intro": quillmark_content::serial::to_canonical_value(&rt) });
let transformed = crate::transformed_data(&meta, &data).expect("transform");
let mut world = QuillWorld::new(&q, &plate).expect("world");
let windows = world
.inject_helper_package(transformed.as_ref(), &meta)
.expect("inject");
let (doc, _) = compile_document(&world).expect("compile");
let helper = world
.source(QuillWorld::helper_fid("lib.typ"))
.expect("helper source");
let intro_idx = windows
.iter()
.position(|w| w.path == "intro")
.expect("intro window");
let mut cls = Classifier::new(&world, &helper, &windows);
let mut hits = Vec::new();
for (page, p) in doc.pages().iter().enumerate() {
collect_page_hits(&p.frame, page, &mut cls, &mut hits);
}
assert!(
hits.iter().any(|h| h.class.window() == Some(intro_idx)),
"block output glyphs must classify into the helper file's recorded window {:?}",
windows[intro_idx].range
);
}
#[test]
fn decoration_marks_do_not_truncate_the_region() {
use quillmark_content::model::{Line, LineKind, Mark, MarkKind, Content};
const YAML: &str = r#"
quill:
name: deco_probe
version: 0.1.0
backend: typst
description: underline and strike region truncation probe
typst:
plate_file: plate.typ
main:
fields:
body:
type: richtext
description: one paragraph with one decorated run
"#;
const PLATE: &str = r#"
#import "@local/quillmark-helper:0.1.0": data
#set page(width: 400pt, height: 400pt, margin: 40pt)
#data.body
"#;
const TEXT: &str = "Start uline and then a long trailing plain run of text.";
let region_width = |kind: MarkKind| -> f32 {
let rt = Content {
text: TEXT.to_string(),
lines: vec![Line {
containers: vec![],
kind: LineKind::Para,
continues: false,
}],
marks: vec![Mark {
start: 6,
end: 11,
kind,
}],
islands: vec![],
};
let q = quill(YAML, PLATE);
let plate = crate::read_plate(&q).expect("plate");
let schema = quillmark_core::quill::build_transform_schema(q.config());
let meta = crate::SchemaMeta::from_schema_json(schema.as_json());
let data =
serde_json::json!({ "body": quillmark_content::serial::to_canonical_value(&rt) });
let transformed = crate::transformed_data(&meta, &data).expect("transform");
let mut world = QuillWorld::new(&q, &plate).expect("world");
let windows = world
.inject_helper_package(transformed.as_ref(), &meta)
.expect("inject");
let (doc, _) = compile_document(&world).expect("compile");
let helper = world
.source(QuillWorld::helper_fid("lib.typ"))
.expect("helper source");
let regions = scan(&doc, &world, &helper, &windows);
regions
.iter()
.filter(|r| r.field == "body")
.map(|r| r.rect[2] - r.rect[0])
.fold(0.0f32, f32::max)
};
let baseline = region_width(MarkKind::Strong);
assert!(
baseline > 150.0,
"sanity: full-line region is wide: {baseline}"
);
for kind in [MarkKind::Emph, MarkKind::Underline, MarkKind::Strike] {
let w = region_width(kind.clone());
assert!(
w >= baseline * 0.9,
"{kind:?} region width {w} truncated vs {baseline} baseline (#936)"
);
}
}
#[test]
fn scalar_windows_track_chains_and_single_owner_enclosing_expressions() {
let src = Source::detached(
r#"
#import "@local/quillmark-helper:0.1.0": data
#data.subject
#data.at("subject")
#data.refs.at(0)
#upper(data.subject)
#(data.subject + data.other)
#let s = data.other
"#,
);
let fields = vec![
"subject".to_string(),
"refs".to_string(),
"other".to_string(),
];
let wins = scalar_windows(&src, &fields);
let text = src.text();
let spans: Vec<(&str, &str)> = wins
.iter()
.map(|(p, r)| (p.as_str(), &text[r.clone()]))
.collect();
for expected in [
("subject", "data.subject"),
("subject", "data.at(\"subject\")"),
("refs", "data.refs.at(0)"),
("other", "data.other"),
("subject", "upper(data.subject)"),
] {
assert!(spans.contains(&expected), "missing {expected:?}: {spans:?}");
}
assert!(
!spans
.iter()
.any(|(_, t)| t.contains("data.subject + data.other")),
"multi-reference expressions are not attributed: {spans:?}"
);
let chain_pos = spans
.iter()
.position(|s| *s == ("subject", "data.subject"))
.unwrap();
let wide_pos = spans
.iter()
.position(|s| *s == ("subject", "upper(data.subject)"))
.unwrap();
assert!(chain_pos < wide_pos, "chains sort before wides: {spans:?}");
}
fn collect_spans(frame: &Frame, out: &mut Vec<Span>) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(group) => collect_spans(&group.frame, out),
FrameItem::Text(text) => out.extend(text.glyphs.iter().map(|g| g.span.0)),
FrameItem::Shape(_, span) => out.push(*span),
FrameItem::Image(_, _, span) => out.push(*span),
_ => {}
}
}
}
#[test]
fn two_tier_classification_resolves_each_segment_independently() {
const YAML: &str = r#"
quill:
name: two_tier_probe
version: 0.1.0
backend: typst
description: PR-F Unknown-1 two-tier classification probe
typst:
plate_file: plate.typ
main:
fields:
body:
type: richtext
description: a two-item list
"#;
const PLATE: &str = r#"
#import "@local/quillmark-helper:0.1.0": data
#set page(width: 400pt, height: 400pt, margin: 40pt)
#data.body
"#;
let q = quill(YAML, PLATE);
let plate = crate::read_plate(&q).expect("plate");
let schema = quillmark_core::quill::build_transform_schema(q.config());
let meta = crate::SchemaMeta::from_schema_json(schema.as_json());
let rt =
quillmark_content::import::from_markdown("- Item ONE\n- Item TWO").expect("import");
let data =
serde_json::json!({ "body": quillmark_content::serial::to_canonical_value(&rt) });
let transformed = crate::transformed_data(&meta, &data).expect("transform");
let mut world = QuillWorld::new(&q, &plate).expect("world");
let windows = world
.inject_helper_package(transformed.as_ref(), &meta)
.expect("inject");
let (doc, _) = compile_document(&world).expect("compile");
let helper = world
.source(QuillWorld::helper_fid("lib.typ"))
.expect("helper source");
let win_idx = windows
.iter()
.position(|w| w.path == "body")
.expect("body window");
assert_eq!(
windows[win_idx].segments.len(),
2,
"one segment per list item"
);
let mut cls = Classifier::new(&world, &helper, &windows);
let mut spans = Vec::new();
for p in doc.pages().iter() {
collect_spans(&p.frame, &mut spans);
}
let mut seg_hits = [0usize; 2];
let mut field_only_hits = 0usize;
let mut untracked_hits = 0usize;
for span in spans {
match cls.classify_seg(span) {
Some((w, Some(s))) if w == win_idx => seg_hits[s] += 1,
Some((w, None)) if w == win_idx => field_only_hits += 1,
_ => untracked_hits += 1,
}
}
assert!(
seg_hits[0] > 0 && seg_hits[1] > 0,
"each list item's own ink resolves to its own segment: {seg_hits:?}"
);
assert_eq!(
field_only_hits, 0,
"list markers do not produce (window, None) ink — see the doc comment"
);
assert!(
untracked_hits > 0,
"the two markers are hit but resolve to no window (detached span)"
);
}
#[test]
fn classify_two_tier_resolves_field_only_ink_between_segments() {
const YAML: &str = r#"
quill:
name: two_tier_mechanism_probe
version: 0.1.0
backend: typst
description: PR-F Unknown-1 classify_two_tier mechanism probe
typst:
plate_file: plate.typ
main:
fields:
body:
type: richtext
description: one paragraph, one bold run
"#;
const PLATE: &str = r#"
#import "@local/quillmark-helper:0.1.0": data
#set page(width: 400pt, height: 400pt, margin: 40pt)
#data.body
"#;
let q = quill(YAML, PLATE);
let plate = crate::read_plate(&q).expect("plate");
let schema = quillmark_core::quill::build_transform_schema(q.config());
let meta = crate::SchemaMeta::from_schema_json(schema.as_json());
let rt =
quillmark_content::import::from_markdown("before **BOLD** after").expect("import");
let data =
serde_json::json!({ "body": quillmark_content::serial::to_canonical_value(&rt) });
let transformed = crate::transformed_data(&meta, &data).expect("transform");
let mut world = QuillWorld::new(&q, &plate).expect("world");
let windows = world
.inject_helper_package(transformed.as_ref(), &meta)
.expect("inject");
let (doc, _) = compile_document(&world).expect("compile");
let helper = world
.source(QuillWorld::helper_fid("lib.typ"))
.expect("helper source");
let real_win = windows
.iter()
.find(|w| w.path == "body")
.expect("body window");
assert_eq!(real_win.segments.len(), 1, "one paragraph, one segment");
let cls = Classifier::new(&world, &helper, &windows);
let mut spans = Vec::new();
for p in doc.pages().iter() {
collect_spans(&p.frame, &mut spans);
}
let mut nodes: Vec<(Span, Range<usize>)> = Vec::new();
for span in spans {
if let Some((file, range)) = cls.resolve_range(span) {
if file == real_win.file
&& real_win.range.start <= range.start
&& range.end <= real_win.range.end
&& !nodes.iter().any(|(_, r)| *r == range)
{
nodes.push((span, range));
}
}
}
nodes.sort_by_key(|(_, r)| r.start);
let text = helper.text();
let bold_idx = nodes
.iter()
.position(|(_, r)| &text[r.clone()] == "BOLD")
.expect("a node holding exactly \"BOLD\": {nodes:?}");
assert!(
bold_idx > 0 && bold_idx + 1 < nodes.len(),
"ink on both sides of BOLD: {nodes:?}"
);
let before = &nodes[..bold_idx];
let (bold_span, _) = nodes[bold_idx].clone();
let after = &nodes[bold_idx + 1..];
let synthetic = vec![FieldWindow {
path: "body".to_string(),
file: real_win.file,
range: real_win.range.clone(),
segments: vec![
SegmentMap {
content: 0..0,
gen: before.first().unwrap().1.start..before.last().unwrap().1.end,
runs: vec![],
},
SegmentMap {
content: 0..0,
gen: after.first().unwrap().1.start..after.last().unwrap().1.end,
runs: vec![],
},
],
}];
let mut synthetic_cls = Classifier::new(&world, &helper, &synthetic);
for (span, range) in before {
assert_eq!(
synthetic_cls.classify_seg(*span),
Some((0, Some(0))),
"a \"before\"-side node ({range:?}) -> segment 0"
);
}
for (span, range) in after {
assert_eq!(
synthetic_cls.classify_seg(*span),
Some((0, Some(1))),
"an \"after\"-side node ({range:?}) -> segment 1"
);
}
assert_eq!(
synthetic_cls.classify_seg(bold_span),
Some((0, None)),
"the excluded bold run sits inside the block window but outside every segment"
);
}
fn aabb(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Aabb {
Aabb {
min_x,
min_y,
max_x,
max_y,
}
}
fn boxable_hit(page: usize, key: (usize, Option<usize>), rect: Aabb) -> Hit {
Hit {
page,
class: HitClass::Boxable { key },
rect: Some(rect),
}
}
fn transparent_hit(page: usize, window: usize) -> Hit {
Hit {
page,
class: HitClass::Transparent { window },
rect: None,
}
}
fn foreign_hit(page: usize) -> Hit {
Hit {
page,
class: HitClass::Foreign,
rect: None,
}
}
fn anonymous_hit(page: usize) -> Hit {
Hit {
page,
class: HitClass::Anonymous,
rect: None,
}
}
fn key_pages(
keys: &[(usize, Option<usize>)],
hits: &[Hit],
) -> HashMap<(usize, Option<usize>), Vec<usize>> {
run_scan_machine(keys, hits)
.into_iter()
.enumerate()
.filter(|(_, b)| !b.is_empty())
.map(|(i, b)| (keys[i], b.into_iter().map(|(p, _)| p).collect()))
.collect()
}
#[test]
fn field_only_ink_is_transparent_but_foreign_ink_is_not() {
let keys = vec![(0usize, Some(0usize))];
let (a, b) = (aabb(0.0, 0.0, 1.0, 1.0), aabb(10.0, 10.0, 11.0, 11.0));
let transparent = vec![
boxable_hit(0, (0, Some(0)), a),
transparent_hit(0, 0),
boxable_hit(0, (0, Some(0)), b),
];
let boxes = run_scan_machine(&keys, &transparent);
assert_eq!(boxes[0].len(), 1, "one page-0 box");
let (_, bx) = boxes[0][0];
assert!(
bx.min_x <= 0.0 && bx.max_x >= 11.0,
"the box unions both hits — the run stayed unbroken"
);
let foreign = vec![
boxable_hit(0, (0, Some(0)), a),
foreign_hit(0),
boxable_hit(0, (0, Some(0)), b),
];
let boxes = run_scan_machine(&keys, &foreign);
assert_eq!(boxes[0].len(), 1, "one page-0 box, unresumed");
let (_, bx) = boxes[0][0];
assert!(
bx.max_x < 11.0,
"only the first hit accrued — same-page foreign ink ends the run, exactly today's rule"
);
}
#[test]
fn anonymous_ink_is_transparent_but_foreign_ink_is_not() {
let keys = vec![(0usize, Some(0usize))];
let (a, b) = (aabb(0.0, 0.0, 1.0, 1.0), aabb(10.0, 10.0, 11.0, 11.0));
let anonymous = vec![
boxable_hit(0, (0, Some(0)), a),
anonymous_hit(0), boxable_hit(0, (0, Some(0)), b),
];
let boxes = run_scan_machine(&keys, &anonymous);
assert_eq!(boxes[0].len(), 1, "one page-0 box");
let (_, bx) = boxes[0][0];
assert!(
bx.min_x <= 0.0 && bx.max_x >= 11.0,
"the box unions both hits — the decoration did not break the run"
);
let foreign = vec![
boxable_hit(0, (0, Some(0)), a),
foreign_hit(0),
boxable_hit(0, (0, Some(0)), b),
];
let boxes = run_scan_machine(&keys, &foreign);
let (_, bx) = boxes[0][0];
assert!(
bx.max_x < 11.0,
"foreign ink still ends the run — only detached ink is exempt"
);
}
#[test]
fn adjacent_segments_of_one_field_run_independently() {
let keys = vec![(0, Some(0)), (0, Some(1))];
let r = aabb(0.0, 0.0, 1.0, 1.0);
let hits = vec![
boxable_hit(0, (0, Some(0)), r),
transparent_hit(0, 0), boxable_hit(0, (0, Some(1)), r),
];
let pages = key_pages(&keys, &hits);
assert_eq!(pages[&(0, Some(0))], vec![0]);
assert_eq!(pages[&(0, Some(1))], vec![0]);
}
#[test]
fn field_only_ink_still_suspends_a_different_fields_current_run() {
let keys = vec![(1, Some(0))];
let r = aabb(0.0, 0.0, 1.0, 1.0);
let cross_page = vec![
boxable_hit(0, (1, Some(0)), r), transparent_hit(0, 0), boxable_hit(1, (1, Some(0)), r), ];
assert_eq!(
key_pages(&keys, &cross_page)[&(1, Some(0))],
vec![0, 1],
"a foreign field's field-only ink suspends the running field \
(the page-turn tolerance still lets it resume)"
);
let same_page = vec![
boxable_hit(0, (1, Some(0)), r),
transparent_hit(0, 0),
boxable_hit(0, (1, Some(0)), r),
];
assert_eq!(
key_pages(&keys, &same_page)[&(1, Some(0))],
vec![0],
"no same-page resume — field-only ink is not a wildcard exception \
to the foreign-ink suspension rule"
);
}
struct GlyphProbe {
node: Range<usize>,
offset: u16,
text: String,
}
fn collect_glyph_probes(
frame: &Frame,
cls: &Classifier,
win: &FieldWindow,
out: &mut Vec<GlyphProbe>,
) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(group) => collect_glyph_probes(&group.frame, cls, win, out),
FrameItem::Text(text) => {
for g in &text.glyphs {
if let Some((file, range)) = cls.resolve_range(g.span.0) {
if file == win.file
&& win.range.start <= range.start
&& range.end <= win.range.end
{
out.push(GlyphProbe {
node: range,
offset: g.span.1,
text: text.text[g.range()].to_string(),
});
}
}
}
}
_ => {}
}
}
}
#[test]
fn glyph_span_1_precision_findings() {
const YAML: &str = r#"
quill:
name: span_precision_probe
version: 0.1.0
backend: typst
description: PR-F Unknown-2 glyph.span.1 precision probe
typst:
plate_file: plate.typ
main:
fields:
body:
type: richtext
description: a formatted paragraph plus a multi-line code fence
"#;
const PLATE: &str = r#"
#import "@local/quillmark-helper:0.1.0": data
#set page(width: 400pt, height: 400pt, margin: 40pt)
#data.body
"#;
let md = "This is **bold** difficult fickle text.\n\n```\nfn add(a, b) {\n return a + b;\n}\n```";
let q = quill(YAML, PLATE);
let plate = crate::read_plate(&q).expect("plate");
let schema = quillmark_core::quill::build_transform_schema(q.config());
let meta = crate::SchemaMeta::from_schema_json(schema.as_json());
let rt = quillmark_content::import::from_markdown(md).expect("import");
let data =
serde_json::json!({ "body": quillmark_content::serial::to_canonical_value(&rt) });
let transformed = crate::transformed_data(&meta, &data).expect("transform");
let mut world = QuillWorld::new(&q, &plate).expect("world");
let windows = world
.inject_helper_package(transformed.as_ref(), &meta)
.expect("inject");
let (doc, _) = compile_document(&world).expect("compile");
let helper = world
.source(QuillWorld::helper_fid("lib.typ"))
.expect("helper source");
let win = windows
.iter()
.find(|w| w.path == "body")
.expect("body window");
assert_eq!(
win.segments.len(),
2,
"one paragraph segment, one code segment"
);
let (para_seg, code_seg) = (&win.segments[0], &win.segments[1]);
assert!(
code_seg.runs.len() >= 2,
"the fence's multiple lines each recorded their own run: {:?}",
code_seg.runs
);
let cls = Classifier::new(&world, &helper, &windows);
let mut probes = Vec::new();
for p in doc.pages().iter() {
collect_glyph_probes(&p.frame, &cls, win, &mut probes);
}
assert!(!probes.is_empty(), "the field must place some glyphs");
let mut checked_para_glyph = false;
for probe in &probes {
if probe.node.start >= para_seg.gen.start && probe.node.end <= para_seg.gen.end {
let owning_run = para_seg
.runs
.iter()
.find(|(_, gen, _)| gen.start <= probe.node.start && probe.node.end <= gen.end)
.unwrap_or_else(|| {
panic!("markup node {:?} must nest inside some run", probe.node)
});
let absolute = probe.node.start + probe.offset as usize;
assert!(
absolute >= owning_run.1.start && absolute < owning_run.1.end + 1,
"node.start + offset ({absolute}) must land inside the owning run {:?} for {:?}",
owning_run.1,
probe.text,
);
assert_eq!(
&helper.text()[absolute..absolute + probe.text.len()],
probe.text,
"node.start + offset must point at this glyph's own bytes"
);
checked_para_glyph = true;
}
}
assert!(
checked_para_glyph,
"at least one paragraph glyph must be checked"
);
let code_probes: Vec<&GlyphProbe> = probes
.iter()
.filter(|p| p.node.start >= code_seg.gen.start && p.node.end <= code_seg.gen.end)
.collect();
assert!(!code_probes.is_empty(), "the code fence must place glyphs");
let distinct_nodes: std::collections::HashSet<Range<usize>> =
code_probes.iter().map(|p| p.node.clone()).collect();
assert_eq!(
distinct_nodes.len(),
1,
"every raw-block glyph shares one node range regardless of physical line: {distinct_nodes:?}"
);
let raw_node = distinct_nodes.into_iter().next().unwrap();
let resets = code_probes
.windows(2)
.filter(|w| w[1].offset == 0 && w[0].offset != 0)
.count();
assert!(
resets >= 1,
"offset must reset at a line boundary at least once across 3 lines: {:?}",
code_probes.iter().map(|p| p.offset).collect::<Vec<_>>()
);
assert!(
raw_node.start >= code_seg.gen.start && raw_node.end <= code_seg.gen.end,
"the raw call's node still nests inside the code segment"
);
assert!(
!code_seg
.runs
.iter()
.any(|(_, gen, _)| gen.start <= raw_node.start && raw_node.end <= gen.end),
"no single run should contain the whole-call node — it is coarser than every run"
);
}
fn compile_frame_text(plate: &str) -> Result<String, String> {
const YAML: &str = r#"
quill:
name: spike_990
version: 0.1.0
backend: typst
description: date value-object verification spike (990)
typst:
plate_file: plate.typ
"#;
fn walk_text(frame: &Frame, out: &mut String) {
for (_, item) in frame.items() {
match item {
FrameItem::Group(g) => walk_text(&g.frame, out),
FrameItem::Text(t) => out.push_str(&t.text),
_ => {}
}
}
}
let q = quill(YAML, plate);
let plate = crate::read_plate(&q).expect("plate");
let world = QuillWorld::new(&q, &plate).expect("world");
match compile_document(&world) {
Ok((doc, _)) => {
let mut s = String::new();
for p in doc.pages() {
walk_text(&p.frame, &mut s);
}
Ok(s)
}
Err(e) => Err(format!("{e:?}")),
}
}
const VALUE_OBJECT: &str = "#set page(width: 400pt, height: 400pt, margin: 20pt)\n\
#let d = (\
value: datetime(year: 2026, month: 1, day: 2), \
display: (..args) => text(datetime(year: 2026, month: 1, day: 2).display(..args)),\
)\n";
#[test]
fn spike_990_dict_display_needs_parens_then_forwards_args() {
let sugar = compile_frame_text(&format!("{VALUE_OBJECT}#d.display(\"[year]\")"))
.expect_err("dict method sugar must be a compile error on Typst 0.15");
assert!(
sugar.contains("cannot directly call dictionary keys as functions"),
"the failure is Typst's dict-key-call restriction, not something else: {sugar}"
);
assert_eq!(
compile_frame_text(&format!("{VALUE_OBJECT}#(d.display)(\"[year]\")"))
.expect("parenthesized call compiles")
.trim(),
"2026",
"the `[year]` format arg forwards through the closure"
);
assert_eq!(
compile_frame_text(&format!("{VALUE_OBJECT}#(d.display)(\"[month repr:long]\")"))
.expect("parenthesized call compiles")
.trim(),
"January",
"a different format arg yields a different result — args are forwarded, not ignored"
);
assert_eq!(
compile_frame_text(&format!("{VALUE_OBJECT}#(d.display)()"))
.expect("no-arg call compiles")
.trim(),
"2026-01-02",
"the no-arg call forwards an empty spread and takes datetime's default format"
);
}
#[test]
fn spike_990_native_datetime_rejects_the_paren_display_grab() {
const NATIVE: &str = "#set page(width: 400pt, height: 400pt, margin: 20pt)\n\
#let dt = datetime(year: 2026, month: 1, day: 2)\n";
let err = compile_frame_text(&format!("{NATIVE}#(dt.display)(\"[year]\")"))
.expect_err("the paren grab on a native datetime must be a compile error");
assert!(
err.contains("cannot access fields on type datetime"),
"the failure is the native-datetime field-access restriction: {err}"
);
assert_eq!(
compile_frame_text(&format!("{NATIVE}#dt.display(\"[year]\")"))
.expect("native method sugar compiles on a datetime")
.trim(),
"2026",
"a native datetime keeps method-call sugar"
);
}
#[test]
fn spike_990_text_over_programmatic_string_classifies_into_recorded_window() {
const PLATE: &str = "#set page(width: 400pt, height: 400pt, margin: 20pt)\n\
#text(datetime(year: 2026, month: 1, day: 2).display(\"[year]\"))\n";
let q = quill(
"quill:\n name: spike_990_text\n version: 0.1.0\n backend: typst\n \
description: text() span attribution spike\ntypst:\n plate_file: plate.typ\n",
PLATE,
);
let plate = crate::read_plate(&q).expect("plate");
let world = QuillWorld::new(&q, &plate).expect("world");
let (doc, _) = compile_document(&world).expect("compile");
let main_id = world.main();
let src = world.source(main_id).expect("main source");
let text = src.text().to_string();
let start = text.find("text(").expect("the text() call");
let end = text.rfind(')').expect("its close paren") + 1;
let window_range = start..end;
let helper = Source::new(QuillWorld::helper_fid("lib.typ"), String::new());
let cls = Classifier::new(&world, &helper, &[]);
let mut spans = Vec::new();
for p in doc.pages() {
collect_spans(&p.frame, &mut spans);
}
assert!(!spans.is_empty(), "the date renders at least one glyph");
for span in &spans {
assert!(!span.is_detached(), "date glyphs are not detached ink");
let (file, range) = cls
.resolve_range(*span)
.expect("a date glyph span resolves to a source range");
assert_eq!(file, main_id, "the span resolves into the plate, not elsewhere");
assert!(
window_range.start <= range.start && range.end <= window_range.end,
"the constructing `text(..)` node's span nests in the recorded window: \
{range:?} ⊄ {window_range:?}"
);
}
let windows = vec![FieldWindow {
path: "issued".to_string(),
file: main_id,
range: window_range,
segments: vec![],
}];
let regions = scan(&doc, &world, &helper, &windows);
let issued: Vec<_> = regions.iter().filter(|r| r.field == "issued").collect();
assert_eq!(
issued.len(),
1,
"the recorded window surfaces exactly one region: {regions:?}"
);
assert!(
issued[0].rect[2] - issued[0].rect[0] > 0.0
&& issued[0].rect[3] - issued[0].rect[1] > 0.0,
"the date region has positive area: {:?}",
issued[0].rect
);
}
#[test]
fn spike_990_none_guard_is_invariant_across_value_object_and_blank() {
let out = compile_frame_text(
"#set page(width: 400pt, height: 400pt, margin: 20pt)\n\
DT#(datetime(year: 2026, month: 1, day: 2) != none)|\
OBJ#((value: 1, display: (..a) => none) != none)|\
NONE#(none != none)",
)
.expect("guard expressions compile");
assert!(out.contains("DTtrue"), "a native datetime is != none: {out}");
assert!(
out.contains("OBJtrue"),
"a value-object dict is != none, so present-date guards are untouched: {out}"
);
assert!(
out.contains("NONEfalse"),
"a blank date stays none, so blank-date guards are untouched: {out}"
);
}
#[test]
fn spike_990_text_wrapper_preserves_page_hash() {
const YAML: &str = "quill:\n name: spike_990_hash\n version: 0.1.0\n \
backend: typst\n description: page-hash invariance spike\ntypst:\n \
plate_file: plate.typ\n";
let page_hash = |plate: &str| -> Vec<u128> {
let q = quill(YAML, plate);
let plate = crate::read_plate(&q).expect("plate");
let world = QuillWorld::new(&q, &plate).expect("world");
let (doc, _) = compile_document(&world).expect("compile");
crate::page_hashes(&doc)
};
let bare = page_hash(
"#set page(width: 400pt, height: 400pt, margin: 20pt)\n\
#(datetime(year: 2026, month: 1, day: 2).display(\"[year]\"))\n",
);
let wrapped = page_hash(
"#set page(width: 400pt, height: 400pt, margin: 20pt)\n\
#text(datetime(year: 2026, month: 1, day: 2).display(\"[year]\"))\n",
);
assert_eq!(
bare, wrapped,
"wrapping identical glyphs in text() must not move the page fingerprint"
);
}
}