diff --git a/README.md b/README.md index 37e67cfa4..33b59cdb4 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ ## The sample magazine Every page below was laid out by DesignCraft from code (`crates/engine/src/sample.rs`) and exported -by its own renderer. Try it yourself with `File → New → Sample Document`, or start the app with +by its own renderer. Try it yourself with `Help → Sample Document`, or start the app with `--sample`. @@ -248,11 +248,11 @@ egui frontend is a separate crate, so the UI can be swapped without touching the |---|---| | L0 | `geom` (paths, units & measurement parsing, corner options) · `color` (CMYK/RGB/Lab, swatches, tints, gradients) | | L1 | `doc` (spreads, pages, parents, layers, frames, stories, styles) · `fonts` (font DB, shaping, outlines) | -| L2 | `compose` (the text engine) | -| L3 | `render` (vello_cpu) | +| L2 | `compose` (the text engine) · `images` | +| L3 | `render` (vello_cpu) · `pdf` · `idml` · `epub` · `pptx` · `textimport` · `format` | | L4 | `tools` (pointer events → commands + overlays) | | L5 | `engine` (session, history, command registry) | -| L6 | `ui-egui` (publishing UI, control channel) | +| L6 | `ui-egui` (publishing UI, control channel) · `mcp` | | L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` | - **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); feature assessment: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md) diff --git a/ROADMAP.md b/ROADMAP.md index 190a169d3..c8dcb0b87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-30** · Document Fonts lookup treats a bare document filename's empty parent as the current directory (#2014), preserving case-insensitive discovery and automation read-root checks. No readiness percentage change. - **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in the next column or frame when needed (#1901). Cell-local footnotes contribute to row height and sit inside the cell rather than outside its measuring frame. Vertical justification keeps @@ -486,6 +487,7 @@ Newest first. - **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. - **2026-10-20** · Smart guides: an edge being drawn or resized snaps to the page's centre line, as in InDesign, so a frame drawn from the top margin to the middle of the page stops there instead of at the nearest baseline grid line. No readiness percentage change. +- **2026-10-21** · An empty graphic frame's X joins its corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign draws it; it used to stay across the old rectangle. Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. - **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change. - **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-21** · New Character Style with text selected starts from what the text has of its own over its paragraph style, as InDesign's ("[None] + Bold + color"), and Apply Style to Selection gives the text the new style, with no override left. A new style's dialog is titled New Paragraph Style / New Character Style. `type.selectionAttrs` reports the text's own character attributes (`ownChars`). Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 8f704d806..e3d451a71 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -591,7 +591,7 @@ fn script(args: &[String]) -> Result<(), String> { } for (kind, out) in &export { let p = json!({"path": out}); - match kind { + let r = match kind { // PNG / JPEG of the first page. Export::Image => b.call("app.export", p)?, Export::Pdf => exec(b, "file.exportPdf", p)?, @@ -600,6 +600,11 @@ fn script(args: &[String]) -> Result<(), String> { Export::Pptx => exec(b, "file.exportPptx", p)?, Export::Command(cmd) => exec(b, cmd, p)?, }; + // What the export itself reports (effects rasterised, transparency flattened…) goes + // to stderr, as `run --export` prints it: the report on stdout covers only the steps. + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } } } outln!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()); diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index f19551a71..00fc2564a 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -95,6 +95,35 @@ fn export_to_an_unknown_file_type_is_an_error() { let _ = std::fs::remove_dir_all(&dir); } +/// `run --export` prints what the PDF export reports, and `script --export` dropped it: the same +/// PDF with a drop shadow came out of a script with nothing on stderr. +#[test] +fn script_export_prints_the_export_warnings_as_run_does() { + let dir = std::env::temp_dir().join(format!("dc-cli-warnings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let steps = [ + r#"frame.create={"rect": [72, 72, 300, 200]}"#, + r#"object.fill={"ids": ["$0.id"], "swatch": "[Black]"}"#, + r#"object.dropShadow={"ids": ["$0.id"], "on": true, "distance": 8, "size": 6}"#, + ]; + let script = dir.join("shadow.dcs"); + std::fs::write(&script, steps.join("\n")).unwrap(); + let pdf = dir.join("shadow.pdf"); + let pdf = pdf.to_str().unwrap(); + let mut run = vec!["run"]; + for step in steps { + run.extend(["--cmd", step]); + } + run.extend(["--export", pdf]); + for args in [run, vec!["script", script.to_str().unwrap(), "--export", pdf]] { + let out = cli().args(&args).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{}: {err}", args[0]); + assert!(err.contains("warning: 1 object(s) with soft effects"), "{}: {err}", args[0]); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// With `--connect` the exports happen in the running app. `--export copy.designcraft` saves a /// copy there: Save As would make the copy the file the open document saves to from then on. #[test] diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 4cbf73605..1f5fd7456 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -198,8 +198,16 @@ pub fn parse_iso_date(s: &str) -> Option { None | Some('Z') => Some(secs), Some(sign @ ('+' | '-')) => { let z = zone.get(1..)?; - let zh: i64 = z.get(0..2)?.parse().ok()?; - let zm: i64 = z.get(2..).map(|r| r.trim_start_matches(':')).filter(|r| !r.is_empty()).map_or(Some(0), |r| r.parse().ok())?; + let digits = |t: &str| -> Option { t.bytes().all(|c| c.is_ascii_digit()).then(|| t.parse().ok()).flatten() }; + let zh = digits(z.get(0..2)?)?; + let zm = match z.get(2..)? { + "" => 0, + r if r.len() == 2 => digits(r)?, + r => digits(r.strip_prefix(':').filter(|m| m.len() == 2)?)?, + }; + if zh > 23 || zm > 59 { + return None; + } let off = zh * 3600 + zm * 60; Some(if sign == '+' { secs - off } else { secs + off }) } @@ -345,6 +353,22 @@ mod tests { } } + #[test] + fn iso_date_rejects_malformed_zone() { + for bad in [ + "2019-12-16T09:48:02+-5:00", + "2019-12-16T09:48:02+05:+30", + "2019-12-16T09:48:02+24:00", + "2019-12-16T09:48:02+05:60", + "2019-12-16T09:48:02+05::30", + "2019-12-16T09:48:02+05:", + "2019-12-16T09:48:02+053", + "2019-12-16T09:48:02+05:3", + ] { + assert_eq!(parse_iso_date(bad), None, "{bad}"); + } + } + #[test] fn chars() { assert_eq!(var_index(var_char(3).unwrap()), Some(3)); diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 6d1aee7ce..846347e0e 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -128,6 +128,8 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option std::path::PathBuf { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + // A bare file name has an empty parent; directory traversal needs an explicit `.`. + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; let exact = dir.join(DOCUMENT_FONTS_FOLDER); if crate::io::is_dir(&exact) { return exact; @@ -287,6 +289,75 @@ mod document_fonts_tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn document_fonts_are_found_for_bare_and_explicit_paths() { + // Only the child changes its working directory; parallel tests keep theirs. + let dir = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "cmd::file::open::document_fonts_tests::document_font_paths_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_DOCUMENT_FONT_PATHS", "1") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "child failed: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("test result: ok. 1 passed; 0 failed;"), "the subprocess helper must run exactly once: {stdout}"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + #[ignore = "subprocess helper, executed by document_fonts_are_found_for_bare_and_explicit_paths"] + fn document_font_paths_child() { + const FAMILY: &str = "DocFont Bare Path Test"; + assert!(std::env::var_os("DESIGNCRAFT_TEST_DOCUMENT_FONT_PATHS").is_some(), "run through the parent test"); + let cwd = std::env::current_dir().unwrap(); + std::fs::create_dir("nested").unwrap(); + std::fs::create_dir("other").unwrap(); + for extension in ["idml", "designcraft"] { + let path = PathBuf::from(format!("t.{extension}")); + write_document(&cwd.join(&path), FAMILY); + write_document(&cwd.join("other").join(&path), FAMILY); + assert_eq!(open(&mut Session::new(), &path)["documentFonts"], 0, "no font folder: {path:?}"); + } + + let roots = crate::file_access::AutomationRoots::new(&[cwd.as_path()], &[]).unwrap(); + for folder in ["Document fonts", DOCUMENT_FONTS_FOLDER] { + std::fs::create_dir_all(folder).unwrap(); + std::fs::write(Path::new(folder).join("case.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + if folder == DOCUMENT_FONTS_FOLDER { + // Both spellings exist on case-sensitive file systems; the exact name still wins. + assert_eq!(super::document_fonts_folder(Path::new("")), Path::new(".").join(DOCUMENT_FONTS_FOLDER)); + } + for extension in ["idml", "designcraft"] { + let name = format!("t.{extension}"); + for path in [PathBuf::from(&name), Path::new(".").join(&name), Path::new("nested/..").join(&name), cwd.join(&name)] { + let mut s = Session::new(); + let r = crate::file_access::confine(roots.as_ref(), || open(&mut s, &path)); + assert_eq!(r["documentFonts"], 1, "{folder}, {path:?}: {r}"); + let f = font_entry(&mut s, FAMILY); + assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{path:?}: {f}"); + } + } + } + + // Opening a second document without the folder must not inherit the first one's font. + let mut s = Session::new(); + assert_eq!(open(&mut s, Path::new("t.idml"))["documentFonts"], 1); + assert_eq!(open(&mut s, Path::new("other/t.designcraft"))["documentFonts"], 0); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + + // Normalising the directory must not grant access outside the automation read root. + let restricted = crate::file_access::AutomationRoots::new(&[Path::new("other")], &[]).unwrap(); + let mut doc = s.doc().unwrap().doc.as_ref().clone(); + let (scope, faces, _) = crate::file_access::confine(restricted.as_ref(), || super::load_document_fonts(&mut doc, "t.idml")); + assert!(scope.is_none()); + assert_eq!(faces, 0); + assert_eq!(doc.font_scope, 0); + } + #[test] fn a_document_without_the_folder_opens_as_before() { let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 77cebe369..8d78e4b4a 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -94,21 +94,30 @@ fn list(d: &Document) -> Vec { used_fonts(d).into_iter().map(|font| json!(font)).collect() } -type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; +pub(super) type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; /// Count the characters of every run of one story against the font each resolves to. fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { - let ranges = st.para_ranges(); - for (pi, r) in ranges.iter().enumerate() { - let (_, base) = d.styles.resolve_para(&st.paras[pi]); + tally_range(d, st, 0..st.text.len(), used); +} + +/// Count the characters in the byte range `within` of a story against the font each resolves to; +/// a composite font counts each character under the font of its entry. An empty paragraph counts +/// the font of its mark, which still sets its line. +pub(super) fn tally_range(d: &Document, st: &Story, within: std::ops::Range, used: &mut FontTally) { + let to_end = within.end >= st.text.len(); + for (pi, r) in st.para_ranges().iter().enumerate() { + let Some(para) = st.paras.get(pi) else { continue }; + let (_, base) = d.styles.resolve_para(para); + let mark = r.is_empty() && within.start <= r.start && (r.start < within.end || to_end); for (rr, fmt) in st.runs() { - let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count(); - if n == 0 && !(r.is_empty() && rr.contains(&r.start)) { + let start = rr.start.max(r.start).max(within.start); + let text = st.text.get(start..rr.end.min(r.end).min(within.end).max(start)).unwrap_or(""); + if text.is_empty() && !(mark && rr.contains(&r.start)) { continue; } let p = d.styles.resolve_char(&base, fmt); if let Some(f) = d.styles.composite_font(&p.font_family) { - let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); for c in text.chars() { if let Some(e) = f.entry(c) { let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); @@ -118,17 +127,23 @@ fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { } } else { let entry = used.entry((p.font_family, p.font_style)).or_default(); - entry.0 += n; - entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars()); + entry.0 += text.chars().count(); + entry.1.extend(text.chars()); } } } } pub(super) fn used_fonts(d: &Document) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut used = FontTally::new(); for_each_story(d, &mut |st| tally_story(d, st, &mut used)); + describe_fonts(d, used) +} + +/// The tallied fonts as this document's fonts resolve them: missing, a substitute style, or the +/// face itself (missing ones first). +pub(super) fn describe_fonts(d: &Document, used: FontTally) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); let mut out: Vec = used .into_iter() .map(|((family, style), (n, characters))| { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f94826fef..eecdab5e3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -211,7 +211,7 @@ pub fn specs() -> Vec { "Character Formatting", [], None, - "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or {warnings} naming a fontFamily or fontStyle that isn't available (still applied; a substitute draws the text)", + "{attrs: {fontFamily?, fontStyle?, size?, leading?: {kind:auto}|{kind:points,value}, tracking?, kerning?, hScale?, vScale?, baselineShift?, fill?, capitalization?, underline?, …}} → null, or, after a fontFamily or fontStyle change, {warnings} naming each font the text now resolves to that isn't available (still applied; a substitute draws the text)", has_text_or_frames, |s, p| format_chars(s, p.get("attrs").unwrap_or(p)) ), diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs index edec044b9..d915da91d 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -162,9 +162,8 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } } validate_char_fill(&s.doc()?.doc, &a, "type.char")?; - let warnings = missing_font_warnings(&s.doc()?.doc, &a); - let result = if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let sets_font = a.font_family.is_some() || a.font_style.is_some() || cleared.iter().any(|k| k == "fontFamily" || k == "fontStyle"); let targets = format_targets(s); // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it // also formats the mark right away, since the mark alone sets that line's height; typing there @@ -187,31 +186,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { })?; } s.typing_format = Some(f); - return Ok(result); + return if sets_font { font_warnings(s, &targets) } else { ok() }; } s.edit(|d, _| { apply_char_attrs(d, &targets, &a, &cleared); - Ok(result) - }) + ok() + })?; + if sets_font { font_warnings(s, &targets) } else { ok() } } /// A font family or style the app doesn't have is still applied, so the document keeps its name -/// and preflight and Find Font list it, but a substitute draws the text. Say so, in preflight's -/// words, instead of succeeding silently. -fn missing_font_warnings(document: &designcraft_doc::Document, attrs: &CharAttrs) -> Vec { - let Some(family) = attrs.font_family.as_deref().filter(|f| !f.trim().is_empty()) else { - return vec![]; - }; - let db = designcraft_fonts::FontDb::global().scoped(document.font_scope); - let style = attrs.font_style.as_deref().unwrap_or("Regular"); - let face = db.face(family, style); - if !db.has_family(family) { - vec![format!("Missing font: {family} (using {})", face.family)] - } else if attrs.font_style.is_some() && !db.has_style(family, style) { - vec![format!("Missing font style: {family} {style} (using {} {})", face.family, face.style)] - } else { - vec![] +/// and preflight and Find Font list it, but a substitute draws the text. After a change to the +/// family or style, say which of the fonts the formatted text now resolves to are missing, in +/// preflight's words: the text's own family with a new style, a new family with the text's style, +/// and a composite font's entries for the characters it sets, as `font.list` resolves them. At a +/// caret, the font the next typing gets (a composite font picks it per character typed). +fn font_warnings(s: &Session, targets: &[Target]) -> Result { + let d = &s.doc()?.doc; + let mut used = crate::cmd::fonts::FontTally::new(); + for t in targets { + let Some(story) = d.text_story(t.story, t.cell) else { continue }; + if !t.range.is_empty() { + crate::cmd::fonts::tally_range(d, story, t.range.clone(), &mut used); + continue; + } + let Some(para) = story.paras.get(story.para_at(t.range.start)) else { continue }; + let (_, base) = d.styles.resolve_para(para); + let next = d.styles.resolve_char(&base, &effective_char_format(s, t)?); + if d.styles.composite_font(&next.font_family).is_none() { + used.entry((next.font_family, next.font_style)).or_default(); + } } + let warnings: Vec = crate::cmd::fonts::describe_fonts(d, used) + .into_iter() + .filter_map(|f| { + if f.missing { + Some(format!("Missing font: {} (using {})", f.family, f.resolved_family)) + } else if f.style_missing { + Some(format!("Missing font style: {} {} (using {} {})", f.family, f.style, f.resolved_family, f.resolved_style)) + } else { + None + } + }) + .collect(); + Ok(if warnings.is_empty() { Value::Null } else { json!({ "warnings": warnings }) }) } /// Kerning spaces a character from the next one, so the pair at a caret is kerned through the @@ -1097,34 +1115,96 @@ mod size_bounds_tests { #[cfg(test)] mod missing_font_tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; /// A font the app doesn't have was applied with no word about it, so a script that asked for /// Courier Prime exported a PDF in the substitute and exited 0. It is still applied (preflight - /// and Find Font list it), and the command now says which font is missing. + /// and Find Font list it), and the command says which font the text now resolves to is + /// missing: the pair of family and style it ends up with, whichever of the two the call set, + /// and a composite font's entries rather than its name. #[test] - fn a_font_that_is_not_available_is_applied_with_a_warning() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - for (attrs, warning) in [ - (json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), - (json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), - (json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 ")), - ] { + fn a_font_change_warns_about_the_font_the_text_resolves_to() { + let composite = |name: &str, family: &str| ("style.compositeFont.set", json!({"name": name, "entries": [{"family": family}]})); + let typed = |attrs: Value| ("type.char", json!({"attrs": attrs})); + let rows: [(&str, Vec<(&str, Value)>, bool, Value, Option<&str>); 11] = [ + ("family and style, both installed", vec![], false, json!({"fontFamily": "Source Serif 4", "fontStyle": "Bold"}), None), + ("family only, missing", vec![], false, json!({"fontFamily": "Nonexistent Sans"}), Some("Missing font: Nonexistent Sans (using ")), + ( + "family and a style it lacks", + vec![], + false, + json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using Source Serif 4 "), + ), + ( + "style only, one the text's family has", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + false, + json!({"fontStyle": "Bold"}), + None, + ), + ( + "style only, one the text's family lacks", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + false, + json!({"fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using "), + ), + ( + "family only, keeping a style the new family lacks", + vec![typed(json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"}))], + false, + json!({"fontFamily": "Source Sans 3"}), + Some("Missing font style: Source Sans 3 Nope (using "), + ), + ("a composite font of installed fonts", vec![composite("Mixed", "Source Serif 4")], false, json!({"fontFamily": "Mixed"}), None), + ( + "a composite font with a missing entry", + vec![composite("Gone", "Nonexistent Sans")], + false, + json!({"fontFamily": "Gone"}), + Some("Missing font: Nonexistent Sans (using "), + ), + ( + "family only at a caret, for the next typing", + vec![], + true, + json!({"fontFamily": "Nonexistent Sans"}), + Some("Missing font: Nonexistent Sans (using "), + ), + ( + "style only at a caret", + vec![typed(json!({"fontFamily": "Source Serif 4"}))], + true, + json!({"fontStyle": "Nope"}), + Some("Missing font style: Source Serif 4 Nope (using "), + ), + ("a change that leaves the font alone", vec![typed(json!({"fontFamily": "Nonexistent Sans"}))], false, json!({"size": 14}), None), + ]; + for (name, before, caret, attrs, warning) in rows { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + for (command, params) in before { + s.execute(command, ¶ms).unwrap(); + } + if caret { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); + } let r = s.execute("type.char", &json!({"attrs": attrs})).unwrap(); match warning { - None => assert_eq!(r, json!(null), "{attrs}"), - Some(w) => assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with(w)), "{attrs}: {r}"), + None => assert_eq!(r, json!(null), "{name}"), + Some(w) => { + assert!(r["warnings"].as_array().is_some_and(|ws| ws.iter().any(|m| m.as_str().is_some_and(|m| m.starts_with(w)))), "{name}: {r}") + } + } + // The font is applied whatever the warning says. + if let Some(family) = attrs.get("fontFamily").filter(|_| !caret) { + assert_eq!(&s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], family, "{name}"); } - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontFamily"], attrs["fontFamily"], "{attrs}"); } - // At a caret the font is for the next typing, and the warning is the same. - s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); - let r = s.execute("type.char", &json!({"attrs": {"fontFamily": "Nonexistent Sans"}})).unwrap(); - assert!(r["warnings"][0].as_str().is_some_and(|m| m.starts_with("Missing font: Nonexistent Sans")), "{r}"); } } diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index bcc4f8fe9..b7e630a54 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -53,7 +53,9 @@ pub fn parse(text: &str) -> Result, String> { let (id, rest) = l.split_at(split); let rest = rest.trim_start_matches(['=', ' ', '\t']).trim(); let params = if rest.is_empty() { Ok(json!({})) } else { serde_json::from_str(rest).map_err(|e| e.to_string()) }; - params.map(|params| Step { command: id.to_string(), params }) + params.map(|p: Value| if p.is_null() { json!({}) } else { p }).and_then(|params: Value| { + if params.is_object() { Ok(Step { command: id.to_string(), params }) } else { Err("`params` must be an object".to_string()) } + }) }; out.push(step.map_err(|e| format!("line {}: {e}", n + 1))?); } @@ -203,6 +205,15 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + #[test] + fn compact_lines_require_object_params() { + for bad in ["a.b 5", "a.b [1]", "a.b \"x\"", "a.b=true"] { + assert_eq!(parse(bad), Err("line 1: `params` must be an object".to_string()), "{bad}"); + } + assert_eq!(parse("\na.b={\"x\":1}").unwrap()[0].params, json!({"x": 1})); + assert_eq!(parse("a.b null").unwrap()[0].params, json!({})); + } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM /// became part of the first command id (#654). #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9eece3fb1..8d552c80c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,7 +30,7 @@ mod view; use column_guides::{COLUMN_DRAG, ColumnDrag, column_guide_at, spread_x_across}; use frames::{draw_frames, draw_selection}; #[cfg(test)] -use frames::{item_canvas_xf, overset_frames, overset_marks, ported_frames, text_frames_in}; +use frames::{frame_x, item_canvas_xf, overset_frames, overset_marks, ported_frames, text_frames_in}; use guides::{GUIDE_DRAG, GUIDE_PRESS, GuideDrag, GuidePress, guide_readout, guide_spot, moving_guides, readout_at}; #[cfg(test)] use guides::{guide_at, guide_drag_delta, guide_measure}; diff --git a/crates/ui-egui/src/canvas/frames.rs b/crates/ui-egui/src/canvas/frames.rs index 718904d7f..e897d689f 100644 --- a/crates/ui-egui/src/canvas/frames.rs +++ b/crates/ui-egui/src/canvas/frames.rs @@ -128,14 +128,38 @@ fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, } // Empty graphic frame: the X. if it.object_style == designcraft_doc::BASIC_GRAPHICS_FRAME && matches!(it.content, Content::Unassigned) { - let r = it.inner_bounds(); let m = a * it.xf; - let p = |x: f64, y: f64| xf.to_screen(m * Point::new(x, y)); - painter.line_segment([p(r.x0, r.y0), p(r.x1, r.y1)], Stroke::new(hair(painter), col.gamma_multiply(0.75))); - painter.line_segment([p(r.x1, r.y0), p(r.x0, r.y1)], Stroke::new(hair(painter), col.gamma_multiply(0.75))); + for (p0, p1) in frame_x(it) { + painter.line_segment([xf.to_screen(m * p0), xf.to_screen(m * p1)], Stroke::new(hair(painter), col.gamma_multiply(0.75))); + } } } +/// The X of an empty graphic frame, in its own space: a four-cornered frame joins its opposite +/// corners, so a frame slanted with Direct Selection keeps its X corner to corner, as InDesign +/// draws it; other shapes span their bounds. +pub(super) fn frame_x(it: &Item) -> [(Point, Point); 2] { + // A rectangle (slanted or not: four corner points, no handles). Ellipses, polygons (a + // four-sided one is a diamond) and curved paths keep the X across their bounds. Edited points + // make any of them a path: a four-cornered one whose corners would cross as a plus (a diamond, + // edited) keeps the bounds too. + let crosses = |a: Point, b: Point| { + let d = b - a; + d.x.abs() > 0.18 * d.hypot() && d.y.abs() > 0.18 * d.hypot() + }; + if matches!(it.shape, designcraft_doc::Shape::Rectangle | designcraft_doc::Shape::Path) + && let [sp] = it.path.subpaths.as_slice() + && sp.closed + && let [c0, c1, c2, c3] = sp.anchors.as_slice() + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p) + && (it.shape == designcraft_doc::Shape::Rectangle || (crosses(c0.p, c2.p) && crosses(c1.p, c3.p))) + { + return [(c0.p, c2.p), (c1.p, c3.p)]; + } + let r = it.inner_bounds(); + [(Point::new(r.x0, r.y0), Point::new(r.x1, r.y1)), (Point::new(r.x1, r.y0), Point::new(r.x0, r.y1))] +} + /// Layer colour of the first selected item: live-corner marks are outlined in it. pub(super) fn selection_color(app: &DesignApp) -> Color32 { let fallback = Color32::from_rgb(79, 153, 255); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 2b1082b7a..9267a6cda 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -137,6 +137,66 @@ fn bleed_and_slug_are_preview_modes_that_show_their_area() { } } +/// An empty graphic frame's X joins its corners: a rectangle's, and a frame slanted by +/// moving its top points, as in the Zero2Graphic tutorial's row of parallelograms. +#[test] +fn an_empty_frames_x_follows_its_corners() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap(); + let x = |app: &DesignApp| { + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + let m = it.xf; + frame_x(&it).map(|(a, b)| { + let (a, b) = (m * a, m * b); + [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] + }) + }; + let mut lines = x(&app).to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(100.0, 100.0), (200.0, 160.0)], [(100.0, 160.0), (200.0, 100.0)]], "corner to corner"); + // Slant it: the two top points move 30 pt right. + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + let top: Vec = + it.path.subpaths[0].anchors.iter().enumerate().filter(|(_, a)| (it.xf * a.p).y < 130.0).map(|(k, _)| json!([0, k])).collect(); + assert_eq!(top.len(), 2); + app.run("path.moveAnchors", json!({"id": id, "anchors": top, "dx": 30, "dy": 0})).unwrap(); + let mut lines = x(&app).to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(100.0, 160.0), (230.0, 100.0)], [(130.0, 100.0), (200.0, 160.0)]], "the slanted corners"); + // An ellipse frame (four smooth anchors) keeps the X across its bounds, not a plus. + let e = app.run("frame.create", json!({"rect": [300, 100, 400, 160], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(e)).unwrap().clone(); + let mut lines: Vec<[(f64, f64); 2]> = frame_x(&it) + .map(|(a, b)| { + let (a, b) = (it.xf * a, it.xf * b); + [(a.x.round(), a.y.round()), (b.x.round(), b.y.round())] + }) + .to_vec(); + lines.iter_mut().for_each(|l| l.sort_by(|a, b| a.partial_cmp(b).unwrap())); + lines.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(lines, vec![[(300.0, 100.0), (400.0, 160.0)], [(300.0, 160.0), (400.0, 100.0)]], "the ellipse's bounds"); + // So does a four-sided polygon frame (a diamond): its corners would cross as a plus. + let mut diamond = it.clone(); + diamond.shape = designcraft_doc::Shape::Polygon; + for a in &mut diamond.path.subpaths[0].anchors { + (a.h_in, a.h_out) = (a.p, a.p); + } + let lines: Vec<_> = frame_x(&diamond).to_vec(); + assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "diagonals, not a plus: {lines:?}"); + // Still after one of its points moves (which makes it a path). + let p = app.run("frame.create", json!({"rect": [300, 200, 400, 260], "shape": "polygon", "sides": 4, "starInset": 0})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("path.moveAnchors", json!({"id": p, "anchors": [[0, 0]], "dx": 5, "dy": 0})).unwrap(); + let it = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(p)).unwrap().clone(); + assert_eq!(it.shape, designcraft_doc::Shape::Path); + let lines: Vec<_> = frame_x(&it).to_vec(); + assert!(lines.iter().all(|(a, b)| (a.x - b.x).abs() > 1e-6 && (a.y - b.y).abs() > 1e-6), "an edited diamond: {lines:?}"); +} + /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and /// Slug the slug box. A slug narrower than the bleed never pulls the edge back in. #[test]