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:
parent
51045daf36
commit
9fdd61a390
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue