one-shot first ver

This commit is contained in:
Brandon Thomas
2026-09-30 05:17:08 -04:00
commit f0b5f8ff55
208 changed files with 15369 additions and 0 deletions
+641
View File
@@ -0,0 +1,641 @@
//! Document inspection: everything panels need that is not pixels.
//!
//! Page geometry comes from hayro (which resolves inheritance and rotation); the rest comes from
//! lopdf (bootstrap, replaced by `printcraft-model` in M2). Inspection is *tolerant*: when lopdf
//! cannot load a file that hayro can render, panels are simply empty and `warnings` says why.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use hayro::hayro_syntax::Pdf;
use lopdf::{Dictionary, Document, Object, ObjectId};
use crate::OpenError;
#[derive(Clone, Debug, Default)]
pub struct DocInfo {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub pdf_version: String,
pub file_size: usize,
pub encrypted: bool,
pub tagged: bool,
pub has_javascript: bool,
pub pages: Vec<PageInfo>,
pub outline: Vec<OutlineItem>,
pub annotations: Vec<Annotation>,
pub fields: Vec<Field>,
pub links: Vec<Link>,
pub layers: Vec<Layer>,
pub attachments: Vec<Attachment>,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct PageInfo {
/// Displayed size in points, after `/Rotate` and `/UserUnit`.
pub width: f32,
pub height: f32,
/// Page label (`/PageLabels`), falling back to the 1-based page number.
pub label: String,
/// Effective crop box in user space [x0, y0, x1, y1] (the visible region).
pub crop: [f32; 4],
/// Clockwise page rotation in degrees (0, 90, 180, 270).
pub rotation: u16,
}
/// A clickable link annotation.
#[derive(Clone, Debug)]
pub struct Link {
pub page: usize,
pub rect: [f32; 4],
pub target: LinkTarget,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LinkTarget {
Page(usize),
Uri(String),
Other(String),
}
#[derive(Clone, Debug)]
pub struct OutlineItem {
pub title: String,
pub page: Option<usize>,
pub children: Vec<OutlineItem>,
pub open: bool,
}
#[derive(Clone, Debug)]
pub struct Annotation {
pub page: usize,
pub subtype: String,
pub author: Option<String>,
pub contents: Option<String>,
pub modified: Option<String>,
/// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading.
pub name: Option<String>,
pub in_reply_to: Option<String>,
/// Rect in PDF user space: [x0, y0, x1, y1].
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldKind {
Text,
CheckBox,
Radio,
PushButton,
Combo,
List,
Signature,
Unknown,
}
#[derive(Clone, Debug)]
pub struct Field {
pub name: String,
pub kind: FieldKind,
pub value: Option<String>,
pub page: Option<usize>,
pub tooltip: Option<String>,
pub has_actions: bool,
/// Rect of the first widget, in user space.
pub rect: Option<[f32; 4]>,
}
#[derive(Clone, Debug)]
pub struct Layer {
pub name: String,
pub visible: bool,
}
#[derive(Clone, Debug)]
pub struct Attachment {
pub name: String,
pub description: Option<String>,
pub size: Option<usize>,
}
/// Inspect a document. Fails only if the renderer itself cannot open the file.
pub fn inspect(bytes: Arc<Vec<u8>>) -> Result<DocInfo, OpenError> {
let pdf = Pdf::new(bytes.clone()).map_err(|e| match format!("{e:?}") {
s if s.to_lowercase().contains("encrypt") || s.to_lowercase().contains("password") => OpenError::NeedsPassword,
s => OpenError::Invalid(s),
})?;
let mut info = DocInfo { file_size: bytes.len(), pdf_version: format!("{:?}", pdf.version()), ..Default::default() };
for (i, page) in pdf.pages().iter().enumerate() {
let (w, h) = page.render_dimensions();
let c = page.intersected_crop_box();
let rotation = match page.rotation() {
hayro::hayro_syntax::page::Rotation::None => 0,
hayro::hayro_syntax::page::Rotation::Horizontal => 90,
hayro::hayro_syntax::page::Rotation::Flipped => 180,
hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270,
};
let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32];
info.pages.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation });
}
match Document::load_mem(&bytes) {
Ok(doc) => Inspector::new(&doc).fill(&mut info),
Err(e) => info.warnings.push(format!("structure inspection unavailable: {e}")),
}
Ok(info)
}
struct Inspector<'a> {
doc: &'a Document,
page_index: HashMap<ObjectId, usize>,
}
impl<'a> Inspector<'a> {
fn new(doc: &'a Document) -> Self {
let page_index = doc.get_pages().into_iter().map(|(n, id)| (id, n as usize - 1)).collect();
Self { doc, page_index }
}
fn fill(&self, info: &mut DocInfo) {
info.encrypted = self.doc.is_encrypted() || self.doc.trailer.get(b"Encrypt").is_ok();
if let Some(d) = self.doc.trailer.get(b"Info").ok().and_then(|o| self.dict(o)) {
info.title = self.text(d, b"Title");
info.author = self.text(d, b"Author");
info.subject = self.text(d, b"Subject");
info.keywords = self.text(d, b"Keywords");
info.creator = self.text(d, b"Creator");
info.producer = self.text(d, b"Producer");
}
let Ok(catalog) = self.doc.catalog() else { return };
info.tagged = catalog
.get(b"MarkInfo")
.ok()
.and_then(|o| self.dict(o))
.and_then(|m| m.get(b"Marked").ok())
.and_then(|o| o.as_bool().ok())
.unwrap_or(false);
self.page_labels(catalog, &mut info.pages);
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
let mut seen = HashSet::new();
info.outline = self.outline_siblings(first, &mut seen, 0);
}
self.annotations(info);
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
&& let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array())
{
let mut seen = HashSet::new();
for f in fields {
self.field(f, None, &mut info.fields, &mut seen, 0);
}
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
self.layers(catalog, &mut info.layers);
if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {
if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
let mut seen = HashSet::new();
for (name, spec) in self.name_tree(ef, &mut seen, 0) {
info.attachments.push(self.attachment(name, spec));
}
}
info.has_javascript |= names.get(b"JavaScript").is_ok();
}
}
// ── object helpers ──────────────────────────────────────────────────────────────────────
fn resolve(&self, o: &'a Object) -> &'a Object {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> {
match self.resolve(o) {
Object::Dictionary(d) => Some(d),
Object::Stream(s) => Some(&s.dict),
_ => None,
}
}
fn text(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
let o = self.resolve(d.get(key).ok()?);
let s = match o {
Object::String(..) => lopdf::decode_text_string(o).ok()?,
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let s = s.trim_matches('\0').trim().to_string();
(!s.is_empty()).then_some(s)
}
fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
}
fn page_of(&self, o: &Object) -> Option<usize> {
match o {
Object::Reference(id) => self.page_index.get(id).copied(),
// Some producers write page *numbers* in remote-style destinations.
Object::Integer(n) => usize::try_from(*n).ok(),
_ => None,
}
}
// ── destinations ────────────────────────────────────────────────────────────────────────
fn dest_page(&self, dest: &Object, depth: u32) -> Option<usize> {
if depth > 8 {
return None;
}
match self.resolve(dest) {
Object::Array(a) => a.first().and_then(|p| self.page_of(p)),
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest_page(d, depth + 1)),
named @ (Object::String(..) | Object::Name(_)) => {
let key = match named {
Object::String(s, _) => s.clone(),
Object::Name(n) => n.clone(),
_ => unreachable!(),
};
self.named_dest(&key).and_then(|d| self.dest_page(d, depth + 1))
}
_ => None,
}
}
fn named_dest(&self, key: &[u8]) -> Option<&'a Object> {
let catalog = self.doc.catalog().ok()?;
if let Some(tree) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| self.dict(o)) {
let mut seen = HashSet::new();
if let Some((_, v)) = self.name_tree(tree, &mut seen, 0).into_iter().find(|(k, _)| k.as_bytes() == key) {
return Some(v);
}
}
// PDF 1.1 style /Dests dictionary.
catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok())
}
fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> {
let mut out = Vec::new();
if depth > 32 || !seen.insert(node as *const _) {
return out;
}
if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) {
for pair in pairs.chunks(2) {
if let [k, v] = pair {
let key = lopdf::decode_text_string(self.resolve(k)).unwrap_or_default();
out.push((key, v));
}
}
}
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
for k in kids {
if let Some(d) = self.dict(k) {
out.extend(self.name_tree(d, seen, depth + 1));
}
}
}
out
}
// ── outline ─────────────────────────────────────────────────────────────────────────────
fn outline_siblings(&self, first: &'a Object, seen: &mut HashSet<ObjectId>, depth: u32) -> Vec<OutlineItem> {
let mut items = Vec::new();
let mut cur = Some(first);
while let Some(o) = cur {
if let Object::Reference(id) = o
&& !seen.insert(*id)
{
break; // cycle
}
let Some(d) = self.dict(o) else { break };
let page = d
.get(b"Dest")
.ok()
.and_then(|dest| self.dest_page(dest, 0))
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest_page(dest, 0)));
let children = match (d.get(b"First").ok(), depth < 32) {
(Some(f), true) => self.outline_siblings(f, seen, depth + 1),
_ => Vec::new(),
};
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, children, open });
cur = d.get(b"Next").ok();
if items.len() > 100_000 {
break;
}
}
items
}
// ── page labels (ISO 32000-2 §12.4.2) ───────────────────────────────────────────────────
fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) {
let Some(tree) = catalog.get(b"PageLabels").ok().and_then(|o| self.dict(o)) else { return };
let mut ranges: BTreeMap<usize, &Dictionary> = BTreeMap::new();
let mut stack = vec![(tree, 0u32)];
while let Some((node, depth)) = stack.pop() {
if let Ok(Object::Array(nums)) = node.get(b"Nums").map(|o| self.resolve(o)) {
for pair in nums.chunks(2) {
if let [k, v] = pair
&& let (Ok(start), Some(d)) = (self.resolve(k).as_i64(), self.dict(v))
&& start >= 0
{
ranges.insert(start as usize, d);
}
}
}
if depth < 32
&& let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o))
{
stack.extend(kids.iter().filter_map(|k| self.dict(k)).map(|d| (d, depth + 1)));
}
}
for (i, page) in pages.iter_mut().enumerate() {
let Some((&start, d)) = ranges.range(..=i).next_back() else { continue };
let first = d.get(b"St").ok().and_then(|o| o.as_i64().ok()).unwrap_or(1).max(1) as usize;
let n = first + (i - start);
let prefix = self.text(d, b"P").unwrap_or_default();
let number = match self.name(d, b"S").as_deref() {
Some("D") => n.to_string(),
Some("R") => roman(n).to_uppercase(),
Some("r") => roman(n),
Some("A") => alpha(n).to_uppercase(),
Some("a") => alpha(n),
_ => String::new(),
};
page.label = format!("{prefix}{number}");
if page.label.is_empty() {
page.label = (i + 1).to_string();
}
}
}
// ── annotations ─────────────────────────────────────────────────────────────────────────
fn annotations(&self, info: &mut DocInfo) {
for (&id, &page) in &self.page_index {
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for a in annots {
let Some(d) = self.dict(a) else { continue };
let Some(subtype) = self.name(d, b"Subtype") else { continue };
if subtype == "Link" {
if let Some(link) = self.link(page, d) {
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
info.has_javascript = true;
}
info.links.push(link);
}
continue;
}
if matches!(subtype.as_str(), "Widget" | "Popup") {
continue;
}
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
let color = match d.get(b"C").map(|o| self.resolve(o)) {
Ok(Object::Array(c)) if c.len() == 3 => {
let f = |i: usize| c[i].as_float().unwrap_or(0.0);
Some([f(0), f(1), f(2)])
}
_ => None,
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
info.annotations.push(Annotation {
page,
subtype,
author: self.text(d, b"T"),
contents: self.text(d, b"Contents"),
modified: self.text(d, b"M").map(|m| pretty_date(&m)),
name: self.text(d, b"NM"),
in_reply_to,
rect,
color,
});
}
}
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
}
fn link(&self, page: usize, d: &Dictionary) -> Option<Link> {
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
let target = if let Ok(dest) = d.get(b"Dest") {
LinkTarget::Page(self.dest_page(dest, 0)?)
} else {
let a = d.get(b"A").ok().and_then(|a| self.dict(a))?;
match self.name(a, b"S").as_deref() {
Some("GoTo") => LinkTarget::Page(self.dest_page(a.get(b"D").ok()?, 0)?),
Some("URI") => {
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
}
Some(other) => LinkTarget::Other(other.to_string()),
None => return None,
}
};
Some(Link { page, rect, target })
}
// ── form fields ─────────────────────────────────────────────────────────────────────────
fn field(
&self,
o: &'a Object,
parent: Option<(&str, Option<String>, Option<i64>)>,
out: &mut Vec<Field>,
seen: &mut HashSet<ObjectId>,
depth: u32,
) {
if depth > 32 {
return;
}
if let Object::Reference(id) = o
&& !seen.insert(*id)
{
return;
}
let Some(d) = self.dict(o) else { return };
let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None));
let partial = self.text(d, b"T");
let full = match (&partial, parent_name.is_empty()) {
(Some(t), true) => t.clone(),
(Some(t), false) => format!("{parent_name}.{t}"),
(None, _) => parent_name.to_string(),
};
let ft = self.name(d, b"FT").or(parent_ft);
let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff);
// Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal.
let kids = match d.get(b"Kids").map(|k| self.resolve(k)) {
Ok(Object::Array(k)) => k.as_slice(),
_ => &[],
};
let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect();
if !field_kids.is_empty() {
for k in field_kids {
self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1);
}
return;
}
let flags = ff.unwrap_or(0);
let kind = match ft.as_deref() {
Some("Tx") => FieldKind::Text,
Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton,
Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio,
Some("Btn") => FieldKind::CheckBox,
Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo,
Some("Ch") => FieldKind::List,
Some("Sig") => FieldKind::Signature,
_ => FieldKind::Unknown,
};
let value = match d.get(b"V").map(|v| self.resolve(v)) {
Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()),
Ok(v @ Object::String(..)) => lopdf::decode_text_string(v).ok(),
Ok(Object::Array(a)) => Some(a.iter().filter_map(|x| lopdf::decode_text_string(self.resolve(x)).ok()).collect::<Vec<_>>().join(", ")),
Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()),
_ => None,
};
// The widget is either this dict (merged field/widget) or its first kid.
let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) };
let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids));
let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok());
let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r)));
out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect });
}
fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option<usize> {
let targets: HashSet<ObjectId> =
std::iter::once(field).chain(kids.iter()).filter_map(|o| if let Object::Reference(id) = o { Some(*id) } else { None }).collect();
if targets.is_empty() {
return None;
}
self.page_index.iter().find_map(|(&pid, &idx)| {
let annots = self.doc.get_dictionary(pid).ok()?.get(b"Annots").ok()?;
let arr = self.resolve(annots).as_array().ok()?;
arr.iter().any(|a| matches!(a, Object::Reference(id) if targets.contains(id))).then_some(idx)
})
}
// ── optional content ────────────────────────────────────────────────────────────────────
fn layers(&self, catalog: &Dictionary, out: &mut Vec<Layer>) {
let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return };
let config = props.get(b"D").ok().and_then(|o| self.dict(o));
let off: HashSet<ObjectId> = config
.and_then(|c| c.get(b"OFF").ok())
.and_then(|o| self.resolve(o).as_array().ok())
.map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect())
.unwrap_or_default();
let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF");
let on: HashSet<ObjectId> = config
.and_then(|c| c.get(b"ON").ok())
.and_then(|o| self.resolve(o).as_array().ok())
.map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect())
.unwrap_or_default();
let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return };
for g in ocgs {
let Some(d) = self.dict(g) else { continue };
let id = g.as_reference().ok();
let visible = match id {
Some(id) if off.contains(&id) => false,
Some(id) if on.contains(&id) => true,
_ => !base_off,
};
out.push(Layer { name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible });
}
}
fn attachment(&self, name: String, spec: &Object) -> Attachment {
let d = self.dict(spec);
let size =
d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then(
|f| match self.resolve(f) {
Object::Stream(s) => s
.dict
.get(b"Params")
.ok()
.and_then(|p| self.dict(p))
.and_then(|p| p.get(b"Size").ok())
.and_then(|s| s.as_i64().ok())
.map(|s| s as usize)
.or(Some(s.content.len())),
_ => None,
},
);
let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name);
Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size }
}
}
fn rect4(o: &Object) -> [f32; 4] {
match o {
Object::Array(a) if a.len() == 4 => {
let v: Vec<f32> = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect();
[v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]
}
_ => [0.0; 4],
}
}
fn roman(mut n: usize) -> String {
const T: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut s = String::new();
for (v, r) in T {
while n >= v {
s.push_str(r);
n -= v;
}
}
s
}
/// a..z, aa..zz, aaa.. (ISO 32000-2: "a to z for the first 26 pages, aa to zz for the next 26").
fn alpha(n: usize) -> String {
let n = n.max(1) - 1;
let letter = (b'a' + (n % 26) as u8) as char;
std::iter::repeat_n(letter, n / 26 + 1).collect()
}
/// `D:20260930104512-04'00'` → `2026-09-30 10:45`.
fn pretty_date(s: &str) -> String {
let d = s.trim_start_matches("D:");
if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) {
format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12])
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roman_and_alpha_labels() {
assert_eq!(roman(4), "iv");
assert_eq!(roman(1994), "mcmxciv");
assert_eq!(alpha(1), "a");
assert_eq!(alpha(27), "aa");
assert_eq!(alpha(53), "aaa");
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");
assert_eq!(pretty_date("yesterday"), "yesterday");
}
}
+22
View File
@@ -0,0 +1,22 @@
//! printcraft-render — page rasterization and document inspection.
//!
//! **Bootstrap status (see ADR-0004):** rasterization goes straight through the `hayro` crate
//! (hayro-interpret + vello_cpu), and inspection (outline, annotations, fields, layers,
//! attachments, metadata) uses `lopdf`. Both are replaced by our own `cos`/`model` crates and the
//! DisplayList device design in M1–M2. The public API here is what the engine and UI rely on, so
//! the swap stays internal to this crate.
mod inspect;
mod raster;
pub use inspect::{Annotation, Attachment, DocInfo, Field, FieldKind, Layer, Link, LinkTarget, OutlineItem, PageInfo, inspect};
pub use raster::{RenderPool, RenderRequest, RenderedPage};
/// Errors surfaced to the user when a document cannot be opened.
#[derive(Debug, thiserror::Error)]
pub enum OpenError {
#[error("the file is not a readable PDF: {0}")]
Invalid(String),
#[error("the document is encrypted and needs a password")]
NeedsPassword,
}
+138
View File
@@ -0,0 +1,138 @@
//! Background page rasterization.
//!
//! A `RenderPool` owns N worker threads. Each worker parses the shared bytes once (hayro's parser
//! is lazy, so this is cheap) and keeps its own render cache. Requests carry a generation number;
//! the UI drops results for stale generations (e.g. after a zoom change).
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings, render};
/// Hard cap on a rendered side, to bound memory at extreme zoom levels (tiling arrives in M3.3).
const MAX_SIDE: f32 = 8192.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RenderRequest {
pub page: usize,
/// Device pixels per PDF point.
pub scale: f32,
/// Caller-defined tag, echoed back (used for zoom generations / thumbnail vs page).
pub tag: u64,
}
pub struct RenderedPage {
pub request: RenderRequest,
pub width: u32,
pub height: u32,
/// Premultiplied RGBA8, row-major.
pub rgba: Vec<u8>,
}
type Queue = Arc<Mutex<Vec<RenderRequest>>>;
pub struct RenderPool {
queue: Queue,
wake: Vec<Sender<()>>,
results: Receiver<RenderedPage>,
_workers: Vec<JoinHandle<()>>,
}
impl RenderPool {
pub fn new(bytes: Arc<Vec<u8>>, threads: usize) -> Self {
let queue: Queue = Arc::new(Mutex::new(Vec::new()));
let (res_tx, results) = channel();
let mut wake = Vec::new();
let mut workers = Vec::new();
for i in 0..threads.max(1) {
let (wtx, wrx) = channel::<()>();
wake.push(wtx);
let queue = queue.clone();
let res_tx = res_tx.clone();
let bytes = bytes.clone();
let handle = std::thread::Builder::new()
.name(format!("printcraft-render-{i}"))
.spawn(move || worker(bytes, queue, wrx, res_tx))
.expect("spawn render worker");
workers.push(handle);
}
Self { queue, wake, results, _workers: workers }
}
/// Replace the pending queue (most urgent first). In-flight renders are not interrupted.
pub fn set_queue(&self, mut requests: Vec<RenderRequest>) {
requests.reverse(); // workers pop from the end
if let Ok(mut q) = self.queue.lock() {
*q = requests;
}
for w in &self.wake {
let _ = w.send(());
}
}
pub fn try_recv(&self) -> Option<RenderedPage> {
self.results.try_recv().ok()
}
}
fn worker(bytes: Arc<Vec<u8>>, queue: Queue, wake: Receiver<()>, out: Sender<RenderedPage>) {
let Ok(pdf) = Pdf::new(bytes) else { return };
let settings = InterpreterSettings::default();
let cache = RenderCache::new();
let pages = pdf.pages();
loop {
let next = queue.lock().ok().and_then(|mut q| q.pop());
let Some(req) = next else {
if wake.recv().is_err() {
return; // pool dropped
}
continue;
};
let Some(page) = pages.get(req.page) else { continue };
let (w, h) = page.render_dimensions();
let scale = req.scale.min(MAX_SIDE / w.max(h).max(1.0));
let rs = RenderSettings { x_scale: scale, y_scale: scale, bg_color: WHITE, ..Default::default() };
let pixmap = render(page, &cache, &settings, &rs);
let (width, height) = (pixmap.width() as u32, pixmap.height() as u32);
let rgba = pixmap.data_as_u8_slice().to_vec();
if out.send(RenderedPage { request: req, width, height, rgba }).is_err() {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ONE_PAGE: &[u8] = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj
4 0 obj << /Length 35 >> stream
0 0 1 rg 10 10 30 20 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn renders_a_blue_rectangle() {
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1);
pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, tag: 7 }]);
let page = loop {
if let Some(p) = pool.try_recv() {
break p;
}
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert_eq!((page.width, page.height, page.request.tag), (100, 50, 7));
// Pixel (20, 30) in y-down device space is inside the rect drawn at y 10..30 (y-up).
let px = |x: u32, y: u32| &page.rgba[((y * page.width + x) * 4) as usize..][..4];
assert_eq!(px(20, 30), &[0, 0, 255, 255]);
assert_eq!(px(80, 5), &[255, 255, 255, 255]);
}
}