Files
pdfcraft/xtask/src/layers.rs
T
bmh129 a10906e991 M10: Recognize Text in the browser, read in Web Workers (ocr.ocr-web)
The web build downloads the OCR models the first time Recognize Text (or
Edit on a scanned page) needs them, installs the engine, and reads pages
in up to two Web Workers (apps/pdfcraft-ocr-worker) built with WebAssembly
SIMD, so the page keeps responding. The page prepares each page and sends
its image to an idle worker, which runs ocrs and sends the lines back.
Where no worker can start, the page reads with the installed engine.
Answers go back to the run that sent the page (ocr_route), so a single
run and a batch can overlap.

Recognize Text in Multiple Files on the web keeps main's skipped-files
bucket, low-confidence notes and cancel between files. Files chosen
before the models are in wait for them. A scan Edit finds while no engine
can read it waits for the models instead of counting as no scan.

Trunk builds the worker beside the app; `cargo xtask web-worker-link`
hashes its files and names it in index.html. The web zip ships the
models in models/.
2026-11-07 21:35:50 -06:00

365 lines
14 KiB
Rust

//! Dependency layering rules (plan/architecture.md §3), enforced by `cargo xtask layers`.
//!
//! The rule engine works on a small, metadata-independent model so it can be unit-tested;
//! `from_metadata` builds that model from `cargo metadata`. (Structure shared with PhotoCraft.)
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
/// Regular layered crate.
Layer(u8),
/// Layer 0/1 core that may depend only on the workspace crates listed in `STANDALONE_DEPS`.
Standalone(u8),
/// Test tooling: dev-dependency only for other crates.
Testkit,
/// Binaries, build tooling: exempt.
Exempt,
}
impl Class {
fn layer(self) -> Option<u8> {
match self {
Class::Layer(l) | Class::Standalone(l) => Some(l),
Class::Testkit => Some(7),
Class::Exempt => None,
}
}
}
/// Every workspace crate (package name without the `pdfcraft-` prefix) and its layer.
pub const TABLE: &[(&str, Class)] = &[
// L0 foundation (standalone, publishable)
("geom", Class::Standalone(0)),
("filters", Class::Standalone(0)),
("crypt", Class::Standalone(0)),
("arlington", Class::Standalone(0)),
("prc", Class::Standalone(0)),
// L1 object layer
("cos", Class::Standalone(1)),
// L2 core model
("content", Class::Layer(2)),
("model", Class::Layer(2)),
("color", Class::Layer(2)),
("fonts", Class::Layer(2)),
("ops", Class::Layer(2)),
// L3 core services
("render", Class::Layer(3)),
("prc-render", Class::Layer(3)),
("text", Class::Layer(3)),
("annot", Class::Layer(3)),
("forms", Class::Layer(3)),
("js", Class::Layer(3)),
("xfa", Class::Layer(3)),
("ml", Class::Layer(3)),
// L4 features
("edit", Class::Layer(4)),
("organize", Class::Layer(4)),
("security", Class::Layer(4)),
("redact", Class::Layer(4)),
("audit", Class::Layer(4)),
("xfdf", Class::Layer(4)),
("sign", Class::Layer(4)),
("ocr", Class::Layer(4)),
("create", Class::Layer(4)),
("export", Class::Layer(4)),
("optimize", Class::Layer(4)),
("preflight", Class::Layer(4)),
("prepress", Class::Layer(4)),
("a11y", Class::Layer(4)),
("compare", Class::Layer(4)),
("measure", Class::Layer(4)),
("search", Class::Layer(4)),
("print", Class::Layer(4)),
("media", Class::Layer(4)),
("ai", Class::Layer(4)),
// L5 interaction
("viewport", Class::Layer(5)),
("tools", Class::Layer(5)),
// L6 façade
("engine", Class::Layer(6)),
// L7 platform + frontends
("platform", Class::Layer(7)),
("ui-common", Class::Layer(7)),
("ui-egui", Class::Layer(7)),
("automation", Class::Layer(7)),
// test support
("testkit", Class::Testkit),
("oracle", Class::Testkit),
// L8 apps + tooling
("pdfcraft", Class::Exempt),
("cli", Class::Exempt),
("web", Class::Exempt),
("ocr-worker", Class::Exempt),
("xtask", Class::Exempt),
];
/// Workspace crates a standalone crate may use (architecture §3 rule 3: `cos → filters, crypt, geom`).
pub const STANDALONE_DEPS: &[(&str, &[&str])] = &[("cos", &["filters", "crypt", "geom"])];
/// Allowed same-layer edges (architecture §3 rule 1). `forms → js` is deliberately absent.
pub const SIDEWAYS: &[(&str, &str)] = &[
("redact", "edit"),
("audit", "edit"),
("a11y", "preflight"),
("prepress", "preflight"),
("export", "ocr"),
("sign", "security"),
("ui-egui", "ui-common"),
("ui-egui", "platform"),
("automation", "platform"),
];
/// External crates that constitute a UI toolkit / windowing dependency (prefix match with `*`).
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "egui_extras", "egui_dock", "egui_tiles", "rfd", "wgpu*"];
pub const UI_MIN_LAYER: u8 = 7;
pub fn short_name(pkg: &str) -> &str {
pkg.strip_prefix("pdfcraft-").unwrap_or(pkg)
}
pub fn classify(pkg: &str) -> Option<Class> {
let s = short_name(pkg);
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
}
fn is_ui_crate(name: &str) -> bool {
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => name == *p,
})
}
fn sideways_allowed(from: &str, to: &str) -> bool {
SIDEWAYS.contains(&(short_name(from), short_name(to)))
}
fn standalone_allowed(from: &str, to: &str) -> bool {
STANDALONE_DEPS.iter().any(|(f, ok)| *f == short_name(from) && ok.contains(&short_name(to)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepKind {
Normal,
Dev,
Build,
}
#[derive(Debug, Clone)]
pub struct Dep {
pub name: String,
pub kind: DepKind,
pub workspace: bool,
}
#[derive(Debug, Clone)]
pub struct Crate {
pub name: String,
pub deps: Vec<Dep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Violation {
Unregistered { krate: String },
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
StandaloneHasWorkspaceDep { krate: String, dep: String },
TestkitAsNormalDep { krate: String, dep: String },
UiBelowL7 { krate: String, dep: String, layer: u8 },
}
impl std::fmt::Display for Violation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Violation::Unregistered { krate } => {
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (plan/architecture.md §3)")
}
Violation::Upward { krate, dep, from, to, kind } => {
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on lower layers (or an allowed sideways edge)")
}
Violation::StandaloneHasWorkspaceDep { krate, dep } => write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}"),
Violation::TestkitAsNormalDep { krate, dep } => write!(f, "{krate}: {dep} may only be a dev-dependency"),
Violation::UiBelowL7 { krate, dep, layer } => {
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L7+")
}
}
}
}
/// Layer and testkit rules for one dependency of `c` that is itself a workspace crate.
fn check_workspace_dep(c: &Crate, class: Class, layer: u8, d: &Dep, out: &mut Vec<Violation>) {
let dep_class = classify(&d.name);
// Test support is a dev-dependency only (`Class::Testkit`) and so never reaches
// the product graph a standalone crate publishes. Checking it against the
// standalone allow-list would stop `cos` using the shared oracles in its own
// tests, which is the one thing those crates exist for.
let testkit_dev = dep_class == Some(Class::Testkit) && d.kind == DepKind::Dev;
if matches!(class, Class::Standalone(_)) && !testkit_dev && !standalone_allowed(&c.name, &d.name) {
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
return;
}
match dep_class {
None => {}
Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => {
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() });
}
Some(Class::Testkit) => {}
Some(dc) => {
let to = dc.layer().unwrap_or(u8::MAX);
let ok = to < layer || (to == layer && sideways_allowed(&c.name, &d.name));
if !ok {
out.push(Violation::Upward { krate: c.name.clone(), dep: d.name.clone(), from: layer, to: to.min(8), kind: d.kind });
}
}
}
}
pub fn check(crates: &[Crate]) -> Vec<Violation> {
let mut out = Vec::new();
for c in crates {
let Some(class) = classify(&c.name) else {
out.push(Violation::Unregistered { krate: c.name.clone() });
continue;
};
if class == Class::Exempt {
continue;
}
let layer = class.layer().unwrap_or(0);
for d in &c.deps {
if d.name == c.name {
continue;
}
if d.workspace {
check_workspace_dep(c, class, layer, d, &mut out);
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) && !(class == Class::Testkit || d.kind == DepKind::Dev && d.name == "egui_kittest")
{
out.push(Violation::UiBelowL7 { krate: c.name.clone(), dep: d.name.clone(), layer });
}
}
}
out.sort_by_key(ToString::to_string);
out.dedup();
out
}
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
let mut out = Vec::new();
for p in pkgs {
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
let mut deps = Vec::new();
for d in p["dependencies"].as_array().into_iter().flatten() {
let dname = d["name"].as_str().unwrap_or_default().to_owned();
let kind = match d["kind"].as_str() {
Some("dev") => DepKind::Dev,
Some("build") => DepKind::Build,
_ => DepKind::Normal,
};
let workspace = members.contains(&dname.as_str());
deps.push(Dep { name: dname, kind, workspace });
}
out.push(Crate { name, deps });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn describe(class: Option<Class>) -> String {
match class {
Some(Class::Layer(l)) => format!("L{l}"),
Some(Class::Standalone(l)) => format!("L{l} standalone"),
Some(Class::Testkit) => "test support".into(),
Some(Class::Exempt) => "exempt".into(),
None => "UNREGISTERED".into(),
}
}
#[cfg(test)]
mod tests {
use super::DepKind::*;
use super::*;
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
}
#[test]
fn current_graph_shape_passes() {
let g = [
c("pdfcraft-geom", &[]),
c("pdfcraft-render", &[("hayro", Normal, false), ("lopdf", Normal, false)]),
c("pdfcraft-engine", &[("pdfcraft-render", Normal, true)]),
c("pdfcraft-ui-egui", &[("pdfcraft-engine", Normal, true), ("egui", Normal, false), ("egui_kittest", Dev, false)]),
c("pdfcraft", &[("pdfcraft-ui-egui", Normal, true)]),
];
assert!(check(&g).is_empty(), "{:?}", check(&g));
}
#[test]
fn upward_and_sideways_edges_flagged() {
let v = check(&[c("pdfcraft-model", &[("pdfcraft-engine", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 6, .. }]));
// forms -> js must go through the ActionRunner trait, never directly.
let v = check(&[c("pdfcraft-forms", &[("pdfcraft-js", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 3, to: 3, .. }]));
}
#[test]
fn listed_sideways_edges_allowed() {
assert!(check(&[c("pdfcraft-redact", &[("pdfcraft-edit", Normal, true)])]).is_empty());
assert!(!check(&[c("pdfcraft-edit", &[("pdfcraft-redact", Normal, true)])]).is_empty());
}
#[test]
fn cos_may_use_only_its_listed_foundation() {
assert!(check(&[c("pdfcraft-cos", &[("pdfcraft-filters", Normal, true), ("pdfcraft-crypt", Normal, true)])]).is_empty());
let v = check(&[c("pdfcraft-cos", &[("pdfcraft-arlington", Normal, true)])]);
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]));
let v = check(&[c("pdfcraft-filters", &[("pdfcraft-geom", Normal, true)])]);
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]));
}
#[test]
fn ui_crates_below_l7_flagged() {
for dep in ["egui", "eframe", "winit", "rfd", "wgpu", "wgpu-core", "egui_dock", "craft-ui"] {
let v = check(&[c("pdfcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL7 { layer: 6, .. }]), "{dep}");
}
assert!(check(&[c("pdfcraft-platform", &[("winit", Normal, false)])]).is_empty());
assert!(check(&[c("pdfcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
}
#[test]
fn test_support_only_as_dev_dependency() {
let v = check(&[c("pdfcraft-cos", &[("pdfcraft-testkit", Normal, true)])]);
assert!(!v.is_empty());
assert!(check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Dev, true)])]).is_empty());
let v = check(&[c("pdfcraft-render", &[("pdfcraft-oracle", Normal, true)])]);
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
// A standalone crate may use test support in its own tests: a dev-dependency is not part
// of the graph the standalone rule protects. `cos` is where the qpdf oracle is needed.
assert!(check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Dev, true)])]).is_empty());
assert!(check(&[c("pdfcraft-geom", &[("pdfcraft-testkit", Dev, true)])]).is_empty());
// ...but only as a dev-dependency, and only for test support. Product crates are still
// off-limits to a standalone crate however they are depended on.
let v = check(&[c("pdfcraft-cos", &[("pdfcraft-oracle", Normal, true)])]);
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}");
let v = check(&[c("pdfcraft-cos", &[("pdfcraft-model", Dev, true)])]);
assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{v:?}");
}
#[test]
fn unregistered_crate_is_error() {
let v = check(&[c("pdfcraft-mystery", &[])]);
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "pdfcraft-mystery"));
}
#[test]
fn apps_exempt() {
for app in ["pdfcraft", "pdfcraft-cli", "pdfcraft-web", "pdfcraft-ocr-worker", "xtask"] {
assert!(check(&[c(app, &[("egui", Normal, false), ("pdfcraft-ui-egui", Normal, true)])]).is_empty());
}
}
}