M10.3: Preflight (overset, missing fonts/links, low-res graphics, RGB in print, empty frames) + panel + status indicator

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 11:54:44 -04:00
parent 51045daf36
commit 9fdd61a390
6 changed files with 194 additions and 1 deletions

View File

@ -7,6 +7,7 @@ mod find;
mod inspect;
mod layout;
mod object;
pub mod preflight;
mod style;
pub mod text;
@ -110,6 +111,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layout::specs());
v.extend(inspect::specs());
v.extend(find::specs());
v.extend(preflight::specs());
v
})
}

View File

@ -0,0 +1,133 @@
//! Live preflight (the Preflight panel and the status-bar indicator): overset text, missing
//! fonts, missing or low-resolution graphics, RGB content in print documents, empty text frames.
use designcraft_doc::{Content, Document, Intent, Item, SpreadRef};
use serde::Serialize;
use serde_json::{Value, json};
use super::{CommandSpec, cmd, has_doc};
use crate::{Result, Session};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Issue {
/// `error` or `warning`.
pub severity: &'static str,
pub kind: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub item: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(query "preflight.run", "Preflight Document", ["Window", "Output", "Preflight"], None,
"{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)]
}
fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> {
let SpreadRef::Doc(si) = sr else { return None };
let sp = d.spreads.get(si)?;
Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0))
}
pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
let mut out = Vec::new();
// Stories: overset and fonts.
let db = designcraft_fonts::FontDb::global();
let mut missing_fonts: Vec<String> = Vec::new();
for story in d.stories.values() {
let cs = s.cache.get(d, story.id, None);
if cs.is_overset() {
let last = story.frames.last().copied();
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it));
let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count();
out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page });
}
for (pi, pf) in story.paras.iter().enumerate() {
let (_, base) = d.styles.resolve_para(pf);
let _ = pi;
let mut fams = vec![base.font_family.clone()];
for (_, f) in story.runs() {
fams.push(d.styles.resolve_char(&base, f).font_family);
}
for fam in fams {
if !db.has_family(&fam) && !missing_fonts.contains(&fam) {
missing_fonts.push(fam);
}
}
}
}
for f in missing_fonts {
out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None });
}
// Items.
for (si, sp) in d.spreads.iter().enumerate() {
for top in &sp.items {
top.walk(&mut |it| {
let page = page_of(d, SpreadRef::Doc(si), it);
match &it.content {
Content::Graphic(g) => match d.assets.get(&g.asset) {
None => {
out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page })
}
Some(a) if a.data.is_empty() => out.push(Issue {
severity: "error",
kind: "missingLink",
message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
item: Some(it.id.0),
page,
}),
Some(a) => {
if let Some((pw, _)) = a.pixels {
// Effective ppi: pixels per inch at the placed size.
let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0;
let ppi = pw as f64 / (placed_w / 72.0).max(1e-6);
if d.settings.intent == Intent::Print && ppi < min_ppi {
out.push(Issue {
severity: "warning",
kind: "lowResolution",
message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name),
item: Some(it.id.0),
page,
});
}
}
}
},
Content::Text(tf) => {
if d.story(tf.story).is_some_and(|s| s.is_empty()) {
out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page });
}
}
_ => {}
}
});
}
}
// RGB swatches in use in a print document.
if d.settings.intent == Intent::Print {
for sw in &d.swatches {
if let designcraft_color::SwatchValue::Color { color: designcraft_color::Color::Rgb { .. }, .. } = sw.value {
out.push(Issue {
severity: "warning",
kind: "rgbColor",
message: format!("RGB swatch in a print document: {}", sw.name),
item: None,
page: None,
});
}
}
}
out
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let min = p.get("minPpi").and_then(Value::as_f64).unwrap_or(150.0);
let issues = check(s, min);
let errors = issues.iter().filter(|i| i.severity == "error").count();
Ok(json!({"errors": errors, "warnings": issues.len() - errors, "issues": issues}))
}

View File

@ -205,6 +205,19 @@ fn place_gun_click_drag_and_into_frame() {
assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_)));
}
#[test]
fn preflight_reports_overset_and_missing_fonts() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 100, 50], "content": "text", "text": "This text will not fit in such a tiny frame at all."}))
.unwrap();
s.execute("text.select", &json!({"story": s.doc().unwrap().doc.stories.keys().next().unwrap().0, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap();
let r = s.execute("preflight.run", &json!({})).unwrap();
let kinds: Vec<&str> = r["issues"].as_array().unwrap().iter().map(|i| i["kind"].as_str().unwrap()).collect();
assert!(kinds.contains(&"overset") && kinds.contains(&"missingFont"), "{kinds:?}");
assert_eq!(r["errors"], 2);
}
/// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText).
fn pdf_text(bytes: &[u8]) -> Vec<String> {
use hayro_interpret::font::Glyph;

View File

@ -24,6 +24,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("align", "Align", "panel-align"),
("links", "Links", "panel-links"),
("info", "Info", "panel-info"),
("preflight", "Preflight", "panel-preflight"),
];
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
@ -126,6 +127,7 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
"color" => panels::swatches::color_panel(app, ui),
"effects" => panels::properties::effects_panel(app, ui),
"links" => panels::properties::links_panel(app, ui),
"preflight" => panels::properties::preflight_panel(app, ui),
_ => panels::properties::info_panel(app, ui),
});
});

View File

@ -82,8 +82,19 @@ pub fn text_attrs(app: &mut DesignApp) -> Option<Value> {
/// Count preflight errors: overset stories (more checks land with the Preflight panel).
pub fn preflight_errors(app: &DesignApp) -> usize {
// Memoized per document snapshot (the status bar asks every frame).
static MEMO: std::sync::Mutex<Option<(usize, usize)>> = std::sync::Mutex::new(None);
let Some(st) = app.session.active() else { return 0 };
st.doc.stories.keys().filter(|sid| app.session.cache.get(&st.doc, **sid, None).is_overset()).count()
let key = std::sync::Arc::as_ptr(&st.doc) as usize;
let mut g = MEMO.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, n)) = *g
&& k == key
{
return n;
}
let n = designcraft_engine::cmd::preflight::check(&app.session, 150.0).iter().filter(|i| i.severity == "error").count();
*g = Some((key, n));
n
}
/// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name.

View File

@ -662,3 +662,35 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
}
pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let issues = designcraft_engine::cmd::preflight::check(&app.session, 150.0);
let errors = issues.iter().filter(|i| i.severity == "error").count();
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());
ui.painter().circle_filled(
r.center(),
4.5,
if errors == 0 { egui::Color32::from_rgb(60, 200, 90) } else { egui::Color32::from_rgb(235, 50, 50) },
);
ui.label(if issues.is_empty() { "No errors".to_string() } else { format!("{errors} error(s), {} warning(s)", issues.len() - errors) });
});
divider(ui);
for i in issues {
let page = i.page.and_then(|p| app.session.active().map(|d| d.doc.page_name(p)));
let text = format!("{} {}", if i.severity == "error" { "⛔" } else { "⚠" }, i.message);
let resp = ui.add(egui::Button::new(egui::RichText::new(text).size(11.0)).frame(false));
if let Some(pg) = &page {
ui.label(egui::RichText::new(format!(" page {pg}")).size(10.0).color(t.text_dim));
}
if resp.clicked() {
if let Some(id) = i.item {
let _ = app.run("selection.set", json!({"ids": [id]}));
}
if let Some(p) = i.page {
crate::canvas::go_to_page(app, p);
}
}
}
}