//! Live preflight (the Preflight panel and the status-bar indicator): overset text, missing //! fonts and glyphs, missing or low-resolution graphics, RGB content in print documents, empty //! text frames. use designcraft_compose::ComposedStory; use designcraft_doc::{Content, Document, Intent, Item, SpreadRef}; use designcraft_fonts::FaceRef; use designcraft_geom::Affine; 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, #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, } pub fn specs() -> Vec { 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, parent: Affine) -> Option { let SpreadRef::Doc(si) = sr else { return None }; let sp = d.spreads.get(si)?; // Item bounds include its own transform; ancestors bring the frame into spread space. let bounds = parent.transform_rect_bbox(it.bounds()); Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0)) } /// Characters a missing-glyph report names before "and N more". const MISSING_GLYPHS_LISTED: usize = 20; /// Inspect rendered characters, including zero-length glyphs generated by a source marker. /// Nested notes and cells report the owning page frame, not their synthetic frame zero. fn missing_glyphs( cs: &ComposedStory, owner: Option, depth: usize, out: &mut Vec<(FaceRef, Vec, designcraft_doc::ItemId)>, ) { if depth > 16 { return; } for f in &cs.frames { let owner = owner.unwrap_or(f.frame); for g in f.lines.iter().flat_map(|l| &l.glyphs).filter(|g| g.visible && (g.gid == 0 || g.face.is_placeholder())) { let c = g.rendered_char; if c.is_control() || c.is_whitespace() || ('\u{E000}'..='\u{E1FF}').contains(&c) { continue; } match out.iter_mut().find(|(face, _, item)| *face == g.face && *item == owner) { Some((_, chars, _)) if !chars.contains(&c) => chars.push(c), Some(_) => {} None => out.push((g.face, vec![c], owner)), } } for c in f.tables.iter().flat_map(|t| &t.cells) { missing_glyphs(&c.text, Some(owner), depth + 1, out); } for n in &f.notes { missing_glyphs(&n.text, Some(owner), depth + 1, out); } } } /// Locate an embedded frame at the outer page frame that actually renders it. fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(designcraft_doc::ItemId, Option)> { let d = &s.active()?.doc; if let Some(loc) = d.find(target) { return Some((target, d.item(target).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc))))); } fn contains(root: &designcraft_compose::FrameText, target: designcraft_doc::ItemId) -> bool { let mut frames = vec![root]; while let Some(frame) = frames.pop() { for object in &frame.objects { let mut found = false; object.object.item.walk(&mut |it| { found |= it.id == target; }); if found { return true; } } frames.extend(frame.notes.iter().flat_map(|n| n.text.frames.iter())); frames.extend(frame.tables.iter().flat_map(|t| t.cells.iter().flat_map(|c| c.text.frames.iter()))); } false } let mut current = target; let mut seen = std::collections::HashSet::new(); let embedded = d.anchored_items(); while seen.insert(current) { let owner = embedded.iter().find_map(|(owner, it)| (it.id == current).then_some(*owner))?; let cs = s.cache.get(d, owner, None); // Search each host frame separately, including objects in its notes and cells. let frame = cs.frames.iter().find(|f| contains(f, current))?; current = frame.frame; if let Some(loc) = d.find(current) { return Some((current, d.item(current).and_then(|it| page_of(d, loc.spread, it, d.parent_xf(&loc))))); } } None } pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; let document = st.path.as_deref().map(std::path::Path::new); let mut out = Vec::new(); // Stories: overset and fonts. let mut missing_fonts: Vec = Vec::new(); let mut unsupported_typography = std::collections::BTreeSet::new(); let mut missing: Vec<(FaceRef, Vec, designcraft_doc::ItemId)> = Vec::new(); for story in d.stories.values() { let cs = s.cache.get(d, story.id, None); missing_glyphs(&cs, None, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); let location = last.and_then(|f| overset_location(s, f)); let n = story.text.get(cs.overset_at.unwrap_or(0)..).unwrap_or("").chars().count(); let unplaced = story.frames.is_empty(); out.push(Issue { severity: if unplaced { "warning" } else { "error" }, kind: if unplaced { "unplacedStory" } else { "overset" }, message: if unplaced { format!("Unplaced story {}: {n} characters have no text frame", story.id) } else { format!("Overset text: {n} characters") }, item: location.map(|(item, _)| item.0).or(last.map(|i| i.0)), page: location.and_then(|(_, page)| page), }); } } // Nested note and cell text uses paragraph rules too. super::fonts::for_each_story(d, &mut |story| { let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { let (para, base) = d.styles.resolve_para(pf); let Some(range) = ranges.get(pi) else { continue }; let has_rtl = story.text.get(range.clone()).is_some_and(|text| text.chars().any(designcraft_fonts::is_rtl)); if has_rtl { if !matches!(para.arabic_justification.as_str(), "" | "DefaultJustification") { unsupported_typography.insert(format!( "Arabic justification policy `{}` is preserved; vendor-specific Naskh/Arabic algorithms are not implemented", para.arabic_justification )); } if para.paragraph_kashida_width.is_some() && para.arabic_justification != "DefaultJustification" { unsupported_typography .insert("Paragraph Kashida width preset is preserved; automatic elongation uses the engine's bounded allocation".into()); } } match designcraft_doc::mojikumi::Rules::resolve(&d.styles, ¶.mojikumi) { Err(reason) => { unsupported_typography.insert(format!("Mojikumi `{}` is not applied: {reason}", para.mojikumi)); } Ok(Some(rules)) if rules.assumed_base() => { unsupported_typography .insert(format!("Mojikumi `{}` names no base set; its rules apply on the default half-em set", para.mojikumi)); } Ok(_) => {} } if !matches!( para.kinsoku_type.as_str(), "" | "KinsokuPushInFirst" | "KinsokuPushOutFirst" | "KinsokuPushOutOnly" | "KinsokuPrioritizeAdjustmentAmount" ) { unsupported_typography .insert(format!("Kinsoku priority `{}` is preserved, but push-in/push-out priority is not applied", para.kinsoku_type)); } for (run, f) in story.runs().filter(|(run, _)| run.start < range.end && run.end > range.start) { let props = d.styles.resolve_char(&base, f); { use designcraft_doc::cjk_settings::AdornmentOverprint as O; let kenten = props.kenten && (props.kenten_overprint_fill == O::On || props.kenten_overprint_stroke == O::On); let ruby = !props.ruby.is_empty() && (props.ruby_overprint_fill == O::On || props.ruby_overprint_stroke == O::On); if kenten || ruby { unsupported_typography .insert("Ruby and kenten overprint show in Overprint Preview, but PDF export doesn't write overprint".to_string()); } } if has_rtl && story.text.get(run.start.max(range.start)..run.end.min(range.end)).is_some_and(|t| t.chars().any(designcraft_fonts::is_rtl)) { use designcraft_doc::arabic::DiacriticPosition as P; if !matches!(props.diacritic_position, P::Default | P::OpenType) { unsupported_typography.insert(format!( "Diacritic preset `{:?}` is preserved; font OpenType anchors and explicit offsets are used", props.diacritic_position )); } if !matches!(props.positional_form.as_str(), "" | "None" | "Calculate" | "Initial" | "Medial" | "Final" | "Isolated") { unsupported_typography.insert(format!("Unknown positional form `{}` is preserved but not applied", props.positional_form)); } } } } }); // Use the same effective character-to-font mapping as Find Font, including // anonymous note/cell stories. Unused composite entries are not missing text. for font in super::fonts::used_fonts(d) { if font.style_missing { out.push(Issue { severity: "error", kind: "missingFontStyle", message: format!("Missing font style: {} {} (using {} {})", font.family, font.style, font.resolved_family, font.resolved_style), item: None, page: None, }); } if font.missing && !missing_fonts.contains(&font.family) { missing_fonts.push(font.family); } } for f in missing_fonts { out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None }); } for message in unsupported_typography { out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None }); } for (face, chars, item) in missing { let listed: Vec = chars.iter().take(MISSING_GLYPHS_LISTED).map(char::to_string).collect(); let more = chars.len().saturating_sub(MISSING_GLYPHS_LISTED); let more = if more > 0 { format!(" and {more} more") } else { String::new() }; out.push(Issue { severity: "error", kind: "missingGlyphs", message: format!("Missing glyphs in {} {}: {}{more}", face.family, face.style, listed.join(" ")), item: Some(item.0), page: d.find(item).zip(d.item(item)).and_then(|(loc, it)| page_of(d, loc.spread, it, d.parent_xf(&loc))), }); } // Carry the ancestor transform in the same order as rendering, retaining pre-order. for (si, sp) in d.spreads.iter().enumerate() { let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect(); while let Some((it, parent)) = pending.pop() { let xf = parent * it.xf; pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); let page = page_of(d, SpreadRef::Doc(si), it, parent); 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() || super::links::link_missing(a, document) => out.push(Issue { severity: "error", kind: "missingLink", message: match a.link.as_deref().and_then(|l| super::links::refused(l, document)) { Some(why) => format!("Missing link: {} ({why})", a.link.as_deref().unwrap_or(&a.name)), None => format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), }, item: Some(it.id.0), page, }), Some(a) => { // Placed PDF and SVG pages export as vectors; `pixels` is only the preview's. let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); if let Some((pw, ph)) = a.pixels.filter(|_| !vector) { // Measure each image axis in points, not its rotated bounding box or // cropped frame. Either axis can fall below the minimum resolution. let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs(); let placed_w = xx.hypot(yx) * g.size.0; let placed_h = xy.hypot(yy) * g.size.1; let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6); let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6); let ppi = ppi_x.min(ppi_y); 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 { 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})) } #[cfg(test)] mod placed_vector_tests { use serde_json::json; use crate::Session; use crate::cmd::base64_encode; /// A placed PDF logo was reported as /// "logo.pdf: effective 72 ppi (< 150)", though it exports as vectors. #[test] fn vector_graphics_have_no_resolution() { let mut logo = Session::new(); logo.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); let pdf = logo.execute("file.exportPdf", &json!({})).unwrap(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"base64": pdf["base64"], "name": "logo.pdf", "x": 72, "y": 72, "width": 300})).unwrap(); // Deselect the logo, or the photo would replace it in its frame. s.execute("selection.set", &json!({"ids": []})).unwrap(); let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.png", "x": 72, "y": 300, "width": 300})).unwrap(); let r = s.execute("preflight.run", &json!({})).unwrap(); let low: Vec<&str> = r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").map(|i| i["message"].as_str().unwrap()).collect(); assert_eq!(low, ["photo.png: effective 10 ppi (< 150)"]); } /// An .indd opened without its links shows each graphic's preview: the asset has data, but its /// file is still missing. #[test] fn a_missing_link_shown_by_its_preview_is_still_reported() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap(); let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let asset = doc.assets.values_mut().next().unwrap(); // The temporary folder is an absolute path with the default settings on Windows, macOS and Linux. On Windows, // `/x` names no drive, so `document_path` refuses it when the document has no path. let link = std::env::temp_dir().join(format!("dc-preflight-missing-{} dir", std::process::id())).join("photo.jpg"); let link = link.to_string_lossy().into_owned(); std::sync::Arc::make_mut(asset).link = Some(link.clone()); let r = s.execute("preflight.run", &json!({})).unwrap(); let missing: Vec<&str> = r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect(); assert_eq!(missing, [format!("Missing link: {link}")]); } } #[cfg(test)] mod arabic_tests { use super::*; #[test] fn arabic_unsupported_presets_are_reported_without_flagging_supported_offsets() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let text = "بُسْمِ"; let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": text})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); s.execute("type.char", &json!({"attrs": {"diacriticPosition": "openType", "diacriticXOffset": 150}})).unwrap(); assert!(!check(&s, 150.0).iter().any(|i| i.kind == "unsupportedTypography")); s.execute("type.para", &json!({"attrs": {"arabicJustification": "NaskhJustification", "paragraphKashidaWidth": 2}})).unwrap(); s.execute("type.char", &json!({"attrs": {"diacriticPosition": "tight"}})).unwrap(); let issues = check(&s, 150.0); let typography: Vec<_> = issues.iter().filter(|i| i.kind == "unsupportedTypography").collect(); assert_eq!(typography.len(), 3, "{typography:?}"); assert!(typography.iter().all(|i| i.severity == "warning")); } } #[cfg(test)] mod missing_glyph_tests { use serde_json::json; use crate::Session; fn missing_glyphs(s: &mut Session) -> Vec<(String, String)> { let r = s.execute("preflight.run", &json!({})).unwrap(); r["issues"] .as_array() .unwrap() .iter() .filter(|i| i["kind"] == "missingGlyphs") .map(|i| (i["severity"].as_str().unwrap().to_string(), i["message"].as_str().unwrap().to_string())) .collect() } #[test] fn characters_drawn_as_missing_glyphs_are_listed() { // Some font has them, for when fallback fonts are on. designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("DC Test Preflight Helper", &['日', '本', '語']).unwrap()); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello 日本語 日本 and 語."})).unwrap(); let family = designcraft_fonts::DEFAULT_FAMILY; assert_eq!(missing_glyphs(&mut s), [("error".to_string(), format!("Missing glyphs in {family} Regular: 日 本 語"))]); // Drawn from fallback fonts: nothing missing. s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); assert!(missing_glyphs(&mut s).is_empty()); } #[test] fn a_long_list_of_missing_glyphs_is_cut_short() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); // 30 private-use characters no font has. let text: String = (0..30).filter_map(|i| char::from_u32(0xF0B00 + i)).collect(); s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); let found = missing_glyphs(&mut s); assert_eq!(found.len(), 1); assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1); } } #[cfg(test)] mod generated_glyph_tests { use super::*; use std::sync::Arc; #[test] fn generated_suffix_missing_glyph_is_reported_at_its_page_frame() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let created = s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); let sid = *d.stories.keys().next().unwrap(); d.footnote_options.affix_in = designcraft_doc::notes::AffixIn::Reference; d.footnote_options.suffix = "\u{10fffd}".into(); d.story_mut(sid).unwrap().insert_note(4, "Note", designcraft_doc::ParaFormat::default()); let report = s.execute("preflight.run", &json!({})).unwrap(); let issue = report["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "missingGlyphs").unwrap(); assert!(issue["message"].as_str().unwrap().contains('\u{10fffd}')); assert_eq!(issue["item"], created["id"]); assert_eq!(issue["page"], 0); } } #[cfg(test)] mod mojikumi_nested_tests { use super::*; use std::sync::Arc; #[test] fn unknown_table_in_a_note_inside_a_cell_is_reported() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); let sid = *d.stories.keys().next().unwrap(); let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 100.0); let cell = &mut table.cells[0].text; let mut pf = designcraft_doc::ParaFormat::default(); pf.para.mojikumi = Some("MojikumiTable/Unrecognized".into()); cell.insert_note(0, "Note", pf); d.story_mut(sid).unwrap().insert_table(0, table); let issues = check(&s, 150.0); assert_eq!(issues.iter().filter(|i| i.kind == "unsupportedTypography" && i.message.contains("Unrecognized")).count(), 1); } } #[cfg(test)] mod overset_page_tests { use serde_json::json; use crate::Session; /// An overset frame inside a group moved onto the right-hand page was reported on the left one. #[test] fn overset_frame_in_a_moved_group_names_its_displayed_page() { let mut s = Session::new(); s.execute("file.new", &json!({"facingPages": true, "pages": 3, "width": 612, "height": 792})).unwrap(); let frame = json!({"spread": 1, "rect": [72, 72, 140, 100], "content": "text", "text": "overflowing words ".repeat(100)}); let a = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap(); let b = s.execute("frame.create", &json!({"spread": 1, "rect": [300, 300, 350, 350]})).unwrap()["id"].as_u64().unwrap(); let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); let overset_page = |s: &mut Session| { let r = s.execute("preflight.run", &json!({})).unwrap(); let issue = r["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").unwrap().clone(); issue["page"].as_u64().unwrap() }; assert_eq!(overset_page(&mut s), 1); s.execute("transform.move", &json!({"dx": 612, "dy": 0, "ids": [g]})).unwrap(); assert_eq!(overset_page(&mut s), 2); } }