#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenderedRegion {
pub field: String,
pub page: usize,
pub rect: [f32; 4],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<[usize; 2]>,
}
impl RenderedRegion {
pub fn contains(&self, page: usize, x: f32, y: f32) -> bool {
self.page == page
&& self.rect[0] <= x
&& x <= self.rect[2]
&& self.rect[1] <= y
&& y <= self.rect[3]
}
}
pub fn field_boxes(regions: &[RenderedRegion], field: &str) -> Vec<RenderedRegion> {
let mut by_page: Vec<RenderedRegion> = Vec::new();
for r in regions
.iter()
.filter(|r| r.field == field && r.span.is_some())
{
let span = r.span.expect("filtered to span-bearing");
match by_page.iter_mut().find(|acc| acc.page == r.page) {
Some(acc) => {
acc.rect[0] = acc.rect[0].min(r.rect[0]);
acc.rect[1] = acc.rect[1].min(r.rect[1]);
acc.rect[2] = acc.rect[2].max(r.rect[2]);
acc.rect[3] = acc.rect[3].max(r.rect[3]);
let s = acc.span.expect("union region carries a span");
acc.span = Some([s[0].min(span[0]), s[1].max(span[1])]);
}
None => by_page.push(RenderedRegion {
field: r.field.clone(),
page: r.page,
rect: r.rect,
span: Some(span),
}),
}
}
by_page.sort_by_key(|r| r.page);
by_page
}
use crate::path::{DocPath, DocSeg};
fn abs_card_index(card_kinds: &[Option<&str>], kind: &str, ord: usize) -> Option<usize> {
card_kinds
.iter()
.enumerate()
.filter(|(_, k)| **k == Some(kind))
.nth(ord)
.map(|(i, _)| i)
}
fn per_kind_ordinal(card_kinds: &[Option<&str>], abs: usize) -> Option<usize> {
let kind = (*card_kinds.get(abs)?)?;
Some(
card_kinds[..abs]
.iter()
.filter(|k| **k == Some(kind))
.count(),
)
}
pub fn plate_addr_to_doc_path(addr: &str, card_kinds: &[Option<&str>]) -> Option<DocPath> {
if addr == "$body" {
return Some(DocPath::main_body());
}
if let Some(rest) = addr.strip_prefix("$cards.") {
let mut it = rest.splitn(3, '.');
let kind = it.next()?;
let ord: usize = it.next()?.parse().ok()?;
let tail = it.next()?;
let abs = abs_card_index(card_kinds, kind, ord)?;
let card = DocPath::card(Some(kind), abs);
return Some(if tail == "$body" {
card.body()
} else {
card.field(tail)
});
}
if addr.starts_with('$') {
return None;
}
Some(DocPath::main().field(addr))
}
pub fn doc_path_to_plate_addr(path: &DocPath, card_kinds: &[Option<&str>]) -> Option<String> {
match path.segs() {
[DocSeg::Main, DocSeg::Body] => Some("$body".to_string()),
[DocSeg::Main, DocSeg::Field { name }] => Some(name.clone()),
[DocSeg::Card {
kind: Some(kind),
index,
}, rest @ ..] => {
if card_kinds.get(*index).copied().flatten() != Some(kind.as_str()) {
return None;
}
let ord = per_kind_ordinal(card_kinds, *index)?;
match rest {
[DocSeg::Field { name }] => Some(format!("$cards.{kind}.{ord}.{name}")),
[DocSeg::Body] => Some(format!("$cards.{kind}.{ord}.$body")),
_ => None,
}
}
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HitGranularity {
Cluster,
Segment,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentHit {
pub field: String,
pub pos: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granularity: Option<HitGranularity>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn region_round_trips_through_json() {
let region = RenderedRegion {
field: "full_name".to_string(),
page: 0,
rect: [180.0, 715.0, 520.0, 735.0],
span: Some([12, 34]),
};
let json = serde_json::to_string(®ion).unwrap();
assert!(json.contains("\"field\":\"full_name\""), "{json}");
assert!(json.contains("\"span\":[12,34]"), "{json}");
let back: RenderedRegion = serde_json::from_str(&json).unwrap();
assert_eq!(back, region);
}
#[test]
fn optional_span_omitted_when_none() {
let region = RenderedRegion {
field: "subject".to_string(),
page: 0,
rect: [1.0, 2.0, 3.0, 4.0],
span: None,
};
let json = serde_json::to_string(®ion).unwrap();
assert!(!json.contains("span"), "scalar region omits span: {json}");
let back: RenderedRegion = serde_json::from_str(&json).unwrap();
assert_eq!(back, region);
}
#[test]
fn content_hit_round_trips_through_json() {
let hit = ContentHit {
field: "body".to_string(),
pos: 42,
granularity: Some(HitGranularity::Cluster),
};
let json = serde_json::to_string(&hit).unwrap();
assert!(json.contains("\"field\":\"body\"") && json.contains("\"pos\":42"));
assert!(json.contains("\"granularity\":\"cluster\""), "{json}");
let back: ContentHit = serde_json::from_str(&json).unwrap();
assert_eq!(back, hit);
let seg = ContentHit {
field: "body".to_string(),
pos: 7,
granularity: Some(HitGranularity::Segment),
};
let json = serde_json::to_string(&seg).unwrap();
assert!(json.contains("\"granularity\":\"segment\""), "{json}");
assert_eq!(serde_json::from_str::<ContentHit>(&json).unwrap(), seg);
}
#[test]
fn content_hit_omits_optionals_when_none() {
let hit = ContentHit {
field: "body".to_string(),
pos: 42,
granularity: None,
};
let json = serde_json::to_string(&hit).unwrap();
assert!(
!json.contains("granularity"),
"unreported granularity omitted: {json}"
);
let back: ContentHit = serde_json::from_str(&json).unwrap();
assert_eq!(back, hit);
}
fn content(field: &str, page: usize, rect: [f32; 4], span: [usize; 2]) -> RenderedRegion {
RenderedRegion {
field: field.to_string(),
page,
rect,
span: Some(span),
}
}
#[test]
fn field_boxes_unions_span_bearing_segments_per_page() {
let regions = vec![
content("$body", 0, [10.0, 700.0, 200.0, 720.0], [0, 12]),
content("$body", 0, [10.0, 660.0, 260.0, 680.0], [13, 40]),
content("$body", 1, [10.0, 700.0, 150.0, 720.0], [41, 55]),
content("subject", 0, [10.0, 740.0, 90.0, 752.0], [0, 5]),
];
let boxes = field_boxes(®ions, "$body");
assert_eq!(boxes.len(), 2, "one box per page $body touches");
assert_eq!(boxes[0].page, 0);
assert_eq!(boxes[0].rect, [10.0, 660.0, 260.0, 720.0], "page-0 union");
assert_eq!(boxes[0].span, Some([0, 40]), "page-0 union span");
assert_eq!(boxes[1].page, 1);
assert_eq!(boxes[1].rect, [10.0, 700.0, 150.0, 720.0]);
}
#[test]
fn field_boxes_empty_for_span_less_field() {
let regions = vec![RenderedRegion {
field: "subject".to_string(),
page: 0,
rect: [10.0, 740.0, 90.0, 752.0],
span: None,
}];
assert!(field_boxes(®ions, "subject").is_empty());
}
const KINDS: &[Option<&str>] = &[Some("note"), Some("annotation"), Some("note")];
fn to_doc(addr: &str) -> Option<String> {
plate_addr_to_doc_path(addr, KINDS).map(|p| p.to_string())
}
fn to_plate(path: &str) -> Option<String> {
doc_path_to_plate_addr(&path.parse().unwrap(), KINDS)
}
#[test]
fn plate_to_docpath_resolves_the_absolute_index() {
assert_eq!(to_doc("$cards.note.1.on").as_deref(), Some("cards.note[2].on"));
assert_eq!(to_doc("$cards.note.0.on").as_deref(), Some("cards.note[0].on"));
assert_eq!(
to_doc("$cards.annotation.0.text").as_deref(),
Some("cards.annotation[1].text")
);
assert_eq!(to_doc("$body").as_deref(), Some("main.body"));
assert_eq!(
to_doc("$cards.note.1.$body").as_deref(),
Some("cards.note[2].body")
);
assert_eq!(to_doc("signature_block").as_deref(), Some("main.signature_block"));
}
#[test]
fn docpath_to_plate_is_the_inverse() {
for plate in [
"$body",
"signature_block",
"$cards.note.0.on",
"$cards.note.1.on",
"$cards.annotation.0.text",
"$cards.note.1.$body",
] {
let doc = to_doc(plate).unwrap();
assert_eq!(to_plate(&doc).as_deref(), Some(plate), "round-trip {plate}");
}
}
#[test]
fn translation_rejects_unplaceable_and_foreign_shapes() {
assert_eq!(to_doc("$cards.note.2.on"), None);
assert_eq!(
doc_path_to_plate_addr(&"cards.annotation[0].x".parse().unwrap(), KINDS),
None
);
assert_eq!(
doc_path_to_plate_addr(&"recipients[0].name".parse().unwrap(), KINDS),
None
);
}
}