Files
designcraft/crates/engine/src/cmd/preflight.rs
T
dotCooCoo f5e1bda7cc Preflight: the missing-link test uses a path that is absolute on Windows too
a_missing_link_shown_by_its_preview_is_still_reported stored the link
/definitely/missing dir/photo.jpg in a document without a path. On
Windows that path names no drive, so document_path refuses it as not
absolute and the preflight message ends with "(the path is not
valid)", and the test failed. The test now stores a link under
std::env::temp_dir(), an absolute path with the default
temporary-folder settings on Windows, macOS and Linux, and expects the
message for that path. The preflight code is unchanged.
2026-10-24 01:21:55 -07:00

505 lines
25 KiB
Rust

//! 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<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, parent: Affine) -> Option<usize> {
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<designcraft_doc::ItemId>,
depth: usize,
out: &mut Vec<(FaceRef, Vec<char>, 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<usize>)> {
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<Issue> {
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<String> = Vec::new();
let mut unsupported_typography = std::collections::BTreeSet::new();
let mut missing: Vec<(FaceRef, Vec<char>, 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, &para.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<String> = 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<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}))
}
#[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);
}
}