From f845c583702d5db477170fe65dc1091be924d7af Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:40 -0400 Subject: [PATCH 001/117] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 5c1aea75330540a99ffa37c4c97a26461420770d Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:17:30 -0600 Subject: [PATCH 002/117] Place: an explicit x, y, spread or width puts the image in a new frame Explicit image placement leaves selected frames untouched; an explicit frame still wins. Null placement parameters behave like omitted values. Reject nonnumeric, nonfinite and nonpositive image widths before editing. Cover rejection without document changes, valid sizing and null selection behavior with regression tests. --- crates/engine/src/cmd/file.rs | 291 +++++++++++++++++++++++++++++++++- 1 file changed, 288 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f63f0d6e9..f18f1d7b9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -103,7 +103,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}", has_doc, file_place ), @@ -327,6 +327,16 @@ fn file_place(s: &mut Session, p: &Value) -> Result { if designcraft_textimport::is_text_file(&name) { return super::place_text::place_text(s, p, &name, &bytes, link, before); } + let want_w = p + .get("width") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_f64() + .filter(|width| width.is_finite() && *width > 0.0) + .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) + }) + .transpose()?; // Image Import Options: which page of a PDF (1-based). let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { Some(n) if designcraft_render::is_pdf(&bytes) => { @@ -362,14 +372,20 @@ fn file_place(s: &mut Session, p: &Value) -> Result { None => None, }; let target_frame = super::id_param(p, "frame").or_else(|| { + if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + return None; + } let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "file.place")?; + // `"spread": null` counts as omitted, like the other placement parameters. + let spread = match p.get("spread") { + Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), + _ => super::spread_param(p, "file.place")?, + }; let lid = s.doc()?.active_layer; let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); - let want_w = p.get("width").and_then(Value::as_f64); if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { let mime = designcraft_render::image_mime(&bytes).to_string(); let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }; @@ -734,6 +750,275 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { } } +#[cfg(all(test, not(target_arch = "wasm32")))] +mod image_place_tests { + use designcraft_doc::{Content, ItemId}; + use designcraft_geom::Rect; + use serde_json::{Value, json}; + + use crate::Session; + + fn image_file() -> std::io::Result { + image_file_with_color(30) + } + + fn image_file_with_color(red: u8) -> std::io::Result { + let file = tempfile::Builder::new().suffix(".png").tempfile()?; + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); + std::fs::write(file.path(), png)?; + Ok(file) + } + + #[test] + fn consecutive_path_placements_create_distinct_graphics() { + let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let mut placements = Vec::new(); + let mut originals = Vec::new(); + + for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { + let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + assert!( + placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), + "each explicit placement needs a distinct frame: {placed}" + ); + let document = &session.doc().unwrap().doc; + let item = document.item(id).unwrap(); + assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); + assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); + originals.push(serde_json::to_value(item).unwrap()); + placements.push(placed); + } + + let document = &session.doc().unwrap().doc; + assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); + assert_eq!(document.assets.len(), 3); + for (placed, original) in placements.iter().zip(originals) { + assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); + } + let links = session.execute("links.list", &json!({})).unwrap(); + assert_eq!(links.as_array().unwrap().len(), 3); + for (placed, image) in placements.into_iter().zip(&images) { + let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); + assert_eq!(row["path"], json!(image.path())); + assert_eq!(row["uses"].as_array().unwrap().len(), 1); + assert_eq!(row["uses"][0]["id"], placed["id"]); + } + } + + #[test] + fn individual_placement_parameters_bypass_selected_frames() { + let image = image_file().unwrap(); + for graphic in [false, true] { + for (key, value, expected) in [ + ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), + ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), + ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), + ("width", 0, Rect::ZERO), + ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), + ] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + if graphic { + session.execute("file.place", &json!({"path": image.path()})).unwrap(); + } + let id = ItemId(selected["id"].as_u64().unwrap()); + let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); + let mut params = json!({"path": image.path()}); + params[key] = json!(value); + + let result = session.execute("file.place", ¶ms); + if key == "width" && value == 0 { + assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); + continue; + } + let placed = result.unwrap(); + + assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); + let document = &session.doc().unwrap().doc; + assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), expected); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); + assert!(scale_x > 0.0 && scale_y > 0.0); + } + } + } + + #[test] + fn explicit_frame_wins_over_placement_and_selection() { + let image = image_file().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); + + let placed = + session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); + + assert_eq!(placed["id"], target["id"]); + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(item.content, Content::Graphic(_))); + assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); + assert_eq!(document.spreads[0].items.len(), 2); + } + + fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { + let image = image_file()?; + for graphic in [false, true] { + for explicit_frame in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + if graphic { + session.execute("file.place", &json!({"path": image.path()}))?; + } + let mut params = json!({"path": image.path(), "width": width}); + if explicit_frame { + params["frame"] = target["id"].clone(); + session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; + } + let before = session.doc()?; + let document = before.doc.clone(); + let selection = before.selection.clone(); + let revision = before.revision; + let assets = document.assets.len(); + + let result = session.execute("file.place", ¶ms); + + assert!( + matches!(&result, Err(crate::EngineError::BadParams { .. })), + "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" + ); + let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; + let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; + assert_eq!(cmd, "file.place"); + assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); + let after = session.doc()?; + assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); + assert_eq!(after.selection, selection); + assert_eq!(after.revision, revision); + assert_eq!(after.doc.assets.len(), assets); + } + } + Ok(()) + } + + #[test] + fn width_rejected_when_zero() { + assert_width_rejected(json!(0)).unwrap(); + } + + #[test] + fn width_rejected_when_negative() { + assert_width_rejected(json!(-80)).unwrap(); + } + + #[test] + fn width_rejected_when_non_numeric() { + for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { + assert_width_rejected(width).unwrap(); + } + } + + #[test] + fn numeric_overflow_rejected_when_parsing_json() { + let result = serde_json::from_str::(r#"{"width":1e400}"#); + let error = result.unwrap_err(); + assert!(error.is_data() || error.is_syntax(), "{error}"); + eprintln!("overflow parser rejected width=1e400: {error}"); + } + + fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { + let image = image_file()?; + let previous_image = image_file_with_color(31)?; + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; + let mut params = json!({"path": image.path()}); + for key in keys { + params[*key] = Value::Null; + } + + let placed = session.execute("file.place", ¶ms)?; + + assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc()?.doc; + let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); + let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); + let Content::Graphic(content) = &item.content else { + return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); + }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + Ok(()) + } + + #[test] + fn selection_used_when_x_null() { + assert_null_placement_uses_selection(&["x"]).unwrap(); + } + + #[test] + fn selection_used_when_y_null() { + assert_null_placement_uses_selection(&["y"]).unwrap(); + } + + #[test] + fn selection_used_when_spread_null() { + assert_null_placement_uses_selection(&["spread"]).unwrap(); + } + + #[test] + fn selection_used_when_width_null() { + assert_null_placement_uses_selection(&["width"]).unwrap(); + } + + #[test] + fn selection_used_when_all_placement_parameters_null() { + assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); + } + + #[test] + fn implicit_placement_fills_empty_and_replaces_graphic_selection() { + let image = image_file().unwrap(); + let previous_image = image_file_with_color(31).unwrap(); + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); + + let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); + + assert_eq!(placed["id"], selected["id"]); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + } +} + #[cfg(test)] mod pdf_crop_tests { use serde_json::json; From 0613948d3d1da1d968a3333747eb890a62a21dc1 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:53:25 -0600 Subject: [PATCH 003/117] SVG: a font family named in any case finds the installed font DesignCraft matched an SVG font-family case-insensitively when importing the installed faces, but the final fontdb query compares names exactly, so "DeJaVu SaNs" loaded DejaVu Sans and then set the text in the serif fallback (Source Serif 4) without a word. Text frames already accept any case. The SVG font selector now runs usvg default query with each named family spelled the way the font database spells it; family order, generics, weight, stretch and style are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo --- crates/images/src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 28a42522d..ece1697f1 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -73,14 +73,18 @@ fn svg_options() -> &'static usvg::Options<'static> { } /// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too), -/// as it would in a text frame. A name that is no family's but a face's PostScript name (as -/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in -/// that face, whatever font-weight and font-style say: the name already picks the weight and -/// style, and a family's other faces would contradict it. Families listed before it come first. +/// as it would in a text frame, and matches in any case, as it does there. A name that is no +/// family's but a face's PostScript name (as Illustrator's SVG export writes them: +/// `font-family="'MyriadPro-Regular'"`) sets the text in that face, whatever font-weight and +/// font-style say: the name already picks the weight and style, and a family's other faces would +/// contradict it. Families listed before it come first. fn select_font() -> usvg::FontSelectionFn<'static> { - let bundled = usvg::FontResolver::default_font_selector(); + use usvg::fontdb::{Family, Query, Stretch, Style, Weight}; Box::new(move |font, db| { let fonts = designcraft_fonts::FontDb::global(); + let spelled = |db: &usvg::fontdb::Database, name: &str| { + db.faces().flat_map(|f| &f.families).find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(n, _)| n.clone()) + }; for family in font.families() { if let usvg::FontFamily::Named(name) = family && !has_family(db, name) @@ -103,11 +107,51 @@ fn select_font() -> usvg::FontSelectionFn<'static> { return Some(id); } } - // A family (or generic family) that matches: usvg's own selection. + // A family (or generic family) that matches: the query below. _ => break, } } - bundled(font, db) + // usvg's default query, but with each named family spelled as the database spells it: + // fontdb compares names exactly, so "DeJaVu SaNs" would quietly fall to the serif. + let names: Vec> = font + .families() + .iter() + .map(|f| match f { + usvg::FontFamily::Named(name) => Some(spelled(db, name).unwrap_or_else(|| name.clone())), + _ => None, + }) + .collect(); + let mut families: Vec = font + .families() + .iter() + .zip(&names) + .map(|(f, name)| match f { + usvg::FontFamily::Serif => Family::Serif, + usvg::FontFamily::SansSerif => Family::SansSerif, + usvg::FontFamily::Cursive => Family::Cursive, + usvg::FontFamily::Fantasy => Family::Fantasy, + usvg::FontFamily::Monospace => Family::Monospace, + usvg::FontFamily::Named(_) => Family::Name(name.as_deref().unwrap_or_default()), + }) + .collect(); + families.push(Family::Serif); + let stretch = match font.stretch() { + usvg::FontStretch::UltraCondensed => Stretch::UltraCondensed, + usvg::FontStretch::ExtraCondensed => Stretch::ExtraCondensed, + usvg::FontStretch::Condensed => Stretch::Condensed, + usvg::FontStretch::SemiCondensed => Stretch::SemiCondensed, + usvg::FontStretch::Normal => Stretch::Normal, + usvg::FontStretch::SemiExpanded => Stretch::SemiExpanded, + usvg::FontStretch::Expanded => Stretch::Expanded, + usvg::FontStretch::ExtraExpanded => Stretch::ExtraExpanded, + usvg::FontStretch::UltraExpanded => Stretch::UltraExpanded, + }; + let style = match font.style() { + usvg::FontStyle::Normal => Style::Normal, + usvg::FontStyle::Italic => Style::Italic, + usvg::FontStyle::Oblique => Style::Oblique, + }; + db.query(&Query { families: &families, weight: Weight(font.weight()), stretch, style }) }) } @@ -518,6 +562,27 @@ mod tests { assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); } + #[test] + fn svg_family_names_match_in_any_case() { + let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let text = |family: &str, attrs: &str| { + format!( + r#"Ab"# + ) + }; + for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] { + let set = glyph_families(&text(family, "")); + assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}"); + } + // Weight and style still reach the query: there's only a regular face, so it sets the text. + let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#)); + assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}"); + // A family nobody has still falls to the bundled serif. + let set = glyph_families(&text("DC Test SVG Nobody", "")); + assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}"); + } + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); From 4a2f6037710a5722e0a7ecac05f68628d40233a9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 14:14:34 +0000 Subject: [PATCH 004/117] Integration: #404's case test is already on main --- crates/images/src/lib.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 382f8e245..25d042c43 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -607,27 +607,6 @@ mod tests { assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); } - #[test] - fn svg_family_names_match_in_any_case() { - let font = designcraft_fonts::testing::font_with("DC Test SVG Casing", &['A', 'b']).unwrap(); - designcraft_fonts::FontDb::global().add_font(font); - let text = |family: &str, attrs: &str| { - format!( - r#"Ab"# - ) - }; - for family in ["DC Test SVG Casing", "dc test svg casing", "DC TEST svg Casing", "Nope, dc test SVG casing, serif"] { - let set = glyph_families(&text(family, "")); - assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Casing" && *gid != 0), "{family}: {set:?}"); - } - // Weight and style still reach the query: there's only a regular face, so it sets the text. - let set = glyph_families(&text("dc test svg casing", r#" font-weight="700" font-style="italic""#)); - assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "DC Test SVG Casing"), "{set:?}"); - // A family nobody has still falls to the bundled serif. - let set = glyph_families(&text("DC Test SVG Nobody", "")); - assert!(set.len() == 2 && set.iter().all(|(f, _)| f == "Source Serif 4"), "{set:?}"); - } - /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); From 352abea9fce5ef73d01761f32ddd9d80150bac72 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:47:19 +0200 Subject: [PATCH 005/117] Truncate ASE swatch names that overflow the u16 length field Found while testing crates/color/src/ase.rs:131-132 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/ase.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index 6cfd72034..f44e3dba8 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec { continue; } let mut b = Vec::new(); - let units: Vec = s.name.encode_utf16().chain(std::iter::once(0)).collect(); - b.extend((units.len() as u16).to_be_bytes()); + // The length field is a u16 that counts the terminating NUL: truncate longer names. + let mut units: Vec = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect(); + // Do not leave half of a surrogate pair at the cut. + if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) { + units.pop(); + } + units.push(0); + b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes()); for u in units { b.extend(u.to_be_bytes()); } @@ -233,6 +239,21 @@ mod tests { assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit"); } + #[test] + fn over_long_name_is_truncated_to_fit() { + let sw = vec![Swatch { + name: "x".repeat(70_000), + value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process }, + locked: false, + named: true, + hidden: false, + }]; + let back = read(&write(&sw)).unwrap(); + assert_eq!(back.len(), 1, "the swatch must survive the round trip"); + assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1)); + assert_eq!(back[0].value, sw[0].value); + } + #[test] fn out_of_range_components_are_clamped() { let mut f = Vec::new(); From a5ff17356d7c3ffe91f74a970b5aa44e3955a3dc Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:56:51 +0000 Subject: [PATCH 006/117] Drop out-of-range font sizes on DOCX import Found while testing crates/textimport/src/docx.rs:183-185 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/docx.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 783b010c6..d5b1c444a 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -180,8 +180,9 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { if let Some(s) = r.on("strike") { a.strikethrough = Some(s); } - if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { - a.size = Some(sz / 2.0); + // Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped. + if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) { + a.size = Some(pt); } if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { a.font_family = Some(f.to_string()); @@ -702,6 +703,19 @@ mod tests { } } + #[test] + fn out_of_range_sizes_are_dropped() { + let document = format!( + r#" + ab + "# + ); + let st = import(&docx(&document, "", "")).unwrap().story; + for at in [0, 1] { + assert_eq!(st.format_after(at).over.size, None, "run {at}"); + } + } + #[test] fn tracked_changes_import_as_accepted_including_moves() { // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import. From 177ac8674195323f9b248e88aa3c10293b384c6d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:41:15 +0000 Subject: [PATCH 007/117] Keep CDATA text in the DOCX/XLSX XML parser Found while testing crates/textimport/src/docx.rs:134-135 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/docx.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 783b010c6..4d90a6b7a 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -115,6 +115,12 @@ pub(crate) fn parse(xml: &str) -> Result { p.kids.push(Node::Text(s)); } } + Event::CData(t) => { + let s = t.decode().map(|s| s.to_string()).unwrap_or_default(); + if let Some(p) = stack.last_mut() { + p.kids.push(Node::Text(s)); + } + } Event::GeneralRef(e) => { let name = String::from_utf8_lossy(&e).to_string(); let s = match name.as_str() { @@ -578,6 +584,12 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn cdata_text() { + let el = parse("").unwrap(); + assert_eq!(el.child("t").unwrap().text(), "x < y"); + } + /// Issue #712: nesting past the budget is an error from the parser, not a stack overflow later. #[test] fn nesting_past_the_budget_is_refused() { From 15a2f3c8c88ae2e011e5912149ac67460ea6d949 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 22:18:33 -0400 Subject: [PATCH 008/117] Release 0.10.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3db056251..50caf06dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,7 +929,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "arboard", "designcraft-engine", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -967,7 +967,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-cms", "kurbo", @@ -979,7 +979,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -1011,7 +1011,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "caseless", "craft-atomic", @@ -1050,7 +1050,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.9.0" +version = "0.10.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1072,7 +1072,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.9.0" +version = "0.10.0" dependencies = [ "flo_curves", "kurbo", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1112,7 +1112,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1127,7 +1127,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1162,7 +1162,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1179,7 +1179,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1197,7 +1197,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-doc", "quick-xml", @@ -1206,7 +1206,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1218,7 +1218,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1245,7 +1245,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5796,7 +5796,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index ca5d0baaf..7772fb441 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 1fec005d199cd240325d5342c8886a57b0f9fc01 Mon Sep 17 00:00:00 2001 From: FredApps Date: Tue, 20 Oct 2026 10:57:09 +0200 Subject: [PATCH 009/117] CI: run cargo xtask ci on pull requests and pushes to main Only path-filtered workflows run on pull requests today, so most PRs land without a test run. Port of the wordcraft#800 workflow: Linux, standard GitHub-hosted runner, read-only token, no secrets, craft-fonts pinned to the release.yml commit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..72bac7ef7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, +# wasm) on every pull request and every push to main. Today only path-filtered workflows run on +# pull requests, so most PRs land with no test run at all. +# +# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Same craft-fonts commit as release.yml, so the font tests see what a release build embeds. + CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695 + +jobs: + gate: + name: cargo xtask ci + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + RUST_BACKTRACE: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + persist-credentials: false + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \ + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + + - name: Install Rust (stable + clippy, rustfmt, wasm32) + run: | + rustup toolchain install stable --profile minimal --component clippy,rustfmt \ + --target wasm32-unknown-unknown + rustup default stable + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Quality gate + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + run: cargo xtask ci From 9887272efbba2d5fb842f57388b43cb63bc271d2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:39:05 +0000 Subject: [PATCH 010/117] Fix "-0p0" for tiny negative pica/cicero values Found while testing crates/geom/src/units.rs:276 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/units.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index da1676d4b..f1077ffce 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -286,6 +286,8 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String { whole += 1.0; rest = 0.0; } + // Decide the sign from the rounded value so float noise doesn't print "-0p0". + let neg = neg && (whole > 0.0 || rest > 0.0); let sep = if unit == Unit::Picas { 'p' } else { 'c' }; format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest)) } @@ -304,6 +306,14 @@ mod tests { (a - b).abs() < 1e-6 } + /// Tiny negatives that round to zero must not print a sign. + #[test] + fn tiny_negative_picas_ciceros_have_no_sign() { + assert_eq!(format_measure(-0.0004, Unit::Picas), "0p0"); + assert_eq!(format_measure(-0.0004, Unit::Ciceros), "0c0"); + assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0"); + } + /// Inches show four decimals, as InDesign's fields do; other units three. #[test] fn inches_show_four_decimals() { From 0e7e5c3f04d339e2dbc6461aa1e703599c47aaaf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:07:40 +0200 Subject: [PATCH 011/117] Clamp polygon side count before narrowing to u32 Found while testing crates/engine/src/cmd/object.rs:710-711 (also :975) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/object.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..3244819c8 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -708,7 +708,7 @@ pub fn specs() -> Vec { super::always, |s, p| { if let Some(n) = p.get("sides").and_then(Value::as_u64) { - s.prefs.polygon_sides = (n as u32).clamp(3, 100); + s.prefs.polygon_sides = n.clamp(3, 100) as u32; } if let Some(v) = p.get("starInset").and_then(Value::as_f64) { s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0); @@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { let text = super::text_param(p, "text"); let caret = bool_or(p, "caret", content == "text"); let vertical = bool_or(p, "vertical", false); - let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100); + let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32; let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0); let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5)); s.edit(|d, sel| { @@ -3787,6 +3787,21 @@ mod polygon_tests { .unwrap(); assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3); } + + #[test] + fn polygon_sides_clamp_before_narrowing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap(); + assert_eq!(r["sides"], 100); + let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap(); + assert_eq!(r["sides"], 100); + let id = + s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"] + .as_u64() + .unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100); + } } #[cfg(test)] From e572a5316f28d5364c32dac5205a4fbb3d2d94f1 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 18:26:44 +0500 Subject: [PATCH 012/117] Graphics: the start-up log says which backend was chosen and how to change it `Fallback::begin` returns before the fallback runs when `WGPU_BACKEND` is set, logging that wgpu picks the backend. So the line below it is reached only when the variable is *not* set -- and it said "(WGPU_BACKEND overrides)", which reads in a log as though the user's environment had chosen the backend when nothing had. before: graphics: DirectX 12 (WGPU_BACKEND overrides) after: graphics: DirectX 12 (first choice; set WGPU_BACKEND to choose another) The report quotes that line as evidence of which backend a start used, which is exactly the wrong conclusion to be led to. Part of #1343. --- apps/designcraft/src/gpu/backend.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 0a3cda618..57073768c 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -231,7 +231,10 @@ impl Fallback { choice.backend.name(), choice.record.failed ), - None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()), + // `begin` returns before this when `WGPU_BACKEND` is set, so reaching here means it is + // not: the message said the opposite, which reads in a log as though the user's + // environment had chosen the backend (#1343). + None => log::info!("graphics: {} (first choice; set WGPU_BACKEND to choose another)", choice.backend.name()), } let instance = instance_backends(choice.backend, &choice.record.failed); Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record }) From 7aba0e15af4f808818b0094c21ae7365a40c0fbf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:10:59 +0000 Subject: [PATCH 013/117] Saturate table alternating-fill and index paragraph counts Found while testing crates/engine/src/cmd/table.rs:1396-1403 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/index.rs | 4 +++- crates/engine/src/cmd/table.rs | 8 ++++---- crates/engine/src/tests_table.rs | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs index bb6f09917..6ca36ee7d 100644 --- a/crates/engine/src/cmd/index.rs +++ b/crates/engine/src/cmd/index.rs @@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result { "currentPage" => IndexRange::CurrentPage, "toEndOfStory" => IndexRange::ToEndOfStory, "suppressPageRange" => IndexRange::SuppressPageRange, - "nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32), + "nextParagraphs" => { + IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX))) + } "see" => IndexRange::See(target()), "seeAlso" => IndexRange::SeeAlso(target()), r => return Err(bad("index.addReference", format!("unknown range `{r}`"))), diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..0780f65f0 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1393,14 +1393,14 @@ fn parse_alt(v: &Value) -> Option { } let d = AltFills::default(); Some(AltFills { - first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32), + first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)), first_color: color_param(v, "firstColor").unwrap_or(d.first_color), first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32), - next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32), + next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)), next_color: color_param(v, "nextColor").unwrap_or(d.next_color), next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32), - skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32), - skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32), + skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), + skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), }) } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 59d2f267b..2fc4fd338 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() { assert!(!convert_enabled(&s, "Body")); check(&s); } + +#[test] +fn alternating_fill_counts_saturate_instead_of_wrapping() { + let (mut s, _, _) = session_with_frame(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap(); + s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}})) + .unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + let a = &t["options"]["altRows"]; + assert_eq!(a["first"], u32::MAX); + assert_eq!(a["skipFirst"], u32::MAX); + assert_eq!(a["skipLast"], u32::MAX); + assert_eq!(a["next"], 1); +} + +#[test] +fn index_next_paragraphs_saturates_instead_of_wrapping() { + let (mut s, _, _) = session_with_frame(); + s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap(); + let l = s.execute("index.list", &json!({})).unwrap(); + assert_eq!(l[0]["range"]["type"], "nextParagraphs"); + assert_eq!(l[0]["range"]["value"], u32::MAX); +} From d271298362af27a67c419d3a06caeda58d9a5fb5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:34:47 +0000 Subject: [PATCH 014/117] Refuse PDF page ranges in the Place PDF dialog instead of using page 1 Fixes #1337 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/dialogs.rs | 8 ++++++++ crates/ui-egui/src/lib.rs | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..1c0bfcfce 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3268,6 +3268,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); + if !d.s("page").trim().is_empty() && d.n("page").is_none() { + // A range or list ("4-5", "2,5") is not a page number: refuse it rather than place page 1. + let e = "Enter a single page number; one page is placed at a time".to_string(); + let mut d = d; + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + return Err(e); + } if d.b("async") { return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop }); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..bea9fbfdc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2951,6 +2951,19 @@ mod browser_import_tests { assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0); } + #[test] + fn pdf_options_refuse_a_page_range_instead_of_placing_page_one() { + for spec in ["4-5", "2,5", "2-3"] { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", pdf()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(spec)); + assert!(dialogs::confirm(&mut app).is_err(), "{spec}"); + assert!(app.session.doc().unwrap().doc.assets.is_empty(), "{spec}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport", "{spec}"); + } + } + #[test] fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() { let mut app = app(); From c35dd44f8227b41a98d6d7275987348d3253e643 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:17:27 +0000 Subject: [PATCH 015/117] Remember the Preview checkbox in Paragraph/Character Style Options Fixes #1340 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/dialogs.rs | 30 +++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 3 +++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..b48fe5e53 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2829,6 +2829,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.horizontal(|ui| { if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") { check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + app.ui.style_preview = d.b("preview"); style_options_preview(app, &mut d); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -5147,7 +5148,7 @@ pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { return; } let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; - app.ui.dialog = Some(Dialog::new(id, json!({"name": name}))); + app.ui.dialog = Some(Dialog::new(id, json!({"name": name, "preview": app.ui.style_preview}))); } /// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named @@ -7763,6 +7764,33 @@ mod tests { assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range")); } + #[test] + fn style_options_preview_choice_is_remembered_on_reopen() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name":"Tutorial","chars":{"size":10}})).unwrap(); + open_style_options(&mut app, true, "Tutorial"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("style_preview_remember_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Preview").click(); + h.run_steps(3); + cancel(h.state_mut()); + open_style_options(h.state_mut(), true, "Tutorial"); + assert!(h.state().ui.dialog.as_ref().unwrap().b("preview"), "Preview stays checked on reopen"); + } + #[test] fn style_options_preview_updates_all_uses_cancels_and_commits_once() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..bf9654734 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -235,6 +235,8 @@ pub struct UiState { pub flattener: String, /// File › Export PDF… options (persisted; see `PdfExportSettings`). pub pdf_export: PdfExportSettings, + /// The Preview checkbox of Paragraph/Character Style Options, remembered between openings. + pub style_preview: bool, /// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4). #[serde(skip)] pub separation: Option, @@ -387,6 +389,7 @@ impl Default for UiState { language: String::new(), flattener: String::new(), pdf_export: PdfExportSettings::default(), + style_preview: false, separation: None, ink_limit: None, display_quality: designcraft_render::DisplayQuality::High, From 72f8186550700684bdf9b51fc426584ad0c76929 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 20 Oct 2026 11:33:05 -0400 Subject: [PATCH 016/117] docs: add SonarCloud and CodeRabbit badges to README (#1266) * docs: add SonarCloud and CodeRabbit badges to README * docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 880433e33..4ca03d4dc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Runs on macOS, Windows, Linux and the web License: MIT OR Apache-2.0 Agent-drivable over MCP +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

From 8dff7ecdb04d3708a112bb42fc1cecc38664f3cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 20 Oct 2026 11:39:20 -0400 Subject: [PATCH 017/117] Skip CodeRabbit reviews on Dependabot PRs (#1386) Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot]. Co-authored-by: Claude Opus 5.5 --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" From ba4b6d40892ab4937d510cbec984f301b3256ffc Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 20:59:04 +0500 Subject: [PATCH 018/117] Say which backend was chosen without claiming it was the first Two corrections to the message, both reported by CodeRabbit on #1371. switched_from being None only means this start did not switch from a backend that never showed a frame. A backend that failed on an earlier start is remembered in the record, and choose() then picks the first candidate that is not in that list -- so calling it the "first choice" misstates how it was arrived at. The line now says either "nothing has failed before" or which backends had, so the log says what happened. The comment claimed that reaching this line means WGPU_BACKEND is unset. wgpu-types returns an empty mask for an empty or unrecognised value and begin() only returns early for a non-empty one, so such a value reaches here with the variable set. The comment says that instead. --- apps/designcraft/src/gpu/backend.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 57073768c..6aa250dd4 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -231,10 +231,21 @@ impl Fallback { choice.backend.name(), choice.record.failed ), - // `begin` returns before this when `WGPU_BACKEND` is set, so reaching here means it is - // not: the message said the opposite, which reads in a log as though the user's - // environment had chosen the backend (#1343). - None => log::info!("graphics: {} (first choice; set WGPU_BACKEND to choose another)", choice.backend.name()), + // Not a switch from an unfinished start. `begin` has already returned when + // `WGPU_BACKEND` names a backend, so wgpu is not being told which to use here -- an + // empty or unrecognised value is no choice and reaches this line like an unset one. + // The message used to say the environment had been asked, which reads in a log as + // though the user had chosen the backend (#1343). What this start did choose is + // either the first candidate or the first one that has not failed before, so it says + // which of the two it is rather than claiming the first choice. + None if choice.record.failed.is_empty() => { + log::info!("graphics: {} (nothing has failed before; set WGPU_BACKEND to choose another)", choice.backend.name()) + } + None => log::info!( + "graphics: {} (the first that hasn't failed before, after {:?}; set WGPU_BACKEND to choose another)", + choice.backend.name(), + choice.record.failed + ), } let instance = instance_backends(choice.backend, &choice.record.failed); Some(Fallback { backend: choice.backend, switched_from: choice.switched_from, instance, path, record: choice.record }) From 6641e7c29fe8be823044adfec847d7342a4852e1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:44:49 +0000 Subject: [PATCH 019/117] Drop out-of-range RTF \uN values instead of wrapping them Found while testing crates/textimport/src/rtf.rs:237 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index baf065c30..c58e51603 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -234,9 +234,12 @@ pub fn import(bytes: &[u8]) -> Result { } "u" => { if let Some(n) = param { - let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 }; - if let Some(ch) = units.push(unit) { - buf.push(ch); + // RTF allows -32768..=65535; anything else is dropped. + if (-32768..=65535).contains(&n) { + let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 }; + if let Some(ch) = units.push(unit) { + buf.push(ch); + } } pending = stack.last().map_or(1, |s| s.uc); } @@ -282,6 +285,12 @@ fn cp1252(b: u8) -> char { mod tests { use super::*; + #[test] + fn out_of_range_unicode_escape_is_dropped() { + assert_eq!(import(br"{\rtf1 a\u4294967361?b}").unwrap().story.text, "ab"); + assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab"); + } + #[test] fn imports_rtf_text_and_formatting() { let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;} From e1e9923471aaa32ef1a964dd39c487f5d4968cab Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:02:07 +0000 Subject: [PATCH 020/117] Keep ':', '<', '>' and '\' in Tagged Text style names Found while testing crates/textimport/src/tagged.rs:35 (also :52) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 58 +++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 9bc84b766..ffb106ea9 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -25,6 +25,31 @@ fn escape(out: &mut String, c: char) { } } +/// A tag value with '<', '>' and '\\' escaped by a backslash, so it keeps the tag intact. +fn escape_value(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + for c in v.chars() { + if matches!(c, '<' | '>' | '\\') { + out.push('\\'); + } + out.push(c); + } + out +} + +/// A tag value with backslash escapes removed. +fn unescape_value(v: &str) -> String { + let mut out = String::with_capacity(v.len()); + let mut it = v.chars(); + while let Some(c) = it.next() { + match c { + '\\' => out.extend(it.next()), + c => out.push(c), + } + } + out +} + /// `story` as Tagged Text. pub fn tagged_text(doc: &Document, story: &Story) -> String { let mut out = String::from("\r\n\r\n"); @@ -34,7 +59,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { out.push_str("\r\n"); } let pf = story.paras.get(i).cloned().unwrap_or_default(); - let _ = write!(out, "", pf.style.replace(['<', '>', ':'], "")); + let _ = write!(out, "", escape_value(&pf.style)); for (rr, f) in story.runs() { let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); if a >= b { @@ -45,7 +70,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { } let mut open = Vec::new(); if f.style != NO_CHAR_STYLE { - let _ = write!(out, "", f.style.replace(['<', '>', ':'], "")); + let _ = write!(out, "", escape_value(&f.style)); open.push("CharStyle"); } if let Some(v) = &f.over.font_family { @@ -153,7 +178,7 @@ pub fn import(bytes: &[u8]) -> Result { } "ParaStyle" => { started = true; - let v = value.trim().to_string(); + let v = unescape_value(value.trim()); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); } @@ -162,10 +187,10 @@ pub fn import(bytes: &[u8]) -> Result { } } "CharStyle" => { - let v = value.trim(); - fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.to_string() }; - if !v.is_empty() && !char_styles.contains(&v.to_string()) { - char_styles.push(v.to_string()); + let v = unescape_value(value.trim()); + fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() }; + if !v.is_empty() && !char_styles.contains(&v) { + char_styles.push(v); } } "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), @@ -268,6 +293,25 @@ mod tests { assert_eq!(back.story.paras, story.paras); } + /// Style names with ':', '<', '>' or '\' were written with those characters dropped, so + /// re-import gave the paragraphs a different style. + #[test] + fn style_names_with_tag_characters_round_trip() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + for name in ["Body: Indent", "Ac", "x\\y", "end\\"] { + let mut story = Story::new(StoryId(0)); + story.insert_with(0, "Text", CharFormat { style: name.into(), ..Default::default() }); + story.paras[0].style = name.into(); + let out = tagged_text(&doc, &story); + let back = import(out.as_bytes()).unwrap(); + assert_eq!(back.story.text, "Text", "{out}"); + assert_eq!(back.story.paras[0].style, name, "{out}"); + assert_eq!(back.story.char_format_at(1).style, name, "{out}"); + assert_eq!(back.para_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]); + assert_eq!(back.char_styles.iter().map(|s| s.name.as_str()).collect::>(), [name]); + } + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; From 1420d8c79b43096efc659eee7a4db19fe5a1d6a5 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 20 Oct 2026 19:52:28 -0600 Subject: [PATCH 021/117] refactor(pdf): share placed-PDF rendering (PDFR) (#876) --- ASSETS.md | 25 ++ Cargo.lock | 19 +- Cargo.toml | 16 +- NOTICE | 29 +- ROADMAP.md | 2 + crates/engine/src/cmd/export.rs | 11 +- crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/links.rs | 3 + crates/engine/tests/pdf_backend.rs | 349 ++++++++++++++++++ crates/render/Cargo.toml | 3 +- crates/render/src/lib.rs | 137 +++++-- crates/render/src/pdf_bounds.rs | 6 +- crates/render/src/pdf_layers.rs | 14 +- crates/render/tests/pdf_geometry.rs | 61 +++ crates/render/tests/placed_pdf.rs | 8 +- docs/architecture.md | 2 + docs/pdf-backend.md | 40 +- .../pdf-backend/hayro-cmap/LICENSE-APACHE | 177 +++++++++ licenses/pdf-backend/hayro-cmap/LICENSE-MIT | 7 + licenses/pdf-backend/hayro-cmap/LICENSE.txt | 28 ++ .../hayro-interpret/LICENSE-APACHE | 177 +++++++++ .../pdf-backend/hayro-interpret/LICENSE-MIT | 7 + .../hayro-interpret/assets/CGATS_LICENSE.txt | 116 ++++++ .../hayro-interpret/assets/LICENSE_FOXIT | 27 ++ .../pdf-backend/hayro-jbig2/LICENSE-APACHE | 177 +++++++++ licenses/pdf-backend/hayro-jbig2/LICENSE-MIT | 7 + .../pdf-backend/hayro-syntax/LICENSE-APACHE | 177 +++++++++ licenses/pdf-backend/hayro-syntax/LICENSE-MIT | 7 + licenses/pdf-backend/hayro/LICENSE-APACHE | 177 +++++++++ licenses/pdf-backend/hayro/LICENSE-MIT | 7 + xtask/src/main.rs | 6 +- xtask/src/pdf_deps.rs | 46 ++- 32 files changed, 1791 insertions(+), 80 deletions(-) create mode 100644 crates/engine/tests/pdf_backend.rs create mode 100644 crates/render/tests/pdf_geometry.rs create mode 100644 licenses/pdf-backend/hayro-cmap/LICENSE-APACHE create mode 100644 licenses/pdf-backend/hayro-cmap/LICENSE-MIT create mode 100644 licenses/pdf-backend/hayro-cmap/LICENSE.txt create mode 100644 licenses/pdf-backend/hayro-interpret/LICENSE-APACHE create mode 100644 licenses/pdf-backend/hayro-interpret/LICENSE-MIT create mode 100644 licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt create mode 100644 licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT create mode 100644 licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE create mode 100644 licenses/pdf-backend/hayro-jbig2/LICENSE-MIT create mode 100644 licenses/pdf-backend/hayro-syntax/LICENSE-APACHE create mode 100644 licenses/pdf-backend/hayro-syntax/LICENSE-MIT create mode 100644 licenses/pdf-backend/hayro/LICENSE-APACHE create mode 100644 licenses/pdf-backend/hayro/LICENSE-MIT diff --git a/ASSETS.md b/ASSETS.md index 2731f47bf..40e8a97b4 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -69,3 +69,28 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` | | `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | + +## Assets bundled by the PDF backend + +These assets were already enabled by Hayro’s default font and CMap features. The pinned +backend keeps the same files; hashes and upstream licence notices are retained here. + +| Crate / file | Author | Source | Licence | SHA-256 | Use | +| --- | --- | --- | --- | --- | --- | +| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by the PDF compatibility exception | +| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by the PDF compatibility exception | diff --git a/Cargo.lock b/Cargo.lock index 50caf06dd..a1861d0b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,8 +832,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" dependencies = [ + "bytemuck", + "hayro", + "hayro-interpret", + "hayro-syntax", "krilla", "thiserror 2.0.21", ] @@ -1181,16 +1185,17 @@ dependencies = [ name = "designcraft-render" version = "0.10.0" dependencies = [ + "craft-pdf", "designcraft-color", "designcraft-compose", "designcraft-doc", "designcraft-fonts", "designcraft-geom", "designcraft-images", - "hayro", "image", "log", "serde", + "thiserror 2.0.21", "vello_common 0.2.0", "vello_cpu 0.2.0", ] @@ -2105,7 +2110,7 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" dependencies = [ "bytemuck", "hayro-interpret", @@ -2135,7 +2140,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2153,7 +2158,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2177,7 +2182,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" dependencies = [ "flate2", "hayro-ccitt", @@ -2541,7 +2546,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 7772fb441..2b6ee0c7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] -# Shared writer; the immutable revision must be published before this consumer. -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } +# Shared reader and writer; publish the immutable revision before this consumer. +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -66,7 +66,6 @@ krilla-svg = "=0.8.1" flo_curves = "0.8" # QR codes (Object › Generate QR Code); MIT. qrcodegen = "1.8" -hayro = "0.7" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch @@ -116,10 +115,9 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] +krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" } winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } -# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. -hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } -hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } -hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } -hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" } diff --git a/NOTICE b/NOTICE index f4e3184f6..f1e997156 100644 --- a/NOTICE +++ b/NOTICE @@ -81,12 +81,35 @@ list (author, source, license) is in ASSETS.md. packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. - hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 - (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at - the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + (Apache-2.0 OR MIT) are fetched from the pinned craft-libs source documented below. hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively -licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. +licensed (MIT/Apache-2.0/BSD/ISC/Zlib), fetched through crates.io or the pinned source below. No Adobe icons, images or artwork are included. + +PDF backend source + +The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 +are fetched from https://github.com/storytold/craft-libs at revision +89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred +from https://github.com/storytold/pdfcraft at revision +7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is +https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each +crate's upstream licence files. The patch ledger is at +https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md. +Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset +origins and unchanged per-file hashes are recorded separately in ASSETS.md. + +Assets already bundled through Hayro's default features retain their licences: +- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved. + Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see + licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT. +- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see + licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt. +- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause, + non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt. + Adobe and contributor names do not endorse or promote this product. +Per-file hashes and sources are recorded in ASSETS.md. diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..a7b5105d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -638,3 +638,5 @@ Newest first. The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published. - **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. + +- PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8bbe41a8a..36fd3d52a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -659,11 +659,11 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I } /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. -fn rasterize_layered(d: &designcraft_doc::Document) -> (Option, usize) { +fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { let ids: Vec = d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect(); if ids.is_empty() { - return (None, 0); + return Ok((None, 0)); } let mut out = d.clone(); let mut n = 0; @@ -678,7 +678,8 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option (Option Result { let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); // Placed PDFs with hidden layers go out as images showing just their visible layers. let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); - let (layered, layer_count) = rasterize_layered(base); + let (layered, layer_count) = rasterize_layered(base)?; let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 973262937..da38b8132 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -629,6 +629,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result { }) .transpose()?; // Image Import Options: which page of a PDF (1-based). + if designcraft_render::is_pdf(&bytes) { + designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; + } let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { Some(n) if designcraft_render::is_pdf(&bytes) => { let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c6e9e7566..6623b6cb8 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -398,6 +398,9 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { let path = path.as_ref(); let b = crate::io::read_file(path).map_err(EngineError::Other)?; + if designcraft_render::is_pdf(&b) { + designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?; + } let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?; Ok((b, px)) } diff --git a/crates/engine/tests/pdf_backend.rs b/crates/engine/tests/pdf_backend.rs new file mode 100644 index 000000000..6b4f93081 --- /dev/null +++ b/crates/engine/tests/pdf_backend.rs @@ -0,0 +1,349 @@ +//! Synthetic placement/export regressions for PDF backend updates. + +use serde_json::json; + +use designcraft_engine::Session; +use designcraft_engine::cmd::{base64_decode, base64_encode}; + +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0]; + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", offsets.len()).as_bytes()); + for offset in offsets.iter().skip(1) { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).as_bytes()); + bytes +} + +fn layered_pdf(second_name: &str) -> Vec { + layered_pdf_with_box(second_name, "") +} + +fn layered_pdf_with_box(second_name: &str, page_box: &str) -> Vec { + let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC /OC /b BDC 1 0 0 rg 55 10 35 80 re f EMC"; + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] {page_box} /Resources << /Properties << /a 5 0 R /b 6 0 R >> >> /Contents 4 0 R >>" + ), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /OCG /Name (Blue) >>".into(), + format!("<< /Type /OCG /Name ({second_name}) >>"), + ]) +} + +fn rendered(bytes: &[u8]) -> designcraft_render::Rendered { + let pm = designcraft_render::render_pdf_page(bytes, 0, 100).expect("render synthetic PDF"); + designcraft_render::Rendered { + width: pm.width().into(), + height: pm.height().into(), + pixels: pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(), + } +} + +// Optional local evidence output; no binary fixtures are checked in. +fn evidence(name: &str, bytes: &[u8]) { + if let Some(dir) = std::env::var_os("CRAFT_PDF_EVIDENCE_DIR") { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap(); + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn placed_pdf_preserves_transparent_background_and_layer_choices() { + let bytes = layered_pdf("Red"); + let img = rendered(&bytes); + assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]); + assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]); + assert_eq!(img.pixel(75, 50), [255, 0, 0, 255]); + let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap(); + let img = rendered(&hidden); + assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]); + assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]); + evidence("layers-hidden.png", &img.to_png()); + evidence("layers-source.pdf", &bytes); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn duplicate_layer_names_keep_name_based_hiding() { + let bytes = layered_pdf("Blue"); + assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2); + let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Blue".into()]).unwrap(); + let img = rendered(&hidden); + assert_eq!(img.pixel(25, 50), [0, 0, 0, 0]); + assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]); + // A second render of the original must not inherit the hidden configuration. + assert_eq!(rendered(&bytes).pixel(25, 50), [0, 0, 255, 255]); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn crop_box_and_rotation_keep_placed_dimensions() { + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 100] /CropBox [10 20 110 80] /Rotate 90 >>".into(), + ]); + assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(1)); + assert_eq!(designcraft_render::pdf_page_size(&bytes, 0), Some((60.0, 100.0))); + let img = rendered(&bytes); + assert_eq!((img.width, img.height), (60, 100)); + assert_eq!(img.pixel(20, 20), [0, 0, 0, 0]); +} + +fn exported(hidden: bool) -> Vec { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap(); + session.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let placed = session + .execute( + "file.place", + &json!({ + "base64": base64_encode(&layered_pdf("Red")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100 + }), + ) + .unwrap(); + if hidden { + session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); + } + let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap(); + let bytes = base64_decode(report["base64"].as_str().unwrap()); + if hidden { + assert!(report["warnings"].to_string().contains("hidden layers")); + } + bytes +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn hidden_layer_export_composites_over_underlying_artwork() { + let bytes = exported(true); + let img = rendered(&bytes); + let right = img.pixel(75, 50); + assert!(right[..3].iter().all(|c| *c < 45), "hidden area must show the black backdrop, not opaque white: {right:?}"); + let left = img.pixel(25, 50); + assert!(left[2] > 180 && left[0] < 80, "visible blue layer: {left:?}"); + evidence("hidden-export.pdf", &bytes); + evidence("hidden-export.png", &img.to_png()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn ordinary_placed_pages_still_embed_as_vectors() { + use hayro_syntax::object::Name; + let bytes = exported(false); + let parsed = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let subtypes: Vec<_> = parsed + .objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter_map(|s| s.dict().get::("Subtype").map(|n| n.as_str().to_owned())) + .collect(); + assert!(subtypes.iter().any(|s| s == "Form"), "placed PDF form: {subtypes:?}"); + assert!(!subtypes.iter().any(|s| s == "Image"), "no rasterization: {subtypes:?}"); + let img = rendered(&bytes); + let right = img.pixel(75, 50); + assert!(right[0] > 180 && right[1] < 80, "visible red vector layer: {right:?}"); + evidence("vector-export.pdf", &bytes); + evidence("vector-export.png", &img.to_png()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn oversized_image_does_not_silently_disappear_from_layered_export() { + // Just above the backend's 2^28-pixel cap. The stream is one byte; no large + // fixture or large image allocation is needed to exercise the refusal. + let content = "/OC /a BDC 0 0 1 rg 10 10 35 80 re f EMC q 100 0 0 100 0 0 cm /Im Do Q"; + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Properties << /a 6 0 R >> /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /XObject /Subtype /Image /Width 32769 /Height 8192 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nX\nendstream".into(), + "<< /Type /OCG /Name (Blue) >>".into(), + ]); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let placed = session + .execute( + "file.place", + &json!({ + "base64": base64_encode(&bytes), "name": "oversized-scan.pdf", "x": 0, "y": 0, "width": 100 + }), + ) + .unwrap(); + session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); + let before = session.execute("file.serialize", &json!({})).unwrap(); + let result = session.execute("file.exportPdf", &json!({})); + assert!(result.is_err(), "export must report the image omitted by the backend's resource limit"); + assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged"); + let error = result.unwrap_err().to_string(); + assert!(error.contains("oversized-scan.pdf") && error.contains("image"), "{error}"); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() { + // A stored layer choice can outlive relinking to a PDF with no layers. + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>".into(), + ]); + assert!(designcraft_render::render_pdf_page(&bytes, 0, 100).is_some()); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none()); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let placed = + session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); + let before = session.execute("file.serialize", &json!({})).unwrap(); + let result = session.execute("file.exportPdf", &json!({})); + assert!(result.is_err(), "failed layer hiding must not export the original visible layers"); + let error = result.unwrap_err().to_string(); + assert!(error.contains("missing-layers.pdf") && error.contains("layer"), "{error}"); + assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn placed_standard_font_text_renders_ink_on_a_transparent_background() { + let content = "BT /F1 18 Tf 10 40 Td (PDF text) Tj ET"; + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]); + let img = rendered(&bytes); + let ink = (40..65).flat_map(|y| (5..95).map(move |x| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[3] > 100).count(); + assert!(ink > 100, "standard-font glyphs must paint visible ink: {ink}"); + assert_eq!(img.pixel(2, 2), [0, 0, 0, 0]); + assert_eq!(img.pixel(50, 80), [0, 0, 0, 0]); + assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &[], None).is_ok()); + evidence("standard-font-text.png", &img.to_png()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn unavailable_placed_page_reports_the_page_and_recovery_action() { + let error = designcraft_render::pdf_page_png_checked(&layered_pdf("Red"), 2, 100, &["Red".into()], None).unwrap_err(); + assert!(matches!(error, designcraft_render::PdfRasterError::MissingPage { page: 3 })); + assert!(error.to_string().contains("place the replacement PDF again")); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn hidden_layers_work_with_an_xref_stream_and_compressed_catalog() { + // Reuse the tiny synthetic objects, but put the catalog in an object stream + // and index everything with a binary cross-reference stream. + let classic = String::from_utf8(layered_pdf("Red")).unwrap(); + let objects: Vec<&str> = (1..=6).map(|i| classic.split_once(&format!("{i} 0 obj\n")).unwrap().1.split_once("\nendobj").unwrap().0).collect(); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = [0u32; 9]; + for (i, object) in objects.iter().enumerate().skip(1) { + offsets[i + 1] = u32::try_from(bytes.len()).unwrap(); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + offsets[7] = u32::try_from(bytes.len()).unwrap(); + let catalog = format!("1 0 {}", objects[0]); + bytes.extend_from_slice( + format!("7 0 obj\n<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n{catalog}\nendstream\nendobj\n", catalog.len()).as_bytes(), + ); + offsets[8] = u32::try_from(bytes.len()).unwrap(); + let mut entries = Vec::new(); + for (i, offset) in offsets.iter().enumerate() { + let (kind, field, generation): (u8, u32, u16) = match i { + 0 => (0, 0, 65535), + 1 => (2, 7, 0), + _ => (1, *offset, 0), + }; + entries.push(kind); + entries.extend_from_slice(&field.to_be_bytes()); + entries.extend_from_slice(&generation.to_be_bytes()); + } + bytes.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len()).as_bytes()); + bytes.extend_from_slice(&entries); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[8]).as_bytes()); + assert_eq!(rendered(&bytes).pixel(75, 50), [255, 0, 0, 255]); + let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).unwrap(); + let img = rendered(&hidden); + assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]); + assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]); + assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn trailing_whitespace_does_not_prevent_layer_hiding() { + let mut bytes = layered_pdf("Red"); + bytes.extend_from_slice(&[b' '; 96]); + let hidden = designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).expect("trailing whitespace is accepted by the layer rewriter"); + let img = rendered(&hidden); + assert_eq!(img.pixel(75, 50), [0, 0, 0, 0]); + assert_eq!(img.pixel(25, 50), [0, 0, 255, 255]); + assert!(designcraft_render::pdf_page_png_checked(&bytes, 0, 100, &["Red".into()], None).is_ok()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn failed_content_decode_aborts_layered_export_without_changing_the_document() { + for filter in ["FlateDecode", "DCTDecode"] { + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /Properties << /a 5 0 R >> >> /Contents 4 0 R >>".into(), + format!("<< /Filter /{filter} /Length 1 >>\nstream\nx\nendstream"), + "<< /Type /OCG /Name (Blue) >>".into(), + ]); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let placed = session + .execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "broken-content.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); + let before = session.execute("file.serialize", &json!({})).unwrap(); + let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string(); + assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}"); + assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}"); + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn hidden_layer_export_keeps_the_placed_trim_box() { + let bytes = layered_pdf_with_box("Red", "/TrimBox [0 0 50 100]"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 50, "height": 100, "pages": 1})).unwrap(); + session.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); + let placed = session + .execute( + "file.place", + &json!({"base64": base64_encode(&bytes), "name": "trimmed-layers.pdf", "pdfCrop": "trim", "x": 0, "y": 0, "width": 100}), + ) + .unwrap(); + session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); + let before = session.execute("file.serialize", &json!({})).unwrap(); + let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap(); + let exported = base64_decode(report["base64"].as_str().unwrap()); + let img = rendered(&exported); + assert_eq!((img.width, img.height), (50, 100)); + // Omitting the shown box squeezes the whole page into the trimmed graphic, + // moving the blue rectangle's right edge from x=45 to x=22.5. + let blue = img.pixel(35, 50); + assert!(blue[2] > 180 && blue[0] < 80, "the trim box keeps the visible layer at its placed coordinates: {blue:?}"); + assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before); + evidence("trimmed-layer-export.pdf", &exported); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 5ababd51e..8cd446af1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -17,9 +17,10 @@ designcraft-compose = { workspace = true } vello_cpu = { workspace = true } vello_common = { workspace = true } image = { workspace = true } -hayro = { workspace = true } +craft-pdf = { workspace = true, features = ["raster"] } designcraft-images = { workspace = true } log = { workspace = true } +thiserror = { workspace = true } serde = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c42d8c897..6823fc006 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1235,29 +1235,93 @@ pub fn is_pdf(bytes: &[u8]) -> bool { /// Page count of a PDF. pub fn pdf_page_count(bytes: &[u8]) -> Option { - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; Some(pdf.pages().len()) } /// Size of PDF page `page` (0-based), in points, as it shows (crop box, rotation applied). pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let p = pdf.pages().get(page)?; let (w, h) = p.render_dimensions(); Some((w as f64, h as f64)) } -/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off; -/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given. +/// A placed page could not be rasterized completely for export. +#[derive(Debug, thiserror::Error)] +pub enum PdfRasterError { + #[error("{0}")] + Input(#[source] craft_pdf::OpenError), + #[error("{0}")] + Render(#[from] craft_pdf::raster::Error), + #[error("the PDF page could not be read or rendered")] + InvalidPage, + #[error("PDF page {page} is unavailable; choose an existing page in Image Import Options or place the replacement PDF again")] + MissingPage { page: usize }, + #[error("the selected PDF layers could not be hidden")] + LayerRewrite, + #[error("an image could not be decoded or exceeded the PDF renderer's resource limits; repair or replace this placed PDF before exporting again")] + ImageDecode, + #[error("the PDF renderer encountered an unsupported font")] + UnsupportedFont, + #[error( + "the PDF renderer could not read all page content or reached its resource limits; repair or replace this placed PDF before exporting again" + )] + ContentTruncated, +} + +/// Reject oversized PDFs before placement, layer rewriting or output allocation. +pub fn check_pdf_input_size(size: usize) -> Result<(), PdfRasterError> { + craft_pdf::check_input_size(size).map_err(PdfRasterError::Input) +} + +/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off. +/// Only the `shown` part ([`designcraft_doc::Asset::shown_box`]) is kept when given. +/// Returns `None` on the same failures as [`pdf_page_png_checked`]. pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option> { - let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) }; - let pm = render_pdf_page(&data, page, max_side)?; + pdf_page_png_checked(bytes, page, max_side, hidden, shown).ok() +} + +/// Rasterize a placed page for export, preserving available interpreter diagnostics. +/// This cannot detect backend approximations that do not emit an interpreter warning. +pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result, PdfRasterError> { + check_pdf_input_size(bytes.len())?; + let data = if hidden.is_empty() { + std::borrow::Cow::Borrowed(bytes) + } else { + std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?) + }; + let raster = render_pdf_raster(&data, page, max_side)?; + if raster.diagnostics.image_decode_failed { + return Err(PdfRasterError::ImageDecode); + } + if raster.diagnostics.unsupported_font { + return Err(PdfRasterError::UnsupportedFont); + } + if raster.diagnostics.content_truncated { + return Err(PdfRasterError::ContentTruncated); + } + let pm = raster_pixmap(&raster); let pm = match shown { - Some(frac) => crop_pixmap(&pm, frac)?, + Some(frac) => crop_pixmap(&pm, frac).ok_or(PdfRasterError::InvalidPage)?, None => pm, }; - let pixels: Vec = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(); - Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png()) + let pixels = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(); + Ok(Rendered { width: u32::from(pm.width()), height: u32::from(pm.height()), pixels }.to_png()) +} + +/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display). +/// Export callers that need failure diagnostics use [`pdf_page_png_checked`]. +pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option { + #[cfg(test)] + PDF_RENDERS.with(|n| n.set(n.get() + 1)); + let raster = render_pdf_raster(bytes, page, max_side).ok()?; + Some(raster_pixmap(&raster)) +} + +fn raster_pixmap(raster: &craft_pdf::raster::Raster) -> Pixmap { + let data = raster.bytes().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect(); + Pixmap::from_parts(data, raster.width as u16, raster.height as u16) } #[cfg(test)] @@ -1266,21 +1330,46 @@ thread_local! { static PDF_RENDERS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF -/// export embeds the page as vectors). -pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option { - #[cfg(test)] - PDF_RENDERS.with(|n| n.set(n.get() + 1)); - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; - let p = pdf.pages().get(page)?; +fn render_pdf_raster(bytes: &[u8], page: usize, max_side: u32) -> Result { + let pdf = craft_pdf::open_slice(bytes, None).map_err(|e| match e { + craft_pdf::OpenError::Load(_) => PdfRasterError::InvalidPage, + other => PdfRasterError::Input(other), + })?; + let p = pdf.pages().get(page).ok_or(PdfRasterError::MissingPage { page: page.saturating_add(1) })?; let (w, h) = p.render_dimensions(); let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0); - let rs = hayro::RenderSettings { x_scale: scale, y_scale: scale, ..Default::default() }; - let pm = hayro::render(p, &hayro::RenderCache::new(), &hayro::hayro_interpret::InterpreterSettings::default(), &rs); - let (pw, ph) = (pm.width(), pm.height()); - let data: Vec = - pm.data_as_u8_slice().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect(); - Some(Pixmap::from_parts(data, pw, ph)) + craft_pdf::raster::render( + p, + &craft_pdf::raster::RenderCache::new(), + &craft_pdf::interpret::InterpreterSettings::default(), + &craft_pdf::raster::Options { scale: [scale; 2], ..Default::default() }, + ) + .map_err(PdfRasterError::Render) +} + +/// A page box of a PDF page (`crop`, `trim`, `bleed`, `art`, `media`) as (x, y, w, h) in points +/// from the top-left of the page as rendered (its visible crop box); missing boxes fall back to the +/// crop box, as PDF readers do. +pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, f64, f64)> { + use craft_pdf::syntax::object::{Rect as PRect, dict::keys}; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; + let p = pdf.pages().get(page)?; + let visible = p.intersected_crop_box(); + let page_box = match kind { + "media" => p.media_box(), + "trim" | "bleed" | "art" => { + let key: &[u8] = match kind { + "trim" => keys::TRIM_BOX, + "bleed" => keys::BLEED_BOX, + _ => keys::ART_BOX, + }; + p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible) + } + _ => visible, + }; + let transform = Affine::new(p.initial_transform(true).as_coeffs()); + let b = transform.transform_rect_bbox(Rect::new(page_box.x0, page_box.y0, page_box.x1, page_box.y1)); + b.is_finite().then_some((b.x0, b.y0, b.width(), b.height())) } /// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as @@ -1289,10 +1378,10 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option Option<([f64; 4], (f64, f64))> { + use craft_pdf::syntax::object::{Rect as PRect, dict::keys}; use designcraft_doc::PdfCrop; - use hayro::hayro_syntax::object::{Rect as PRect, dict::keys}; const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0]; - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let p = pdf.pages().get(page)?; let (w, h) = p.render_dimensions(); let (w, h) = (f64::from(w), f64::from(h)); diff --git a/crates/render/src/pdf_bounds.rs b/crates/render/src/pdf_bounds.rs index 74665ae3c..17881df6e 100644 --- a/crates/render/src/pdf_bounds.rs +++ b/crates/render/src/pdf_bounds.rs @@ -2,8 +2,8 @@ //! of every painted path, glyph and image, each cut to the clip in force when it's painted. //! Nothing is rasterized, so measuring a page costs a fraction of drawing it. -use hayro::hayro_interpret::font::Glyph; -use hayro::hayro_interpret::{ +use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::{ BlendMode, ClipPath, Context, Device, FillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, TransformExt, interpret_page, }; @@ -12,7 +12,7 @@ use vello_cpu::kurbo::{self, Affine, BezPath, Rect, Shape}; /// The bounds of what PDF page `page` draws, as fractions of the page as rendered (its visible /// crop box, rotated as it displays): left, top, right, bottom. `None` when it draws nothing. pub(crate) fn page_content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> { - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let p = pdf.pages().get(page)?; let (w, h) = p.render_dimensions(); let (w, h) = (f64::from(w), f64::from(h)); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index 344585e3b..bc6d864a7 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use hayro::hayro_syntax::Pdf; -use hayro::hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier}; /// (object number, name, on by default) of each layer. fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { @@ -28,7 +28,7 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { .filter_map(|r| { let d = xref.get::>(ObjectIdentifier::from(r))?; let name = d - .get::>(b"Name") + .get::>(b"Name") .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) .unwrap_or_default(); let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; @@ -39,13 +39,13 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { /// The layers of a placed PDF: (name, visible by default). pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { - Pdf::new(Arc::new(bytes.to_vec())).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() + craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() } /// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file /// has no such layers or can't be read. pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { - let pdf = Pdf::new(Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let layers = ocgs(&pdf); if layers.is_empty() { return None; @@ -74,8 +74,8 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { ); let xref_at = out.len(); let prev = { - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string(); - tail.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? + let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string(); + tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? }; out.extend_from_slice( format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) diff --git a/crates/render/tests/pdf_geometry.rs b/crates/render/tests/pdf_geometry.rs new file mode 100644 index 000000000..dcf9427e2 --- /dev/null +++ b/crates/render/tests/pdf_geometry.rs @@ -0,0 +1,61 @@ +fn page(width: f32, height: f32) -> Vec { + let content = format!("1 0 0 rg 0 0 {width} {height} re f"); + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() +} + +#[test] +fn thin_pdf_page_has_visible_preview_and_export_pixels() { + let bytes = page(1000.0, 0.2); + let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap(); + assert_eq!((preview.width(), preview.height()), (700, 1)); + assert!(preview.data().iter().any(|pixel| pixel.a > 0)); + let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap(); + let image = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), (700, 1)); + assert!(image.pixels().any(|pixel| pixel[3] > 0)); +} + +#[test] +fn empty_media_box_preserves_the_parsers_default_page_repair() { + let bytes = page(0.0, 0.0); + let missing = String::from_utf8(bytes.clone()).unwrap().replace("/MediaBox [0 0 0 0]", ""); + let size = designcraft_render::pdf_page_size(&bytes, 0).unwrap(); + assert!(size.0 > 0.0 && size.1 > 0.0); + assert_eq!(Some(size), designcraft_render::pdf_page_size(missing.as_bytes(), 0)); + let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap(); + let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap(); + let image = image::load_from_memory(&png).unwrap(); + assert_eq!((image.width(), image.height()), (u32::from(preview.width()), u32::from(preview.height()))); +} + +#[test] +fn pdf_input_limit_is_actionable_before_placement() { + let error = designcraft_render::check_pdf_input_size(craft_pdf::MAX_INPUT_BYTES + 1).unwrap_err(); + assert!(matches!(error, designcraft_render::PdfRasterError::Input(_))); + assert!(error.to_string().contains("input limit")); +} + +#[test] +fn pdf_boxes_match_scaled_and_rotated_page_coordinates() { + for (rotation, size, trim, media) in [ + (0, (200.0, 120.0), (20.0, 40.0, 140.0, 60.0), (-20.0, -40.0, 400.0, 200.0)), + (90, (120.0, 200.0), (20.0, 20.0, 60.0, 140.0), (-40.0, -20.0, 200.0, 400.0)), + (180, (200.0, 120.0), (40.0, 20.0, 140.0, 60.0), (-180.0, -40.0, 400.0, 200.0)), + (270, (120.0, 200.0), (40.0, 40.0, 60.0, 140.0), (-40.0, -180.0, 200.0, 400.0)), + ] { + let bytes = String::from_utf8(page(200.0, 100.0)).unwrap().replace( + "/MediaBox [0 0 200 100]", + &format!("/MediaBox [0 0 200 100] /CropBox [10 20 110 80] /TrimBox [20 30 90 60] /UserUnit 2 /Rotate {rotation}"), + ); + let bytes = bytes.as_bytes(); + assert_eq!(designcraft_render::pdf_page_size(bytes, 0), Some(size), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "crop"), Some((0.0, 0.0, size.0, size.1)), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "trim"), Some(trim), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "media"), Some(media), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "art"), Some((0.0, 0.0, size.0, size.1)), "missing box, rotation {rotation}"); + } +} diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs index 234e43ba7..118453067 100644 --- a/crates/render/tests/placed_pdf.rs +++ b/crates/render/tests/placed_pdf.rs @@ -1,4 +1,4 @@ -//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! Placed PDFs are drawn through craft-pdf's patched Hayro crates. Calculator functions whose `idiv` //! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh //! shadings with points far off the page, ten thousand overlapping triangles or forty thousand //! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is @@ -9,14 +9,14 @@ use std::sync::Arc; use std::sync::mpsc::{self, RecvTimeoutError}; use std::time::Duration; +use craft_pdf::interpret::Function; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::{Object, ObjectIdentifier}; use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, PdfCrop, Shape, SpreadRef}; use designcraft_geom::{Affine, Rect, shapes}; use designcraft_render::{Rendered, Renderer, pdf_crop_box, pdf_page_size}; -use hayro::hayro_interpret::Function; -use hayro::hayro_syntax::Pdf; -use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; const WHITE: [u8; 4] = [255, 255, 255, 255]; const RED: [u8; 4] = [255, 0, 0, 255]; diff --git a/docs/architecture.md b/docs/architecture.md index a90b75df3..6e0c4c66b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,3 +113,5 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary | | 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | + +PDF parsing and placed-page rasterization use the pinned `craft-pdf` reader. DesignCraft retains document, placement, layer and export policy; dependency checks and bounds are described in [pdf-backend.md](pdf-backend.md). diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index fcc6afeaa..4cbc985d3 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,9 +1,12 @@ -# PDF writing boundary +# Shared PDF backend -DesignCraft pins `craft-pdf` and its patched Krilla 0.8.2 backend to craft-libs revision -`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. The `write-pdf` capability is required for -placed PDF pages and book merging. The ordinary reader dependencies, `craft-ui` and -`craft-print` revisions remain unchanged by this writer migration. +DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the four Hayro reader crates to +craft-libs revision `89dcbb3060377a26c99a6d80caa16084366704c4`. Existing `craft-ui` and `craft-print` revisions +remain unchanged. The reader migration is reviewed on top of the separate writer change. + +## Writing + +The `write-pdf` capability is required for placed PDF pages and book merging. The shared library supplies fallible font copying/preparation, metadata setup, version and archival configuration, final serialization and a native unwind guard. DesignCraft retains @@ -32,3 +35,30 @@ Tests cover metadata key presence, hostile page selection, successful PDF export subprocess recovery for all three export entry points. Native/WASM export comparisons and independent PDF parsing/rendering supplement the repository gates. Physical printer output, Windows/Linux runtime and PDF/A/PDF/X certification remain separate acceptance work. + +## Reading + +Placed-PDF geometry, layers and raster output use `craft-pdf` with its explicit `raster` +feature. The facade and four patched Hayro crates are pinned to one immutable craft-libs +revision. The backend source was transferred from PdfCraft revision +`7437bde378f43856eada40af25b983812e615e8a`; the per-crate `vendor/SOURCE.json` manifests and `vendor/README.craft-reader.md` +record the transfer and subsequent craft-libs patches. Asset provenance and hashes +are recorded separately in ASSETS.md. +`cargo xtask pdf-deps` checks unique resolved packages, exact versions and sources. + +DesignCraft retains placement, layer-name policy, document state and the application render +cache. Render output is premultiplied RGBA8. Page boxes use the same crop, rotation and +UserUnit transform as raster output, including fractional placed-PDF dimensions. +Layered-PDF export stops on failed layer rewriting, missing pages or reported incomplete +content rather than saving an unchanged or incomplete placed page. Shared diagnostics only +report known backend warnings; they do not certify all PDF fidelity. + +DesignCraft uses a 512 MiB reader input bound, checked before parsing or copying. +Raster output has shared geometry/allocation bounds. Native unwind guards turn escaped +backend panics into errors; WASM aborts and process-wide allocation failures are not recoverable. +Application fonts, image assets, editing and export presets remain app-owned. Existing +craft-ui/craft-print versions are preserved. The reader migration builds on the independently qualified writer change. + +The shared commit must be published before a fresh public checkout can resolve these pins. +No sibling checkout is required. Licences and existing backend asset notices are recorded in +NOTICE, ASSETS.md and licenses/pdf-backend. The backend's removed LAB.icc is not restored. diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE b/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE-MIT b/licenses/pdf-backend/hayro-cmap/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-cmap/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE.txt b/licenses/pdf-backend/hayro-cmap/LICENSE.txt new file mode 100644 index 000000000..2552cc990 --- /dev/null +++ b/licenses/pdf-backend/hayro-cmap/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE b/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-interpret/LICENSE-MIT b/licenses/pdf-backend/hayro-interpret/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt b/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt new file mode 100644 index 000000000..550e190e5 --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see +http://creativecommons.org/publicdomain/zero/1.0/ \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT b/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT new file mode 100644 index 000000000..8b4ed6ddd --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT @@ -0,0 +1,27 @@ +// Copyright 2014 PDFium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE b/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT b/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE b/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-syntax/LICENSE-MIT b/licenses/pdf-backend/hayro-syntax/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-syntax/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/licenses/pdf-backend/hayro/LICENSE-APACHE b/licenses/pdf-backend/hayro/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/licenses/pdf-backend/hayro/LICENSE-MIT b/licenses/pdf-backend/hayro/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c89b7e8d7..e730feb51 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -20,12 +20,12 @@ usage: cargo xtask commands: assets check that every icon/image/font/asset is attributed in ASSETS.md layers enforce the crate dependency layering (plan/architecture.md §3) + pdf-deps verify the pinned PDF backend, including Krilla dependencies wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) - pdf-deps verify the pinned shared PDF writer and patched Krilla parity recompute the InDesign feature-parity summary in docs/parity-checklist.md ico ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) @@ -37,11 +37,11 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), + Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), - Some("pdf-deps") => pdf_deps::run(&root()), Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), @@ -162,6 +162,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo fmt --all -- --check") }), ), + ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ( "clippy", Box::new(|| { @@ -178,7 +179,6 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test --workspace") }), ), - ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index d7dfb1cb7..bb2a4f01d 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -1,11 +1,18 @@ -//! Check the shared writer source, including transitive users such as krilla-svg. +//! Check the shared reader source, including the parser reached through Krilla. use std::path::Path; use std::process::Command; use serde_json::Value; -const REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8"; -const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("krilla", "0.8.2")]; +const REV: &str = "89dcbb3060377a26c99a6d80caa16084366704c4"; +const PACKAGES: &[(&str, &str)] = &[ + ("craft-pdf", "0.1.0"), + ("krilla", "0.8.2"), + ("hayro", "0.7.1"), + ("hayro-interpret", "0.7.0"), + ("hayro-syntax", "0.7.2"), + ("hayro-jbig2", "0.3.0"), +]; pub fn run(root: &Path) -> Result<(), String> { let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) @@ -18,7 +25,7 @@ pub fn run(root: &Path) -> Result<(), String> { } let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; validate(&metadata)?; - println!("PDF writer: facade and patched Krilla resolve once to {REV}"); + println!("PDF backend: facade, Krilla and Hayro resolve once to {REV}"); Ok(()) } @@ -34,6 +41,15 @@ fn validate(metadata: &Value) -> Result<(), String> { return Err(format!("{name}: expected {version} from {source}; got {package}")); } } + let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?; + let facade = packages.iter().find(|p| p["name"] == "craft-pdf").ok_or("craft-pdf: missing package")?; + let node = nodes.iter().find(|n| n["id"] == facade["id"]).ok_or("craft-pdf: missing resolved node")?; + let features = node["features"].as_array().ok_or("craft-pdf: missing features")?; + for required in ["read", "interpret", "raster", "write", "write-pdf"] { + if !features.iter().any(|f| f.as_str() == Some(required)) { + return Err(format!("craft-pdf: missing required {required} feature")); + } + } Ok(()) } @@ -43,17 +59,17 @@ mod tests { use serde_json::json; fn metadata() -> Value { - json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"name":name,"version":version, - "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})).collect::>()}) + json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"id":name,"name":name,"version":version, + "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})).collect::>(), "resolve": {"nodes": [{"id": "craft-pdf", "features": ["read", "interpret", "raster", "write", "write-pdf"]}]}}) } #[test] - fn accepts_one_pinned_facade_and_writer() { + fn accepts_one_pinned_facade_and_reader() { assert!(validate(&metadata()).is_ok()); } #[test] - fn rejects_unpatched_and_local_writer_sources() { + fn rejects_unpatched_and_local_reader_sources() { for source in [Value::Null, json!("registry+https://github.com/rust-lang/crates.io-index")] { let mut m = metadata(); m["packages"][1]["source"] = source; @@ -63,7 +79,7 @@ mod tests { #[test] fn rejects_missing_duplicate_and_wrong_version_packages() { - for index in [0, 1] { + for index in 0..PACKAGES.len() { let mut m = metadata(); m["packages"][index]["version"] = json!("99.0.0"); assert!(validate(&m).is_err()); @@ -77,6 +93,18 @@ mod tests { } } + #[test] + fn rejects_missing_reader_or_writer_features() { + for index in 0..5 { + let mut m = metadata(); + m["resolve"]["nodes"][0]["features"].as_array_mut().unwrap().remove(index); + assert!(validate(&m).is_err()); + } + let mut m = metadata(); + m["resolve"] = Value::Null; + assert!(validate(&m).is_err()); + } + #[test] fn rejects_facade_revision_drift_and_malformed_metadata() { let mut m = metadata(); From d49343f132f063bb0bf6f69024d153e2eaad9eba Mon Sep 17 00:00:00 2001 From: HEOJUNFO Date: Wed, 21 Oct 2026 11:18:27 +0900 Subject: [PATCH 022/117] Export placed greyscale TIFFs as DeviceGray (#1426) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pdf/src/export.rs | 87 ++++++++++++++++++++++++++++++++++++++-- crates/pdf/src/tests.rs | 20 +++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4e7627e9e..e773896af 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1635,6 +1635,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool { icc.get(16..20) == Some(b"RGB ".as_slice()) } +/// Whether an ICC profile describes grey. +fn is_grey_icc(icc: &[u8]) -> bool { + icc.get(16..20) == Some(b"GRAY".as_slice()) +} + +/// Whether `data` decodes to grey (with or without alpha), read from its header. +fn is_grey(data: &[u8]) -> bool { + use image::ImageDecoder; + image::ImageReader::new(std::io::Cursor::new(data)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_decoder().ok()) + .is_some_and(|dec| !dec.color_type().has_color()) +} + /// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets /// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG /// without a profile stays DeviceGray or DeviceCMYK. @@ -1674,6 +1689,10 @@ fn lossless(data: &Arc>, fmt: Option, mode: RasterMo _ => None, }; direct.or_else(|| { + // A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426). + if let Some(grey) = GreyImage::new(data) { + return Image::from_custom(grey, i).ok(); + } // TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB. if mode.tag_rgb { return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok()); @@ -1728,6 +1747,61 @@ impl krilla::image::CustomImage for SrgbImage { } } +/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so +/// a page that only holds black prints as black, not as RGB. +#[derive(Clone, Hash)] +struct GreyImage { + grey: Arc>, + /// Only when some pixel is not opaque. + alpha: Option>>, + size: (u32, u32), + icc: Option>>, +} + +impl GreyImage { + /// `None` when `data` isn't grey or can't be decoded. + fn new(data: &[u8]) -> Option { + if !is_grey(data) { + return None; + } + let img = image::load_from_memory(data).ok()?.to_luma_alpha8(); + let size = img.dimensions(); + let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?; + if pixels == 0 { + return None; + } + let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels)); + for p in img.pixels() { + grey.push(p.0[0]); + alpha.push(p.0[1]); + } + let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha)); + let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new); + Some(Self { grey: Arc::new(grey), alpha, size, icc }) + } +} + +impl krilla::image::CustomImage for GreyImage { + fn color_channel(&self) -> &[u8] { + &self.grey + } + fn alpha_channel(&self) -> Option<&[u8]> { + self.alpha.as_deref().map(Vec::as_slice) + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + self.size + } + fn icc_profile(&self) -> Option<&[u8]> { + self.icc.as_deref().map(Vec::as_slice) + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Luma + } +} + /// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a /// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for /// the output intent's press, and tagging them with another CMYK profile would let a RIP @@ -1774,11 +1848,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option { if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; } - let rgb = img.to_rgb8(); let mut buf = Vec::new(); let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90); - image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; - jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode) + // A grey image stays a grey JPEG (#1426). + let grey = !img.color().has_color(); + if grey { + let l = img.to_luma8(); + image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?; + } else { + let rgb = img.to_rgb8(); + image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; + } + jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode) } #[cfg(test)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ee02e2190..39f472ffc 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -340,6 +340,26 @@ fn cmyk_tiff_keeps_its_inks() { } } +/// A placed greyscale TIFF went into PDFs as DeviceRGB, unlike a greyscale PNG or JPEG (#1426): +/// a page holding only a black-and-white scan declared colour. +#[test] +fn grey_tiff_exports_as_device_gray() { + let px: Vec = vec![0, 64, 128, 255, 255, 128, 64, 0]; + let mut tif = Vec::new(); + tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tif)).unwrap().write_image::(4, 2, &px).unwrap(); + let mut d = doc_with_text("x"); + place(&mut d, tif, (4, 2)); + for standard in [Standard::None, Standard::PdfX4] { + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings); + assert_eq!(image_xobjects(&r.bytes), vec![("DeviceGray".to_string(), px.clone())], "{standard:?}"); + } + // Recompressed, it stays a grey JPEG. + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images: true, compress: false, ..Default::default() }).unwrap(); + let s = String::from_utf8_lossy(&r.bytes); + assert!(s.contains("/DCTDecode") && s.contains("/DeviceGray") && !s.contains("/DeviceRGB"), "a grey JPEG"); +} + /// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner. fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec { let img = From 417ef40c4ce9004435750e568960937c8926b45a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 20 Oct 2026 20:26:43 -0600 Subject: [PATCH 023/117] Pin winit to storytold/winit (same commit) (#1113) The fork moved from skylartaylor/winit to the storytold org. The pinned revision 3d51077f is unchanged; only the repository URL differs. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1861d0b3..ef68540f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5627,7 +5627,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" +source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", diff --git a/Cargo.toml b/Cargo.toml index 2b6ee0c7b..5a936b211 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,7 +116,7 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" } -winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } +winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" } hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" } hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" } From 58791345029fa5cfeb035f52ecefd5147bd158e0 Mon Sep 17 00:00:00 2001 From: Nikolai Nyegaard Date: Wed, 21 Oct 2026 12:01:45 +0200 Subject: [PATCH 024/117] Preferences: list the languages alphabetically by their own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones - The list used the registry order, which is the order the languages were added --- crates/ui-egui/src/dialogs.rs | 4 +-- crates/ui-egui/src/i18n.rs | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..ebab15050 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1984,8 +1984,8 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui( ui, |ui| { - for (code, name) in crate::i18n::LANGUAGES { - if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() { + for (code, name) in crate::i18n::picker_languages() { + if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() { // Applied by `show` right after this frame, which has `&mut DesignApp`. d.fields.insert("applyLanguage".into(), json!(code)); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..60b827dc3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -34,6 +34,16 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("ka", "ქართული"), ]; +/// The interface languages in the order the Preferences picker lists them: English first (the +/// source language), then the rest by their own name with accents folded, so Čeština sits under +/// C. Names in other scripts follow the Latin ones in code point order: Cyrillic, Arabic, +/// Georgian, then the CJK languages. +pub fn picker_languages() -> Vec<(&'static str, &'static str)> { + let mut rest: Vec<(&str, &str)> = LANGUAGES.iter().copied().filter(|(code, _)| !code.is_empty()).collect(); + rest.sort_by_cached_key(|(_, name)| name_sort_key(name)); + std::iter::once(LANGUAGES[0]).chain(rest).collect() +} + /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ // Menus. @@ -5133,3 +5143,42 @@ mod tests { } } } + +/// `name` lowercased with the Latin accents that occur in language names folded away, so a +/// byte-wise sort reads alphabetically. Other scripts are left as they are. +fn name_sort_key(name: &str) -> String { + name.chars() + .flat_map(char::to_lowercase) + .map(|c| match c { + 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'č' => 'c', + 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e', + 'ì'..='ï' | 'ī' => 'i', + 'ñ' | 'ń' | 'ň' => 'n', + 'ò'..='ö' | 'ø' | 'ő' => 'o', + 'ś' | 'š' => 's', + 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u', + 'ý' | 'ÿ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + c => c, + }) + .collect() +} + +#[cfg(test)] +mod picker_order_tests { + use super::*; + + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = picker_languages(); + assert_eq!(order.first(), Some(&("", "English"))); + assert_eq!(order.len(), LANGUAGES.len()); + let keys: Vec = order[1..].iter().map(|(_, name)| name_sort_key(name)).collect(); + assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}"); + let pos = |code: &str| order.iter().position(|(c, _)| *c == code).unwrap_or(usize::MAX); + assert!(pos("cs") < pos("de"), "Čeština sorts under C"); + assert!(pos("pl") < pos("ru"), "Latin names come before Cyrillic"); + assert!(pos("ru") < pos("ja"), "Cyrillic names come before CJK"); + } +} From 0fc845750aafaf596591f3e48e2eac1632b0e943 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 20:04:12 +0500 Subject: [PATCH 025/117] An underline runs across a tab, as the rest of the line's text (#1466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab draws nothing, so it was left out of the underline and strikethrough bars: they stopped at the last letter before each tab and began again after it. Forms rule their fill-in lines with underlined tabs — `Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥` — so an imported form lost every writing line, and an IDML `Underline="true"` on a run of tabs drew nothing at all. A tab is a character with an advance, and InDesign rules its whole width. `PlacedGlyph::spans_rules` says which glyphs a rule runs across: the drawn ones, and a tab or a right-indent tab that advances. The spaces a line break trimmed off the end, the break characters and the markers' carriers are not part of the line's text and are still skipped, so an underline stops at the text and not at the frame edge. Both places that draw the bars from the glyphs use it: the renderer (`build_line`) and the PDF export (`text_rules`). The HTML and PPTX exports mark up runs of text, which already included their tabs. Measured on the repro in the issue, a 420×120 pt page at 2×: before one underline, 63 px: the word "Name" now three, 63 + 219 + 231 px: the word and both tab stretches and the exported PDF gains the two fills for them. A tab with a leader is one continuous bar across the leader dots to the right-aligned text after it. Tests: the renderer's bars and the PDF export's bars for "NameCity" are one bar covering the tab's width, not two with a gap where it is. Both fail without the change (two bars, 36→67.7 and 72→93.2, the tab's 67.7–72 unruled). --- crates/compose/src/lib.rs | 14 ++++++++++++ crates/pdf/src/text.rs | 38 ++++++++++++++++++++++++++++++- crates/render/src/text.rs | 48 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..8188ea290 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -165,6 +165,20 @@ pub struct PlacedGlyph { } impl PlacedGlyph { + /// Whether an underline or a strikethrough runs across this glyph. + /// + /// A tab is not drawn, but it is a character with an advance, and the rule runs across its + /// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and + /// without this they lost every writing line (#1466). The spaces a line break trimmed off the + /// end, the break characters themselves and the markers' carriers are not part of the line's + /// text and are not spanned. + pub fn spans_rules(&self) -> bool { + if self.visible { + return true; + } + self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB) + } + /// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x` /// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`], /// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 300a47d93..1aaf435d9 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -160,7 +160,7 @@ fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option Vec<(designcraft_compose::Rule, Rect)> { let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); for line in &ft.lines { - for g in line.glyphs.iter().filter(|g| g.visible) { + for g in line.glyphs.iter().filter(|g| g.spans_rules()) { let style = &cs.styles[g.style as usize]; let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) }; if !on { @@ -638,3 +638,39 @@ fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: f64) -> BezPath { } bp } + +#[cfg(test)] +mod rule_tests { + use super::*; + use designcraft_compose::Cache; + use designcraft_doc::{Document, ParaFormat, SpreadRef}; + use designcraft_geom::Rect; + + /// A tab draws nothing but advances to its stop, and the underline runs across it, as it does + /// on screen: a form's fill-in lines are underlined tabs (#1466). + #[test] + fn an_underline_runs_across_a_tab() { + let mut doc = Document::new(&Default::default()); + for style in &mut doc.styles_mut().paragraph { + style.chars.underline = Some(true); + } + let layer = doc.default_layer(); + let (_, sid) = + doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 300.0), layer, "Name\tCity", ParaFormat::default()).unwrap(); + let cs = Cache::new().get(&doc, sid, None); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line"); + let bars = text_rules(&cs, &cs.frames[0], true); + assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}"); + let bar = bars[0].1; + assert!(bar.x0 <= tab.x + 0.5 && bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab: {bar:?}, tab {} + {}", tab.x, tab.adv); + // The strikethrough of the same text behaves the same way. + for style in &mut doc.styles_mut().paragraph { + style.chars.underline = None; + style.chars.strikethrough = Some(true); + } + let cs = Cache::new().get(&doc, sid, None); + assert!(text_rules(&cs, &cs.frames[0], true).is_empty(), "no underline now"); + assert_eq!(text_rules(&cs, &cs.frames[0], false).len(), 1, "one strike across the line"); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 70f31b4f6..a4fa80ad0 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -162,7 +162,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let mut strikes: Vec<(String, f32, Rect)> = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { - if !g.visible { + if !g.spans_rules() { continue; } let style = &cs.styles[g.style as usize]; @@ -185,6 +185,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line } } } + // A tab carries the rule across its width but draws nothing itself. + if !g.visible { + continue; + } let outline = db.outline(&g.face, g.gid); if outline.elements().is_empty() { continue; @@ -725,3 +729,45 @@ mod cache_identity_tests { assert_eq!(glyphs.lines[0].runs, runs); } } + +#[cfg(test)] +mod rule_tests { + use super::*; + + /// A tab is not drawn, but it is a character with an advance, and InDesign underlines it: + /// forms rule their fill-in lines with underlined tabs (`Name ⇥⇥⇥⇥, city ⇥⇥⇥⇥`). The bars + /// stopped at the last letter before each tab, so an imported form lost every writing line + /// (#1466). + #[test] + fn an_underline_runs_across_a_tab() { + let mut doc = designcraft_doc::Document::new(&Default::default()); + for style in &mut doc.styles_mut().paragraph { + style.chars.underline = Some(true); + } + let layer = doc.default_layer(); + let (_, sid) = doc + .add_text_frame( + designcraft_doc::SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 400.0, 300.0), + layer, + "Name\tCity", + designcraft_doc::ParaFormat::default(), + ) + .unwrap(); + let owner = crate::Cache::new(); + let cs = owner.get(&doc, sid, None); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.rendered_char == '\t').expect("the tab is placed on the line"); + assert!(!tab.visible, "a tab draws nothing itself"); + assert!(tab.adv > 1.0, "it advances to its stop: {}", tab.adv); + + let mut cache = GlyphCache::default(); + cache.tick(); + let glyphs = cache.get(&cs, &cs.frames[0]); + let bars = &glyphs.lines[0].underlines; + assert_eq!(bars.len(), 1, "one bar across the line, not one before the tab and one after: {bars:?}"); + let bar = bars[0].2; + assert!(bar.x0 <= tab.x + 0.5, "the bar starts at the text, not at the tab: {bar:?}"); + assert!(bar.x1 >= tab.x + tab.adv - 0.5, "the bar covers the tab's width: {bar:?}, tab {} + {}", tab.x, tab.adv); + } +} From 9826154db3cda783cc33c9418189cfd405691d10 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 13:56:26 -0400 Subject: [PATCH 026/117] ci: add SonarCloud scan workflow and config --- sonar-project.properties | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..e69de29bb From b67232a6c8255cce824598f896fdecfe6223654d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 13:57:06 -0400 Subject: [PATCH 027/117] ci: add SonarCloud scan workflow and config --- sonar-project.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index e69de29bb..33ab5fd47 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -0,0 +1,4 @@ +sonar.projectKey=storytold_designcraft +sonar.organization=storytold +sonar.sources=. +sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/** From 4d62a501c18fb3cf453c6e9f19f15cbc9c558e26 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 21 Oct 2026 13:57:30 -0400 Subject: [PATCH 028/117] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef68540f4..ffca2b064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "arboard", "designcraft-engine", @@ -955,7 +955,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-cms", "kurbo", @@ -983,7 +983,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "caseless", "craft-atomic", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1063,7 +1063,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.10.0" +version = "0.11.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1088,7 +1088,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.10.0" +version = "0.11.0" dependencies = [ "flo_curves", "kurbo", @@ -1101,7 +1101,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1116,7 +1116,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1144,7 +1144,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1166,7 +1166,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1183,7 +1183,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-doc", "quick-xml", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1250,7 +1250,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5801,7 +5801,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 5a936b211..a19abf1a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From c2dbcff51f85851cee12ffeee8e827a28808f93d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:46:54 -0400 Subject: [PATCH 029/117] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ sonar-project.properties | 4 ---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/sonarcloud.yml delete mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 33ab5fd47..000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,4 +0,0 @@ -sonar.projectKey=storytold_designcraft -sonar.organization=storytold -sonar.sources=. -sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/** From e2a2502fba08d2c3f59fe5f75b9fafa0098c9f04 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:32 -0400 Subject: [PATCH 030/117] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..f1095122d --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_designcraft +sonar.organization=storytold From aa1a0a73f3ab73fcf22a33a54d4f7c740735c3d7 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:45:41 +0200 Subject: [PATCH 031/117] Gradient panel: drag the gradient onto the Swatches panel to keep it A gradient made in the Gradient panel stayed unnamed: there was no way to drag it into the Swatches panel for reuse. The panel's thumbnail now drags the selection's gradient, and the Swatches list takes the drop as a new gradient swatch (swatch.new, "New Gradient Swatch"). A gradient that already is a swatch is left as it is. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 67 +++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 3e7030e87..e5e9a934c 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -18,8 +18,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); - for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { - swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); + // A gradient dragged from the Gradient panel's thumbnail is dropped on the list. + let (_, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { + for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { + swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); + } + }); + // An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one. + if let Some(g) = dropped + && doc.swatch(&g.0).is_some_and(|w| w.hidden) + { + let _ = app.run("swatch.new", json!({"swatch": g.0})); } for g in &doc.color_groups { let open_id = egui::Id::new(("color_group_open", &g.name)); @@ -575,6 +584,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { #[derive(Clone, Debug)] pub struct SwatchDrag(pub String); +/// The selection's gradient dragged from the Gradient panel's thumbnail (its swatch name): the +/// Swatches panel takes it as a new gradient swatch. +#[derive(Clone, Debug)] +pub struct GradientDrag(pub String); + /// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK). fn color_json(c: &designcraft_color::Color) -> serde_json::Value { use designcraft_color::Color; @@ -680,8 +694,18 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let full = ui.available_width().max(60.0); ui.add_enabled_ui(has_item, |ui| { ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click_and_drag()); thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border); + // Drag the gradient onto the Swatches panel to keep it as a swatch. + if is_gradient { + resp.dnd_set_drag_payload(GradientDrag(fill.swatch.clone())); + if resp.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag"))); + thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border); + } + } // Clicking the thumbnail applies the gradient shown (InDesign's way to start one). if resp.clicked() && !is_gradient { send = Some(json!({})); @@ -1025,6 +1049,43 @@ mod gradient_panel_tests { assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)); assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset); } + + /// The Gradient panel's thumbnail drags the selection's gradient onto the Swatches panel, + /// which keeps it as a gradient swatch. + #[test] + fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() { + use egui::{Event, Modifiers, PointerButton}; + let mut app = app_with_frame(); + app.run("object.gradient", json!({})).unwrap(); + app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap(); + app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120})).unwrap(); + let named = + |app: &DesignApp| app.session.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect::>(); + let before = named(&app); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + h.run_steps(6); + // The thumbnail sits left of the Type row. + let ty = h.get_by_label("Type:").rect(); + let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); + let to = h.get_by_label("[Black]").rect().center(); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for i in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + let after = named(&h.state().app); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch")); + let d = &h.state().app.session.doc().unwrap().doc; + assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. }))); + let id = h.state().app.session.doc().unwrap().selection.items[0]; + assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name"); + } } #[cfg(test)] From dec78db02c90a5060b4562036cff22d871f47059 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:16 -0400 Subject: [PATCH 032/117] security: enforce HTTPS on curl downloads (SonarCloud S6506) (#1519) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 2d7b80ee8..12ad8e0f2 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -148,7 +148,7 @@ if has appimage; then TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then download="$TOOL.download" - curl -fsSL -o "$download" \ + curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - chmod 755 "$download" From 3b985e3813c1031b955db2a27d0fae0fcd9397e9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:27 -0400 Subject: [PATCH 033/117] Use chrono instead of hand-rolled UTC conversion (#1479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil / civil_from_days / days_from_civil copies (the 146_097 / 719_468 magic numbers) across the logging timestamp, text-variable dates, EPUB dcterms:modified, PDF metadata dates and PPTX core.xml dates with chrono, added as a workspace dependency (default-features = false, std only, so wasm is unaffected). - apps/designcraft logging `timestamp` (private civil_from_days deleted). - doc text variables: `civil` and `days_from_civil` use chrono::NaiveDate; `format_date` and `parse_iso_date` keep their own format/parse logic and still call these helpers. - epub `iso8601`, pdf `civil`, pptx `civil`: UTC datetime via chrono::DateTime::from_timestamp. Output is byte-identical and pinned by the existing date, ISO-date, EPUB, image, EPS and logging tests. The OOXML/EPUB dcterms timestamps keep their exact strings. Each converted helper keeps a proleptic-Gregorian fallback for dates outside chrono's NaiveDate range (and for the serial/overflow dates parse_iso_date can hand days_from_civil), so no input changes or panics. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on a clock outside chrono's range; keep 5-digit years unprefixed logging.rs timestamp(): chrono's From unwraps, so a clock past year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp and read such a clock, or a pre-epoch clock, as the epoch like the old saturating code did. Years past 9999 are written with {:04} like before, because %Y adds a '+'. Tests cover both cases. epub iso8601(): write the year with {:04} instead of %Y, matching the old {year:04} output, so years past 9999 are not prefixed with '+'. Test added. AGENTS.md: the "Time goes through chrono" rule now notes the narrow fallback for dates outside chrono::NaiveDate's range, which crates/doc/src/vars.rs retains (Howard Hinnant's algorithm). * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies doc/vars.rs kept hand-rolled Hinnant `civil_fallback`/`days_from_civil_fallback` copies for dates outside chrono's ±262143-year range, and AGENTS.md carved a "narrow fallback" exception for them, which left calendar arithmetic outside chrono after all. Replace both copies with the Gregorian 400-year cycle (146_097 days): chrono converts the day/year within one cycle and whole cycles are added back via i128 (so i64::MIN/MAX can't overflow). `civil` is reached by the system clock through `format_date(now(), ..)`, so it must stay exact; `days_from_civil` only ever sees a 4-digit parsed year via `parse_iso_date`, but it uses the same cycle form for consistency. Both reproduce the pre-chrono proleptic Gregorian result, so outputs are unchanged (verified against the old formula over wide ranges and at the i64 extremes). New test pins dates past chrono's range on both sides of the epoch, through the public `format_date` and round-tripping `days_from_civil`/`civil`, with values from the old formula. Removed the AGENTS.md "narrow fallback" exception. epub/pdf/pptx `civil`/`iso8601` are left as-is: the PR already dropped their hand-rolled copies for pure chrono, and their remaining epoch (`unwrap_or_default`) fallback only triggers for a metadata/clock timestamp past year 262143 -- the live-clock/file-mtime case FredApps accepted, with outputs that clamp the year anyway (pptx 0..9999, PDF DateTime). No hand-rolled copy remains there to replace. Tested: cargo test -p designcraft-doc; fmt --check; clippy --all-targets -D warnings. * Carry an overflowing day into the next month again in parse_iso_date parse_iso_date accepts any day 1..=31, and the old days_from_civil carried a day past the end of its month into the next one (2019-02-30 is 2019-03-02). from_ymd_opt rejects such a day, and unwrap_or(0) turned it into 1970-01-01. chrono now places the 1st of the month and the day is added on, which restores the old normalization. Tested through days_from_civil and parse_iso_date. * time: preserve old out-of-range date conversion (no epoch fallback) `DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant past chrono's year range to 1970-01-01, changing output the old hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a cycle-based helper that routes the calendar through chrono (the calendar repeats every 400 years = 146_097 days, so month/day/time come from chrono and only the year is carried, keeping every i64 exact), then apply each format's existing year clamp afterwards. - apps/designcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch. - crates/epub/src/lib.rs: `iso8601` via `utc_parts`. - crates/pdf/src/export.rs: `civil` cycle-based; `date_time` keeps its 0..=9999 year clamp. - crates/pptx/src/package.rs: `civil` cycle-based, keeps its 0..=9999 year clamp on the year only (month/day/time keep the real instant). Pinned old outputs in tests (through the public helpers): logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch) epub 10_000_000_000_000 -> 318857-05-20T17:46:40Z -10_000_000_000_000 -> -314918-08-13T06:13:20Z i64::MAX -> 292277026596-12-04T15:30:07Z i64::MIN -> -292277022657-01-27T08:29:52Z pdf civil(10_000_000_000_000) -> (318857,5,20,17,46,40) pptx civil(10_000_000_000_000) -> (9999,5,20,17,46,40) (year clamped) * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 1 + Cargo.lock | 14 ++++++ Cargo.toml | 2 + apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/logging.rs | 57 +++++++++++++++++------- crates/doc/Cargo.toml | 1 + crates/doc/src/vars.rs | 78 +++++++++++++++++++++++++-------- crates/epub/Cargo.toml | 1 + crates/epub/src/lib.rs | 45 ++++++++++++++----- crates/pdf/Cargo.toml | 1 + crates/pdf/src/export.rs | 46 +++++++++++++------ crates/pptx/Cargo.toml | 1 + crates/pptx/src/package.rs | 39 +++++++++++------ 13 files changed, 214 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65693656c..0be7fa87b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`). diff --git a/Cargo.lock b/Cargo.lock index ffca2b064..1180cf0b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -683,6 +683,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -936,6 +945,7 @@ name = "designcraft" version = "0.11.0" dependencies = [ "arboard", + "chrono", "designcraft-engine", "designcraft-ui-egui", "eframe", @@ -1005,6 +1015,7 @@ dependencies = [ name = "designcraft-doc" version = "0.11.0" dependencies = [ + "chrono", "designcraft-color", "designcraft-geom", "proptest", @@ -1056,6 +1067,7 @@ dependencies = [ name = "designcraft-epub" version = "0.11.0" dependencies = [ + "chrono", "designcraft-color", "designcraft-doc", "zip", @@ -1146,6 +1158,7 @@ dependencies = [ name = "designcraft-pdf" version = "0.11.0" dependencies = [ + "chrono", "craft-pdf", "designcraft-color", "designcraft-compose", @@ -1168,6 +1181,7 @@ dependencies = [ name = "designcraft-pptx" version = "0.11.0" dependencies = [ + "chrono", "designcraft-color", "designcraft-compose", "designcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index a19abf1a9..0e728e87f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" rayon = "1" unicode-bidi = "0.3" diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 7dc83bc55..81a267aa9 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for designcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs index d3e0dbea7..5fe70a749 100644 --- a/apps/designcraft/src/logging.rs +++ b/apps/designcraft/src/logging.rs @@ -90,27 +90,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -317,6 +326,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index ed7430db7..1d3da4062 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -14,6 +14,7 @@ designcraft-color = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 1ea4f8ced..0a3f5ea46 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -126,28 +126,46 @@ pub fn now() -> i64 { } } -/// (year, month 1–12, day 1–31) from days since 1970-01-01 (H. Hinnant's public-domain algorithm). +/// (year, month 1–12, day 1–31) from days since 1970-01-01. +/// +/// `NaiveDate` only covers about ±262143 years, but the system clock can run past it, so this +/// stays exact for every `days`. The Gregorian calendar repeats every 400 years (146_097 days): +/// chrono converts the day within its cycle and whole cycles go back into the year (an i128 so no +/// `days` overflows), which matches the proleptic Gregorian answer the old hand-rolled code gave. +/// 1970-01-01 is `num_days_from_ce` 719_163. fn civil(days: i64) -> (i64, u32, u32) { - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (yoe + era * 400 + i64::from(m <= 2), m, d) + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + // Unreachable: `rest + 719_163` is always a valid CE day. + None => (1970, 1, 1), + } } -/// Days since 1970-01-01 of a civil date (inverse of [`civil`]). +/// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year. +/// +/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year within +/// its cycle and whole cycles are added back as days (an i128 so the extremes can't overflow), +/// which matches the proleptic Gregorian count the old hand-rolled code gave. 1970-01-01 is +/// `num_days_from_ce` 719_163. +/// +/// A day past the end of its month carries into the next one, as before chrono (`2019-02-30` +/// is 2019-03-02: `parse_iso_date` accepts any day 1..=31): chrono places the 1st of the month +/// and the rest of the days are added on. Only a month outside 1..=12 reads as 0. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y.rem_euclid(400); - let mp = i64::from(if m > 2 { m - 3 } else { m + 9 }); - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, 1)) + .and_then(|first| { + let days = i128::from(chrono::Datelike::num_days_from_ce(&first)) - 719_163 + cycles * 146_097 + i128::from(d) - 1; + i64::try_from(days).ok() + }) + .unwrap_or(0) } /// Unix seconds of an ISO 8601 timestamp such as `2019-12-16T09:48:02+01:00` (seconds, the @@ -294,6 +312,30 @@ mod tests { assert_eq!(format_date(951_782_400, "yyyy-MM-dd"), "2000-02-29"); } + #[test] + fn dates_beyond_chrono_range() { + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; the clock can reach this, + // so keep the proleptic Gregorian date the old formula gave rather than the epoch, on both + // sides of the epoch. Values from the pre-chrono formula, through the public `format_date`. + assert_eq!(format_date(10_000_000_000_000, "yyyy-MM-dd HH:mm"), "318857-05-20 17:46"); + assert_eq!(format_date(-8_334_659_289_600, "yyyy-MM-dd"), "-262145-03-01"); + // `days_from_civil` is exact and inverts `civil` past the range, both sides. + for (y, m, d, days) in + [(262_144i64, 1u32, 1u32, 95_026_602i64), (300_000, 6, 15, 108_853_388), (-262_145, 3, 1, -96_465_964), (-300_000, 2, 29, -110_292_219)] + { + assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}"); + assert_eq!(civil(days), (y, m, d), "{days}"); + } + // The extremes must not overflow or panic (i128 intermediates). + let _ = (civil(i64::MAX), civil(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); + // A day past the end of its month carries over, as before chrono, instead of reading as 1970. + assert_eq!(days_from_civil(2019, 2, 30), days_from_civil(2019, 3, 2)); + assert_eq!(days_from_civil(2024, 2, 30), days_from_civil(2024, 3, 1)); + assert_eq!(days_from_civil(2019, 4, 31), days_from_civil(2019, 5, 1)); + assert_eq!(parse_iso_date("2019-02-30"), Some(1_551_484_800)); + assert_eq!(parse_iso_date("2019-02-30T09:48:02Z"), parse_iso_date("2019-03-02T09:48:02Z")); + } + #[test] fn iso_dates() { assert_eq!(parse_iso_date("2026-10-01T14:05:00Z"), Some(1_790_863_500)); diff --git a/crates/epub/Cargo.toml b/crates/epub/Cargo.toml index 0cbe1673c..2836b0a92 100644 --- a/crates/epub/Cargo.toml +++ b/crates/epub/Cargo.toml @@ -12,3 +12,4 @@ workspace = true designcraft-doc = { workspace = true } designcraft-color = { workspace = true } zip = { workspace = true } +chrono = { workspace = true } diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 84bf56f1e..bd4571501 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -123,19 +123,32 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { } /// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. +/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the +/// instant is moved into range by whole cycles and only the year is carried (matches the old +/// `civil_from_days`), rather than resetting to the epoch. fn iso8601(t: i64) -> String { - let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60) + let (y, dt) = utc_parts(t, 0); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) +} + +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the instant moved into chrono's range by +/// whole 400-year cycles). Month, day and time of day come from chrono; only the year is carried, +/// which keeps every `i64` exact. chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// The package's `dcterms:modified`: the given time, else the document's last save or creation. @@ -1582,6 +1595,14 @@ mod tests { (951_782_400, "2000-02-29T00:00:00Z"), (1_791_891_000, "2026-10-13T11:30:00Z"), (-86_400, "1969-12-31T00:00:00Z"), + // Years past 9999 keep the plain zero-padded year (no `+`). + (253_402_300_800, "10000-01-01T00:00:00Z"), + // Beyond chrono's range the old `civil_from_days` still converted; pin that output + // (never the epoch) in both directions and at the `i64` extremes. + (10_000_000_000_000, "318857-05-20T17:46:40Z"), + (-10_000_000_000_000, "-314918-08-13T06:13:20Z"), + (i64::MAX, "292277026596-12-04T15:30:07Z"), + (i64::MIN, "-292277022657-01-27T08:29:52Z"), ] { assert_eq!(iso8601(t), want); } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6f40462b7..09da578ee 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -20,6 +20,7 @@ designcraft-images = { workspace = true } image = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } # The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it). hayro-syntax = "0.7.2" diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4e7627e9e..df44acbf2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -436,20 +436,25 @@ fn date_time(t: i64) -> krilla::metadata::DateTime { .utc_offset_minute(0) } -/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. +/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. A time +/// outside chrono's year range still converts (the calendar repeats every 400 years, so the +/// instant is moved into range by whole cycles and only the year is carried, matching the old +/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp. pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - (year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8) + use chrono::{Datelike, Timelike}; + let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); + (y, d.month() as u8, d.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) + } + _ => (1970, 1, 1, 0, 0, 0), // unreachable + } } /// A successful conversion and its name-independent diagnostic. Replayed once for each asset. @@ -2117,4 +2122,19 @@ mod eps_tests { assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}"); } } + + #[test] + fn civil_dates_match_the_old_cycle_conversion_including_beyond_chrono() { + use super::civil; + assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + // Beyond chrono's year range the old `civil` still converted; pin that output, never the + // epoch. `date_time` is what clamps the year to the PDF `0..=9999` range afterwards. + assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0)); + assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40)); + assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20)); + assert_eq!(civil(i64::MAX), (292277026596, 12, 4, 15, 30, 7)); + assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); + } } diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml index c6e1c876f..afc90c69c 100644 --- a/crates/pptx/Cargo.toml +++ b/crates/pptx/Cargo.toml @@ -19,6 +19,7 @@ skrifa = { workspace = true } image = { workspace = true } thiserror = { workspace = true } zip = { workspace = true } +chrono = { workspace = true } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs index ed5ad553b..13939aecc 100644 --- a/crates/pptx/src/package.rs +++ b/crates/pptx/src/package.rs @@ -290,20 +290,25 @@ fn core(p: &Package) -> String { s } -/// Unix seconds → UTC civil date and time (proleptic Gregorian). +/// Unix seconds → UTC civil date and time (proleptic Gregorian), year clamped to the W3CDTF +/// `0..=9999` range. A time outside chrono's year range still converts (the calendar repeats every +/// 400 years, so the instant is moved into range by whole cycles and only the year is carried, +/// matching the old `civil`), then the year is clamped — never a silent reset to the epoch. fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = yoe + era * 400 + i64::from(m <= 2); - (y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32) + use chrono::{Datelike, Timelike}; + let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); + (y.clamp(0, 9999), d.month(), d.day(), t.hour(), t.minute(), t.second()) + } + _ => (1970, 1, 1, 0, 0, 0), // unreachable + } } /// A neutral theme: text sets its own fonts and colours, so these only matter for new objects. @@ -331,5 +336,13 @@ mod tests { assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0)); assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + // Beyond chrono's year range the old `civil` still converted and clamped only the year to + // the W3CDTF `0..=9999` range; pin that (month/day/time keep the real instant), never the + // epoch, in both directions and at the `i64` extremes. + assert_eq!(civil(253_402_300_800), (9999, 1, 1, 0, 0, 0)); + assert_eq!(civil(10_000_000_000_000), (9999, 5, 20, 17, 46, 40)); + assert_eq!(civil(-10_000_000_000_000), (0, 8, 13, 6, 13, 20)); + assert_eq!(civil(i64::MAX), (9999, 12, 4, 15, 30, 7)); + assert_eq!(civil(i64::MIN), (0, 1, 27, 8, 29, 52)); } } From 2aa5da5950580f077cadb4d6c1a0e29e1e173c48 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:40:26 -0400 Subject: [PATCH 034/117] Paragraph rules: Rule Above's offset is measured from the baseline (#1535) * Paragraph rules: Rule Above's offset is measured from the first line's baseline * ROADMAP: Rule Above offset progress line --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 5 +++-- crates/compose/src/tests.rs | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..5d91d43f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..89ea89bd3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1860,11 +1860,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None }; } // Rules and shading for the paragraph. - if let Some((fi, bl, asc, _)) = first_line_rect { + if let Some((fi, bl, _, _)) = first_line_rect { let ft = &mut out.frames[fi]; if pp.rule_above.on { let r = &pp.rule_above; - let y = bl - asc - r.offset; + // The offset runs from the first line's baseline up to the rule's bottom edge. + let y = bl - r.offset; let col = ft .lines .iter() diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..15c412ada 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7290,3 +7290,23 @@ fn greek_text_gets_greek_hyphenation() { let el = hyphen::Lang::for_language("Greek").unwrap(); assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]); } + +/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in +/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above +/// (an INDD project list whose rules crossed the previous paragraph's text). +#[test] +fn rule_above_offset_is_measured_from_the_baseline() { + use designcraft_doc::Rule; + let rule = Rule { on: true, weight: 1.5, offset: 10.0, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(rule), ..Default::default() }; + let (d, sid, fid) = doc_with("Row one\nRow two", Rect::new(0.0, 0.0, 300.0, 200.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (first, second) = (lines[0], lines[1]); + let mut decos: Vec = cs.frame(fid).unwrap().decos.iter().map(|dc| dc.rect).collect(); + decos.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + let rule = decos[1]; + assert!((rule.y1 - (second.baseline - 10.0)).abs() < 1e-6, "{rule:?}, baseline {}", second.baseline); + assert!((rule.height() - 1.5).abs() < 1e-6); + assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline); +} From 438d7b6d79e74b3491f84eef5e295951a07b925c Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:46:38 -0500 Subject: [PATCH 035/117] Dedupe the 400-year-cycle UTC conversion and the EPUB package writer (#1537) * Dedupe the 400-year-cycle UTC conversion and the EPUB package writer SonarCloud flagged new-code duplication on main (17%): five copies of the "move the instant into chrono's range by whole 400-year cycles" conversion and two copies of the EPUB OPF/container/zip writer. - designcraft-doc gains a `time` module: `date_parts(days)` and `utc_parts(secs, nanos)`. App logging, EPUB `iso8601`, PDF `civil`, PPTX `civil` and text-variable `civil` are now thin callers; each keeps its own format string and year clamp. The app reaches it through `designcraft_engine::doc`, so its direct chrono dependency is dropped. - EPUB: `package_opf` + `write_package` shared by fixed-layout and reflowable export (same entry order and compression). - Boundary tests on the helper: pre-1970, year 9999 -> 10000, i64 extremes, nanos past a second. Output is unchanged: existing date tests pass, and fixed/reflowable EPUBs (with/without cover, author, pre-1970 and i64::MAX dates) are byte-identical to origin/main. * time: drop the unreachable i128 clamp and match arms in utc_parts Whole 400-year cycles times 400 stay under 2.6e16 for any i64 day count, so the year can't overflow i64 and the i128 + saturate was dead code (carried over from the copies this replaced). The date and time lookups can't fail either; each keeps one unwrap_or_default instead of a match with a fake 1970 fallback. Output and tests unchanged. --- Cargo.lock | 1 - apps/designcraft/Cargo.toml | 1 - apps/designcraft/src/logging.rs | 22 +---- crates/doc/src/lib.rs | 1 + crates/doc/src/time.rs | 74 +++++++++++++++++ crates/doc/src/vars.rs | 20 +---- crates/epub/src/lib.rs | 137 +++++++++++++------------------- crates/pdf/src/export.rs | 15 +--- crates/pptx/src/package.rs | 15 +--- 9 files changed, 140 insertions(+), 146 deletions(-) create mode 100644 crates/doc/src/time.rs diff --git a/Cargo.lock b/Cargo.lock index 1180cf0b1..0b022ecfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,7 +945,6 @@ name = "designcraft" version = "0.11.0" dependencies = [ "arboard", - "chrono", "designcraft-engine", "designcraft-ui-egui", "eframe", diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 81a267aa9..7dc83bc55 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -18,7 +18,6 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } -chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for designcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs index 5fe70a749..04238e853 100644 --- a/apps/designcraft/src/logging.rs +++ b/apps/designcraft/src/logging.rs @@ -97,31 +97,11 @@ pub fn timestamp(t: SystemTime) -> String { // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value // past `i64::MAX` seconds saturates rather than falling back to the epoch. let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); - let (y, t) = utc_parts(secs, since.subsec_nanos()); + let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos()); // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into -/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), -/// so month, day and time of day come from chrono and only the year is carried, which keeps every -/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. -fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { - use chrono::Datelike; - let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. - let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); - match (date, time) { - (Some(d), Some(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) - } - _ => (1970, chrono::NaiveDateTime::default()), // unreachable - } -} - fn numbered(dir: &Path, n: usize) -> PathBuf { if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bf834ae0..5c854ba9f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -35,6 +35,7 @@ pub mod story; pub mod styles; mod swatch_refs; pub mod table; +pub mod time; pub mod vars; pub mod xref; diff --git a/crates/doc/src/time.rs b/crates/doc/src/time.rs new file mode 100644 index 000000000..fb21b4b4b --- /dev/null +++ b/crates/doc/src/time.rs @@ -0,0 +1,74 @@ +//! Unix time → proleptic Gregorian calendar, exact for every `i64`. +//! +//! chrono is the only calendar logic. `NaiveDate` covers about ±262143 years, but clocks and files +//! can hold instants past that, so these extend its range: the Gregorian calendar repeats every +//! 400 years (146_097 days), so the instant is moved into chrono's range by whole cycles, chrono +//! gives the month, day and time of day, and the cycles are added back to the year (at most about +//! 2.5e16, so it fits an `i64`). Callers own only their output format and any year clamp. + +use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime}; + +/// Days since 1970-01-01 → (proleptic Gregorian year, a chrono date with the same month and day; +/// its own year is not the real one). +pub fn date_parts(days: i64) -> (i64, NaiveDate) { + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // 1970-01-01 is CE day 719_163; one cycle on from it is well inside chrono's range. + let d = NaiveDate::from_num_days_from_ce_opt(rest as i32 + 719_163).unwrap_or_default(); + (i64::from(d.year()) + cycles * 400, d) +} + +/// Unix seconds (+ nanos, clamped to 999_999_999) → (proleptic Gregorian year, a chrono date and +/// time with the same month, day and time of day; its own year is not the real one). +pub fn utc_parts(secs: i64, nanos: u32) -> (i64, NaiveDateTime) { + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (y, d) = date_parts(days); + let t = NaiveTime::from_num_seconds_from_midnight_opt(sod as u32, nanos.min(999_999_999)).unwrap_or_default(); + (y, d.and_time(t)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Timelike; + + fn ymdhms(secs: i64) -> (i64, u32, u32, u32, u32, u32) { + let (y, t) = utc_parts(secs, 0); + (y, t.month(), t.day(), t.hour(), t.minute(), t.second()) + } + + #[test] + fn epoch_and_ordinary_dates() { + assert_eq!(ymdhms(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(ymdhms(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + } + + #[test] + fn before_1970() { + assert_eq!(ymdhms(-1), (1969, 12, 31, 23, 59, 59)); + assert_eq!(ymdhms(-86_400), (1969, 12, 31, 0, 0, 0)); + assert_eq!(ymdhms(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20)); + } + + #[test] + fn year_9999_rolls_into_10000() { + assert_eq!(ymdhms(253_402_300_799), (9999, 12, 31, 23, 59, 59)); + assert_eq!(ymdhms(253_402_300_800), (10000, 1, 1, 0, 0, 0)); + } + + #[test] + fn i64_extremes_stay_exact() { + assert_eq!(ymdhms(i64::MAX), (292277026596, 12, 4, 15, 30, 7)); + assert_eq!(ymdhms(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); + // Days (not seconds) at the extremes: whole cycles still fit, so the year can't overflow. + let ((hi, _), (lo, _)) = (date_parts(i64::MAX), date_parts(i64::MIN)); + assert!(hi > 25_000_000_000_000_000 && hi < i64::MAX && lo < -25_000_000_000_000_000 && lo > i64::MIN); + } + + #[test] + fn nanos_past_a_second_are_clamped() { + assert_eq!(utc_parts(0, 999_999_999).1.nanosecond(), 999_999_999); + assert_eq!(utc_parts(0, 1_500_000_000).1.nanosecond(), 999_999_999); + assert_eq!(utc_parts(0, u32::MAX).1.second(), 0); + } +} diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 0a3f5ea46..c53eedb1a 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -126,24 +126,12 @@ pub fn now() -> i64 { } } -/// (year, month 1–12, day 1–31) from days since 1970-01-01. -/// -/// `NaiveDate` only covers about ±262143 years, but the system clock can run past it, so this -/// stays exact for every `days`. The Gregorian calendar repeats every 400 years (146_097 days): -/// chrono converts the day within its cycle and whole cycles go back into the year (an i128 so no -/// `days` overflows), which matches the proleptic Gregorian answer the old hand-rolled code gave. -/// 1970-01-01 is `num_days_from_ce` 719_163. +/// (year, month 1–12, day 1–31) from days since 1970-01-01, exact for every `days` +/// (see [`crate::time::date_parts`]). fn civil(days: i64) -> (i64, u32, u32) { use chrono::Datelike; - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - // Unreachable: `rest + 719_163` is always a valid CE day. - None => (1970, 1, 1), - } + let (y, d) = crate::time::date_parts(days); + (y, d.month(), d.day()) } /// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year. diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index bd4571501..1df759608 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -127,30 +127,11 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { /// instant is moved into range by whole cycles and only the year is carried (matches the old /// `civil_from_days`), rather than resetting to the epoch. fn iso8601(t: i64) -> String { - let (y, dt) = utc_parts(t, 0); + let (y, dt) = designcraft_doc::time::utc_parts(t, 0); // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) } -/// Unix seconds (+ nanos) → (proleptic Gregorian year, the instant moved into chrono's range by -/// whole 400-year cycles). Month, day and time of day come from chrono; only the year is carried, -/// which keeps every `i64` exact. chrono is the only calendar logic. -fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { - use chrono::Datelike; - let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. - let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); - match (date, time) { - (Some(d), Some(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) - } - _ => (1970, chrono::NaiveDateTime::default()), // unreachable - } -} - /// The package's `dcterms:modified`: the given time, else the document's last save or creation. fn modified(doc: &Document, opts: &EpubOptions) -> String { let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); @@ -206,8 +187,6 @@ pub struct FixedPage { /// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let lang = language_tag(&opts.language); let id = identifier(doc, opts, &title); @@ -239,12 +218,41 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions "\n\n\n{}\n

\n\n", xml_text(&title) ); - let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", - xml_text(&id), - xml_text(&title) + let rendition = format!( + "\npre-paginated\nauto\n" ); + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); + let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); + write_package(&opf, &nav, entries) +} + +/// The package metadata both EPUB flavours share. +struct PackageHead<'a> { + lang: &'a str, + id: &'a str, + title: &'a str, + author: Option<&'a str>, + modified: &'a str, +} + +/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after +/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. +fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { + let PackageHead { lang, id, title, author, modified } = h; + let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); + format!( + "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", + xml_text(id), + xml_text(title) + ) +} + +/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order +/// as (path, bytes, stored uncompressed). +fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { + let io = |e: std::io::Error| EpubError(e.to_string()); + let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); { @@ -259,9 +267,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } - for (name, data) in &files { - let o = if name.ends_with(".png") { stored } else { deflate }; - z.start_file(name.as_str(), o).map_err(zerr)?; + for (name, data, store) in entries { + z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?; z.write_all(data).map_err(io)?; } z.finish().map_err(zerr)?; @@ -1379,8 +1386,6 @@ fn base64(data: &[u8]) -> String { /// Export the document as a reflowable EPUB 3. pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); @@ -1441,56 +1446,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n", - xml_text(&id), - xml_text(&title) - ); - let container = "\n\n"; - let mut buf = std::io::Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).map_err(zerr)?; - z.write_all(b"application/epub+zip").map_err(io)?; - for (name, data) in [ - ("META-INF/container.xml", container.as_bytes()), - ("OEBPS/content.opf", opf.as_bytes()), - ("OEBPS/nav.xhtml", nav.as_bytes()), - ("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()), - ] { - z.start_file(name, deflate).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (k, c) in chapters.iter().enumerate() { - z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?; - z.write_all(c.as_bytes()).map_err(io)?; - } - for (name, (_, data)) in &images { - z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (k, f) in opts.fonts.iter().enumerate() { - z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?; - z.write_all(&f.data).map_err(io)?; - } - if let Some(c) = &opts.cover { - z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?; - z.write_all(c).map_err(io)?; - let xhtml = format!( - "\n\n\n{}\n\"{}\"/\n\n", - xml_text(&title), - cover_file(c).0, - xml_attr(&title) - ); - z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; - z.write_all(xhtml.as_bytes()).map_err(io)?; - } - z.finish().map_err(zerr)?; + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); + let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); + let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; + entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); + entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); + entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); + let cover_xhtml = opts.cover.as_ref().map(|c| { + format!( + "\n\n\n{}\n\"{}\"/\n\n", + xml_text(&title), + cover_file(c).0, + xml_attr(&title) + ) + }); + if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { + entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); + entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); } - Ok(buf.into_inner()) + write_package(&opf, &nav, entries) } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index df44acbf2..3abcdb521 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -442,19 +442,8 @@ fn date_time(t: i64) -> krilla::metadata::DateTime { /// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp. pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { use chrono::{Datelike, Timelike}; - let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400)); - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. - let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); - match (date, time) { - (Some(d), Some(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); - (y, d.month() as u8, d.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) - } - _ => (1970, 1, 1, 0, 0, 0), // unreachable - } + let (y, t) = designcraft_doc::time::utc_parts(t, 0); + (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) } /// A successful conversion and its name-independent diagnostic. Replayed once for each asset. diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs index 13939aecc..7e9e123e0 100644 --- a/crates/pptx/src/package.rs +++ b/crates/pptx/src/package.rs @@ -296,19 +296,8 @@ fn core(p: &Package) -> String { /// matching the old `civil`), then the year is clamped — never a silent reset to the epoch. fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) { use chrono::{Datelike, Timelike}; - let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400)); - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. - let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); - let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); - match (date, time) { - (Some(d), Some(t)) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }); - (y.clamp(0, 9999), d.month(), d.day(), t.hour(), t.minute(), t.second()) - } - _ => (1970, 1, 1, 0, 0, 0), // unreachable - } + let (y, t) = designcraft_doc::time::utc_parts(t, 0); + (y.clamp(0, 9999), t.month(), t.day(), t.hour(), t.minute(), t.second()) } /// A neutral theme: text sets its own fonts and colours, so these only matter for new objects. From 496b1899a2ab397e8057e7e482f7c21c248dc795 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:18:41 -0400 Subject: [PATCH 036/117] macOS menu bar: menus keep working after a language change; key equivalents follow the shortcuts (#1540) * macOS menu bar: keep working after a rebuild; key equivalents follow the shortcuts in effect * macOS menu bar: the shortcut test sets its key with window.setShortcut --- ROADMAP.md | 1 + apps/designcraft/src/native_menu.rs | 52 ++++++++++++++++++++--------- crates/ui-egui/src/menus.rs | 20 +++++++++-- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5d91d43f9..013cc48db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 86a88e5c0..e444641bf 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -27,6 +27,9 @@ pub struct NativeMenu { shown_for: (String, bool), /// File › Open Recent, rebuilt when the list of recent files changes. recent: Option, + /// The commands with key equivalents and the shortcut each was given: the menu is rebuilt + /// when one changes, so a shortcut moved to another command leaves its old item. + keys: Vec<(String, Option)>, } struct RecentMenu { @@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String { menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label)) } -/// A command's default shortcut, as the in-window menus show it. -fn shortcut(id: &str) -> Option<&'static str> { - menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut)) +/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it. +fn shortcut(app: &DesignApp, id: &str) -> Option { + menus::shortcut_of(app, id) } impl NativeMenu { pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self { // A click in the menu bar reaches the app with no input over the window, so nothing would // draw the frame that runs the command until the pointer or a key comes back: wake egui. + // muda keeps the first handler for the life of the process, so this channel serves every + // menu bar built later (`build_bar`). let (tx, events) = channel(); let repaint = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { let _ = tx.send(e); repaint.request_repaint(); })); + Self::build_bar(app, events) + } + + /// The menu bar for the current language and settings, fed by `events`. + fn build_bar(app: &mut DesignApp, events: Receiver) -> Self { let menu = Menu::new(); let mut items = HashMap::new(); let mut counter = 0usize; @@ -75,7 +85,7 @@ impl NativeMenu { for entry in menus::MAC_APP_MENU { let _ = match *entry { AppMenuItem::Cmd { id, label, command } => { - let sc = shortcut(command).and_then(accel); + let sc = shortcut(app, command).as_deref().and_then(accel); let item = MenuItem::with_id(id, menu_title(app, label), true, sc); let appended = app_menu.append(&item); items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item))); @@ -96,15 +106,19 @@ impl NativeMenu { } menu.init_for_nsapp(); app.native_menu = true; - app.native_shortcuts = items - .values() - .filter_map(|(id, p, _)| { - let sc = shortcut(id)?; - (p.is_null() && accel(sc).is_some()).then(|| id.clone()) - }) - .collect(); + let keys: Vec<(String, Option)> = + items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect(); + app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect(); let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None }); - Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent } + Self { + _menu: menu, + items, + events, + last_refresh: 0.0, + shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), + recent, + keys, + } } /// Put the current recent files at the top of File › Open Recent when they changed. @@ -153,13 +167,21 @@ impl NativeMenu { // back by Cancel): the system menu bar follows at once, like the panels (the new menu // replaces the old one as the application's menu before that one is dropped). if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 { - *self = Self::install(app, ctx); + let events = std::mem::replace(&mut self.events, channel().1); + *self = Self::build_bar(app, events); } let now = designcraft_ui_egui::now_ms(); if now - self.last_refresh < 250.0 { return; } self.last_refresh = now; + // A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the + // menu bar before the window sees the key, so the bar must carry the new keys. + if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) { + let events = std::mem::replace(&mut self.events, channel().1); + *self = Self::build_bar(app, events); + return; + } self.refresh_recent(app); for (cmd, params, h) in self.items.values() { let en = menus::native_menu_enabled(app, ctx, cmd); @@ -199,10 +221,10 @@ fn build( } // Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes. Item::Cmd { id, .. } if !menus::shown(app, id) => {} - Item::Cmd { label, id, params, shortcut } => { + Item::Cmd { label, id, params, .. } => { *counter += 1; let mid = format!("dc{counter}"); - let sc = if params.is_null() { shortcut.and_then(accel) } else { None }; + let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None }; let label = menu_title(app, label); let handle = if menus::checked(app, id, params).is_some() { let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..5677c329e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3081,9 +3081,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { } } if let Some((id, exact)) = fired { - // The native menu's accelerator fires only for the shortcut's own key and modifiers; the - // Plus key standing in for = never reaches it. - if exact && app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) { + // The native menu bar carries the shortcuts in effect, the user's included, and its + // accelerator fires only for the shortcut's own key and modifiers; the Plus key standing + // in for = never reaches it. + if exact && app.native_shortcuts.contains(&id) { continue; // The native menu handles it. } // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's @@ -4506,6 +4507,19 @@ mod tests { let z = app.view().unwrap().zoom; assert_eq!(z > z0, zooms_in, "native menu, {k:?} {m:?}: {z0} -> {z}"); } + // A shortcut the user set runs here until the native menu bar carries it as its key + // equivalent; then only the bar runs it, once. + run_ui(&mut app, "window.setShortcut", &json!({"id": "view.zoomOut", "shortcut": "Cmd+9"})).unwrap().unwrap(); + for (native, zooms_out) in [(false, true), (true, false)] { + if native { + app.native_shortcuts.insert("view.zoomOut".to_string()); + } + let z0 = app.view().unwrap().zoom; + frame(&mut app, key(egui::Key::Num9, egui::Modifiers::COMMAND)); + frame(&mut app, vec![]); + let z = app.view().unwrap().zoom; + assert_eq!(z < z0, zooms_out, "native {native}: {z0} -> {z}"); + } } #[test] From 8a6f9725b03c2ffd255cc582841e7f12f4d1a5ab Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:25:12 -0400 Subject: [PATCH 037/117] IDML: test a ruler guide in a column gutter without facing pages (#1096) (#1170) --- crates/idml/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..81cebff70 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4194,6 +4194,59 @@ mod liquid_page_rules { } } +/// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes +/// it (a `` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex` +/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it. +#[test] +fn ruler_guide_in_a_column_gutter_imports_without_facing_pages() { + use designcraft_doc::Orientation::{Horizontal, Vertical}; + let package = |view: &str| { + let prefs = + PREFS.replace(r#"FacingPages="true""#, r#"FacingPages="false""#).replace("", &format!("{view}")); + let spread = SPREAD.replace( + r#" + + "#, + r#" + UseMasterColor + + + + Cyan + + + Cyan + + "#, + ); + assert_ne!(spread, SPREAD, "the fixture's first page was replaced"); + import_idml(&zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ])) + .unwrap() + }; + let guides = + |d: &Document| d.spreads[0].pages[0].guides.iter().map(|g| (g.orientation, g.position, g.spread, g.layer.is_some())).collect::>(); + // The first page starts at x 0 in our spread coordinates, so the spread's and the page's ruler + // origins agree; no ViewPreference means InDesign's default, the spread. + for view in ["", r#""#, r#""#] { + let first = package(view); + assert!(!first.settings.facing_pages); + assert_eq!(guides(&first), [(Vertical, 180.0, false, true), (Horizontal, 300.0, true, true)], "{view:?}"); + // The guide sits between the page's left margin (20) and right margin (470). + let m = first.spreads[0].pages[0].margin_rect(); + assert!(m.x0 < 180.0 && 180.0 < m.x1); + let back = import_idml(&export_idml(&first)).unwrap(); + assert_eq!(guides(&back), guides(&first), "round trip, {view:?}"); + } +} + /// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body /// region (cells with `[None]` take it from there) and overridden on one cell. const FIRST_BASELINE_STYLES: &str = r#" From 2660395db553f182d23dadec8f4885c6498a0014 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:31:36 -0400 Subject: [PATCH 038/117] macOS: a Language submenu in the app menu (#1541) --- ROADMAP.md | 1 + apps/designcraft/src/native_menu.rs | 12 ++++++++++++ crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/menus.rs | 11 ++++++++++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 013cc48db..9c4cb465c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index e444641bf..1ac9437d1 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -91,6 +91,18 @@ impl NativeMenu { items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item))); appended } + AppMenuItem::Language => { + let sub = Submenu::new(menu_title(app, "Language"), true); + for (code, name) in designcraft_ui_egui::i18n::LANGUAGES { + let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code }); + let params = serde_json::json!({ "lang": code }); + let checked = menus::checked(app, "app.language", ¶ms).unwrap_or(false); + let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None); + let _ = sub.append(&item); + items.insert(id, ("app.language".into(), params, Handle::Check(item))); + } + app_menu.append(&sub) + } AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()), AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)), AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..c2abdb9a9 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3155,7 +3155,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Graphic…", ["Graphic…", "Graphique…", "Graphic…", "Graphic…", "图形…"]), ("Home", ["Home", "Accueil", "Home", "Home", "主页"]), ("Join:", ["Join:", "Sommet :", "Join:", "Join:", "连接:"]), - ("Language", ["Language", "Langue", "Language", "Language", "语言"]), + ("Language", ["Sprache", "Langue", "Idioma", "言語", "语言"]), ("Last Spread", ["Last Spread", "Dernière planche", "Last Spread", "Last Spread", "最后一个跨页"]), ("Load DTD…", ["Load DTD…", "Charger la DTD…", "Load DTD…", "Load DTD…", "载入 DTD…"]), ("Location:", ["Location:", "Emplacement :", "Location:", "Location:", "位置:"]), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5677c329e..c455b718d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2286,6 +2286,9 @@ pub enum AppMenuItem { command: &'static str, }, Sep, + /// Interface language: a submenu with each UI language in its own name, the current one + /// checked (`app.language {lang}`). + Language, Services, Hide, HideOthers, @@ -2298,6 +2301,7 @@ pub const MAC_APP_MENU: &[AppMenuItem] = &[ AppMenuItem::Cmd { id: "dc-discord", label: "Join the ArtCraft Discord…", command: "help.discord" }, AppMenuItem::Sep, AppMenuItem::Cmd { id: "dc-settings", label: "Settings…", command: "app.preferences" }, + AppMenuItem::Language, AppMenuItem::Sep, AppMenuItem::Services, AppMenuItem::Sep, @@ -5566,7 +5570,12 @@ mod tests { let (Some(about), Some(settings)) = (pos("help.about"), pos("app.preferences")) else { panic!("About and Settings are in the app menu") }; assert!(about < settings); assert_eq!(MAC_APP_MENU.get(settings - 1), Some(&AppMenuItem::Sep)); - assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Sep)); + // Language follows Settings, as in the suite's other apps, titled in the interface language. + assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language)); + assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep)); + for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { + assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}"); + } let label = |command: &str| { MAC_APP_MENU.iter().find_map(|m| match m { AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label), From 3be2736d0a05ad329c0a2494067d8c7f32181e50 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:47:49 -0400 Subject: [PATCH 039/117] Guides: select ruler guides and set their position (#1282) (#1445) * Guides: a dragged guide shows where it is (#1282) While a guide is dragged out of a ruler or moved, a label beside the pointer reads X: or Y: and the guide's place, in the ruler units and measured the way the rulers measure (from the zero point, and from the page origin when rulers restart per page). * Guides: keep the drag readout inside the canvas Near the canvas's right or bottom edge the readout flips to the other side of the pointer, and it is kept inside the canvas in every case (readout_at). * Guides: the drag readout measures the spread under the pointer The readout measured from the spread in the middle of the view (where the rulers measure), but a guide is dropped on the spread under the pointer, so with spreads above one another the number shown could differ from the guide's place. It now measures that spread (ruler_pieces_of). * Guides: no readout for an existing guide over another spread An existing guide moves only on its own spread (guide_drag), so dropped over another it stays where it was: the readout no longer shows a place there it won't go. * #1282: guide selection commands (guide.select, guide.selectAll, guide.deselect, guide.moveBy) * #1282: select ruler guides on the canvas, X/Y in the Control bar and Properties * #1282: guides take clicks over objects, a new guide is selected, group drag preview * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: SBaksa --- crates/doc/src/selection.rs | 14 + crates/engine/src/cmd/edit.rs | 53 +- crates/engine/src/cmd/guides.rs | 537 +++++++++++++++++++- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/lib.rs | 11 + crates/engine/src/tests.rs | 3 +- crates/tools/src/lib.rs | 5 + crates/tools/src/select.rs | 61 ++- crates/ui-egui/src/canvas.rs | 315 +++++++++++- crates/ui-egui/src/guide_selection_tests.rs | 258 ++++++++++ crates/ui-egui/src/i18n.rs | 5 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 39 +- crates/ui-egui/src/object_control.rs | 15 +- crates/ui-egui/src/panels/mod.rs | 43 ++ crates/ui-egui/src/panels/properties.rs | 29 ++ docs/target-app-parity.md | 2 +- 27 files changed, 1327 insertions(+), 86 deletions(-) create mode 100644 crates/ui-egui/src/guide_selection_tests.rs diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 2d28bf2c2..11c4e2ab1 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -2,9 +2,19 @@ use serde::{Deserialize, Serialize}; +use crate::edit::SpreadRef; use crate::ids::{ItemId, StoryId}; use crate::table::CellRange; +/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GuideSel { + pub spread: SpreadRef, + pub page: usize, + pub index: usize, +} + /// A table cell: the table (in the selection's story) and the owning cell's grid position. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -83,6 +93,10 @@ pub struct Selection { /// The page selected with the Page tool (absolute index). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option, + /// Selected ruler guides. Never together with items, text or cells: selecting one clears + /// the other. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, } impl Selection { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ee963317..e5a2d2296 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -256,28 +256,39 @@ pub fn specs() -> Vec { st.bump_revision(); ok() }), - cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| { - if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { - return super::text::delete_selection(s); - } - let ids = super::targets(s, p)?; - // With a placed graphic itself selected (Direct Selection), only the graphic goes: its - // frame stays, empty and selected (as in InDesign). Other items are deleted. - let content = s.doc()?.selection.content && p.get("ids").is_none(); - s.edit(|d, sel| { - let mut kept = Vec::new(); - for id in &ids { - if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { - it.content = designcraft_doc::Content::Unassigned; - kept.push(*id); - } else { - let _ = d.remove_item(*id); - } + cmd!( + "edit.clear", + "Clear", + ["Edit"], + Some("Delete"), + "{ids?} — delete selected items (or the selected text or ruler guides)", + has_doc, + |s, p| { + if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { + return super::text::delete_selection(s); } - *sel = Selection::items(kept); - Ok(json!({"deleted": ids.len()})) - }) - }), + if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() { + return s.execute("guide.delete", &json!({})); + } + let ids = super::targets(s, p)?; + // With a placed graphic itself selected (Direct Selection), only the graphic goes: its + // frame stays, empty and selected (as in InDesign). Other items are deleted. + let content = s.doc()?.selection.content && p.get("ids").is_none(); + s.edit(|d, sel| { + let mut kept = Vec::new(); + for id in &ids { + if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { + it.content = designcraft_doc::Content::Unassigned; + kept.push(*id); + } else { + let _ = d.remove_item(*id); + } + } + *sel = Selection::items(kept); + Ok(json!({"deleted": ids.len()})) + }) + } + ), cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| { if let Some(text) = copy_text(s)? { return Ok(json!({"text": text})); diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index e8be163ef..97bf4e9cc 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -1,18 +1,28 @@ -//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides. +//! Ruler guides: add (dragged from the rulers), select, move, delete, list; Layout › Create +//! Guides. //! //! Guides are stored on their page in spread coordinates; a spread guide (dropped on the //! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard. +//! Selected guides are [`Selection::guides`], addressed as (spread, page, index): the commands +//! that delete guides renumber it, and [`keep_selection_valid`] drops what another command made +//! stale. -use designcraft_doc::{Document, Guide, Orientation, SpreadRef}; +use std::collections::{HashMap, HashSet}; + +use designcraft_doc::{Document, Guide, GuideSel, Orientation, Selection, SpreadRef}; use designcraft_geom::Rect; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{Result, Session}; +use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; +use crate::{DocState, Result, Session}; const MAX_GUIDE_DIVISIONS: u64 = 1000; const MAX_GUIDES_CREATED: usize = 10_000; const MAX_GUIDE_GUTTER: f64 = 1_000_000.0; +/// The farthest a guide may be from the spread origin, in points, either way. +const MAX_GUIDE_POSITION: f64 = 1_000_000.0; +/// The most guides one `guides` list may name. +const MAX_GUIDES_NAMED: usize = 10_000; pub fn specs() -> Vec { vec![ @@ -21,34 +31,87 @@ pub fn specs() -> Vec { "New Guide", [], None, - "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}", + "{orientation: horizontal|vertical, position (spread coordinate, at most 1000000 pt either way), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} or {orientation: both, point: [x, y], spread?, page?, spreadGuide?: bool, liquid?: bool} → {page, index}; `both` adds a horizontal guide through y and a vertical guide through x as one undo step → {guides: [{page, index}, {page, index}]}. The new guides become the selection; objects are deselected", has_doc, add ), - cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), - cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), + cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position (at most 1000000 pt either way)}", has_doc, move_guide), + cmd!( + "guide.moveBy", + "Move Guides", + [], + None, + "{dx?, dy?, guides?: [{spread?, page, index}] (default: the selected guides)} — vertical guides move by dx, horizontal ones by dy, as one undo step", + has_doc, + move_by + ), + cmd!( + "guide.delete", + "Delete Guide", + [], + None, + "{spread?, page, index}, or {} for the selected guides; an address without page or index is an error", + has_doc, + delete + ), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { let r = super::spread_param(p, "guide.deleteAll")?; - s.edit(|d, _| { + s.edit(|d, sel| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; for pg in &mut sp.pages { n += pg.guides.len(); pg.guides.clear(); } + sel.guides.retain(|g| g.spread != r); Ok(json!({"deleted": n})) }) }), - cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { - let d = &s.doc()?.doc; - let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; - let mut out = Vec::new(); - for (pi, pg) in sp.pages.iter().enumerate() { - for (i, g) in pg.guides.iter().enumerate() { - out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread})); + cmd!( + query "guide.list", + "Guides", + [], + None, + "{spread?} → [{page, index, orientation, position, spread, selected}]", + has_doc, + |s, p| { + let st = s.doc()?; + let r = super::spread_param(p, "guide.list")?; + let sp = st.doc.spread(r).ok_or_else(|| bad("guide.list", "no such spread"))?; + let picked: HashSet = st.selection.guides.iter().copied().collect(); + let mut out = Vec::new(); + for (pi, pg) in sp.pages.iter().enumerate() { + for (i, g) in pg.guides.iter().enumerate() { + let selected = picked.contains(&GuideSel { spread: r, page: pi, index: i }); + out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread, "selected": selected})); + } } + Ok(Value::Array(out)) } - Ok(Value::Array(out)) + ), + cmd!( + noundo "guide.select", + "Select Guide", + [], + None, + "{spread?, page, index, guides?: [{spread?, page, index}] (several, in place of page and index), add?: bool (keep the guides already selected), toggle?: bool (deselect a selected guide instead: Shift-click)} — selects ruler guides and deselects objects → {selected: n}", + has_doc, + select + ), + cmd!( + noundo "guide.selectAll", + "Select All Guides", + ["Edit"], + Some("Cmd+Alt+G"), + "{spread?} — every guide on the spread that is not locked or on a locked or hidden layer → {selected: n}", + has_doc, + select_all + ), + cmd!(noundo "guide.deselect", "Deselect Guides", [], None, "{}", has_doc, |s, _| { + let st = s.doc_mut()?; + st.selection.guides.clear(); + st.bump_revision(); + super::ok() }), cmd!( "layout.createGuides", @@ -80,6 +143,14 @@ fn finite_number(p: &Value, key: &str, command: &str) -> Result> { Ok(Some(number)) } +/// `position` as a guide position: finite and within [`MAX_GUIDE_POSITION`] of the origin. +fn guide_position(position: f64, command: &str) -> Result { + if !position.is_finite() || position.abs() > MAX_GUIDE_POSITION { + return Err(bad(command, format!("a guide position must be between -{MAX_GUIDE_POSITION} and {MAX_GUIDE_POSITION}"))); + } + Ok(position) +} + fn orientation(p: &Value) -> Result { match str_param(p, "orientation") { Some("horizontal") => Ok(Orientation::Horizontal), @@ -115,6 +186,9 @@ fn add(s: &mut Session, p: &Value) -> Result { }; vec![(o, pos, x)] }; + for (_, pos, _) in &guides { + guide_position(*pos, "guide.add")?; + } let d = &s.doc()?.doc; let placed: Vec<(Orientation, f64, usize)> = guides .into_iter() @@ -128,11 +202,13 @@ fn add(s: &mut Session, p: &Value) -> Result { }) .collect(); let layer = s.doc()?.active_layer; - s.edit(|d, _| { + s.edit(|d, sel| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; let mut out = Vec::new(); + let mut added = Vec::new(); for (o, pos, pi) in placed { let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; + let index = pg.guides.len(); pg.guides.push(Guide { orientation: o, position: pos, @@ -141,8 +217,11 @@ fn add(s: &mut Session, p: &Value) -> Result { layer: Some(layer), liquid: liquid && !spread_guide, }); - out.push(json!({"page": pi, "index": pg.guides.len() - 1})); + out.push(json!({"page": pi, "index": index})); + added.push(GuideSel { spread: r, page: pi, index }); } + // A new guide is selected, as in InDesign; nothing else stays selected. + *sel = Selection { guides: added, ..Default::default() }; match <[Value; 1]>::try_from(out) { Ok([one]) => Ok(one), Err(out) => Ok(json!({"guides": out})), @@ -168,6 +247,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str fn move_guide(s: &mut Session, p: &Value) -> Result { let (r, pi, i) = guide_ref(p, "guide.move")?; let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?; + let pos = guide_position(pos, "guide.move")?; s.edit(|d, _| { check_editable(d, r, pi, i, "guide.move")?; let g = d @@ -181,18 +261,209 @@ fn move_guide(s: &mut Session, p: &Value) -> Result { } fn delete(s: &mut Session, p: &Value) -> Result { - let (r, pi, i) = guide_ref(p, "guide.delete")?; - s.edit(|d, _| { - check_editable(d, r, pi, i, "guide.delete")?; - let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?; - if i >= pg.guides.len() { - return Err(bad("guide.delete", "no such guide")); + // An address names one guide: a partial one (only `spread`, say) is an error, not the selection. + let targets = if ["spread", "page", "index"].iter().all(|k| p.get(k).is_none()) { + s.doc()?.selection.guides.clone() + } else { + let (spread, page, index) = guide_ref(p, "guide.delete")?; + vec![GuideSel { spread, page, index }] + }; + if targets.is_empty() { + return Err(bad("guide.delete", "no guide selected")); + } + s.edit(|d, sel| { + for g in &targets { + check_editable(d, g.spread, g.page, g.index, "guide.delete")?; } - pg.guides.remove(i); - Ok(Value::Null) + let deleted = remove_guides(d, sel, &targets, "guide.delete")?; + Ok(json!({"deleted": deleted})) }) } +/// Removes `targets` (existing guides; repeats count once) and renumbers the guide selection to +/// match. Returns how many guides it removed. +fn remove_guides(d: &mut Document, sel: &mut Selection, targets: &[GuideSel], command: &str) -> Result { + let gone: HashSet = targets.iter().copied().collect(); + // Per page, the removed indices in ascending order. + let mut by_page: HashMap<(SpreadRef, usize), Vec> = HashMap::new(); + for g in &gone { + by_page.entry((g.spread, g.page)).or_default().push(g.index); + } + for ((spread, page), indices) in &mut by_page { + indices.sort_unstable(); + let pg = d.spread_mut(*spread).and_then(|sp| sp.pages.get_mut(*page)).ok_or_else(|| bad(command, "no such page"))?; + for &i in indices.iter().rev() { + if i >= pg.guides.len() { + return Err(bad(command, "no such guide")); + } + pg.guides.remove(i); + } + } + sel.guides = sel + .guides + .iter() + .filter(|g| !gone.contains(g)) + .map(|g| { + let below = by_page.get(&(g.spread, g.page)).map_or(0, |indices| indices.partition_point(|&i| i < g.index)); + GuideSel { index: g.index.saturating_sub(below), ..*g } + }) + .collect(); + Ok(gone.len()) +} + +/// The guides a `guides: [{spread?, page, index}]` list names, if the params have one. +fn guides_param(p: &Value, command: &str) -> Result>> { + let Some(list) = p.get("guides") else { return Ok(None) }; + let list = list.as_array().ok_or_else(|| bad(command, "`guides` must be a list of {spread?, page, index}"))?; + if list.len() > MAX_GUIDES_NAMED { + return Err(bad(command, format!("`guides` may name at most {MAX_GUIDES_NAMED} guides"))); + } + list.iter() + .map(|g| { + let (spread, page, index) = guide_ref(g, command)?; + Ok(GuideSel { spread, page, index }) + }) + .collect::>>() + .map(Some) +} + +/// The guide `g` addresses, if there is one. +fn guide_of(d: &Document, g: GuideSel) -> Option<&Guide> { + d.spread(g.spread)?.pages.get(g.page)?.guides.get(g.index) +} + +/// A guide that can be selected: it exists, and neither it nor its layer is locked or hidden. +pub(crate) fn selectable(d: &Document, g: GuideSel) -> bool { + guide_of(d, g).is_some_and(|guide| guide.editable_in(d)) +} + +fn select(s: &mut Session, p: &Value) -> Result { + let named = match guides_param(p, "guide.select")? { + Some(list) => list, + None => { + let (spread, page, index) = guide_ref(p, "guide.select")?; + vec![GuideSel { spread, page, index }] + } + }; + let (add, toggle) = (bool_or(p, "add", false), bool_or(p, "toggle", false)); + let st = s.doc_mut()?; + for g in &named { + check_editable(&st.doc, g.spread, g.page, g.index, "guide.select")?; + } + let mut guides = if add || toggle { st.selection.guides.clone() } else { Vec::new() }; + let mut have: HashSet = guides.iter().copied().collect(); + let mut seen = HashSet::new(); + let mut dropped = HashSet::new(); + for g in named.into_iter().filter(|g| seen.insert(*g)) { + if !have.insert(g) { + if toggle { + dropped.insert(g); + } + } else { + guides.push(g); + } + } + guides.retain(|g| !dropped.contains(g)); + let n = guides.len(); + st.selection = Selection { guides, ..Default::default() }; + st.bump_revision(); + Ok(json!({"selected": n})) +} + +fn select_all(s: &mut Session, p: &Value) -> Result { + let r = super::spread_param(p, "guide.selectAll")?; + let st = s.doc_mut()?; + let sp = st.doc.spread(r).ok_or_else(|| bad("guide.selectAll", "no such spread"))?; + let guides: Vec = sp + .pages + .iter() + .enumerate() + .flat_map(|(page, pg)| (0..pg.guides.len()).map(move |index| GuideSel { spread: r, page, index })) + .filter(|g| selectable(&st.doc, *g)) + .collect(); + let n = guides.len(); + st.selection = Selection { guides, ..Default::default() }; + st.bump_revision(); + Ok(json!({"selected": n})) +} + +fn move_by(s: &mut Session, p: &Value) -> Result { + let dx = finite_number(p, "dx", "guide.moveBy")?.unwrap_or(0.0); + let dy = finite_number(p, "dy", "guide.moveBy")?.unwrap_or(0.0); + let mut targets = match guides_param(p, "guide.moveBy")? { + Some(list) => list, + None => s.doc()?.selection.guides.clone(), + }; + let mut seen = HashSet::new(); + targets.retain(|g| seen.insert(*g)); + if targets.is_empty() { + return Err(bad("guide.moveBy", "no guide selected")); + } + s.edit(|d, _| { + for g in &targets { + check_editable(d, g.spread, g.page, g.index, "guide.moveBy")?; + } + for g in &targets { + let guide = d + .spread_mut(g.spread) + .and_then(|sp| sp.pages.get_mut(g.page)) + .and_then(|pg| pg.guides.get_mut(g.index)) + .ok_or_else(|| bad("guide.moveBy", "no such guide"))?; + let to = guide.position + + match guide.orientation { + Orientation::Vertical => dx, + Orientation::Horizontal => dy, + }; + guide.position = guide_position(to, "guide.moveBy").map_err(|_| bad("guide.moveBy", "the guide would move out of range"))?; + } + Ok(json!({"moved": targets.len()})) + }) +} + +/// After a command: guides stay selected only while nothing else is (objects, text, cells, a +/// page), and only those that are still there and can be selected. A command that changed +/// which guides a page holds without keeping the guide selection up to date clears it, since +/// its (page, index) addresses may name other guides now. `before` is the document and +/// selection before the command, when it ran on the same document. +pub(crate) fn keep_selection_valid(st: &mut DocState, before: Option<(&Document, &Selection)>, id: &str) { + let sel = &mut st.selection; + if sel.guides.is_empty() { + return; + } + if !sel.items.is_empty() || sel.text.is_some() || sel.cells.is_some() || sel.page.is_some() { + sel.guides.clear(); + return; + } + let kept_up = id.starts_with("guide.") || matches!(id, "edit.undo" | "edit.redo") || before.is_some_and(|(_, old)| old.guides != sel.guides); + if !kept_up + && let Some((old, _)) = before + && !std::ptr::eq(old, st.doc.as_ref()) + && !same_guide_addresses(old, &st.doc) + { + sel.guides.clear(); + return; + } + let mut seen = HashSet::new(); + sel.guides.retain(|g| selectable(&st.doc, *g) && seen.insert(*g)); +} + +/// Every (spread, page, index) of `old` still names a guide of the same orientation on the same +/// page in `new`: guides were only added at the end of their pages, or moved. +fn same_guide_addresses(old: &Document, new: &Document) -> bool { + let same = |a: &[std::sync::Arc], b: &[std::sync::Arc]| { + a.len() == b.len() + && a.iter().zip(b).all(|(a, b)| { + a.pages.len() == b.pages.len() + && a.pages.iter().zip(&b.pages).all(|(a, b)| { + a.id == b.id + && a.guides.len() <= b.guides.len() + && a.guides.iter().zip(&b.guides).all(|(a, b)| a.orientation == b.orientation) + }) + }) + }; + same(&old.spreads, &new.spreads) && same(&old.parents, &new.parents) +} + fn guide_divisions(p: &Value, key: &str) -> Result { let Some(value) = p.get(key) else { return Ok(0) }; let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?; @@ -273,7 +544,11 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { .filter(|count| *count <= MAX_GUIDES_CREATED) .ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?; let layer = s.doc()?.active_layer; - s.edit(|d, _| { + s.edit(|d, sel| { + if remove { + // The cleared pages' guide addresses name the new guides now. + sel.guides.retain(|g| g.spread != r || only.is_some_and(|o| o != g.page)); + } let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; let mut n = 0usize; for (pi, pg) in sp.pages.iter_mut().enumerate() { @@ -390,6 +665,214 @@ mod tests { } } +#[cfg(test)] +mod selection_tests { + use serde_json::{Value, json}; + + use crate::Session; + + /// A page with vertical guides at 100, 200, 300 and a horizontal one at 400. + fn guides() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for x in [100.0, 200.0, 300.0] { + s.execute("guide.add", &json!({"orientation": "vertical", "position": x})).unwrap(); + } + s.execute("guide.add", &json!({"orientation": "horizontal", "position": 400.0})).unwrap(); + s + } + + fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.guides.iter().map(|g| g.index).collect() + } + + fn positions(s: &mut Session) -> Vec { + let l = s.execute("guide.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect() + } + + #[test] + fn selecting_guides_clears_objects_and_the_other_way_round() { + let mut s = guides(); + let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("guide.select", &json!({"page": 0, "index": 1})).unwrap(); + assert!(s.doc().unwrap().selection.items.is_empty()); + assert_eq!(selected(&s), [1]); + // Shift-click adds, Shift-click again removes. + s.execute("guide.select", &json!({"page": 0, "index": 3, "toggle": true})).unwrap(); + assert_eq!(selected(&s), [1, 3]); + s.execute("guide.select", &json!({"page": 0, "index": 1, "toggle": true})).unwrap(); + assert_eq!(selected(&s), [3]); + let r = s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}], "add": true})).unwrap(); + assert_eq!(r["selected"], 3); + let l = s.execute("guide.list", &json!({})).unwrap(); + assert_eq!(l.as_array().unwrap().iter().map(|g| g["selected"] == true).collect::>(), [true, false, true, true]); + // Selecting an object (Shift-click too) deselects the guides. + s.execute("selection.toggle", &json!({"id": id})).unwrap(); + assert!(selected(&s).is_empty()); + s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + assert!(selected(&s).is_empty()); + s.execute("guide.selectAll", &json!({})).unwrap(); + s.execute("guide.deselect", &json!({})).unwrap(); + assert!(selected(&s).is_empty()); + // No such guide. + assert!(s.execute("guide.select", &json!({"page": 0, "index": 9})).is_err()); + assert!(s.execute("guide.select", &json!({"page": 3, "index": 0})).is_err()); + assert!(s.execute("guide.select", &json!({"guides": "all"})).is_err()); + } + + #[test] + fn select_all_guides_skips_locked_ones() { + let mut s = guides(); + let layer = s.execute("layer.new", &json!({"name": "Grid"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": layer})).unwrap(); + s.execute("guide.add", &json!({"orientation": "vertical", "position": 500.0})).unwrap(); + s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap(); + assert_eq!(s.execute("guide.selectAll", &json!({})).unwrap()["selected"], 4); + assert_eq!(selected(&s), [0, 1, 2, 3]); + assert!(s.execute("guide.select", &json!({"page": 0, "index": 4})).is_err()); + // Locking the layer of a selected guide deselects it. + s.execute("layer.set", &json!({"id": layer, "locked": false})).unwrap(); + s.execute("guide.selectAll", &json!({})).unwrap(); + s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap(); + assert_eq!(selected(&s), [0, 1, 2, 3]); + let spec = crate::cmd::find_command("guide.selectAll").unwrap(); + assert_eq!((spec.menu, spec.shortcut), (&["Edit"][..], Some("Cmd+Alt+G"))); + } + + #[test] + fn selected_guides_move_and_delete_as_one_undo_step() { + let mut s = guides(); + s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap(); + // Vertical guides move by dx, horizontal ones by dy. + s.execute("guide.moveBy", &json!({"dx": 10.0, "dy": -5.0})).unwrap(); + assert_eq!(positions(&mut s), [110.0, 200.0, 310.0, 395.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]); + assert_eq!(selected(&s), [0, 2, 3]); + // Delete (edit.clear, as the Delete key) takes all three; the guide left is the one at 200. + assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 3); + assert_eq!(positions(&mut s), [200.0]); + assert!(selected(&s).is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(positions(&mut s), [100.0, 200.0, 300.0, 400.0]); + assert_eq!(selected(&s), [0, 2, 3]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(positions(&mut s), [200.0]); + assert!(s.execute("guide.moveBy", &json!({"dx": 1.0})).is_err(), "nothing selected"); + // A guide stays within 1000000 pt of the origin. + assert!(s.execute("guide.moveBy", &json!({"dx": f64::MAX, "guides": [{"page": 0, "index": 0}]})).is_err()); + assert!(s.execute("guide.moveBy", &json!({"dx": 1e6, "guides": [{"page": 0, "index": 0}]})).is_err()); + assert_eq!(positions(&mut s), [200.0]); + s.execute("guide.moveBy", &json!({"dx": -1e6 - 200.0, "guides": [{"page": 0, "index": 0}]})).unwrap(); + assert_eq!(positions(&mut s), [-1e6]); + assert!(s.execute("guide.move", &json!({"page": 0, "index": 0, "position": 1e6 + 1.0})).is_err()); + assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": -2e6})).is_err()); + assert!(s.execute("guide.add", &json!({"orientation": "both", "point": [0.0, 1e300]})).is_err()); + assert_eq!(positions(&mut s), [-1e6]); + } + + #[test] + fn the_guide_selection_follows_deletes_and_drops_stale_guides() { + let mut s = guides(); + s.execute("guide.select", &json!({"guides": [{"page": 0, "index": 2}, {"page": 0, "index": 3}]})).unwrap(); + // Deleting a guide before them renumbers them. + s.execute("guide.delete", &json!({"page": 0, "index": 0})).unwrap(); + assert_eq!(selected(&s), [1, 2]); + assert_eq!(positions(&mut s), [200.0, 300.0, 400.0]); + // Deleting a selected one deselects it. + s.execute("guide.delete", &json!({"page": 0, "index": 1})).unwrap(); + assert_eq!(selected(&s), [1]); + // Undo puts back the selection that went with the document. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(selected(&s), [1, 2]); + // A new guide is the selection. + s.execute("guide.add", &json!({"orientation": "vertical", "position": 50.0})).unwrap(); + assert_eq!(selected(&s), [3]); + // Undoing the add to before the first delete: those addresses held other guides then. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(selected(&s), [2, 3]); + // Create Guides replacing the page's guides: their addresses name new guides, so the + // selection goes. + s.execute("layout.createGuides", &json!({"columns": 2, "removeExisting": true})).unwrap(); + assert!(selected(&s).is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("guide.select", &json!({"page": 0, "index": 0})).unwrap(); + s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": 0})).unwrap(); + assert_eq!(selected(&s), [0], "guides only added: the addresses still hold"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("guide.selectAll", &json!({})).unwrap(); + s.execute("guide.deleteAll", &json!({})).unwrap(); + assert!(selected(&s).is_empty()); + // Deleting the page goes with it. + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap(); + s.execute("guide.selectAll", &json!({"spread": 1})).unwrap(); + assert_eq!(selected(&s), [0]); + s.execute("layout.pages.delete", &json!({"pages": [1]})).unwrap(); + assert!(selected(&s).is_empty()); + let _: Value = s.execute("guide.list", &json!({})).unwrap(); + } +} + +#[cfg(test)] +mod new_guide_tests { + use designcraft_doc::GuideSel; + use serde_json::json; + + use crate::Session; + + fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.guides.clone() + } + + #[test] + fn a_new_guide_is_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("guide.add", &json!({"orientation": "vertical", "position": 100.0})).unwrap(); + let spread = s.doc().unwrap().selection.guides[0].spread; + assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]); + assert!(s.doc().unwrap().selection.items.is_empty(), "objects are deselected"); + // Both ways (Cmd-drag from the ruler corner): the two new guides. + s.execute("guide.add", &json!({"orientation": "both", "point": [50.0, 60.0], "spreadGuide": true})).unwrap(); + assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 1 }, GuideSel { spread, page: 0, index: 2 }]); + // Undo gives back the selection from before. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(selected(&s), [GuideSel { spread, page: 0, index: 0 }]); + } + + #[test] + fn delete_takes_a_whole_address_or_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + for spread in [0, 1] { + s.execute("guide.add", &json!({"spread": spread, "orientation": "vertical", "position": 100.0})).unwrap(); + } + s.execute("guide.select", &json!({"spread": 1, "page": 0, "index": 0})).unwrap(); + // A spread without page and index is not "the selection". + assert!(s.execute("guide.delete", &json!({"spread": 0})).is_err()); + assert!(s.execute("guide.delete", &json!({"spread": 1, "page": 0})).is_err()); + assert_eq!(selected(&s).len(), 1); + // A selection naming a guide twice, apart, deletes it once. + s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 200.0})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 300.0})).unwrap(); + let spread = selected(&s)[0].spread; + let at = |index| GuideSel { spread, page: 0, index }; + s.doc_mut().unwrap().selection.guides = vec![at(0), at(2), at(0)]; + assert_eq!(s.execute("guide.delete", &json!({})).unwrap()["deleted"], 2); + let left = s.execute("guide.list", &json!({"spread": 1})).unwrap(); + assert_eq!(left.as_array().unwrap().iter().map(|g| g["position"].as_f64().unwrap()).collect::>(), [200.0]); + assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap().as_array().unwrap().len(), 1); + } +} + #[cfg(test)] mod layer_tests { use serde_json::json; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b16b9cfc..a4d5413bb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -20,7 +20,7 @@ pub(crate) mod find; pub use find::same_find_domain; pub(crate) mod flex; mod fonts; -mod guides; +pub(crate) mod guides; mod index; mod inspect; mod interactive; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 183b81c5c..d16dc9dd1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -571,6 +571,7 @@ impl Session { Ok(r) => r, Err(e) => { // What it did before it failed can be undone too. + self.keep_guide_selection_valid(before.as_ref(), id); self.record_undo_step(spec, before, nested); return Err(e); } @@ -613,6 +614,7 @@ impl Session { } } } + self.keep_guide_selection_valid(before.as_ref(), id); self.record_undo_step(spec, before, nested); if spec.journal { self.journal.push((id.to_string(), params.clone())); @@ -621,6 +623,15 @@ impl Session { Ok(r) } + /// Drops selected guides the command `id` made stale, against the document as it will be + /// recorded: after the passes that run with the command (smart reflow, live captions, autosize). + fn keep_guide_selection_valid(&mut self, before: Option<&(u64, Arc, Selection)>, id: &str) { + if let Some(st) = self.active_mut() { + let old = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, sel)| (d.as_ref(), sel)); + cmd::guides::keep_selection_valid(st, old, id); + } + } + /// Record undo if the document changed (and we're not previewing an interaction), with the /// selection from before the command. A command run by an undoable one leaves the step to it: /// one command is one undo step. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..42f57b3a1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -294,8 +294,9 @@ fn cmd_move_does_not_snap() { fn cmd_resize_still_snaps_to_a_guide() { use designcraft_tools::{PointerEvent, PointerKind}; let mut s = session(); - s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap(); + // The new guide is selected: the frame created after it is the selection. s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap(); + s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap(); s.set_tool("selection"); let v = ViewInfo::at_zoom(1.0); let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a92f1c132..b5153e183 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -140,6 +140,9 @@ pub struct SnapView { pub smart_dimensions: bool, pub smart_spacing: bool, pub zone_px: f64, + /// View › Grids & Guides › Lock Guides: ruler guides can't be selected or moved. + #[serde(default)] + pub guides_locked: bool, } impl SnapView { @@ -153,6 +156,7 @@ impl SnapView { smart_dimensions: true, smart_spacing: true, zone_px: 4.0, + guides_locked: false, }; pub const OFF: Self = Self { snap_to_guides: false, @@ -164,6 +168,7 @@ impl SnapView { smart_dimensions: false, smart_spacing: false, zone_px: 4.0, + guides_locked: false, }; /// Guides, the document grid, or smart guides are on. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 891f1d4ab..b3d3780f2 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -2,7 +2,8 @@ //! //! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), -//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool and +//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no +//! object), double-clicking a text frame switches to the Type tool and //! double-clicking a handle fits the frame to its content (corner: both sides, edge: that one). //! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]). //! @@ -179,6 +180,42 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select` +/// takes them: none while guides are hidden or locked. +fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec { + if !cx.snap.show_guides || cx.snap.guides_locked { + return vec![]; + } + let mut out = Vec::new(); + for slot in &cx.layout.slots { + let Some(sp) = cx.doc.spread(slot.spread) else { continue }; + let pasteboard = slot.xf.inverse().transform_rect_bbox(cx.layout.pasteboard); + for (pi, pg) in sp.pages.iter().enumerate() { + for (gi, g) in pg.guides.iter().enumerate() { + if !g.editable_in(cx.doc) { + continue; + } + // The line as the canvas draws it: a spread guide crosses the pasteboard (a + // vertical one the spread's height and a little), a page guide its page. + let span = match (g.spread, g.orientation) { + (false, _) => pg.bounds(), + (true, designcraft_doc::Orientation::Horizontal) => pasteboard, + (true, designcraft_doc::Orientation::Vertical) => sp.bounds().inflate(0.0, 36.0), + }; + let line = match g.orientation { + designcraft_doc::Orientation::Horizontal => Rect::new(span.x0, g.position, span.x1, g.position), + designcraft_doc::Orientation::Vertical => Rect::new(g.position, span.y0, g.position, span.y1), + }; + let b = slot.xf.transform_rect_bbox(line); + if b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0 { + out.push(json!({"spread": slot.spread, "page": pi, "index": gi})); + } + } + } + } + out +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -1112,7 +1149,12 @@ impl Tool for SelectionTool { } } } - vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))] + let guides = if ids.is_empty() { guides_crossing(cx, r) } else { vec![] }; + if guides.is_empty() { + vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))] + } else { + vec![Action::Exec("guide.select".into(), json!({"guides": guides, "add": ev.mods.shift}))] + } } Drag::None | Drag::Place { .. } => vec![], } @@ -1133,7 +1175,20 @@ impl Tool for SelectionTool { } let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; if cx.selection.items.is_empty() { - return vec![]; + // Selected ruler guides: the arrows nudge them, Delete deletes them. + if cx.selection.guides.is_empty() { + return vec![]; + } + let nudge = |dx: f64, dy: f64| vec![Action::Exec("guide.moveBy".into(), json!({"dx": dx, "dy": dy}))]; + return match key { + ToolKey::Left => nudge(-inc, 0.0), + ToolKey::Right => nudge(inc, 0.0), + ToolKey::Up => nudge(0.0, -inc), + ToolKey::Down => nudge(0.0, inc), + ToolKey::Delete | ToolKey::Backspace => vec![Action::Exec("edit.clear".into(), json!({}))], + ToolKey::Escape => vec![Action::Exec("guide.deselect".into(), json!({}))], + _ => vec![], + }; } match key { ToolKey::Left => mv(-inc, 0.0), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..322f75d80 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2,7 +2,7 @@ //! selection, text ports and threads, the text caret, rulers, and pointer/keyboard input. use designcraft_compose as compose; -use designcraft_doc::{Content, Document, Item, SpreadRef}; +use designcraft_doc::{Content, Document, GuideSel, Item, SpreadRef}; use designcraft_engine::doc::Selection; use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2}; use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey}; @@ -531,8 +531,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside); } if !preview { - draw_guides(app, &painter, &xf, &doc, &layout, &t); - if let Some(g) = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))) + let drag = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))); + let press = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_PRESS))); + let moving = drag.as_ref().zip(press).and_then(|(g, press)| moving_guides(app, &xf, rect, g, press)); + draw_guides(app, &painter, &xf, &doc, &layout, &t, moving.as_ref()); + if let Some(g) = drag && let Some(p) = g.at { let col = Color32::from_rgb(74, 227, 255); @@ -546,6 +549,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { painter.line_segment([a, b], Stroke::new(1.0, col)); } + // Where it is, beside the pointer (#1282). + if let Some(text) = guide_readout(app, &xf, &g) { + let galley = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), t.text); + let at = readout_at(p, galley.size(), rect); + let bg = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(bg, 2.0, t.panel); + painter.rect_stroke(bg, 2.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + painter.galley(at, galley, t.text); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -950,8 +962,11 @@ fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Re boxes.iter().filter_map(|o| sp.outset_rect(*o)).map(|r| xf.rect(slot.xf.transform_rect_bbox(r))).fold(trim, |a, b| a.union(b)) } -fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { +/// `moving`: guides being dragged, drawn where they would be dropped instead of where they are. +fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens, moving: Option<&MovingGuides>) { let s = &doc.settings; + let selection: std::collections::HashSet = + app.session.active().map(|st| st.selection.guides.iter().copied().collect()).unwrap_or_default(); for slot in &layout.slots { let Some(sp) = doc.spread(slot.spread) else { continue }; // Everything below is in spread coordinates, placed through the slot (which may be turned). @@ -1007,8 +1022,21 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document } } } - // The page's own guides, then those it shows from its parent page. - for g in p.guides.iter().cloned().chain(doc.parent_guides(p)).filter(|g| g.visible_in(doc)) { + // The page's own guides, then those it shows from its parent page. Selected guides are + // drawn thicker, in their layer's color. + let own = p.guides.len(); + for (gi, mut g) in p.guides.iter().cloned().chain(doc.parent_guides(p)).enumerate().filter(|(_, g)| g.visible_in(doc)) { + let key = GuideSel { spread: slot.spread, page: pi, index: gi }; + let selected = gi < own && selection.contains(&key); + if gi < own + && let Some(m) = moving + && m.guides.contains(&key) + { + g.position += match g.orientation { + designcraft_doc::Orientation::Vertical => m.dx, + designcraft_doc::Orientation::Horizontal => m.dy, + }; + } // Spread guides cross the pasteboard; page guides their page. let span = if g.spread { pasteboard } else { pr }; let (a, bb) = match g.orientation { @@ -1018,7 +1046,12 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document (Point::new(g.position, span.y0), Point::new(g.position, span.y1)) } }; - let stroke = Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)); + let stroke = if selected { + let color = g.layer.and_then(|l| doc.layer(l)).map_or(Color32::from_rgb(79, 153, 255), |l| c32(l.color)); + Stroke::new(2.0, color) + } else { + Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)) + }; if g.liquid { // Liquid guides are dashed. painter.extend(egui::Shape::dashed_line(&[pt(a), pt(bb)], stroke, 6.0, 3.0)); @@ -2013,6 +2046,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, /// coordinates: the ruler origin (one per page for a page origin) moved by zero point `zp`. fn ruler_pieces(app: &DesignApp, doc: &Document, layout: &CanvasLayout, zp: [f64; 2]) -> Vec<(f64, f64, Point)> { let Some(slot) = current_slot(app, layout).and_then(|i| layout.slots.get(i)) else { return Vec::new() }; + ruler_pieces_of(doc, slot, zp) +} + +/// [`ruler_pieces`] for the spread in `slot`. +fn ruler_pieces_of(doc: &Document, slot: &SpreadSlot, zp: [f64; 2]) -> Vec<(f64, f64, Point)> { let x = |v: f64, y: f64| if v.is_finite() { slot.to_canvas(Point::new(v, y)).x } else { v }; doc.ruler_pieces(slot.spread) .into_iter() @@ -2375,6 +2413,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if !space && !menu_open && xf.rot == 0 && zero_point_drag(app, ui, resp, rect, &xf) { return; } + // Hidden or locked guides can't stay selected. + if (!app.ui.guides || app.ui.guides_locked) && app.session.active().is_some_and(|st| !st.selection.guides.is_empty()) { + let _ = app.run("guide.deselect", json!({})); + } if !space && !menu_open && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; } @@ -2841,6 +2883,66 @@ struct GuideDrag { at: Option, } +const GUIDE_PRESS: &str = "canvas_guide_press"; + +/// A press on an existing guide: where it was (screen) and whether Shift was down. Released +/// where it was pressed, it is a click, which selects the guide. +#[derive(Clone, Copy, Debug)] +struct GuidePress { + origin: Pos2, + shift: bool, +} + +/// How far (screen pixels) the pointer may move between press and release for a click. +const GUIDE_CLICK_SLOP: f32 = 3.0; + +/// Where a dragged guide is, as the rulers measure it (#1282): `X: …` for a vertical guide, `Y: …` +/// for a horizontal one, from the zero point (and the page origin, when rulers restart per page) +/// in the document's ruler units. `None` with no document or rulers. +fn guide_readout(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option { + let (label, value, unit) = guide_measure(app, xf, g)?; + Some(format!("{label}: {}", designcraft_geom::units::format_measure(value, unit))) +} + +/// Where a pointer readout of `size` goes for a pointer at `p`: below right of it, on the other +/// side where that would leave the canvas `area` (near its right or bottom edge), and kept inside +/// it (with the label's 3-point frame) however small the canvas is. +fn readout_at(p: Pos2, size: egui::Vec2, area: Rect) -> Pos2 { + const GAP: f32 = 12.0; + const PAD: f32 = 3.0; + let x = if p.x + GAP + size.x + PAD > area.max.x { p.x - GAP - size.x } else { p.x + GAP }; + let y = if p.y + GAP + size.y + PAD > area.max.y { p.y - GAP - size.y } else { p.y + GAP }; + let clamp = |v: f32, lo: f32, hi: f32| if hi < lo { lo } else { v.clamp(lo, hi) }; + pos2(clamp(x, area.min.x + PAD, area.max.x - PAD - size.x), clamp(y, area.min.y + PAD, area.max.y - PAD - size.y)) +} + +/// [`guide_readout`]'s parts: the axis label, the value (points) and its unit. +fn guide_measure(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<(&'static str, f64, designcraft_geom::Unit)> { + let p = g.at?; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let c = xf.to_canvas(p); + // Measured on the spread under the pointer, which the guide is dropped on (`guide_drag`), + // as the rulers measure that spread: not the one in the middle of the view. + let slot = layout.spread_at(c).and_then(|(r, _)| layout.slot(r)).or_else(|| current_slot(app, &layout).and_then(|i| layout.slots.get(i)))?; + // An existing guide moves only on its own spread (`guide_drag`): over another, dropping it + // leaves it where it was, so there is no place to show. + if g.from.is_some_and(|(r, _, _)| r != slot.spread) { + return None; + } + let pieces = ruler_pieces_of(&st.doc, slot, st.doc.zero_point()); + let first = pieces.first()?; + let (label, value, unit) = match g.orientation { + designcraft_doc::Orientation::Vertical => { + // The horizontal ruler restarts at each page: the piece under the guide measures it. + let o = pieces.iter().find(|(from, to, _)| *from <= c.x && c.x < *to).unwrap_or(first).2; + ("X", c.x - o.x, st.doc.settings.horizontal_units) + } + designcraft_doc::Orientation::Horizontal => ("Y", c.y - first.2.y, st.doc.settings.vertical_units), + }; + value.is_finite().then_some((label, value, unit)) +} + /// The guide under a screen point (within 3 px), as (spread, page, index, orientation). fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::SpreadRef, usize, usize, designcraft_doc::Orientation)> { let st = app.session.active()?; @@ -2870,8 +2972,10 @@ fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::Sprea None } -/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools, not over an -/// object) to move it, drop it back on a ruler to delete it. Returns true while it owns the pointer. +/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools) to move it, drop +/// it back on a ruler to delete it. A press near an unlocked guide takes it even over an object: +/// the guide is selected and the object is not. Returns true from the press to the release, so +/// the tools never see that press. fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { let id = egui::Id::new(GUIDE_DRAG); let (pressed, released, origin, latest) = @@ -2896,17 +3000,14 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec && matches!(app.session.tool_id(), "selection" | "directSelection") && let Some((r, pi, gi, orientation)) = guide_at(app, xf, o) { - // Objects take precedence over the guides behind them. - let over_item = app.session.active().is_some_and(|st| { - let layout = CanvasLayout::new(&st.doc, st.editing_parents); - layout.spread_at(xf.to_canvas(o)).is_some_and(|(sr, p)| match sr { - designcraft_doc::SpreadRef::Doc(si) => st.doc.hit_item(si, p, 3.0 / xf.zoom).is_some(), - _ => false, - }) - }); - if !over_item { - drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) }); + let shift = ui.input(|i| i.modifiers.shift); + // A press on a guide selects it (Shift: on release, as a click adds or removes it). + let selected = app.session.active().is_some_and(|st| st.selection.guides.contains(&GuideSel { spread: r, page: pi, index: gi })); + if !shift && !selected { + let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi})); } + ui.data_mut(|d| d.insert_temp(egui::Id::new(GUIDE_PRESS), GuidePress { origin: o, shift })); + drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) }); } } let Some(mut g) = drag else { return false }; @@ -2922,7 +3023,23 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec return true; } ui.data_mut(|d| d.remove::(id)); + let press: Option = ui.data_mut(|d| { + let press = d.get_temp(egui::Id::new(GUIDE_PRESS)); + d.remove::(egui::Id::new(GUIDE_PRESS)); + press + }); let Some(p) = g.at else { return true }; + if let (Some((r, pi, gi)), Some(press)) = (g.from, press) + && (p - press.origin).length() <= GUIDE_CLICK_SLOP + { + // A click: the guide is selected (at the press); Shift adds it or removes it. + if press.shift { + let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi, "toggle": true})); + } + return true; + } + // Dragging one of several selected guides moves (or deletes) them all. + let group = g.from.is_some_and(|(spread, page, index)| drags_group(app, GuideSel { spread, page, index })); let on_canvas = rect.contains(p); let target = app.session.active().and_then(|st| { let layout = CanvasLayout::new(&st.doc, st.editing_parents); @@ -2941,9 +3058,17 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec }; match (g.from, on_canvas, target) { // Dropped back on a ruler: delete. + (Some(_), false, _) if group => { + let _ = app.run("guide.delete", json!({})); + } (Some((r, pi, gi)), false, _) => { let _ = app.run("guide.delete", json!({"spread": r, "page": pi, "index": gi})); } + (Some((r, _, _)), true, Some((tr, _, _))) if tr == r && group => { + if let Some((dx, dy)) = press.and_then(|press| guide_drag_delta(app, xf, &g, press.origin, p)) { + let _ = app.run("guide.moveBy", json!({"dx": dx, "dy": dy})); + } + } (Some((r, pi, gi)), true, Some((tr, sp, _))) if tr == r => { let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } @@ -2962,6 +3087,55 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +/// Whether dragging guide `from` takes the other selected guides along: it is one of several +/// selected. +fn drags_group(app: &DesignApp, from: GuideSel) -> bool { + app.session.active().is_some_and(|st| st.selection.guides.len() > 1 && st.selection.guides.contains(&from)) +} + +/// How far a drag of the existing guide `g.from`, pressed at `origin` and now at `p` (screen), +/// moves guides, in spread coordinates: the dragged guide goes to the pointer, the other selected +/// guides as far along their own axis as the pointer moved. `None` while the pointer is not over +/// the guide's own spread, where a drop leaves it in place. +fn guide_drag_delta(app: &DesignApp, xf: &Xf, g: &GuideDrag, origin: Pos2, p: Pos2) -> Option<(f64, f64)> { + let (r, pi, gi) = g.from?; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (tr, sp) = layout.spread_at(xf.to_canvas(p))?; + if tr != r { + return None; + } + let start = layout.slot(r)?.to_spread(xf.to_canvas(origin)); + let at = st.doc.spread(r)?.pages.get(pi)?.guides.get(gi)?.position; + let (dx, dy) = match g.orientation { + designcraft_doc::Orientation::Vertical => (sp.x - at, sp.y - start.y), + designcraft_doc::Orientation::Horizontal => (sp.x - start.x, sp.y - at), + }; + (dx.is_finite() && dy.is_finite()).then_some((dx, dy)) +} + +/// Guides being dragged on the canvas and how far they have moved (spread coordinates). +struct MovingGuides { + guides: std::collections::HashSet, + dx: f64, + dy: f64, +} + +/// What a drag of an existing guide moves, for the preview: the dragged guide, or every selected +/// guide when it is one of several selected. `None` before the drag leaves the click distance, +/// over a ruler (a drop there deletes) and off the guide's spread. +fn moving_guides(app: &DesignApp, xf: &Xf, rect: Rect, g: &GuideDrag, press: GuidePress) -> Option { + let (spread, page, index) = g.from?; + let p = g.at?; + if !rect.contains(p) || (p - press.origin).length() <= GUIDE_CLICK_SLOP { + return None; + } + let (dx, dy) = guide_drag_delta(app, xf, g, press.origin, p)?; + let from = GuideSel { spread, page, index }; + let guides = if drags_group(app, from) { app.session.active()?.selection.guides.iter().copied().collect() } else { [from].into_iter().collect() }; + Some(MovingGuides { guides, dx, dy }) +} + /// Presses in a row on the canvas: the last press's count, and where and when it was released /// (`at` is −∞ while the button is down). #[derive(Clone, Copy, Debug)] @@ -2991,6 +3165,10 @@ fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks { #[path = "parent_canvas_tests.rs"] mod parent_canvas_tests; +#[cfg(test)] +#[path = "guide_selection_tests.rs"] +mod guide_selection_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; @@ -4033,6 +4211,105 @@ mod tests { assert_eq!(doc.settings.zero_point, [0.0, 0.0], "the zero point stays"); } + /// A guide being dragged out of a ruler says where it is, as the rulers measure it (#1282): + /// from the zero point, in the ruler units, and the label follows the pointer while it moves. + #[test] + fn a_dragged_guide_shows_where_it_is() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("view.zeroPoint", json!({"at": [100, 50]})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + let dest = rect.min + vec2(160.0, 110.0); + let (xf, at) = { + let app = h.state(); + let xf = Xf::new(rect, app.view().unwrap()); + let st = app.session.active().unwrap(); + (xf, CanvasLayout::new(&st.doc, false).spread_at(xf.to_canvas(dest)).unwrap().1) + }; + let units = h.state().session.doc().unwrap().doc.settings.horizontal_units; + for (orientation, label, want) in + [(designcraft_doc::Orientation::Vertical, "X", at.x - 100.0), (designcraft_doc::Orientation::Horizontal, "Y", at.y - 50.0)] + { + let g = GuideDrag { orientation, from: None, at: Some(dest) }; + let (l, v, _) = guide_measure(h.state(), &xf, &g).unwrap(); + assert_eq!(l, label); + assert!((v - want).abs() < 1e-6, "{label}: {v} vs {want}"); + let text = guide_readout(h.state(), &xf, &g).unwrap(); + assert_eq!(text, format!("{label}: {}", designcraft_geom::units::format_measure(v, units))); + } + // While a guide is actually being dragged out of the top ruler, the readout is there. + let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0); + h.hover_at(ruler); + h.step(); + primary(&mut h, ruler, true); + h.event(egui::Event::PointerMoved(dest)); + h.run_steps(2); + let g = h.ctx.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).expect("dragging"); + assert_eq!(g.orientation, designcraft_doc::Orientation::Horizontal); + assert!(guide_readout(h.state(), &xf, &g).is_some_and(|t| t.starts_with("Y: "))); + primary(&mut h, dest, false); + } + + /// Over a spread other than the one in the middle of the view, the readout measures that + /// spread, the one the guide is dropped on: it reads the position the guide gets. + #[test] + fn the_guide_readout_measures_the_spread_under_the_pointer() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5})).unwrap(); + app.run("view.zeroPoint", json!({"at": [100, 50]})).unwrap(); + let h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + let xf = Xf::new(rect, h.state().view().unwrap()); + let st = h.state().session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let current = current_slot(h.state(), &layout).unwrap(); + // A point inside the last spread, well away from the one in view. + let last = layout.slots.last().unwrap(); + assert_ne!(layout.slots.iter().position(|s| s.spread == last.spread), Some(current), "the last spread isn't the one in view"); + let inside = Point::new(last.bounds.center().x, last.bounds.y0 + (last.bounds.y1 - last.bounds.y0) * 0.3); + let (r, sp) = layout.spread_at(inside).unwrap(); + assert_eq!(r, last.spread); + let g = GuideDrag { orientation: designcraft_doc::Orientation::Horizontal, from: None, at: Some(xf.to_screen(inside)) }; + let (label, v, _) = guide_measure(h.state(), &xf, &g).unwrap(); + assert_eq!(label, "Y"); + // The guide would be stored at `sp.y` on that spread, which its ruler reads from the zero point. + // (To screen and back in f32: a few hundred-thousandths of a point.) + assert!((v - (sp.y - 50.0)).abs() < 1e-3, "{v} vs {}", sp.y - 50.0); + // An existing guide of another spread can't be dropped there (it only moves on its own + // spread): no readout over this one, a readout over its own. + let first = layout.slots[current].spread; + let moving = GuideDrag { orientation: designcraft_doc::Orientation::Horizontal, from: Some((first, 0, 0)), at: Some(xf.to_screen(inside)) }; + assert!(guide_measure(h.state(), &xf, &moving).is_none(), "an existing guide over another spread"); + let own = layout.slots[current].bounds; + let over_own = Point::new(own.center().x, own.y0 + (own.y1 - own.y0) * 0.3); + let moving_here = GuideDrag { at: Some(xf.to_screen(over_own)), ..moving }; + assert!(guide_measure(h.state(), &xf, &moving_here).is_some(), "over its own spread"); + } + + /// The guide readout stays on the canvas: below right of the pointer in the open, flipped + /// left or up near the right or bottom edge, and inside the canvas in every corner. + #[test] + fn the_guide_readout_stays_inside_the_canvas() { + let area = Rect::from_min_max(pos2(50.0, 40.0), pos2(850.0, 640.0)); + let size = vec2(80.0, 14.0); + let inside = |at: Pos2| { + let r = Rect::from_min_size(at, size).expand(3.0); + r.min.x >= area.min.x - 1e-3 && r.min.y >= area.min.y - 1e-3 && r.max.x <= area.max.x + 1e-3 && r.max.y <= area.max.y + 1e-3 + }; + assert_eq!(readout_at(pos2(400.0, 300.0), size, area), pos2(412.0, 312.0), "in the open: below right"); + let right = readout_at(pos2(845.0, 300.0), size, area); + assert!(right.x + size.x < 845.0 && inside(right), "near the right edge: left of the pointer, {right:?}"); + let bottom = readout_at(pos2(400.0, 636.0), size, area); + assert!(bottom.y + size.y < 636.0 && inside(bottom), "near the bottom: above it, {bottom:?}"); + for p in [area.min, pos2(area.max.x, area.min.y), area.max, pos2(area.min.x, area.max.y), pos2(-500.0, 2000.0)] { + assert!(inside(readout_at(p, size, area)), "{p:?}"); + } + // A canvas narrower than the label: pinned to its top left, not past it. + let tiny = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 10.0)); + assert_eq!(readout_at(pos2(20.0, 5.0), size, tiny), pos2(3.0, 3.0)); + } + #[test] fn rulers_and_xy_fields_measure_from_the_zero_point_on_every_spread() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs new file mode 100644 index 000000000..23ad14c91 --- /dev/null +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -0,0 +1,258 @@ +//! Ruler guides selected like objects (#1282): click, Shift-click, marquee, drag, keys, and the +//! X/Y fields. + +use egui::{Event, Modifiers, PointerButton}; +use egui_kittest::Harness; + +use super::*; + +fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +/// A page with vertical guides at x 200 and 300 and a horizontal one at y 400. +fn with_guides() -> Harness<'static, DesignApp> { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for (orientation, position) in [("vertical", 200.0), ("vertical", 300.0), ("horizontal", 400.0)] { + app.run("guide.add", json!({"orientation": orientation, "position": position})).unwrap(); + } + harness(app) +} + +/// Screen position of spread 0's point (x, y). +fn screen_of(h: &Harness<'_, DesignApp>, x: f64, y: f64) -> Pos2 { + let app = h.state(); + let st = app.session.active().unwrap(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + xf.to_screen(CanvasLayout::new(&st.doc, st.editing_parents).xf(SpreadRef::Doc(0)) * Point::new(x, y)) +} + +fn press(h: &mut Harness<'_, DesignApp>, pos: Pos2, pressed: bool, mods: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: mods }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, DesignApp>, pos: Pos2, mods: Modifiers) { + h.event(Event::ModifiersChanged(mods)); + h.hover_at(pos); + h.step(); + press(h, pos, true, mods); + press(h, pos, false, mods); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + press(h, a, true, Modifiers::NONE); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + press(h, b, false, Modifiers::NONE); + h.run_steps(2); +} + +fn selected(h: &Harness<'_, DesignApp>) -> Vec { + h.state().session.doc().unwrap().selection.guides.iter().map(|g| g.index).collect() +} + +fn positions(h: &Harness<'_, DesignApp>) -> Vec { + h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.iter().map(|g| g.position).collect() +} + +#[test] +fn clicking_guides_selects_them_and_shift_click_adds_or_removes() { + let mut h = with_guides(); + let id = h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); + let at = screen_of(&h, 200.0, 600.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(selected(&h), [0]); + assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the object is deselected"); + assert_eq!(positions(&h), [200.0, 300.0, 400.0], "a click doesn't move the guide"); + let at = screen_of(&h, 100.0, 400.0); + click(&mut h, at, Modifiers::SHIFT); + assert_eq!(selected(&h), [0, 2]); + let at = screen_of(&h, 200.0, 650.0); + click(&mut h, at, Modifiers::SHIFT); + assert_eq!(selected(&h), [2]); + // A click on empty canvas, or on an object, clears the guide selection. + let at = screen_of(&h, 550.0, 650.0); + click(&mut h, at, Modifiers::NONE); + assert!(selected(&h).is_empty()); + let at = screen_of(&h, 300.0, 600.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(selected(&h), [1]); + let at = screen_of(&h, 450.0, 150.0); + click(&mut h, at, Modifiers::NONE); + assert!(selected(&h).is_empty()); + assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]); + // With View › Lock Guides on, guides can't be selected, and selected ones are deselected. + h.state_mut().run("guide.selectAll", json!({})).unwrap(); + h.state_mut().ui.guides_locked = true; + h.run_steps(2); + assert!(selected(&h).is_empty()); + let at = screen_of(&h, 200.0, 600.0); + click(&mut h, at, Modifiers::NONE); + assert!(selected(&h).is_empty()); +} + +#[test] +fn dragging_a_selected_guide_moves_all_selected_guides_as_one_step() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap(); + let undo = h.state().session.doc().unwrap().history.undo.len(); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0)); + drag(&mut h, a, b); + let p = positions(&h); + assert!((p[0] - 250.0).abs() < 1.0 && p[1] == 300.0 && (p[2] - 420.0).abs() < 1.0, "{p:?}"); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(selected(&h), [0, 2]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(positions(&h), [200.0, 300.0, 400.0]); + // Dragging an unselected guide selects it and moves only it. + let (a, b) = (screen_of(&h, 300.0, 600.0), screen_of(&h, 350.0, 600.0)); + drag(&mut h, a, b); + assert_eq!(selected(&h), [1]); + let p = positions(&h); + assert!(p[0] == 200.0 && (p[1] - 350.0).abs() < 1.0 && p[2] == 400.0, "{p:?}"); +} + +#[test] +fn a_marquee_selects_guides_only_when_it_touches_no_object() { + let mut h = with_guides(); + // Across both vertical guides, below the horizontal one, on empty page. + let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0)); + drag(&mut h, a, b); + assert_eq!(selected(&h), [0, 1]); + let id = h.state_mut().run("frame.create", json!({"rect": [220, 520, 260, 540]})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0)); + drag(&mut h, a, b); + assert!(selected(&h).is_empty()); + assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]); +} + +#[test] +fn keys_nudge_and_delete_selected_guides() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 1}, {"page": 0, "index": 2}]})).unwrap(); + let inc = h.state().session.doc().unwrap().doc.settings.keyboard_increment; + let vi = h.state().view_info(); + let key = |h: &mut Harness<'_, DesignApp>, k: ToolKey, shift: bool| { + let m = Mods { shift, alt: false, cmd: false, ctrl: false, space: false }; + assert!(h.state_mut().session.tool_key(k, m, vi).unwrap()); + }; + key(&mut h, ToolKey::Right, false); + key(&mut h, ToolKey::Down, true); + assert_eq!(positions(&h), [200.0, 300.0 + inc, 400.0 + 10.0 * inc]); + key(&mut h, ToolKey::Delete, false); + assert_eq!(positions(&h), [200.0]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(positions(&h).len(), 3); + assert_eq!(selected(&h), [1, 2]); + key(&mut h, ToolKey::Escape, false); + assert!(selected(&h).is_empty()); +} + +#[test] +fn the_guide_field_measures_from_the_zero_point() { + let mut h = with_guides(); + h.state_mut().run("view.zeroPoint", json!({"at": [100, 50]})).unwrap(); + h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap(); + let f = crate::panels::guide_field(h.state()).unwrap(); + assert_eq!((f.row(), f.value), (0, 100.0)); + h.state_mut().run("guide.move", f.move_to(36.0)).unwrap(); + assert_eq!(positions(&h)[0], 136.0); + h.state_mut().run("guide.select", json!({"page": 0, "index": 2})).unwrap(); + let f = crate::panels::guide_field(h.state()).unwrap(); + assert_eq!((f.row(), f.value), (1, 350.0)); + // Several guides: no field value. + h.state_mut().run("guide.selectAll", json!({})).unwrap(); + assert!(crate::panels::guide_field(h.state()).is_none()); +} + +#[test] +fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() { + let mut h = with_guides(); + assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G")); + crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null); + assert_eq!(selected(&h), [0, 1, 2]); + assert_eq!(crate::i18n::tr("de", "Select All Guides"), "Alle Hilfslinien auswählen"); + for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { + assert_ne!(crate::i18n::tr(code, "Select All Guides"), "Select All Guides", "{code}"); + assert_ne!(crate::i18n::tr(code, "Ruler Guide"), "Ruler Guide", "{code}"); + } + h.state_mut().ui.guides = false; + assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll")); +} + +#[test] +fn a_guide_over_a_frame_takes_the_click() { + let mut h = with_guides(); + h.state_mut().run("guide.deselect", json!({})).unwrap(); + // The vertical guide at x 200 crosses the frame. + let id = h.state_mut().run("frame.create", json!({"rect": [150, 500, 260, 560]})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let bounds = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].items[0].bounds(); + let before = bounds(&h); + let at = screen_of(&h, 200.0, 530.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(selected(&h), [0]); + assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the frame is not selected"); + // Dragging it there moves the guide, not the frame. + let (a, b) = (screen_of(&h, 200.0, 530.0), screen_of(&h, 230.0, 530.0)); + drag(&mut h, a, b); + assert!((positions(&h)[0] - 230.0).abs() < 1.0, "{:?}", positions(&h)); + assert_eq!(bounds(&h), before); + assert!(h.state().session.doc().unwrap().selection.items.is_empty()); + // Away from the guide, a click inside the frame selects it. + let at = screen_of(&h, 160.0, 530.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]); + assert!(selected(&h).is_empty()); +} + +#[test] +fn a_guide_dragged_out_of_a_ruler_is_the_selection() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap(); + let rect = h.state().canvas_rect.unwrap(); + let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0); + let dest = screen_of(&h, 250.0, 500.0); + drag(&mut h, ruler, dest); + let p = positions(&h); + assert!(p.len() == 4 && (p[3] - 500.0).abs() < 1.0, "{p:?}"); + assert_eq!(selected(&h), [3]); +} + +#[test] +fn dragging_one_of_several_selected_guides_previews_them_all() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap(); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0)); + let g = GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b) }; + let press = GuidePress { origin: a, shift: false }; + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let m = moving_guides(app, &xf, app.canvas_rect.unwrap(), &g, press).expect("moving"); + assert!((m.dx - 50.0).abs() < 1.0 && (m.dy - 20.0).abs() < 1.0, "{} {}", m.dx, m.dy); + let mut moved: Vec = m.guides.iter().map(|g| g.index).collect(); + moved.sort_unstable(); + assert_eq!(moved, [0, 2]); + // The drop moves them by the same amounts. + let (dx, dy) = guide_drag_delta(app, &xf, &g, a, b).unwrap(); + assert_eq!((dx, dy), (m.dx, m.dy)); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c2abdb9a9..91c6db7de 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -318,6 +318,11 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), ("Deselect All", ["Auswahl aufheben", "Tout désélectionner", "Deseleccionar todo", "すべての選択を解除", "取消全选"]), + ( + "Select All Guides", + ["Alle Hilfslinien auswählen", "Sélectionner tous les repères", "Seleccionar todas las guías", "すべてのガイドを選択", "选择所有参考线"], + ), + ("Ruler Guide", ["Hilfslinie", "Repère de règle", "Guía de regla", "定規ガイド", "标尺参考线"]), ("Find/Change…", ["Suchen/Ersetzen …", "Rechercher/Remplacer…", "Buscar/Cambiar…", "検索と置換…", "查找/更改…"]), ("Check Spelling…", ["Rechtschreibprüfung …", "Vérifier l'orthographe…", "Revisar ortografía…", "スペルチェック…", "拼写检查…"]), ("Preferences…", ["Voreinstellungen …", "Préférences…", "Preferencias…", "環境設定…", "首选项…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..11a01c1cc 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -147,6 +147,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "تكرار"), ("Select All", "تحديد الكل"), ("Deselect All", "إلغاء تحديد الكل"), + ("Select All Guides", "تحديد كل الأدلة"), + ("Ruler Guide", "دليل المسطرة"), ("Find/Change…", "البحث/التغيير…"), ("Find/Change", "البحث/التغيير"), ("Check Spelling…", "تدقيق إملائي…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..b295e5e5b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -37,6 +37,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplikovat"), ("Select All", "Vybrat vše"), ("Deselect All", "Zrušit výběr"), + ("Select All Guides", "Vybrat všechna vodítka"), + ("Ruler Guide", "Vodítko pravítka"), ("Find/Change…", "Najít/Změnit…"), ("Check Spelling…", "Zkontrolovat pravopis…"), ("Preferences…", "Předvolby…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..7a5c5d17e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -64,6 +64,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), ("Deselect All", "Auswahl aufheben"), + ("Select All Guides", "Alle Hilfslinien auswählen"), + ("Ruler Guide", "Hilfslinie"), ("Find/Change…", "Suchen/Ersetzen …"), ("Check Spelling…", "Rechtschreibprüfung …"), ("Preferences…", "Voreinstellungen …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..e74d445fb 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -143,6 +143,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), ("Deselect All", "Deseleziona tutto"), + ("Select All Guides", "Seleziona tutte le guide"), + ("Ruler Guide", "Guida del righello"), ("Find/Change…", "Trova/Sostituisci…"), ("Check Spelling…", "Controllo ortografico…"), ("Preferences…", "Preferenze…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..072c188ff 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -145,6 +145,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "複製"), ("Select All", "すべてを選択"), ("Deselect All", "すべての選択を解除"), + ("Select All Guides", "すべてのガイドを選択"), + ("Ruler Guide", "定規ガイド"), ("Find/Change…", "検索と置換…"), ("Check Spelling…", "スペルチェック…"), ("Preferences…", "環境設定…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..df9fabc07 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -66,6 +66,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "დუბლირება"), ("Select All", "ყველაფრის მონიშვნა"), ("Deselect All", "ყველა მონიშვნის მოხსნა"), + ("Select All Guides", "ყველა დამხმარე ხაზის მონიშვნა"), + ("Ruler Guide", "სახაზავის დამხმარე ხაზი"), ("Find/Change…", "ძებნა/ჩანაცვლება…"), ("Check Spelling…", "მართლწერის შემოწმება…"), ("Preferences…", "პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..fca29c211 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), ("Deselect All", "Usuń zaznaczenie"), + ("Select All Guides", "Zaznacz wszystkie linie pomocnicze"), + ("Ruler Guide", "Linia pomocnicza"), ("Find/Change…", "Znajdź/zmień…"), ("Check Spelling…", "Sprawdź pisownię…"), ("Preferences…", "Preferencje…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..6d3a05793 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -154,6 +154,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplicar"), ("Select All", "Selecionar tudo"), ("Deselect All", "Desmarcar tudo"), + ("Select All Guides", "Selecionar todas as guias"), + ("Ruler Guide", "Guia da régua"), ("Find/Change…", "Localizar/alterar…"), ("Find/Change", "Localizar/alterar"), ("Check Spelling…", "Verificar ortografia…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..dc518b60c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -37,6 +37,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), ("Deselect All", "Снять выделение"), + ("Select All Guides", "Выделить все направляющие"), + ("Ruler Guide", "Направляющая"), ("Find/Change…", "Найти/заменить…"), ("Check Spelling…", "Проверка орфографии…"), ("Preferences…", "Установки…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..223dba543 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -146,6 +146,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), ("Deselect All", "Зняти всі виділення"), + ("Select All Guides", "Виділити всі напрямні"), + ("Ruler Guide", "Напрямна"), ("Find/Change…", "Знайти й замінити…"), ("Check Spelling…", "Перевірити орфографію…"), ("Preferences…", "Налаштування…"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..0844b95a4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -457,6 +457,7 @@ impl UiState { smart_dimensions: self.smart_dimensions, smart_spacing: self.smart_spacing, zone_px: self.snap_zone, + guides_locked: self.guides_locked, } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c455b718d..da5819e6f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -366,6 +366,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:edit.stepAndRepeat", "-", "cmd:edit.selectAll", + "cmd:guide.selectAll", "cmd:edit.deselectAll", "-", "ui:app.findChange", @@ -1320,16 +1321,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; - let page = crate::canvas::current_page(app).unwrap_or(0); - let spread = if st.editing_parents { - // The parent spread the canvas shows. - let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); - let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread); - json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0))) - } else { - json!(st.doc.page_loc(page).map_or(0, |l| l.0)) - }; + let Some(spread) = guide_spread(app) else { return Some(Err("no document open".into())) }; return Some(app.run("guide.deleteAll", json!({"spread": spread}))); } "app.deletePage" | "app.duplicateSpread" => { @@ -2443,6 +2435,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id.starts_with("fit.") { return enabled(app, "object.fit"); } + if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { + return false; + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -2544,6 +2539,16 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { app.ui.dialog = Some(crate::dialogs::Dialog::new(&format!("cmd:{id}"), json!({}))); return; } + // Select All Guides: on the spread in view, while guides are shown and not locked. + if params.is_null() && id == "guide.selectAll" { + if app.ui.guides + && !app.ui.guides_locked + && let Some(spread) = guide_spread(app) + { + let _ = app.run(id, json!({"spread": spread})); + } + return; + } let p = if params.is_null() { json!({}) } else { params.clone() }; // Update Link asks before replacing edits made to linked text in the layout. if id == "links.update" { @@ -2553,6 +2558,20 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { let _ = app.run(id, p); } +/// The spread the guide commands of the menus act on: the one in view (a parent spread while +/// parents are edited). +fn guide_spread(app: &DesignApp) -> Option { + let st = app.session.active()?; + let page = crate::canvas::current_page(app).unwrap_or(0); + Some(if st.editing_parents { + let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); + let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread); + json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0))) + } else { + json!(st.doc.page_loc(page).map_or(0, |l| l.0)) + }) +} + /// A menu button whose popup stays within the viewport and scrolls when needed. /// All menu buttons share this wrapper, including workspace, zoom and panel menus. pub fn menu_button<'a, R>( diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs index 7c2634e23..0a845a38d 100644 --- a/crates/ui-egui/src/object_control.rs +++ b/crates/ui-egui/src/object_control.rs @@ -64,6 +64,8 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { let selected = info.is_some(); let reference = app.ui.ref_point.min(8); let geometry = info.as_ref().map(|i| i.page_rect); + // One ruler guide selected: X (vertical guide) or Y (horizontal guide) is where it is. + let guide = panels::guide_field(app); group(ui, "reference", 26.0, |ui, rect| { let proxy = Rect::from_center_size(rect.center(), vec2(26.0, 26.0)); if let Some(point) = properties::ref_point_proxy(ui, proxy, reference) { @@ -81,10 +83,17 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { for (column, (label, id, key, index)) in fields.into_iter().enumerate() { let x = column as f32 * 108.0; cell(ui, rect, x, row, 14.0, true, |ui| widgets::caption_w(ui, label, 14.0)); - let value = values.map(|v| v[index]); - if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, selected, |ui| { - NumField::measure(id, value, units).width(86.0).spinner().enabled(selected).show(ui) + let on_guide = guide.filter(|g| g.row() == index); + let value = if guide.is_some() { on_guide.map(|g| g.value) } else { values.map(|v| v[index]) }; + let enabled = selected || on_guide.is_some(); + let unit = on_guide.map_or(units, |g| g.unit); + if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, enabled, |ui| { + NumField::measure(id, value, unit).width(86.0).spinner().enabled(enabled).show(ui) }) { + if let Some(g) = on_guide { + let _ = app.run("guide.move", g.move_to(v)); + continue; + } set.insert(key.into(), json!(v)); if constrain && column == 1 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..f2229b604 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -117,6 +117,49 @@ pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { st.selection.items.first().and_then(|id| st.doc.item(*id)).map_or(1.0, |it| if stroke { it.stroke.tint } else { it.fill.tint }) } +/// The one selected ruler guide, as the X field (vertical guide) or Y field (horizontal guide) +/// of the Control and Properties panels shows it: measured as the rulers measure, from the zero +/// point (and the page, when rulers restart per page), in that ruler's units. +#[derive(Clone, Copy, Debug)] +pub struct GuideField { + pub guide: designcraft_doc::GuideSel, + pub orientation: designcraft_doc::Orientation, + /// Points from the ruler origin. + pub value: f64, + /// Where `value` 0 is: a spread x for a vertical guide, a y for a horizontal one. + pub origin: f64, + pub unit: designcraft_geom::Unit, +} + +impl GuideField { + /// Which field shows it: 0 for X, 1 for Y. + pub fn row(&self) -> usize { + match self.orientation { + designcraft_doc::Orientation::Vertical => 0, + designcraft_doc::Orientation::Horizontal => 1, + } + } + /// The `guide.move` params that put it at `value` (as the field reads). + pub fn move_to(&self, value: f64) -> Value { + let g = self.guide; + json!({"spread": g.spread, "page": g.page, "index": g.index, "position": value + self.origin}) + } +} + +/// [`GuideField`] when exactly one ruler guide is selected. +pub fn guide_field(app: &DesignApp) -> Option { + let st = app.session.active()?; + let [g] = st.selection.guides.as_slice() else { return None }; + let pg = st.doc.spread(g.spread)?.pages.get(g.page)?; + let guide = pg.guides.get(g.index)?; + let (origin, unit) = match guide.orientation { + designcraft_doc::Orientation::Vertical => (st.doc.ruler_origin(g.spread, guide.position)?.x, st.doc.settings.horizontal_units), + designcraft_doc::Orientation::Horizontal => (st.doc.ruler_origin(g.spread, pg.x + pg.width / 2.0)?.y, st.doc.settings.vertical_units), + }; + let value = guide.position - origin; + value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit }) +} + pub fn sel_info(app: &DesignApp) -> Option { let st = app.session.active()?; if st.selection.items.is_empty() { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..399cc34e7 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -59,6 +59,9 @@ fn selection_title(app: &DesignApp, info: &Option) -> String { if st.selection.text.is_some() { return crate::i18n::tr(&app.ui.language, "Characters").into(); } + if info.is_none() && !st.selection.guides.is_empty() { + return crate::i18n::tr(&app.ui.language, "Ruler Guide").into(); + } match info { None => crate::i18n::tr(&app.ui.language, "No Selection").into(), Some(i) if i.count > 1 => crate::i18n::tr(&app.ui.language, "Multiple Objects").into(), @@ -106,6 +109,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { quick_actions_text(app, ui); return; } + if info.is_none() && app.session.active().is_some_and(|st| !st.selection.guides.is_empty()) { + section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); + guide_section(app, ui); + return; + } // The Page tool with no objects selected: the page's own settings, as InDesign's. if info.is_none() && app.session.tool_id() == "page" { let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app)); @@ -613,6 +621,27 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } } +/// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one +/// horizontal guide; with several guides both fields are empty. +fn guide_section(app: &mut DesignApp, ui: &mut Ui) { + let guide = super::guide_field(app); + let b = block(ui, 2.0 * FIELD_H + 4.0); + for (row, (label, key)) in [("X:", "gx"), ("Y:", "gy")].into_iter().enumerate() { + let yy = row as f32 * 25.0; + let on = guide.filter(|g| g.row() == row); + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, label, 18.0)); + let typed = place(ui, sub(b, 59.0, yy, 135.0, FIELD_H), |ui| { + if on.is_none() { + ui.disable(); + } + measure(ui, key, on.map(|g| g.value), on.map_or_else(|| units(app), |g| g.unit), 135.0) + }); + if let (Some(v), Some(g)) = (typed, on) { + let _ = app.run("guide.move", g.move_to(v)); + } + } +} + /// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the /// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..89fe4d401 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -283,7 +283,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. From 6f7fc7134a487d9aa22aac4b173fa53ba5133403 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:59:10 -0400 Subject: [PATCH 040/117] IDML: dragged column guides import, validated, and round-trip (#1096) (#1171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IDML: keep column guides that were dragged off centre (#1096) InDesign records dragged column guides in `MarginPreference ColumnsPositions`, as pairs of offsets from the margin box's left edge. The importer discarded them and divided the columns evenly, so a page whose gutter the designer had moved came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was read as columns at 215 and 235. `Columns::positions` already existed, documented as "Custom column positions (after dragging column guides)", but nothing set it and nothing read it. Now `Page::column_rects` returns the custom rects when they are set — that one method is what the canvas guides, snapping, threading and the first frame of a placed story all go through, so they follow the dragged gutter — the importer fills it, and the exporter writes the page's real positions instead of recomputing an even split, so the round trip keeps them. `layout.marginsAndColumns` clears them when a count or gutter is set, which is what InDesign's Margins and Columns does. Without that the stale positions would outrank the new value and the dialog would look broken. Verified against InDesign-authored IDML rather than assumed, which settled two things. The attribute is written whether or not anything was dragged — a real page's values are exactly its even split — so an even set is dropped rather than marking every imported page custom and freezing its columns; the tolerance for that comparison only absorbs float noise, about 1e-14 pt on that page. And `` is a `` child, not a `` one, which is where this importer already looks: the reporter's missing ruler guide was 099fbd2, landed the day the ticket was filed, not a second bug. That commit left `lib.rs` still listing guides as ignored on import; corrected. * IDML: custom column positions are validated and round-trip (#1096) Columns::positions are used only when they are 2 x count finite edges, ascending from 0 to the margin box width (within 0.01 pt); anything else is the even split, in Page::column_rects, on IDML import (bounded parse, checked against the page's margin box) and on IDML export. Columns::with_gutter_moved moves one gutter's pair of guides between its neighbours for the coming column-guide command. ROADMAP: drop four progress-log lines the #1121 merge duplicated and move its line to the top. * IDML: a page without its own margins checks the default's column positions against its width (#1096) * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: Hamza --- crates/doc/src/page.rs | 158 +++++++++++++++++++++++++++++++- crates/engine/src/cmd/layout.rs | 44 +++++++++ crates/idml/src/export.rs | 7 +- crates/idml/src/import.rs | 89 ++++++++++++++++-- crates/idml/src/lib.rs | 3 +- crates/idml/src/tests.rs | 69 ++++++++++++++ 6 files changed, 357 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 862a1cda9..d1a77d78e 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -42,6 +42,77 @@ impl Default for Columns { } } +/// The most columns a page can have, as in InDesign. +pub const MAX_COLUMNS: u32 = 216; + +/// How far, in points, the first and last custom column positions may sit from the margin box's +/// edges: room for float noise in files and page arithmetic, far less than any dragged guide. +pub const COLUMN_POSITION_TOLERANCE: f64 = 0.01; + +/// Whether `positions` are usable custom column positions for `count` columns across a margin box +/// `margin_width` wide, in IDML's `ColumnsPositions` form: each column's left and right edge as an +/// offset from the margin box's left edge, so `2 × count` finite numbers, ascending across the +/// whole list, the first at 0 and the last at `margin_width` (within +/// [`COLUMN_POSITION_TOLERANCE`]). +pub fn valid_column_positions(positions: &[f64], count: u32, margin_width: f64) -> bool { + (1..=MAX_COLUMNS).contains(&count) + && positions.len() == 2 * count as usize + && margin_width.is_finite() + && positions.iter().all(|v| v.is_finite()) + && positions.windows(2).all(|w| matches!(w, [a, b] if a <= b)) + && positions.first().is_some_and(|v| v.abs() <= COLUMN_POSITION_TOLERANCE) + && positions.last().is_some_and(|v| (v - margin_width).abs() <= COLUMN_POSITION_TOLERANCE) +} + +impl Columns { + /// The custom positions when they are valid for a margin box `margin_width` wide (see + /// [`valid_column_positions`]). Anything else means the columns are evenly spaced: a stored set + /// that no longer fits (a page resized after the drag, a hostile file) is not drawn. + pub fn valid_positions(&self, margin_width: f64) -> Option<&[f64]> { + let positions = self.positions.as_deref()?; + valid_column_positions(positions, self.count, margin_width).then_some(positions) + } + + /// Every column's left and right edge as offsets from the margin box's left edge: the valid + /// custom positions, else the even split. This is what IDML writes as `ColumnsPositions`. + pub fn edges(&self, margin_width: f64) -> Vec { + let w = if margin_width.is_finite() { margin_width.max(0.0) } else { 0.0 }; + match self.valid_positions(w) { + Some(p) => p.iter().map(|&v| inside(v, w)).collect(), + None => column_rects(Rect::new(0.0, 0.0, w, 0.0), self.count.max(1), self.gutter).iter().flat_map(|r| [r.x0, r.x1]).collect(), + } + } + + /// The column edges (as [`Columns::edges`]) with gutter `gutter` (0 is the one between the first + /// two columns) moved so its left edge sits at `new_left`, an offset from the margin box's left + /// edge. Both of the gutter's guides move, so it keeps its width, and it stops at its + /// neighbouring gutters or the margins: no column gets a negative width. `None` when there is + /// no such gutter, `new_left` is not finite, or the result would not be valid. + pub fn with_gutter_moved(&self, margin_width: f64, gutter: usize, new_left: f64) -> Option> { + if !new_left.is_finite() { + return None; + } + let mut pos = self.edges(margin_width); + let i = gutter.checked_mul(2)?.checked_add(1)?; + let &[lo, left, right, hi] = pos.get(i - 1..i.checked_add(3)?)? else { + return None; + }; + let width = right - left; + let at = new_left.min(hi - width).max(lo); + let [l, r] = pos.get_mut(i..i + 2)? else { + return None; + }; + *l = at; + *r = (at + width).min(hi); + valid_column_positions(&pos, self.count, margin_width).then_some(pos) + } +} + +/// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN. +fn inside(v: f64, w: f64) -> f64 { + v.max(0.0).min(w) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Orientation { @@ -250,17 +321,23 @@ impl Page { let b = self.bounds(); Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top)) } - /// Column rects within the margins. + /// Column rects within the margins: the custom positions when the column guides have been + /// dragged and still fit the margin box (see [`valid_column_positions`]), otherwise an even + /// split. pub fn column_rects(&self) -> Vec { let m = self.margin_rect(); - column_rects(m, self.columns.count.max(1), self.columns.gutter) + let w = m.width(); + match self.columns.valid_positions(w) { + Some(p) => p.as_chunks::<2>().0.iter().map(|&[a, b]| Rect::new(m.x0 + inside(a, w), m.y0, m.x0 + inside(b, w), m.y1)).collect(), + None => column_rects(m, self.columns.count.max(1), self.columns.gutter), + } } } /// Split `area` into `n` equal columns separated by `gutter`. pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec { // The public allocator also receives native/imported options, outside command validation. - let n = n.clamp(1, 216) as usize; + let n = n.clamp(1, MAX_COLUMNS) as usize; let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() } @@ -450,6 +527,81 @@ mod tests { assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9); } + /// Dragged column guides place the columns; a set that cannot mean anything falls back to the + /// even split rather than producing nothing or a column outside the margins (#1096). + #[test] + fn custom_column_positions_place_the_columns() { + let mut p = page(PageSide::Single, 0.0); + let m = p.margin_rect(); + let w = m.width(); + let even = p.column_rects(); + + p.columns.positions = Some(vec![0.0, 100.0, 130.0, w]); + assert_eq!(p.column_rects(), vec![Rect::new(m.x0, m.y0, m.x0 + 100.0, m.y1), Rect::new(m.x0 + 130.0, m.y0, m.x1, m.y1)]); + // Float noise at the edges is absorbed and kept inside the box. + p.columns.positions = Some(vec![-0.001, 100.0, 130.0, w + 0.001]); + assert_eq!((p.column_rects()[0].x0, p.column_rects()[1].x1), (m.x0, m.x1)); + + let huge: Vec = (0..10_000).map(f64::from).collect(); + let unusable = [ + vec![], + vec![10.0], + vec![0.0, w], // one column's worth for two columns + vec![0.0, 100.0, 130.0, 200.0, 230.0, w], // three columns' worth + vec![0.0, 100.0, 90.0, w], // descending across pairs + vec![0.0, 130.0, 100.0, w], // an inverted gutter + vec![0.0, f64::NAN, 130.0, w], + vec![0.0, 100.0, f64::INFINITY, w], + vec![5.0, 100.0, 130.0, w], // not from the left margin + vec![0.0, 100.0, 130.0, w + 500.0], // a page narrowed after the drag + vec![0.0, 100.0, 130.0, w - 50.0], // a page widened after the drag + huge, + ]; + for bad in unusable { + p.columns.positions = Some(bad.clone()); + assert_eq!(p.column_rects(), even, "{:?}", bad.iter().take(6).collect::>()); + assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::>()); + } + p.columns.positions = None; + assert_eq!(p.column_rects(), even); + + // A count outside 1..=216 has no valid positions, however many are given. + for count in [0, 217, u32::MAX] { + let c = Columns { count, gutter: 0.0, positions: Some(vec![0.0, w]) }; + assert!(c.valid_positions(w).is_none(), "{count}"); + } + } + + /// Moving a gutter moves both its guides, keeps its width and stops at the neighbouring gutters + /// and the margins; the result is valid custom positions. + #[test] + fn with_gutter_moved_moves_a_gutter_pair_and_clamps() { + // Three columns across 450 with gutter 15: 0 140 155 295 310 450. + let c = Columns { count: 3, gutter: 15.0, positions: None }; + let even = c.edges(450.0); + assert_eq!(even, vec![0.0, 140.0, 155.0, 295.0, 310.0, 450.0]); + + assert_eq!(c.with_gutter_moved(450.0, 0, 100.0), Some(vec![0.0, 100.0, 115.0, 295.0, 310.0, 450.0])); + // Past the left margin: the first column closes to nothing. + assert_eq!(c.with_gutter_moved(450.0, 0, -50.0), Some(vec![0.0, 0.0, 15.0, 295.0, 310.0, 450.0])); + // Past the next gutter: the middle column closes to nothing, the next gutter stays put. + assert_eq!(c.with_gutter_moved(450.0, 0, 400.0), Some(vec![0.0, 280.0, 295.0, 295.0, 310.0, 450.0])); + // The last gutter stops at the right margin. + assert_eq!(c.with_gutter_moved(450.0, 1, 1000.0), Some(vec![0.0, 140.0, 155.0, 435.0, 450.0, 450.0])); + + // It starts from custom positions already there, and its result is accepted as custom. + let moved = Columns { positions: c.with_gutter_moved(450.0, 0, 100.0), ..c.clone() }; + assert!(moved.valid_positions(450.0).is_some()); + assert_eq!(moved.with_gutter_moved(450.0, 1, 200.0), Some(vec![0.0, 100.0, 115.0, 200.0, 215.0, 450.0])); + + // No such gutter, or no position to move it to. + assert_eq!(c.with_gutter_moved(450.0, 2, 100.0), None); + assert_eq!(c.with_gutter_moved(450.0, usize::MAX, 100.0), None); + assert_eq!(c.with_gutter_moved(450.0, 0, f64::NAN), None); + assert_eq!(Columns { count: 1, gutter: 12.0, positions: None }.with_gutter_moved(450.0, 0, 100.0), None); + assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None); + } + #[test] fn spread_spine_and_hit() { let s = Spread { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 615e7c1df..667bb927b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -97,11 +97,15 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { } _ => {} } + // Setting a count or a gutter re-divides the columns evenly, as InDesign's Margins and + // Columns does: dragged column positions are what the new value replaces. if let Some(c) = p.get("columns").and_then(Value::as_u64) { pg.columns.count = (c as u32).clamp(1, 216); + pg.columns.positions = None; } if let Some(g) = p.get("gutter").and_then(Value::as_f64) { pg.columns.gutter = g.max(0.0); + pg.columns.positions = None; } } @@ -1650,6 +1654,46 @@ mod adjust_tests { // An unknown parent is an error, not a silent no-op. assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err()); } + + /// Margins and Columns re-divides the columns: a new count or gutter replaces column guides + /// that were dragged (an imported Custom columns page), rather than being ignored by them. + #[test] + fn a_new_column_count_replaces_dragged_column_positions() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + // There is no command for dragging a column guide yet, so set what an import would leave. + let drag = |s: &mut Session| { + s.edit(|d, _| { + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + Ok(()) + }) + .unwrap(); + }; + + drag(&mut s); + let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert_eq!(dragged.len(), 2); + assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}"); + + // A new count: evenly divided again, and the stale positions are gone. + s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None)); + assert_eq!(pg.column_rects().len(), 3); + + // So does a new gutter on its own. + drag(&mut s); + s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!(pg.columns.positions, None); + assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6); + + // Margins alone leave them alone: the offsets are relative to the margin box. + drag(&mut s); + s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0])); + } } #[cfg(test)] diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..bd1d117e0 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -8,7 +8,7 @@ use designcraft_doc::{ CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story, TextFrameOptions, story as st, }; -use designcraft_geom::{Affine, PathData, Rect}; +use designcraft_geom::{Affine, PathData}; use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt}; use crate::xml::{El, Node, document, document_with_pi}; @@ -2901,8 +2901,9 @@ impl Group { fn margin_el(pg: &designcraft_doc::Page) -> El { let m = pg.margin_rect(); - let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter); - let pos: Vec = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect(); + // Dragged column guides that still fit the margin box are written as they stand, so they + // survive the round trip; otherwise the even split, which InDesign writes for unmoved columns. + let pos: Vec = pg.columns.edges(m.width()).into_iter().map(num).collect(); El::new("MarginPreference") .attr("ColumnCount", pg.columns.count.max(1)) .attr("ColumnGutter", num(pg.columns.gutter)) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..271eba146 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2534,9 +2534,15 @@ impl<'r> Importer<'r> { .map(|q| (q.margins, q.columns.clone())) }; let (margins, columns) = match (p.find("MarginPreference"), &from_parent) { - (Some(m), _) => margins_of(m), + (Some(m), _) => { + let (margins, columns) = margins_of(m); + (margins, fit_columns(columns, &margins, r.width())) + } (None, Some(own)) => own.clone(), - (None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())), + (None, None) => { + let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())); + (margins, fit_columns(columns, &margins, r.width())) + } }; let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns); out_pages.push(Page { @@ -3465,10 +3471,39 @@ fn composer_in(name: &str) -> designcraft_doc::Composer { fn margins_of(m: &El) -> (Margins, Columns) { let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d); - ( - Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, - Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, - ) + let margins = Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }; + let count = g("ColumnCount", 1.0).clamp(1.0, f64::from(designcraft_doc::MAX_COLUMNS)) as u32; + let gutter = g("ColumnGutter", 12.0); + let positions = m.get("ColumnsPositions").and_then(|s| custom_columns(s, count, gutter)); + (margins, Columns { count, gutter, positions }) +} + +/// `ColumnsPositions` when the column guides have been dragged off the even split: `2 × count` +/// offsets from the margin box's left edge, valid as [`designcraft_doc::valid_column_positions`] +/// over the box they span. InDesign writes the attribute whether or not they have been moved, so +/// an even set is dropped: `positions` means "custom", and keeping an even copy would freeze the +/// columns against a later change of count or gutter. Anything malformed is the even split; at +/// most one number past `2 × count` is parsed, so a long list costs nothing. +fn custom_columns(s: &str, count: u32, gutter: f64) -> Option> { + let want = 2 * count as usize; + let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::().ok()).collect::>>()?; + let width = *pos.last()?; + if !designcraft_doc::valid_column_positions(&pos, count, width) { + return None; + } + let even = Columns { count, gutter, positions: None }.edges(width); + // The tolerance only absorbs float noise in InDesign's arithmetic; a dragged guide moves far more. + pos.iter().zip(&even).any(|(a, b)| (a - b).abs() > 1e-4).then_some(pos) +} + +/// `columns` with custom positions only when they fit the margin box of a `page_width`-wide page +/// with these margins (the box's width doesn't depend on which side of the spine it is). +fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Columns { + let width = (page_width - margins.inside - margins.outside).max(0.0); + if columns.valid_positions(width).is_none() { + columns.positions = None; + } + columns } /// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. @@ -3995,6 +4030,48 @@ mod page_column_import_safety_tests { assert_eq!(margins, Margins::uniform(36.)); } } + + /// Hostile `ColumnsPositions` are the even split: never half-read, never a panic, and a long + /// list is not parsed past what `ColumnCount` allows (#1096). + #[test] + fn hostile_column_positions_fall_back_to_the_even_split() { + let positions = |count: &str, raw: &str| { + let m = El::new("MarginPreference").attr("ColumnCount", count).attr("ColumnGutter", "20").attr("ColumnsPositions", raw); + margins_of(&m).1.positions + }; + assert_eq!(positions("2", "0 150 170 450"), Some(vec![0., 150., 170., 450.])); + let huge = (0..10_000).map(|i| i.to_string()).collect::>().join(" "); + for (count, raw) in [ + ("2", "0 215 235 450"), // the even split + ("2", "0 150 170"), // odd + ("2", "0 450"), // too few for the count + ("2", "0 100 120 200 220 450"), // too many for the count + ("2", "0 150 90 450"), // descending + ("2", "0 NaN 170 450"), + ("2", "0 inf 170 450"), + ("2", "0 150 170 1e309"), + ("2", "0 x 170 450"), + ("2", "10 150 170 450"), // not from the margin + ("2", ""), + ("2", huge.as_str()), + ("4000000000", huge.as_str()), // the count is capped at 216, so is the list + ("216", huge.as_str()), + ("NaN", "0 150 170 450"), + ] { + assert_eq!(positions(count, raw), None, "{count}: {}", raw.get(..raw.len().min(40)).unwrap_or("")); + } + // The cap itself: 216 one-point columns with no gutter are the even split; one edge moved + // makes them custom. + let gapless = |raw: &str| { + let m = El::new("MarginPreference").attr("ColumnCount", "216").attr("ColumnGutter", "0").attr("ColumnsPositions", raw); + margins_of(&m).1.positions + }; + let edges = |first_right: &str| { + (0..432).map(|i| if i == 1 { first_right.to_string() } else { ((i + 1) / 2).to_string() }).collect::>().join(" ") + }; + assert_eq!(gapless(&edges("1")), None); + assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432)); + } } #[cfg(test)] diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 38c4fabbe..8f7b22ff9 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -17,7 +17,8 @@ //! ## Supported subset //! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph, //! character and object styles (with `BasedOn` and style groups), fonts, document preferences -//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections, +//! (page size, facing pages, bleed/slug, margins and columns including dragged column positions, +//! units, grids), ruler guides, layers, sections, //! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`, //! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text //! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 81cebff70..cf9ce28a4 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4194,6 +4194,75 @@ mod liquid_page_rules { } } +/// A dragged column gutter (InDesign's Custom columns) survives an IDML import, and an even set — +/// which InDesign writes for every page, moved or not — does not pretend to be custom (#1096). +#[test] +fn custom_column_positions_import_and_round_trip() { + // Page 1 of SPREAD is 500 wide with Left=20 Right=30, so its margin box is 450 wide and + // starts at x=30. Two columns with gutter 20 split evenly as "0 215 235 450". + let with = |cols: &str| { + let spread = SPREAD.replace( + r#""#, + &format!( + r#""# + ), + ); + import_idml_with( + &zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]), + &|_| None, + ) + .unwrap() + }; + let rects = |d: &designcraft_doc::Document| d.spreads[0].pages[0].column_rects().iter().map(|r| (r.x0, r.x1)).collect::>(); + + // Dragged: the gutter sits at 150..170 instead of 215..235, and the columns follow it. + let d = with(r#"ColumnsPositions="0 150 170 450""#); + assert_eq!(d.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0])); + assert_eq!(rects(&d), vec![(30.0, 180.0), (200.0, 480.0)]); + + // The even set is dropped, so a later change of count or gutter is not frozen out. The + // geometry is the same either way, which is what makes keeping it pointless. + let even = with(r#"ColumnsPositions="0 215 235 450""#); + assert_eq!(even.spreads[0].pages[0].columns.positions, None); + assert_eq!(rects(&even), vec![(30.0, 245.0), (265.0, 480.0)]); + // Including when InDesign's own arithmetic leaves the last bits different. + assert_eq!(with(r#"ColumnsPositions="0 215.00000000000003 234.99999999999997 450""#).spreads[0].pages[0].columns.positions, None); + + // Malformed sets are ignored rather than half-read: an odd count, three columns' worth for a + // count of two, a descending pair, a NaN, a set that stops short of the page's margin box, and + // a list far longer than any count allows. Each page keeps the even split. + let huge = format!(r#"ColumnsPositions="{}""#, (0..10_000).map(|i| i.to_string()).collect::>().join(" ")); + for bad in [ + r#"ColumnsPositions="0 150 170""#, + r#"ColumnsPositions="0 100 120 200 220 450""#, + r#"ColumnsPositions="0 150 90 450""#, + r#"ColumnsPositions="0 x 170 450""#, + r#"ColumnsPositions="0 NaN 170 450""#, + r#"ColumnsPositions="0 150 170 400""#, + r#"ColumnsPositions="""#, + huge.as_str(), + ] { + let d = with(bad); + assert_eq!(d.spreads[0].pages[0].columns.positions, None, "{}", bad.get(..bad.len().min(60)).unwrap_or("")); + assert_eq!(rects(&d), rects(&even)); + } + + // And they come back from an export, so the round trip keeps the dragged gutter. + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0])); + assert_eq!(rects(&back), rects(&d)); + // A page nobody dragged still exports the even split, not a frozen copy of it. + assert_eq!(import_idml(&export_idml(&even)).unwrap().spreads[0].pages[0].columns.positions, None); +} + /// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes /// it (a `` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex` /// and `GuideZone`), in a document without facing pages. A spread guide sits beside it. From d7b9f716f131f21531c971e7eea5d2e18fc789f3 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:12:42 -0400 Subject: [PATCH 041/117] View: Lock Guides and Lock Column Guides are commands (#1096) (#1174) * View: Lock Guides and Lock Column Guides are commands (#1096) view.lockGuides {on?} (Cmd+Alt+;) sets the Lock Guides UI state the Properties panel and ruler-guide drags use. view.lockColumnGuides {on?} is a document setting, on by default, saved with the document, not an undo step, and read from and written to IDML ColumnGuideLocked. Both are in View > Grids & Guides with checkmarks. * View: Lock Column Guides is translated wherever Lock Guides is (#1096) * View: Lock Column Guides is undoable (#1096) * ROADMAP: the progress line lands in one commit after the guide PRs --- crates/doc/src/lib.rs | 9 +++++ crates/engine/src/cmd/guides.rs | 54 +++++++++++++++++++++++++ crates/idml/src/export.rs | 2 +- crates/idml/src/import.rs | 3 ++ crates/idml/src/tests.rs | 12 ++++++ crates/ui-egui/src/i18n.rs | 4 ++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 29 +++++++++++++ crates/ui-egui/src/panels/properties.rs | 4 +- docs/target-app-parity.md | 2 +- 20 files changed, 127 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5c854ba9f..b2d3b4f4c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -399,11 +399,19 @@ pub struct DocSettings { /// Lock Zero Point: dragging from the ruler corner leaves it where it is. #[serde(skip_serializing_if = "std::ops::Not::not")] pub zero_point_locked: bool, + /// View › Grids & Guides › Lock Column Guides (IDML `ColumnGuideLocked`): column guides can't + /// be dragged. On in new documents, as in InDesign. + #[serde(skip_serializing_if = "is_true")] + pub column_guides_locked: bool, /// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets /// with nothing selected (InDesign's document defaults). pub new_object: NewObjectColors, } +fn is_true(b: &bool) -> bool { + *b +} + fn is_origin(p: &[f64; 2]) -> bool { p[0] == 0.0 && p[1] == 0.0 } @@ -461,6 +469,7 @@ impl Default for DocSettings { ruler_origin: RulerOrigin::Spread, zero_point: [0.0, 0.0], zero_point_locked: false, + column_guides_locked: true, new_object: NewObjectColors::default(), } } diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 97bf4e9cc..18826fb18 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -67,6 +67,15 @@ pub fn specs() -> Vec { Ok(json!({"deleted": n})) }) }), + cmd!( + "view.lockColumnGuides", + "Lock Column Guides", + ["View", "Grids & Guides"], + None, + "{on?: bool (default: toggle)} → {locked} — locked column guides can't be dragged; saved with the document (IDML ColumnGuideLocked), on in new documents", + has_doc, + lock_column_guides + ), cmd!( query "guide.list", "Guides", @@ -125,6 +134,18 @@ pub fn specs() -> Vec { ] } +fn lock_column_guides(s: &mut Session, p: &Value) -> Result { + let cur = s.doc()?.doc.settings.column_guides_locked; + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur); + if on == cur { + return Ok(json!({"locked": cur})); + } + s.edit(|d, _| { + d.settings.column_guides_locked = on; + Ok(json!({"locked": on})) + }) +} + fn checked_usize(value: &Value, key: &str, command: &str) -> Result { let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?; usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform"))) @@ -590,6 +611,39 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn lock_column_guides_toggles_sets_and_undoes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let locked = |s: &Session| s.doc().unwrap().doc.settings.column_guides_locked; + let undo_steps = |s: &Session| s.doc().unwrap().history.undo.len(); + assert!(locked(&s), "locked in new documents"); + let before = undo_steps(&s); + assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false); + assert!(!locked(&s)); + assert_eq!(undo_steps(&s), before + 1, "one undo step per toggle"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(locked(&s), "undo restores the lock"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(!locked(&s), "redo unlocks again"); + // Setting the current value changes nothing and adds no undo step. + let steps = undo_steps(&s); + assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap()["locked"], false); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap()["locked"], true); + assert!(locked(&s)); + assert_eq!(s.execute("view.lockColumnGuides", &json!({})).unwrap()["locked"], false); + // Saved with the document; files without the setting read as locked. + let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap(); + assert!(json.contains("\"columnGuidesLocked\":false"), "{json}"); + let back: Document = serde_json::from_str(&json).unwrap(); + assert!(!back.settings.column_guides_locked); + let old = json.replace("\"columnGuidesLocked\":false,", "").replace(",\"columnGuidesLocked\":false", ""); + assert!(!old.contains("columnGuidesLocked")); + let back: Document = serde_json::from_str(&old).unwrap(); + assert!(back.settings.column_guides_locked); + } + #[test] fn guides_add_move_delete_and_grid() { let mut s = Session::new(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index bd1d117e0..99dcb383e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -769,7 +769,7 @@ impl<'a> Ex<'a> { }, ) .attr("CreatePrimaryTextFrame", bool_s(s.primary_text_frame)) - .attr("ColumnGuideLocked", "true") + .attr("ColumnGuideLocked", bool_s(s.column_guides_locked)) .attr("MasterTextFrame", bool_s(s.primary_text_frame)) .attr("SnippetImportUsesOriginalLocation", "false"), ); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 271eba146..ef504065e 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -850,6 +850,9 @@ impl<'r> Importer<'r> { if let Some(v) = e.boolean("CreatePrimaryTextFrame") { s.primary_text_frame = v; } + if let Some(v) = e.boolean("ColumnGuideLocked") { + s.column_guides_locked = v; + } } fn grid_prefs(&mut self, e: &El) { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index cf9ce28a4..37fb2af50 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2773,6 +2773,18 @@ fn round_trips_the_zero_point_and_ruler_origin() { assert_eq!(fixture("NaN 4").settings.zero_point, [0.0, 0.0]); } +#[test] +fn round_trips_lock_column_guides() { + let mut d = Document::new(&NewDocument::default()); + assert!(zip_text(&export_idml(&d), "Resources/Preferences.xml").contains(r#"ColumnGuideLocked="true""#)); + assert!(import_idml(&export_idml(&d)).unwrap().settings.column_guides_locked); + d.settings.column_guides_locked = false; + let bytes = export_idml(&d); + let prefs = zip_text(&bytes, "Resources/Preferences.xml"); + assert!(prefs.contains(r#"ColumnGuideLocked="false""#), "{prefs}"); + assert!(!import_idml(&bytes).unwrap().settings.column_guides_locked); +} + #[test] fn kenten_shatai_and_grid_settings_import_and_round_trip() { use designcraft_doc::cjk::CharacterAlignment; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 91c6db7de..acf70ff32 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3164,6 +3164,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Last Spread", ["Last Spread", "Dernière planche", "Last Spread", "Last Spread", "最后一个跨页"]), ("Load DTD…", ["Load DTD…", "Charger la DTD…", "Load DTD…", "Load DTD…", "载入 DTD…"]), ("Location:", ["Location:", "Emplacement :", "Location:", "Location:", "位置:"]), + ( + "Lock Column Guides", + ["Spaltenhilfslinien sperren", "Verrouiller les repères de colonne", "Bloquear guías de columna", "列ガイドをロック", "锁定栏参考线"], + ), ("Lock Guides", ["Lock Guides", "Verrouiller les repères", "Lock Guides", "Lock Guides", "锁定参考线"]), ("Miter Limit:", ["Miter Limit:", "Limite de pointe :", "Miter Limit:", "Miter Limit:", "斜接限制:"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 11a01c1cc..587ed775b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -395,6 +395,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), ("Lock Guides", "قفل الأدلة"), + ("Lock Column Guides", "قفل أدلة الأعمدة"), ("Show Document Grid", "إظهار شبكة المستند"), ("Show Baseline Grid", "إظهار شبكة خطوط الأساس"), ("Options", "خيارات"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index b295e5e5b..c052b8f33 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1095,6 +1095,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Načíst DTD…"), ("Location:", "Umístění:"), ("Lock Guides", "Zamknout vodítka"), + ("Lock Column Guides", "Zamknout vodítka sloupců"), ("Miter Limit:", "Limit ostrého spoje:"), ("New Article from Selection", "Nový článek z výběru"), ("New Note at Cursor", "Nová poznámka u kurzoru"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 7a5c5d17e..7065c6f33 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1153,6 +1153,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "DTD laden …"), ("Location:", "Position:"), ("Lock Guides", "Hilfslinien sperren"), + ("Lock Column Guides", "Spaltenhilfslinien sperren"), ("Miter Limit:", "Gehrungsgrenze:"), ("New Article from Selection", "Neuer Artikel aus Auswahl"), ("New Note at Cursor", "Neue Notiz an der Einfügemarke"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e74d445fb..8199e2ebb 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1235,6 +1235,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Carica DTD…"), ("Location:", "Posizione:"), ("Lock Guides", "Blocca guide"), + ("Lock Column Guides", "Blocca guide colonne"), ("Miter Limit:", "Limite spigolo:"), ("New Article from Selection", "Nuovo articolo dalla selezione"), ("New Note at Cursor", "Nuova nota al cursore"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 072c188ff..e79f4ba06 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1351,6 +1351,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "DTD を読み込み…"), ("Location:", "位置:"), ("Lock Guides", "ガイドをロック"), + ("Lock Column Guides", "列ガイドをロック"), ("Miter Limit:", "マイターの上限:"), ("New Article from Selection", "選択範囲から新規アーティクルを作成"), ("New Note at Cursor", "カーソル位置に新規注釈"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index df9fabc07..c8405c16e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1172,6 +1172,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "DTD-ის ჩატვირთვა…"), ("Location:", "მდებარეობა:"), ("Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("Lock Column Guides", "სვეტების დამხმარე ხაზების ჩაკეტვა"), ("Miter Limit:", "მახვილის ზღვარი:"), ("New Article from Selection", "ახალი სტატია მონიშნულიდან"), ("New Note at Cursor", "ახალი შენიშვნა კურსორთან"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index fca29c211..51bb7fc5c 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1105,6 +1105,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Wczytaj DTD…"), ("Location:", "Położenie:"), ("Lock Guides", "Zablokuj linie pomocnicze"), + ("Lock Column Guides", "Zablokuj linie pomocnicze łamów"), ("Miter Limit:", "Limit ostrych narożników:"), ("New Article from Selection", "Nowy artykuł z zaznaczenia"), ("New Note at Cursor", "Nowa notatka przy kursorze"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 6d3a05793..bb3d21831 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -403,6 +403,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), ("Lock Guides", "Bloquear guias"), + ("Lock Column Guides", "Bloquear guias de colunas"), ("Show Document Grid", "Mostrar grade do documento"), ("Show Baseline Grid", "Mostrar grade de linha de base"), ("Options", "Opções"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index dc518b60c..27ce1fed3 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1082,6 +1082,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Загрузить DTD…"), ("Location:", "Расположение:"), ("Lock Guides", "Заблокировать направляющие"), + ("Lock Column Guides", "Заблокировать направляющие колонок"), ("Miter Limit:", "Ограничение острого угла:"), ("New Article from Selection", "Новая статья из выделенного"), ("New Note at Cursor", "Новая заметка в позиции курсора"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 223dba543..d7ac7ce01 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1232,6 +1232,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Завантажити DTD…"), ("Location:", "Розташування:"), ("Lock Guides", "Заблокувати напрямні"), + ("Lock Column Guides", "Заблокувати напрямні колонок"), ("Miter Limit:", "Межа гострого з’єднання:"), ("New Article from Selection", "Нова стаття з виділення"), ("New Note at Cursor", "Нова примітка в позиції курсора"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0844b95a4..3674b3e81 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -296,6 +296,7 @@ pub struct UiState { pub align_to: String, /// Properties > Text Style tab: 0 = Paragraph Styles, 1 = Character Styles. pub text_style_tab: u8, + /// View › Grids & Guides › Lock Guides: ruler guides can't be dragged. pub guides_locked: bool, pub smart_guides: bool, #[serde(default = "default_true")] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index da5819e6f..fb43f8cb9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -180,6 +180,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), + ("view.lockGuides", "Lock Guides", Some("Cmd+Alt+;"), "{on?: bool} — ruler guides can't be dragged; toggles without `on`"), ("view.snapToGuides", "Snap to Guides", Some("Cmd+Shift+;"), "{}"), ("view.snapToDocumentGrid", "Snap to Document Grid", None, "{}"), ("view.smartGuides", "Smart Guides", None, "{}"), @@ -812,6 +813,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", ">Grids & Guides", "ui:view.guides", + "ui:view.lockGuides", + "cmd:view.lockColumnGuides", "ui:view.snapToGuides", "ui:view.snapToDocumentGrid", "ui:view.smartGuides", @@ -1505,6 +1508,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.rulers), "view.guides" => flag(&mut app.ui.guides), + "view.lockGuides" => { + app.ui.guides_locked = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.guides_locked); + Ok(json!(app.ui.guides_locked)) + } "view.snapToGuides" => flag(&mut app.ui.snap_to_guides), "view.snapToDocumentGrid" => flag(&mut app.ui.snap_to_document_grid), "view.smartGuides" => flag(&mut app.ui.smart_guides), @@ -2364,6 +2371,8 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.typicalDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::Typical, "view.highQualityDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::High, "view.guides" => app.ui.guides, + "view.lockGuides" => app.ui.guides_locked, + "view.lockColumnGuides" => app.session.active().is_some_and(|d| d.doc.settings.column_guides_locked), "view.snapToGuides" => app.ui.snap_to_guides, "view.snapToDocumentGrid" => app.ui.snap_to_document_grid, "view.smartGuides" => app.ui.smart_guides, @@ -3969,6 +3978,26 @@ mod tests { assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err()); } + #[test] + fn lock_guides_and_lock_column_guides_are_checked_in_grids_and_guides() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let view = menu_ids("View"); + assert!(view.contains(&"view.lockGuides") && view.contains(&"view.lockColumnGuides")); + assert_eq!(ui_label("view.lockGuides").and_then(|c| c.1), Some("Cmd+Alt+;")); + // Lock Guides: off at first, toggles, and `on` sets it. + assert_eq!(checked(&app, "view.lockGuides", &Value::Null), Some(false)); + assert_eq!(app.run("view.lockGuides", json!({})).unwrap(), json!(true)); + assert!(app.ui.guides_locked); + assert_eq!(app.run("view.lockGuides", json!({"on": true})).unwrap(), json!(true)); + assert_eq!(checked(&app, "view.lockGuides", &Value::Null), Some(true)); + assert_eq!(app.run("view.lockGuides", json!({"on": false})).unwrap(), json!(false)); + // Lock Column Guides: the document's setting, on in new documents. + assert_eq!(checked(&app, "view.lockColumnGuides", &Value::Null), Some(true)); + app.run("view.lockColumnGuides", json!({})).unwrap(); + assert_eq!(checked(&app, "view.lockColumnGuides", &Value::Null), Some(false)); + } + #[test] fn fitting_has_indesigns_shortcuts() { // LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 399cc34e7..1cbd623a6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -521,7 +521,9 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if smart != app.ui.smart_guides { let _ = app.run("view.smartGuides", json!({})); } - app.ui.guides_locked = locked; + if locked != app.ui.guides_locked { + let _ = app.run("view.lockGuides", json!({"on": locked})); + } rule(ui); section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); let (a, b) = button_pair(ui, crate::i18n::tr(&app.ui.language, "Import File"), crate::i18n::tr(&app.ui.language, "Insert Page")); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89fe4d401..92b86f0f1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From 1ba06027ac1ea5c5cc9cca04e31d199a66ea170c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:23:32 -0400 Subject: [PATCH 042/117] Layout: Margins and Columns keeps dragged columns, which scale with the margin box (#1096) (#1172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IDML: keep column guides that were dragged off centre (#1096) InDesign records dragged column guides in `MarginPreference ColumnsPositions`, as pairs of offsets from the margin box's left edge. The importer discarded them and divided the columns evenly, so a page whose gutter the designer had moved came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was read as columns at 215 and 235. `Columns::positions` already existed, documented as "Custom column positions (after dragging column guides)", but nothing set it and nothing read it. Now `Page::column_rects` returns the custom rects when they are set — that one method is what the canvas guides, snapping, threading and the first frame of a placed story all go through, so they follow the dragged gutter — the importer fills it, and the exporter writes the page's real positions instead of recomputing an even split, so the round trip keeps them. `layout.marginsAndColumns` clears them when a count or gutter is set, which is what InDesign's Margins and Columns does. Without that the stale positions would outrank the new value and the dialog would look broken. Verified against InDesign-authored IDML rather than assumed, which settled two things. The attribute is written whether or not anything was dragged — a real page's values are exactly its even split — so an even set is dropped rather than marking every imported page custom and freezing its columns; the tolerance for that comparison only absorbs float noise, about 1e-14 pt on that page. And `` is a `` child, not a `` one, which is where this importer already looks: the reporter's missing ruler guide was 099fbd2, landed the day the ticket was filed, not a second bug. That commit left `lib.rs` still listing guides as ignored on import; corrected. * IDML: custom column positions are validated and round-trip (#1096) Columns::positions are used only when they are 2 x count finite edges, ascending from 0 to the margin box width (within 0.01 pt); anything else is the even split, in Page::column_rects, on IDML import (bounded parse, checked against the page's margin box) and on IDML export. Columns::with_gutter_moved moves one gutter's pair of guides between its neighbours for the coming column-guide command. ROADMAP: drop four progress-log lines the #1121 merge duplicated and move its line to the top. * Layout: Margins and Columns keeps dragged columns unless the count or gutter changes (#1096) * Layout: a new margin or page width resets dragged columns (#1096) * Layout: #1121's margin case follows the new-width rule (#1096) * Layout: dragged columns scale with a new margin or page width (#1096) * Layout: the page-width test drags a page whose columns are its own (#1096) * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: Hamza --- crates/engine/src/cmd/layout.rs | 210 ++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 667bb927b..7df8aebf6 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -88,6 +88,7 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { /// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page. fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { + let width_before = pg.margin_rect().width(); match p.get("margins") { Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)), Some(v @ Value::Object(_)) => { @@ -97,18 +98,62 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { } _ => {} } - // Setting a count or a gutter re-divides the columns evenly, as InDesign's Margins and - // Columns does: dragged column positions are what the new value replaces. + scale_columns(pg, width_before); + // A new count or gutter re-divides the columns evenly: dragged column positions are what + // the new value replaces. The dialog always sends both, so an unchanged value keeps them. if let Some(c) = p.get("columns").and_then(Value::as_u64) { - pg.columns.count = (c as u32).clamp(1, 216); - pg.columns.positions = None; + let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216); + if c != pg.columns.count { + pg.columns.count = c; + pg.columns.positions = None; + } } - if let Some(g) = p.get("gutter").and_then(Value::as_f64) { - pg.columns.gutter = g.max(0.0); + if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) { + let g = g.max(0.0); + if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE { + pg.columns.gutter = g; + pg.columns.positions = None; + } + } +} + +/// Dragged column positions are measured across the margin box. When the box was +/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set +/// that doesn't scale into a valid one goes back to the even split rather than stay hidden. +fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) { + let width = pg.margin_rect().width(); + if (width - width_before).abs() <= GUTTER_TOLERANCE { + return; + } + let Some(positions) = pg.columns.positions.as_mut() else { return }; + let factor = width / width_before; + if factor.is_finite() && factor > 0.0 { + positions.iter_mut().for_each(|p| *p *= factor); + // The ends sit exactly on the margins whatever the rounding. + if let Some(first) = positions.first_mut() { + *first = 0.0; + } + if let Some(last) = positions.last_mut() { + *last = width; + } + } + if pg.columns.valid_positions(width).is_none() { pg.columns.positions = None; } } +/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box. +fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) { + let before = pg.margin_rect().width(); + pg.width = width; + scale_columns(pg, before); +} + +/// How far a gutter may differ from the page's and still count as unchanged, in points. The +/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt +/// off), so an OK that leaves the field alone sends back a slightly different number. +const GUTTER_TOLERANCE: f64 = 0.01; + pub fn specs() -> Vec { vec![ cmd!( @@ -481,7 +526,7 @@ pub fn specs() -> Vec { for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { let sp = std::sync::Arc::make_mut(sp); for pg in &mut sp.pages { - pg.width = d.settings.page_width; + set_page_width(pg, d.settings.page_width); pg.height = d.settings.page_height; } sp.relayout(); @@ -961,7 +1006,7 @@ pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usiz let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); if let Some(w) = w { - pg.width = w.max(1.0); + set_page_width(pg, w.max(1.0)); } if let Some(h) = h { pg.height = h.max(1.0); @@ -1689,10 +1734,155 @@ mod adjust_tests { assert_eq!(pg.columns.positions, None); assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6); - // Margins alone leave them alone: the offsets are relative to the margin box. + // Margins that keep the margin box's width leave them alone; a new width scales them. drag(&mut s); - s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap(); assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0])); + s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap(); + assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0); + } + + /// The params the Margins and Columns dialog sends on OK: every margin, the count and the + /// gutter, whether or not the user changed them. + fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value { + json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin}, + "columns": columns, "gutter": gutter, "adjustLayout": false}) + } + + const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0]; + + fn dragged() -> designcraft_doc::Columns { + designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) } + } + + /// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide. + #[track_caller] + fn assert_scaled(positions: Option>, width: f64) { + let want: Vec = DRAGGED.iter().map(|p| p * width / 540.0).collect(); + let got = positions.unwrap_or_default(); + assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}"); + } + + /// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs. + #[test] + fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let drag = |s: &mut Session| { + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged(); + Ok(()) + }) + .unwrap(); + }; + let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(); + + // The same count and gutter (the gutter as an inch field rounds it) keep them in use. + drag(&mut s); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec())); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec())); + let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}"); + + // Top and bottom margins leave the margin box's width alone, and the columns with it. + let mut taller = dialog_params(36.0, 2, 12.0); + taller["margins"]["top"] = json!(72.0); + taller["margins"]["bottom"] = json!(72.0); + s.execute("layout.marginsAndColumns", &taller).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec())); + + // A new width scales them with the margin box, and the old width scales them back. + s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap(); + assert_scaled(positions(&s), 468.0); + let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}"); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap(); + assert_scaled(positions(&s), 540.0); + + // A new count resets them. + drag(&mut s); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.count, pg.columns.positions), (3, None)); + + // So does a new gutter. + drag(&mut s); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None)); + } + + /// A page of another width (Page Size, Document Setup) scales its dragged columns. + #[test] + fn a_new_page_width_scales_dragged_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let drag = |s: &mut Session| { + s.edit(|d, _| { + // A dragged page's columns are its own (`local_margins`), as the drag will make them. + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + pg.columns = dragged(); + pg.local_margins = true; + Ok(()) + }) + .unwrap(); + }; + let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(); + + drag(&mut s); + s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them"); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap(); + assert_scaled(positions(&s), 628.0); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap(); + assert_scaled(positions(&s), 540.0); + + drag(&mut s); + s.execute("layout.documentSetup", &json!({"width": 700})).unwrap(); + assert_scaled(positions(&s), 628.0); + } + + /// Parent pages follow the same rule, and the document pages that follow them see it. + #[test] + fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let drag = |s: &mut Session| { + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged(); + d.sync_parent_margins(); + Ok(()) + }) + .unwrap(); + }; + let positions = |s: &Session| { + let d = &s.doc().unwrap().doc; + (d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone()) + }; + let on_parent = |m: f64, c: u32, g: f64| { + let mut p = dialog_params(m, c, g); + p["parent"] = json!(0); + p + }; + + drag(&mut s); + assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec()))); + s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap(); + assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec()))); + s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap(); + let (parent, follower) = positions(&s); + assert_scaled(parent, 516.0); + assert_scaled(follower, 516.0); + + drag(&mut s); + s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap(); + assert_eq!(positions(&s), (None, None)); + + drag(&mut s); + s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap(); + assert_eq!(positions(&s), (None, None)); } } From a912438ab868ad9de2bbe9005cef30d285dfbd32 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:26:38 -0400 Subject: [PATCH 043/117] Layout: autoflow, inserted pages and new parents follow dragged columns, mirrored across sides (#1096) (#1173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IDML: keep column guides that were dragged off centre (#1096) InDesign records dragged column guides in `MarginPreference ColumnsPositions`, as pairs of offsets from the margin box's left edge. The importer discarded them and divided the columns evenly, so a page whose gutter the designer had moved came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was read as columns at 215 and 235. `Columns::positions` already existed, documented as "Custom column positions (after dragging column guides)", but nothing set it and nothing read it. Now `Page::column_rects` returns the custom rects when they are set — that one method is what the canvas guides, snapping, threading and the first frame of a placed story all go through, so they follow the dragged gutter — the importer fills it, and the exporter writes the page's real positions instead of recomputing an even split, so the round trip keeps them. `layout.marginsAndColumns` clears them when a count or gutter is set, which is what InDesign's Margins and Columns does. Without that the stale positions would outrank the new value and the dialog would look broken. Verified against InDesign-authored IDML rather than assumed, which settled two things. The attribute is written whether or not anything was dragged — a real page's values are exactly its even split — so an even set is dropped rather than marking every imported page custom and freezing its columns; the tolerance for that comparison only absorbs float noise, about 1e-14 pt on that page. And `` is a `` child, not a `` one, which is where this importer already looks: the reporter's missing ruler guide was 099fbd2, landed the day the ticket was filed, not a second bug. That commit left `lib.rs` still listing guides as ignored on import; corrected. * IDML: custom column positions are validated and round-trip (#1096) Columns::positions are used only when they are 2 x count finite edges, ascending from 0 to the margin box width (within 0.01 pt); anything else is the even split, in Page::column_rects, on IDML import (bounded parse, checked against the page's margin box) and on IDML export. Columns::with_gutter_moved moves one gutter's pair of guides between its neighbours for the coming column-guide command. ROADMAP: drop four progress-log lines the #1121 merge duplicated and move its line to the top. * Layout: autoflow, inserted pages and new parents follow dragged columns (#1096) * IDML: a page without its own margins checks the default's column positions against its width (#1096) * Layout: dragged columns mirror when a page changes side (#1096) * Layout: pages that follow a one-page parent take its dragged columns mirrored (#1096) * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: Hamza --- crates/doc/src/build.rs | 83 +++++++++++++++++++++++++- crates/doc/src/page.rs | 61 ++++++++++++++++++- crates/engine/src/cmd/datamerge/mod.rs | 2 +- crates/engine/src/cmd/inspect.rs | 4 +- crates/engine/src/cmd/layout.rs | 53 ++++++++++++++-- crates/engine/src/cmd/place_text.rs | 39 +++++++++++- 6 files changed, 231 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index f8ae0ec19..d4500f7ce 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -343,6 +343,8 @@ impl Document { let mut pages: Vec<(Page, Vec>)> = Vec::new(); let mut parity: Vec = Vec::new(); let mut kept: Vec<(usize, Arc)> = Vec::new(); + // Whether each shuffled page was a left page, to mirror its dragged columns if it moves. + let mut was_left: BTreeMap = BTreeMap::new(); let mut abs = 0; for sp in self.spreads.drain(..) { if !sp.allow_shuffle && sp.pages.len() > 1 { @@ -350,8 +352,9 @@ impl Document { kept.push((pages.len(), sp)); continue; } - for _ in &sp.pages { + for p in &sp.pages { parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0)); + was_left.insert(p.id, p.side == PageSide::Left); abs += 1; } let mut sp = Arc::unwrap_or_clone(sp); @@ -445,6 +448,19 @@ impl Document { out.insert(at, sp); } self.spreads = out; + // A page that crosses the spine keeps its inside and outside margins, so its margin box + // mirrors; its dragged column guides mirror with it, as in InDesign. + let crossed = |p: &Page| was_left.get(&p.id).is_some_and(|&l| l != (p.side == PageSide::Left)) && p.columns.positions.is_some(); + for sp in &mut self.spreads { + if !sp.pages.iter().any(crossed) { + continue; + } + for p in &mut Arc::make_mut(sp).pages { + if crossed(&*p) { + p.columns = p.columns.mirrored(p.margin_rect().width()); + } + } + } // Pages can change sides, and so parent pages. self.sync_parent_margins(); } @@ -466,6 +482,10 @@ impl Document { if let Some(t) = &template { p.width = t.width; p.height = t.height; + // Dragged column guides too: the same width and margins keep them valid. + // They are taken as for this single page; repaginating mirrors them if the + // page lands on a left page. + p.columns.positions = p.columns_from(t).positions; } ids.push(p.id); p @@ -675,7 +695,7 @@ impl Document { if page.local_margins { continue; } - let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, q.columns.clone())) else { + let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, page.columns_from(q))) else { continue; }; if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) { @@ -879,6 +899,65 @@ mod tests { assert!(d.delete_pages(&[0, 1]).is_err()); } + /// Pages inserted after a page with dragged column guides keep them, as they keep its column + /// count and gutter; pages that follow a parent take the parent's. + #[test] + fn inserted_pages_keep_dragged_columns() { + let dragged = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + let widths = |p: &Page| p.column_rects().iter().map(|r| r.width()).collect::>(); + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() }); + let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0]; + (pg.columns, pg.local_margins) = (dragged.clone(), true); + d.insert_pages(Some(0), 2, None).unwrap(); + for i in 1..3 { + let p = d.page(i).unwrap(); + assert_eq!(p.columns, dragged, "page {i}"); + assert_eq!(widths(p), vec![100.0, 410.0], "page {i}"); + } + + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() }); + Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged.clone(); + d.sync_parent_margins(); + d.insert_pages(Some(0), 1, d.parents.first().map(|p| p.id)).unwrap(); + assert_eq!(d.page(1).map(widths), Some(vec![100.0, 410.0])); + } + + /// A right page whose gutter was dragged toward the spine keeps it there when inserting a + /// page before it makes it a left page: its dragged columns mirror across the margin box. + #[test] + fn dragged_columns_mirror_when_a_page_changes_side() { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: true, ..Default::default() }); + let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0]; + assert_eq!(pg.side, PageSide::Right); + let w = pg.margin_rect().width(); + (pg.columns, pg.local_margins) = (Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 112.0, w]) }, true); + let id = pg.id; + // How far the gutter's spine-side edge is from the margin on the spine side. + let from_spine = |p: &Page| { + let (m, c) = (p.margin_rect(), p.column_rects()); + match p.side { + PageSide::Left => m.x1 - c[1].x0, + _ => c[0].x1 - m.x0, + } + }; + assert!((from_spine(d.page(0).unwrap()) - 100.0).abs() < 1e-9); + + d.insert_pages(None, 1, None).unwrap(); + let moved = d.page(1).unwrap(); + assert_eq!((moved.id, moved.side), (id, PageSide::Left)); + assert!((from_spine(moved) - 100.0).abs() < 1e-9, "{:?}", moved.column_rects()); + let widths: Vec = moved.column_rects().iter().map(|r| r.width()).collect(); + assert!((widths[0] - (w - 112.0)).abs() < 1e-9 && (widths[1] - 100.0).abs() < 1e-9, "{widths:?}"); + // The inserted page is a right page like the one it copies: no mirroring. + assert_eq!(d.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 112.0, w])); + + // Moving it back to the right undoes the mirror. + d.delete_pages(&[0]).unwrap(); + let back = d.page(0).unwrap(); + assert_eq!(back.side, PageSide::Right); + assert!((from_spine(back) - 100.0).abs() < 1e-9, "{:?}", back.column_rects()); + } + #[test] fn items_travel_with_pages() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index d1a77d78e..7041f7ed8 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -106,6 +106,15 @@ impl Columns { *r = (at + width).min(hi); valid_column_positions(&pos, self.count, margin_width).then_some(pos) } + + /// These columns reflected across a margin box `margin_width` wide, as on the facing page + /// across the spine: each custom position `p` becomes `margin_width - p`, in reverse order so + /// they stay ascending. Count and gutter stay; positions that are not valid (see + /// [`Columns::valid_positions`]) become `None`, the even split, which mirrors onto itself. + pub fn mirrored(&self, margin_width: f64) -> Columns { + let positions = self.valid_positions(margin_width).map(|p| p.iter().rev().map(|&v| inside(margin_width - v, margin_width)).collect()); + Columns { count: self.count, gutter: self.gutter, positions } + } } /// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN. @@ -304,9 +313,10 @@ impl Page { pub fn bounds(&self) -> Rect { Rect::new(self.x, 0.0, self.x + self.width, self.height) } - /// Same margins and columns as `other`. + /// Same margins and columns as `other`, with `other`'s columns as it would take them from this + /// page: mirrored when one is a left page and the other isn't. pub fn same_margins(&self, other: &Page) -> bool { - self.margins == other.margins && self.columns == other.columns + self.margins == other.margins && other.columns_from(self) == other.columns } /// Left and right margins resolved for the page side (inside is at the spine). pub fn left_right_margins(&self) -> (f64, f64) { @@ -315,6 +325,16 @@ impl Page { _ => (self.margins.inside, self.margins.outside), } } + /// `src`'s columns as they fall on this page: the same when both pages put their inside + /// margin on the same physical side, mirrored across `src`'s margin box when one is a left + /// page and the other is not, as InDesign mirrors dragged column guides across the spine. + pub fn columns_from(&self, src: &Page) -> Columns { + if (self.side == PageSide::Left) == (src.side == PageSide::Left) { + src.columns.clone() + } else { + src.columns.mirrored(src.margin_rect().width()) + } + } /// The live area inside the margins. pub fn margin_rect(&self) -> Rect { let (l, r) = self.left_right_margins(); @@ -602,6 +622,43 @@ mod tests { assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None); } + /// Mirroring reflects dragged columns across the margin box and keeps them ascending; an + /// invalid set becomes the even split. + #[test] + fn mirrored_columns_reflect_across_the_margin_box() { + let two = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + assert_eq!(two.mirrored(540.0), Columns { positions: Some(vec![0.0, 410.0, 440.0, 540.0]), ..two.clone() }); + assert_eq!(two.mirrored(540.0).mirrored(540.0), two); + + let three = Columns { count: 3, gutter: 10.0, positions: Some(vec![0.0, 50.0, 60.0, 200.0, 210.0, 300.0]) }; + let m = three.mirrored(300.0); + assert_eq!(m.positions, Some(vec![0.0, 90.0, 100.0, 240.0, 250.0, 300.0])); + assert!(m.valid_positions(300.0).is_some()); + + // Positions that do not fit the margin box, or none at all, mirror to the even split. + assert_eq!(two.mirrored(600.0).positions, None); + assert_eq!(two.mirrored(f64::NAN).positions, None); + let unsorted = Columns { positions: Some(vec![0.0, 130.0, 100.0, 540.0]), ..two.clone() }; + assert_eq!(unsorted.mirrored(540.0).positions, None); + let even = Columns { positions: None, ..two.clone() }; + assert_eq!(even.mirrored(540.0), even); + + // A left page takes a right page's columns mirrored, a page on the same side as they are. + let mut r = page(PageSide::Right, 612.0); + r.columns = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 522.0]) }; + let l = page(PageSide::Left, 0.0); + assert_eq!(l.columns_from(&r).positions, Some(vec![0.0, 392.0, 422.0, 522.0])); + assert_eq!(page(PageSide::Right, 0.0).columns_from(&r), r.columns); + + // A left page holding them mirrored has the same margins as that right page (a one-page + // parent in a facing document); holding them unmirrored, it doesn't. + let mut l = page(PageSide::Left, 0.0); + l.columns = l.columns_from(&r); + assert!(r.same_margins(&l)); + l.columns = r.columns.clone(); + assert!(!r.same_margins(&l)); + } + #[test] fn spread_spine_and_hit() { let s = Spread { diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index b29ae3b3c..38b543ff7 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -436,7 +436,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) { p.guides = from.guides.clone(); p.parent = from.parent; p.margins = from.margins; - p.columns = from.columns.clone(); + p.columns = p.columns_from(from); p.local_margins = from.local_margins; p.width = from.width; p.height = from.height; diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f003fd30a..c1ddcf036 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -85,7 +85,9 @@ fn inspect(s: &mut Session, _p: &Value) -> Result { let abs = d.first_page_of_spread(si) + pi; json!({"index": abs, "name": d.page_name(abs), "bounds": [p.x, 0.0, p.x + p.width, p.height], "side": p.side, "parent": p.parent.and_then(|pid| d.parents.iter().find(|x| x.id == pid)).and_then(|x| x.parent.as_ref()).map(|i| i.label()), - "margins": p.margins, "columns": p.columns.count}) + "margins": p.margins, "columns": p.columns.count, "gutter": p.columns.gutter, + "customColumns": p.columns.valid_positions(p.margin_rect().width()).is_some(), + "columnRects": p.column_rects().iter().map(|r| [r.x0, r.y0, r.x1, r.y1]).collect::>()}) }).collect::>(), "items": sp.items.iter().map(|i| item_json(i)).collect::>(), }) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 7df8aebf6..9016c3848 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -246,8 +246,9 @@ pub fn specs() -> Vec { let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); let pg = &mut nsp.pages[npi]; let dx = pg.x - src.x; - (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = - (src.width, src.height, src.margins, src.columns.clone(), src.liquid); + // A copy on the other side of the spine has its dragged columns mirrored. + let columns = pg.columns_from(src); + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid); pg.guides = src.guides.clone(); for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { g.position += dx; @@ -301,8 +302,20 @@ pub fn specs() -> Vec { let name = str_param(p, "name").unwrap_or("Parent").to_string(); s.edit(|d, _| { let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix()); - let m = d.page(0).map(|p| (p.columns.count, p.columns.gutter, p.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0))); - let id = d.add_parent(&prefix, &name, m.0, m.1, m.2); + let src = d.page(0).cloned(); + let (columns, margins) = + src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins)); + let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins); + // Dragged column guides carry over to the parent pages they still fit, mirrored + // on the parent page across the spine from the first page. + if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) { + for pg in &mut std::sync::Arc::make_mut(sp).pages { + let columns = pg.columns_from(src); + if columns.valid_positions(pg.margin_rect().width()).is_some() { + pg.columns.positions = columns.positions; + } + } + } Ok(json!({"id": id.0, "prefix": prefix})) }) }), @@ -2182,6 +2195,38 @@ mod parent_tests { assert_eq!(r, json!({"on": false, "parent": null})); assert!(!s.doc().unwrap().editing_parents); } + + /// A new parent takes the first page's columns, dragged column guides included: the right + /// parent page as the first (right) page has them, the left one mirrored across the spine. + /// `document.inspect` reports where those columns are. + #[test] + fn a_new_parent_keeps_dragged_columns() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + // There is no command for dragging a column guide yet, so set what an import would leave. + s.edit(|d, _| { + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + (pg.columns, pg.local_margins) = (dragged.clone(), true); + Ok(()) + }) + .unwrap(); + let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap(); + assert_eq!(parent.pages.len(), 2); + let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap(); + let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right)); + assert_eq!(right.columns, dragged); + assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::>(), vec![100.0, 410.0]); + assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0])); + assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::>(), vec![410.0, 100.0]); + + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + let page = &inspect["spreads"][0]["pages"][0]; + assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}"); + assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}"); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index fa93d4e40..c54b55084 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -277,7 +277,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s if added >= max_pages { return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages"))); } - let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) }; + let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) }; let pages = want.div_ceil(per_page).clamp(1, max_pages - added); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); new_pages.extend(d.insert_pages(Some(page), pages, parent)?); @@ -511,6 +511,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Autoflow follows dragged column guides: one frame per dragged column, on the first page and + /// on every page it adds. + #[test] + fn autoflow_sets_frames_on_dragged_columns() { + let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + // The margin box is 504 pt wide; the gutter is dragged left. There is no command for + // dragging a column guide yet, so set what an import would leave, on the parent. + let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) }; + s.edit(|d, _| { + for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages { + pg.columns = dragged.clone(); + } + d.sync_parent_margins(); + Ok(()) + }) + .unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + assert!(d.page_count() >= 3, "{r}"); + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len()); + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "frame {i}"); + let m = d.page(page).unwrap().margin_rect(); + let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2]; + assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn autoflow_keeps_the_frame_the_user_drew() { let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id())); From c3382b422c4ba6664a6ccf564ca9c50b49f55f4a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:26:52 -0400 Subject: [PATCH 044/117] Guides: a dragged ruler guide snaps to guides, margins, columns and objects (#1542) * Guides: a dragged guide shows where it is (#1282) While a guide is dragged out of a ruler or moved, a label beside the pointer reads X: or Y: and the guide's place, in the ruler units and measured the way the rulers measure (from the zero point, and from the page origin when rulers restart per page). * Guides: keep the drag readout inside the canvas Near the canvas's right or bottom edge the readout flips to the other side of the pointer, and it is kept inside the canvas in every case (readout_at). * Guides: the drag readout measures the spread under the pointer The readout measured from the spread in the middle of the view (where the rulers measure), but a guide is dropped on the spread under the pointer, so with spreads above one another the number shown could differ from the guide's place. It now measures that spread (ruler_pieces_of). * Guides: no readout for an existing guide over another spread An existing guide moves only on its own spread (guide_drag), so dropped over another it stays where it was: the readout no longer shows a place there it won't go. * #1282: guide selection commands (guide.select, guide.selectAll, guide.deselect, guide.moveBy) * #1282: select ruler guides on the canvas, X/Y in the Control bar and Properties * #1282: guides take clicks over objects, a new guide is selected, group drag preview * Guides: a dragged ruler guide snaps to guides, margins, columns and objects (#1221) --------- Co-authored-by: SBaksa --- crates/tools/src/frame.rs | 2 + crates/tools/src/lib.rs | 5 + crates/tools/src/select.rs | 4 + crates/tools/src/snap.rs | 58 +++++- crates/tools/src/xform.rs | 2 + crates/ui-egui/src/canvas.rs | 193 +++++++++++++++++--- crates/ui-egui/src/guide_selection_tests.rs | 78 +++++++- docs/target-app-parity.md | 2 +- 8 files changed, 307 insertions(+), 37 deletions(-) diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 4b52e2ee5..ff65e7ce7 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -76,6 +76,7 @@ pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end: x_edges, y_edges, exclude: &[], + exclude_guides: &[], copying: false, lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())], angle: None, @@ -160,6 +161,7 @@ fn line_hit(cx: &ToolContext, spread: SpreadRef, point: Point, len: Option) x_edges: [true, true, true], y_edges: [true, true, true], exclude: &[], + exclude_guides: &[], copying: false, lengths: [len, None], angle: None, diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index b5153e183..f0fd68019 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -191,6 +191,9 @@ pub enum Gesture { Create, Rotate, Point, + /// A ruler guide being dragged: one line on one axis, with no size or spacing to match and + /// no pull from the baseline grid. + Guide, } pub struct SnapRequest<'a> { @@ -202,6 +205,8 @@ pub struct SnapRequest<'a> { /// Top, center, bottom. pub y_edges: [bool; 3], pub exclude: &'a [ItemId], + /// Ruler guides that are not targets: the ones being dragged. + pub exclude_guides: &'a [designcraft_doc::GuideSel], pub copying: bool, /// Proposed width and height for the dimensions pass. None when that length is not being set. pub lengths: [Option; 2], diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index b3d3780f2..866003102 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -929,6 +929,7 @@ impl Tool for SelectionTool { x_edges, y_edges, exclude: &exclude, + exclude_guides: &[], copying: ev.mods.alt && !content, lengths: [None, None], angle: None, @@ -1002,6 +1003,7 @@ impl Tool for SelectionTool { x_edges, y_edges, exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [axis_length(x_edges, proposed.width(), pad_x), axis_length(y_edges, proposed.height(), pad_y)], angle: None, @@ -1054,6 +1056,7 @@ impl Tool for SelectionTool { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(resulting), @@ -1092,6 +1095,7 @@ impl Tool for SelectionTool { x_edges: [true, true, true], y_edges: [true, true, true], exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 0cbc9aadd..11da46178 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -31,6 +31,7 @@ pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec, axis: Axis) -> Option= 0 while the line is inside the page /// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the /// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not -/// turn this off. Only a page the rect crosses on x contributes. +/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide +/// ([`Gesture::Guide`]) doesn't snap to it. fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { - if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides { + if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) { return None; } let baseline = &cx.doc.settings.baseline_grid; @@ -1000,7 +1002,8 @@ fn push_parent_lengths(out: &mut Vec, cx: &ToolContext, req: &SnapRequest<' /// /// A page guide attracts when the moving rect overlaps that page on the other axis. A spread /// guide uses the spread bounds. Locked guides are included, and so are the guides a page shows -/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it. +/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it, and not +/// when the request excludes it (the guides being dragged). fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { if !cx.snap.snap_to_guides || !cx.snap.show_guides { return None; @@ -1010,10 +1013,15 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> let flags = axis_flags(req, axis); let (moving_lo, moving_hi) = perp_ends(req.rect, axis); let mut best: Option = None; - for page in &sp.pages { + for (pi, page) in sp.pages.iter().enumerate() { let page_bounds = page.bounds(); - // The page's own guides, then those it shows from its parent page. - for guide in page.guides.iter().cloned().chain(cx.doc.parent_guides(page)) { + // The page's own guides, less the excluded ones, then those it shows from its parent page. + let own = page + .guides + .iter() + .enumerate() + .filter(|&(gi, _)| !req.exclude_guides.contains(&designcraft_doc::GuideSel { spread: req.spread, page: pi, index: gi })); + for guide in own.map(|(_, g)| g.clone()).chain(cx.doc.parent_guides(page)) { if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) { continue; } @@ -1427,6 +1435,7 @@ mod tests { x_edges: [false, false, false], y_edges: [true, true, true], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1443,6 +1452,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1475,6 +1485,7 @@ mod tests { x_edges: [false, false, false], y_edges: [true, true, true], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1504,6 +1515,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1515,6 +1527,28 @@ mod tests { assert!(hit.guides.is_empty()); } + /// A dragged guide is not its own target: excluded, the guide at 104 doesn't pull a line at 102. + /// Nor does the baseline grid pull it. + #[test] + fn excluded_guide_is_not_a_target() { + let far = Rect::new(400.0, 400.0, 420.0, 420.0); + let (doc, _, _) = doc_with_items(far, far, 104.0); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + let line = Rect::new(102.0, 100.0, 102.0, 300.0); + let dragged = [designcraft_doc::GuideSel { spread: SpreadRef::Doc(0), page: 0, index: 0 }]; + let hit = snap(&cx, move_req(line, &[])); + assert!((hit.delta.x - 2.0).abs() < 1e-6, "{:?}", hit.delta); + let hit = snap(&cx, SnapRequest { exclude_guides: &dragged, ..move_req(line, &[]) }); + assert_eq!(hit.delta.x, 0.0); + // A dragged guide passes the baseline grid's lines (36 + 12n), which a moved object meets. + let flat = Rect::new(100.0, 86.0, 300.0, 86.0); + assert!((snap(&cx, y_req(flat)).delta.y - -2.0).abs() < 1e-6); + assert_eq!(snap(&cx, SnapRequest { gesture: Gesture::Guide, ..y_req(flat) }).delta.y, 0.0); + } + #[test] fn parent_centered_stroke_attracts_at_the_outer_edge() { let mut doc = Document::new(&NewDocument::default()); @@ -1542,6 +1576,7 @@ mod tests { x_edges: [true, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1569,6 +1604,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1599,6 +1635,7 @@ mod tests { x_edges: [false, false, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1630,6 +1667,7 @@ mod tests { x_edges: [true, false, false], y_edges: [false, false, false], exclude: &[id], + exclude_guides: &[], copying: true, lengths: [None, None], angle: None, @@ -1666,6 +1704,7 @@ mod tests { x_edges: [true, false, false], y_edges: [false, false, false], exclude: &[original], + exclude_guides: &[], copying: true, lengths: [None, None], angle: None, @@ -1793,6 +1832,7 @@ mod tests { x_edges: [false, false, true], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [Some(197.0), None], angle: None, @@ -1836,6 +1876,7 @@ mod tests { x_edges: [false, false, true], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [Some(82.0), None], angle: None, @@ -1871,6 +1912,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -2009,6 +2051,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [Some(38.0), None], angle: None, @@ -2041,6 +2084,7 @@ mod tests { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(28.0), @@ -2085,6 +2129,7 @@ mod tests { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(28.0), @@ -2157,6 +2202,7 @@ mod tests { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(angle), diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 258bcd62b..561ad0c86 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -100,6 +100,7 @@ impl Tool for XformTool { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(resulting), @@ -151,6 +152,7 @@ impl Tool for XformTool { x_edges, y_edges, exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [length_on(x_edges, w, pad_x), length_on(y_edges, h, pad_y)], angle: None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 322f75d80..f8ddc9487 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -539,10 +539,18 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { && let Some(p) = g.at { let col = Color32::from_rgb(74, 227, 255); + let spot = guide_spot(app, &xf, &g); + let line = spot.as_ref().map_or(p, |s| s.screen); let (a, b) = match g.orientation { - designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), - designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), + designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, line.y), pos2(rect.max.x, line.y)), + designcraft_doc::Orientation::Vertical => (pos2(line.x, rect.min.y), pos2(line.x, rect.max.y)), }; + // What caught it, as for a moved object. + for mark in spot.iter().flat_map(|s| &s.marks) { + if let Overlay::Guide { a, b } = mark { + painter.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, t.smart_guide)); + } + } // A new guide from the rulers with the Page tool is a liquid guide: dashed. if g.from.is_none() && app.session.tool_id() == "page" { dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); @@ -2881,6 +2889,9 @@ struct GuideDrag { from: Option<(designcraft_doc::SpreadRef, usize, usize)>, /// Pointer position (screen). at: Option, + /// Modifiers held at the pointer's last move: Shift snaps to the ruler's ticks, Command + /// suspends snapping. + mods: egui::Modifiers, } const GUIDE_PRESS: &str = "canvas_guide_press"; @@ -2921,10 +2932,12 @@ fn guide_measure(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<(&'static st let p = g.at?; let st = app.session.active()?; let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let c = xf.to_canvas(p); - // Measured on the spread under the pointer, which the guide is dropped on (`guide_drag`), - // as the rulers measure that spread: not the one in the middle of the view. - let slot = layout.spread_at(c).and_then(|(r, _)| layout.slot(r)).or_else(|| current_slot(app, &layout).and_then(|i| layout.slots.get(i)))?; + // Measured where the guide is dropped (`guide_drag`): its snapped spot, on the spread under + // the pointer, as the rulers measure that spread, not the one in the middle of the view. + let (c, slot) = match guide_spot(app, xf, g) { + Some(spot) => (layout.to_canvas(spot.spread, spot.sp), layout.slot(spot.spread)?), + None => (xf.to_canvas(p), current_slot(app, &layout).and_then(|i| layout.slots.get(i))?), + }; // An existing guide moves only on its own spread (`guide_drag`): over another, dropping it // leaves it where it was, so there is no place to show. if g.from.is_some_and(|(r, _, _)| r != slot.spread) { @@ -2943,6 +2956,119 @@ fn guide_measure(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<(&'static st value.is_finite().then_some((label, value, unit)) } +/// Where a dragged ruler guide is (#1221). +struct GuideSpot { + /// The spread under the pointer. + spread: SpreadRef, + /// The pointer in that spread's coordinates. + pointer: Point, + /// [`Self::pointer`] with the guide's axis snapped. + sp: Point, + /// [`Self::sp`] on screen, where the preview line goes. + screen: Pos2, + /// Alignment marks (canvas coordinates) when an object or page edge or center caught it. + marks: Vec, +} + +/// Where the guide of drag `g` is: its axis (x for a vertical guide, y for a horizontal one) +/// snapped as a moved object's is, to ruler guides, margins, columns, bleed, the document grid and +/// smart-guide targets (not the baseline grid, which would catch a horizontal guide almost +/// anywhere). Shift snaps to the ruler's ticks instead and Command suspends snapping. The guides +/// being dragged are not targets. An existing guide over another spread, where it +/// can't be dropped, stays at the pointer. The preview, the readout and the drop all read it. +/// `None` with no spread under the pointer. +fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option { + let p = g.at?; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (r, pointer) = layout.spread_at(xf.to_canvas(p))?; + let spot = |sp: Point, marks: Vec| GuideSpot { spread: r, pointer, sp, screen: xf.to_screen(layout.to_canvas(r, sp)), marks }; + if g.mods.command || g.from.is_some_and(|(from, _, _)| from != r) { + return Some(GuideSpot { spread: r, pointer, sp: pointer, screen: p, marks: Vec::new() }); + } + let vertical = matches!(g.orientation, designcraft_doc::Orientation::Vertical); + let place = |v: f64| if vertical { Point::new(v, pointer.y) } else { Point::new(pointer.x, v) }; + if g.mods.shift { + let slot = layout.slot(r)?; + let at = ruler_tick_of(&st.doc, slot, g.orientation, xf.zoom, pointer).map_or(pointer, place); + return Some(spot(at, Vec::new())); + } + let spread = st.doc.spread(r)?; + // Not targets: the dragged guide, and the others of a selected group, which move with it. + let exclude: Vec = match g.from { + Some((sr, pi, gi)) if drags_group(app, GuideSel { spread: sr, page: pi, index: gi }) => st.selection.guides.to_vec(), + Some((sr, pi, gi)) => vec![GuideSel { spread: sr, page: pi, index: gi }], + None => Vec::new(), + }; + // How far the guide reaches across its axis: its page, or the whole spread. + let page = match g.from { + Some((_, pi, gi)) => spread.pages.get(pi).filter(|pg| pg.guides.get(gi).is_some_and(|guide| !guide.spread)), + None => spread.pages.iter().find(|pg| pg.bounds().contains(pointer)), + }; + let reach = page.map_or_else(|| spread.bounds(), |pg| pg.bounds()); + let (at, rect, x_edges, y_edges) = if vertical { + (pointer.x, DRect::new(pointer.x, reach.y0, pointer.x, reach.y1), [true; 3], [false; 3]) + } else { + (pointer.y, DRect::new(reach.x0, pointer.y, reach.x1, pointer.y), [false; 3], [true; 3]) + }; + let cx = designcraft_tools::ToolContext { + doc: &st.doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: xf.zoom.max(1e-6), + layer: st.active_layer, + snap: app.ui.snap_view(), + unit: app.ui.units, + stroke_in_front: false, + }; + let s = designcraft_tools::snap::snap( + &cx, + designcraft_tools::SnapRequest { + spread: r, + gesture: designcraft_tools::Gesture::Guide, + rect, + x_edges, + y_edges, + exclude: &[], + exclude_guides: &exclude, + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer, + }, + ); + let snapped = at + if vertical { s.delta.x } else { s.delta.y }; + Some(if snapped.is_finite() { spot(place(snapped), s.guides) } else { spot(pointer, Vec::new()) }) +} + +/// The nearest ruler tick to spread point `sp` along a guide of `orientation`'s axis, in spread +/// coordinates: the horizontal ruler's (the piece under the point) where that axis runs across the +/// screen, else the vertical ruler's. `None` when the ruler has no ticks there. +fn ruler_tick_of(doc: &Document, slot: &SpreadSlot, orientation: designcraft_doc::Orientation, zoom: f64, sp: Point) -> Option { + let vertical = matches!(orientation, designcraft_doc::Orientation::Vertical); + // A quarter-turned spread runs its x axis down the screen. + let across = vertical == slot.rotation.is_multiple_of(2); + let c = slot.to_canvas(sp); + let pieces = ruler_pieces_of(doc, slot, doc.zero_point()); + let step = |unit: designcraft_geom::Unit| { + let (major, sub) = unit.ruler_ticks(zoom); + let step = major / f64::from(sub.max(1)); + (step.is_finite() && step > 0.0).then_some(step) + }; + let tick = |v: f64, o: f64, step: f64| o + ((v - o) / step).round() * step; + let c = if across { + let &(_, _, o) = pieces.iter().find(|(from, to, _)| *from <= c.x && c.x < *to).or(pieces.first())?; + Point::new(tick(c.x, o.x, step(doc.settings.horizontal_units)?), c.y) + } else { + let o = pieces.first()?.2; + Point::new(c.x, tick(c.y, o.y, step(doc.settings.vertical_units)?)) + }; + let s = slot.to_spread(c); + Some(if vertical { s.x } else { s.y }).filter(|v| v.is_finite()) +} + /// The guide under a screen point (within 3 px), as (spread, page, index, orientation). fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::SpreadRef, usize, usize, designcraft_doc::Orientation)> { let st = app.session.active()?; @@ -2994,7 +3120,7 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec None }; if let Some(orientation) = orientation { - drag = Some(GuideDrag { orientation, from: None, at: Some(o) }); + drag = Some(GuideDrag { orientation, from: None, at: Some(o), mods: egui::Modifiers::NONE }); } } else if rect.contains(o) && matches!(app.session.tool_id(), "selection" | "directSelection") @@ -3007,13 +3133,14 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi})); } ui.data_mut(|d| d.insert_temp(egui::Id::new(GUIDE_PRESS), GuidePress { origin: o, shift })); - drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) }); + drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o), mods: egui::Modifiers::NONE }); } } let Some(mut g) = drag else { return false }; if let Some(p) = latest { g.at = Some(p); } + g.mods = ui.input(|i| i.modifiers); if !released { ui.ctx().set_cursor_icon(match g.orientation { designcraft_doc::Orientation::Horizontal => egui::CursorIcon::ResizeVertical, @@ -3041,12 +3168,11 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec // Dragging one of several selected guides moves (or deletes) them all. let group = g.from.is_some_and(|(spread, page, index)| drags_group(app, GuideSel { spread, page, index })); let on_canvas = rect.contains(p); - let target = app.session.active().and_then(|st| { - let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let (r, sp) = layout.spread_at(xf.to_canvas(p))?; - let spread = st.doc.spread(r)?; - let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(sp)); - Some((r, sp, on_page)) + // Placed where the preview showed it (snapped); the page under the pointer owns a new guide. + let target = guide_spot(app, xf, &g).and_then(|spot| { + let spread = app.session.active()?.doc.spread(spot.spread)?; + let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(spot.pointer)); + Some((spot.spread, spot.sp, on_page)) }); let pos_of = |sp: Point| match g.orientation { designcraft_doc::Orientation::Horizontal => sp.y, @@ -3065,7 +3191,7 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.delete", json!({"spread": r, "page": pi, "index": gi})); } (Some((r, _, _)), true, Some((tr, _, _))) if tr == r && group => { - if let Some((dx, dy)) = press.and_then(|press| guide_drag_delta(app, xf, &g, press.origin, p)) { + if let Some((dx, dy)) = press.and_then(|press| guide_drag_delta(app, xf, &g, press.origin)) { let _ = app.run("guide.moveBy", json!({"dx": dx, "dy": dy})); } } @@ -3093,18 +3219,19 @@ fn drags_group(app: &DesignApp, from: GuideSel) -> bool { app.session.active().is_some_and(|st| st.selection.guides.len() > 1 && st.selection.guides.contains(&from)) } -/// How far a drag of the existing guide `g.from`, pressed at `origin` and now at `p` (screen), -/// moves guides, in spread coordinates: the dragged guide goes to the pointer, the other selected -/// guides as far along their own axis as the pointer moved. `None` while the pointer is not over -/// the guide's own spread, where a drop leaves it in place. -fn guide_drag_delta(app: &DesignApp, xf: &Xf, g: &GuideDrag, origin: Pos2, p: Pos2) -> Option<(f64, f64)> { +/// How far a drag of the existing guide `g.from`, pressed at `origin` (screen), moves guides, in +/// spread coordinates: the dragged guide goes to its [`guide_spot`], the other selected guides as +/// far along their own axis as it moved. `None` while the pointer is not over the guide's own +/// spread, where a drop leaves it in place. +fn guide_drag_delta(app: &DesignApp, xf: &Xf, g: &GuideDrag, origin: Pos2) -> Option<(f64, f64)> { let (r, pi, gi) = g.from?; - let st = app.session.active()?; - let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let (tr, sp) = layout.spread_at(xf.to_canvas(p))?; - if tr != r { + let spot = guide_spot(app, xf, g)?; + if spot.spread != r { return None; } + let sp = spot.sp; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); let start = layout.slot(r)?.to_spread(xf.to_canvas(origin)); let at = st.doc.spread(r)?.pages.get(pi)?.guides.get(gi)?.position; let (dx, dy) = match g.orientation { @@ -3130,7 +3257,7 @@ fn moving_guides(app: &DesignApp, xf: &Xf, rect: Rect, g: &GuideDrag, press: Gui if !rect.contains(p) || (p - press.origin).length() <= GUIDE_CLICK_SLOP { return None; } - let (dx, dy) = guide_drag_delta(app, xf, g, press.origin, p)?; + let (dx, dy) = guide_drag_delta(app, xf, g, press.origin)?; let from = GuideSel { spread, page, index }; let guides = if drags_group(app, from) { app.session.active()?.selection.guides.iter().copied().collect() } else { [from].into_iter().collect() }; Some(MovingGuides { guides, dx, dy }) @@ -4231,7 +4358,7 @@ mod tests { for (orientation, label, want) in [(designcraft_doc::Orientation::Vertical, "X", at.x - 100.0), (designcraft_doc::Orientation::Horizontal, "Y", at.y - 50.0)] { - let g = GuideDrag { orientation, from: None, at: Some(dest) }; + let g = GuideDrag { orientation, from: None, at: Some(dest), mods: egui::Modifiers::NONE }; let (l, v, _) = guide_measure(h.state(), &xf, &g).unwrap(); assert_eq!(l, label); assert!((v - want).abs() < 1e-6, "{label}: {v} vs {want}"); @@ -4270,7 +4397,12 @@ mod tests { let inside = Point::new(last.bounds.center().x, last.bounds.y0 + (last.bounds.y1 - last.bounds.y0) * 0.3); let (r, sp) = layout.spread_at(inside).unwrap(); assert_eq!(r, last.spread); - let g = GuideDrag { orientation: designcraft_doc::Orientation::Horizontal, from: None, at: Some(xf.to_screen(inside)) }; + let g = GuideDrag { + orientation: designcraft_doc::Orientation::Horizontal, + from: None, + at: Some(xf.to_screen(inside)), + mods: egui::Modifiers::NONE, + }; let (label, v, _) = guide_measure(h.state(), &xf, &g).unwrap(); assert_eq!(label, "Y"); // The guide would be stored at `sp.y` on that spread, which its ruler reads from the zero point. @@ -4279,7 +4411,12 @@ mod tests { // An existing guide of another spread can't be dropped there (it only moves on its own // spread): no readout over this one, a readout over its own. let first = layout.slots[current].spread; - let moving = GuideDrag { orientation: designcraft_doc::Orientation::Horizontal, from: Some((first, 0, 0)), at: Some(xf.to_screen(inside)) }; + let moving = GuideDrag { + orientation: designcraft_doc::Orientation::Horizontal, + from: Some((first, 0, 0)), + at: Some(xf.to_screen(inside)), + mods: egui::Modifiers::NONE, + }; assert!(guide_measure(h.state(), &xf, &moving).is_none(), "an existing guide over another spread"); let own = layout.slots[current].bounds; let over_own = Point::new(own.center().x, own.y0 + (own.y1 - own.y0) * 0.3); diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs index 23ad14c91..6c41d0122 100644 --- a/crates/ui-egui/src/guide_selection_tests.rs +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -243,7 +243,8 @@ fn dragging_one_of_several_selected_guides_previews_them_all() { let mut h = with_guides(); h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap(); let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0)); - let g = GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b) }; + let g = + GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE }; let press = GuidePress { origin: a, shift: false }; let app = h.state(); let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); @@ -253,6 +254,79 @@ fn dragging_one_of_several_selected_guides_previews_them_all() { moved.sort_unstable(); assert_eq!(moved, [0, 2]); // The drop moves them by the same amounts. - let (dx, dy) = guide_drag_delta(app, &xf, &g, a, b).unwrap(); + let (dx, dy) = guide_drag_delta(app, &xf, &g, a).unwrap(); assert_eq!((dx, dy), (m.dx, m.dy)); } + +/// Spread points per screen pixel at the harness's view. +fn pt_per_px(h: &Harness<'_, DesignApp>) -> f64 { + let app = h.state(); + 1.0 / Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).zoom +} + +/// A letter page with two columns: the left column's right side. +fn with_columns() -> (Harness<'static, DesignApp>, f64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"preset": "Letter", "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + app.run("guide.add", json!({"orientation": "vertical", "position": 200.0})).unwrap(); + let side = app.session.doc().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1; + (harness(app), side) +} + +/// A dragged guide snaps to a column guide as a moved object does (#1221). +#[test] +fn a_dragged_guide_snaps_to_a_column_guide() { + let (mut h, side) = with_columns(); + let near = side + 1.5 * pt_per_px(&h); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0)); + drag(&mut h, a, b); + assert_eq!(positions(&h), [side]); +} + +/// Snap to Guides and Smart Guides off: the guide goes where the pointer is. +#[test] +fn with_snapping_off_a_dragged_guide_stays_at_the_pointer() { + let (mut h, side) = with_columns(); + h.state_mut().ui.snap_to_guides = false; + h.state_mut().ui.smart_guides = false; + let near = side + 1.5 * pt_per_px(&h); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0)); + drag(&mut h, a, b); + let p = positions(&h); + assert!((p[0] - near).abs() < 0.1 && p[0] != side, "{p:?} vs {near}"); +} + +/// A frame's edge catches a dragged guide, and the preview shows the alignment mark. +#[test] +fn a_dragged_guide_snaps_to_a_frame_edge_with_a_smart_guide() { + let mut h = with_guides(); + h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap(); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let near = 400.0 + 1.5 * pt_per_px(&h); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0)); + let g = + GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE }; + { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let spot = guide_spot(app, &xf, &g).unwrap(); + assert_eq!(spot.sp.x, 400.0); + assert!(spot.marks.iter().any(|m| matches!(m, Overlay::Guide { .. })), "{:?}", spot.marks); + // Command suspends snapping. + let spot = guide_spot(app, &xf, &GuideDrag { mods: Modifiers::COMMAND, ..g.clone() }).unwrap(); + assert!((spot.sp.x - near).abs() < 0.1 && spot.marks.is_empty()); + } + drag(&mut h, a, b); + assert_eq!(positions(&h), [400.0, 300.0, 400.0]); +} + +/// A new guide dragged out of a ruler snaps too: here to the horizontal guide at y 400. +#[test] +fn a_guide_dragged_out_of_a_ruler_snaps() { + let mut h = with_guides(); + let rect = h.state().canvas_rect.unwrap(); + let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0); + let dest = screen_of(&h, 250.0, 400.0 + 1.5 * pt_per_px(&h)); + drag(&mut h, ruler, dest); + assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 92b86f0f1..f61acacba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -283,7 +283,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. From 333f2a04e31e0bff7eff4739640f0e765e6e14e9 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:52:17 -0400 Subject: [PATCH 045/117] Selection: drag a column guide to move its gutter (#1096) (#1176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IDML: keep column guides that were dragged off centre (#1096) InDesign records dragged column guides in `MarginPreference ColumnsPositions`, as pairs of offsets from the margin box's left edge. The importer discarded them and divided the columns evenly, so a page whose gutter the designer had moved came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was read as columns at 215 and 235. `Columns::positions` already existed, documented as "Custom column positions (after dragging column guides)", but nothing set it and nothing read it. Now `Page::column_rects` returns the custom rects when they are set — that one method is what the canvas guides, snapping, threading and the first frame of a placed story all go through, so they follow the dragged gutter — the importer fills it, and the exporter writes the page's real positions instead of recomputing an even split, so the round trip keeps them. `layout.marginsAndColumns` clears them when a count or gutter is set, which is what InDesign's Margins and Columns does. Without that the stale positions would outrank the new value and the dialog would look broken. Verified against InDesign-authored IDML rather than assumed, which settled two things. The attribute is written whether or not anything was dragged — a real page's values are exactly its even split — so an even set is dropped rather than marking every imported page custom and freezing its columns; the tolerance for that comparison only absorbs float noise, about 1e-14 pt on that page. And `` is a `` child, not a `` one, which is where this importer already looks: the reporter's missing ruler guide was 099fbd2, landed the day the ticket was filed, not a second bug. That commit left `lib.rs` still listing guides as ignored on import; corrected. * IDML: test a ruler guide in a column gutter without facing pages (#1096) * View: Lock Guides and Lock Column Guides are commands (#1096) view.lockGuides {on?} (Cmd+Alt+;) sets the Lock Guides UI state the Properties panel and ruler-guide drags use. view.lockColumnGuides {on?} is a document setting, on by default, saved with the document, not an undo step, and read from and written to IDML ColumnGuideLocked. Both are in View > Grids & Guides with checkmarks. * IDML: custom column positions are validated and round-trip (#1096) Columns::positions are used only when they are 2 x count finite edges, ascending from 0 to the margin box width (within 0.01 pt); anything else is the even split, in Page::column_rects, on IDML import (bounded parse, checked against the page's margin box) and on IDML export. Columns::with_gutter_moved moves one gutter's pair of guides between its neighbours for the coming column-guide command. ROADMAP: drop four progress-log lines the #1121 merge duplicated and move its line to the top. * Layout: Margins and Columns keeps dragged columns unless the count or gutter changes (#1096) * Layout: autoflow, inserted pages and new parents follow dragged columns (#1096) * Integration: close #1121's round-trip test beside the ruler-guide test * View: Lock Column Guides is translated wherever Lock Guides is (#1096) * View: Lock Column Guides is translated wherever Lock Guides is (#1096) * Layout: a new margin or page width resets dragged columns (#1096) * IDML: a page without its own margins checks the default's column positions against its width (#1096) * Layout: #1121's margin case follows the new-width rule (#1096) * View: Lock Column Guides is undoable (#1096) * Layout: dragged columns scale with a new margin or page width (#1096) * Layout: dragged columns mirror when a page changes side (#1096) * Layout: the page-width test drags a page whose columns are its own (#1096) * Selection: drag a column guide to move its gutter (#1096) * Layout: pages that follow a one-page parent take its dragged columns mirrored (#1096) * Selection: column-guide drag review fixes (#1096) * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: Hamza --- ROADMAP.md | 8 + crates/engine/src/cmd/layout.rs | 163 ++++++++++++++ crates/engine/src/tooling.rs | 14 ++ crates/ui-egui/src/canvas.rs | 369 +++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + docs/target-app-parity.md | 1 + 15 files changed, 565 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9c4cb465c..f9be21cc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,14 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. +- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. +- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. 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-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** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 9016c3848..047bd90ba 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -430,6 +430,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "layout.moveColumnGuide", + "Move Column Guide", + [], + None, + "{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked", + column_guides_unlocked, + move_column_guide + ), cmd!( "layout.documentSetup", "Document Setup…", @@ -870,6 +879,52 @@ pub fn specs() -> Vec { ] } +/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column +/// Guides is off. +fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + match s.active() { + Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()), + _ => Ok(()), + } +} + +/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`. +fn move_column_guide(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layout.moveColumnGuide"; + let index = |key: &str| -> Result { + let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?; + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer"))) + }; + let (page, gutter) = (index("page")?, index("gutter")?); + let position = + p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?; + let r = match p.get("parent") { + None | Some(Value::Null) => super::spread_param(p, CMD)?, + Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?), + }; + s.edit(|d, _| { + if d.settings.column_guides_locked { + return Err(bad(CMD, "column guides are locked")); + } + let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?; + let m = pg.margin_rect(); + let count = pg.columns.count; + if gutter.checked_add(1).is_none_or(|g| g >= count as usize) { + return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1)))); + } + let positions = + pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?; + let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v); + pg.columns.positions = Some(positions.clone()); + match r { + designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true, + designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(), + } + Ok(json!({"positions": positions, "left": left})) + }) +} + /// A parent spread named by prefix, label (`A-Parent`) or index. fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { match v { @@ -2227,6 +2282,114 @@ mod parent_tests { assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}"); assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}"); } + + fn columns_of(s: &crate::Session, abs: usize) -> (Option>, bool) { + let pg = s.doc().unwrap().doc.page(abs).unwrap().clone(); + (pg.columns.positions, pg.local_margins) + } + + /// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked. + fn three_columns(pages: u64) -> crate::Session { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": pages})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap(); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + s + } + + /// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring + /// gutter and the margin, makes a following page's columns its own, and undoes in one step. + #[test] + fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() { + let mut s = three_columns(1); + assert_eq!(columns_of(&s, 0), (None, false), "the page follows A"); + let mv = + |s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x})); + let r = mv(&mut s, 0, 236.0).unwrap(); + assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0})); + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0])); + // Past the next gutter: it stops there, and the middle column has no width. + assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0)); + // Past the right margin: the last gutter stops 12 pt (its width) inside it. + assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0])); + // Past the left margin. + assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0])); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag"); + } + + /// On a parent page the drag changes the parent, and the document pages that follow it get + /// the new columns; a page with columns of its own keeps them. + #[test] + fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() { + let mut s = three_columns(3); + // Page 1, the left page of spread 1, gets columns of its own. + let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0; + s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap(); + let own = columns_of(&s, 1); + assert!(own.1 && own.0.is_some()); + let d = &s.doc().unwrap().doc; + // A's right page: the one page 0 (a right page) and page 2 follow. + let x0 = d.parents[0].pages[1].margin_rect().x0; + s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap(); + let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0]; + assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone())); + assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false)); + assert_eq!(columns_of(&s, 2), (Some(moved), false)); + assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own"); + // The same parent page by spread reference. + let spread = json!({"kind": "parent", "index": 0}); + s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap(); + assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0); + } + + /// A drag's preview in one document is put back there when another document became active. + #[test] + fn a_column_guide_preview_is_cancelled_in_its_own_document() { + let mut s = three_columns(1); + let uid = s.doc().unwrap().uid; + s.begin_interaction("Move Column Guide").unwrap(); + s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + s.cancel_interaction_of(uid); + s.set_active(0); + assert!(s.doc().unwrap().interaction.is_none()); + assert_eq!(columns_of(&s, 0), (None, false)); + } + + #[test] + fn move_column_guide_refuses_while_locked_and_hostile_params() { + let mut s = three_columns(1); + s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap(); + assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err()); + assert_eq!(columns_of(&s, 0), (None, false)); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + for p in [ + json!({"page": 0, "gutter": 0}), + json!({"page": 0, "gutter": 0, "position": f64::NAN}), + json!({"page": 0, "gutter": 0, "position": "200"}), + json!({"page": 0, "gutter": 2, "position": 200}), + json!({"page": 0, "gutter": u64::MAX, "position": 200}), + json!({"page": 0, "gutter": -1, "position": 200}), + json!({"page": 0, "gutter": 0.5, "position": 200}), + json!({"page": 9, "gutter": 0, "position": 200}), + json!({"page": u64::MAX, "gutter": 0, "position": 200}), + json!({"gutter": 0, "position": 200}), + json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}), + json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}), + json!({"page": 0, "gutter": 0, "position": f64::MAX}), + ] { + let r = s.execute("layout.moveColumnGuide", &p); + // A huge but finite position is clamped to the margin; everything else is refused. + assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}"); + } + } } #[cfg(test)] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c58236090..65d069bac 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -331,4 +331,18 @@ impl Session { } } } + + /// Cancel the interaction of the document with `uid`, active or not: a gesture that began in + /// one document and lost it to another tab ends there, leaving the document as it began. + pub fn cancel_interaction_of(&mut self, uid: u64) { + if self.active().is_some_and(|st| st.uid == uid) { + self.cancel_interaction(); + } else if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid) + && let Some(it) = st.interaction.take() + { + st.doc = it.doc; + st.selection = it.selection; + st.revision = it.revision; + } + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8ddc9487..9868d4d8b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -623,6 +623,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; + // A column guide that a press would drag, or one being dragged: a resize cursor across it. + let dragging = ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).map(|g| g.spread); + let column = dragging.or_else(|| { + let menu_open = context_menu_open(ui.ctx()); + (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) + .then(|| column_guide_at(app, &xf, p).map(|h| h.0)) + .flatten() + }); + let c = match column { + Some(r) if spread_x_across(app, r) => Cursor::ResizeH, + Some(_) => Cursor::ResizeV, + None => c, + }; let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); @@ -2393,6 +2406,11 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live // Distribute while resizing). Middle-drag pans too, with any tool; tools only // see the primary button. + // A column guide drag keeps the pointer until the button is up, whatever Space or a menu does + // meanwhile. + if ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).is_some() && column_guide_drag(app, ui, resp, rect, &xf) { + return; + } let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); let hand = space && !ui.input(|i| i.modifiers.command); if !tool_drag && ((hand && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) { @@ -2428,6 +2446,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if !space && !menu_open && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; } + if !space && !menu_open && xf.rot == 0 && column_guide_drag(app, ui, resp, rect, &xf) { + return; + } let m = ui.input(|i| mods(i, space)); let vi = app.view_info(); let pos = |p: Pos2| xf.to_canvas(p); @@ -3263,6 +3284,222 @@ fn moving_guides(app: &DesignApp, xf: &Xf, rect: Rect, g: &GuideDrag, press: Gui Some(MovingGuides { guides, dx, dy }) } +/// An object is under screen point `p` where the Selection tool would pick it (a document +/// spread's, or a parent spread's while editing parents): it takes the press before the guides +/// behind it. +fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool { + let Some(st) = app.session.active() else { return false }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cx = designcraft_tools::ToolContext { + doc: &st.doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: xf.zoom.max(1e-6), + layer: st.active_layer, + snap: app.ui.snap_view(), + unit: app.ui.units, + stroke_in_front: false, + }; + cx.hit(xf.to_canvas(p)).is_some() +} + +const COLUMN_DRAG: &str = "canvas_column_drag"; + +/// A column guide being dragged with a Selection tool: its gutter follows the pointer. +#[derive(Clone, Debug)] +struct ColumnDrag { + /// The document the drag's interaction belongs to. + doc: u64, + spread: SpreadRef, + page: usize, + gutter: usize, + /// The pointer's spread x minus the gutter's left edge at the press: the gutter keeps that + /// distance from the pointer. + grab: f64, + /// The left edge last previewed. + last: Option, +} + +/// The interior column guide under screen point `p` (within 3 px) that a press there would drag, +/// as (spread, page, gutter, the gutter's left edge in spread x): a Selection tool, guides shown +/// (not in Preview or Presentation), column guides unlocked, and no object or ruler guide that +/// takes the press there. +fn column_guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(SpreadRef, usize, usize, f64)> { + let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); + if !app.ui.guides || preview || !matches!(app.session.tool_id(), "selection" | "directSelection") { + return None; + } + let st = app.session.active()?; + if st.doc.settings.column_guides_locked { + return None; + } + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (r, sp) = layout.spread_at(xf.to_canvas(p))?; + let tol = 3.0 / xf.zoom.max(1e-9); + let hit = st.doc.spread(r)?.pages.iter().enumerate().find_map(|(pi, pg)| { + let m = pg.margin_rect(); + if sp.y < m.y0 - tol || sp.y > m.y1 + tol { + return None; + } + pg.column_rects().windows(2).enumerate().find_map(|(gi, w)| match w { + // Columns that cannot move (gutters wider than the margin box) offer no guide. + [a, b] + if ((sp.x - a.x1).abs() <= tol || (sp.x - b.x0).abs() <= tol) + && pg.columns.with_gutter_moved(m.width(), gi, a.x1 - m.x0).is_some() => + { + Some((r, pi, gi, a.x1)) + } + _ => None, + }) + })?; + // A ruler guide there takes the press first (see `guide_drag`). + let ruler_guide = !app.ui.guides_locked && guide_at(app, xf, p).is_some(); + (!ruler_guide && !over_item(app, xf, p)).then_some(hit) +} + +/// Whether spread `r`'s x axis runs across the screen (the spread view is not turned a quarter). +fn spread_x_across(app: &DesignApp, r: SpreadRef) -> bool { + app.session.active().is_none_or(|st| { + let [a, b, ..] = CanvasLayout::new(&st.doc, st.editing_parents).xf(r).as_coeffs(); + a.abs() >= b.abs() + }) +} + +/// Canvas x `x` moved to the nearest tick mark of the horizontal ruler (its finest increment). +fn ruler_tick_x(app: &DesignApp, doc: &Document, layout: &CanvasLayout, zoom: f64, x: f64) -> Option { + let pieces = ruler_pieces(app, doc, layout, doc.zero_point()); + let &(_, _, o) = pieces.iter().find(|(from, to, _)| *from <= x && x < *to).or(pieces.first())?; + let (major, sub) = doc.settings.horizontal_units.ruler_ticks(zoom); + let step = major / f64::from(sub.max(1)); + (step.is_finite() && step > 0.0).then(|| o.x + ((x - o.x) / step).round() * step).filter(|v| v.is_finite()) +} + +/// Where a dragged gutter's left edge goes for spread x `want`: Shift snaps it to the ruler's tick +/// marks; otherwise its edges snap to the document grid, guides and objects as those snaps are +/// set. Measured against the document as it was when the drag began. +fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f64) -> f64 { + let Some(st) = app.session.active() else { return want }; + let doc = st.interaction.as_ref().map_or_else(|| st.doc.clone(), |i| i.doc.clone()); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let Some(slot) = layout.slots.iter().find(|s| s.spread == g.spread) else { return want }; + let Some(pg) = doc.spread(g.spread).and_then(|sp| sp.pages.get(g.page)) else { return want }; + let m = pg.margin_rect(); + if shift { + if !spread_x_across(app, g.spread) { + return want; + } + let c = slot.to_canvas(Point::new(want, m.y0)); + return ruler_tick_x(app, &doc, &layout, zoom, c.x).map_or(want, |x| slot.to_spread(Point::new(x, c.y)).x); + } + let cols = pg.column_rects(); + let width = match (cols.get(g.gutter), cols.get(g.gutter + 1)) { + (Some(a), Some(b)) => (b.x0 - a.x1).max(0.0), + _ => 0.0, + }; + let cx = designcraft_tools::ToolContext { + doc: &doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: zoom.max(1e-6), + layer: st.active_layer, + // A line has no size or spacing to match. + snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, + unit: app.ui.units, + stroke_in_front: false, + }; + let rect = DRect::new(want, m.y0, want + width, m.y1); + let s = designcraft_tools::snap::snap( + &cx, + designcraft_tools::SnapRequest { + spread: g.spread, + gesture: designcraft_tools::Gesture::Move, + rect, + x_edges: [true, false, true], + y_edges: [false; 3], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(want, m.center().y), + }, + ); + let x = want + s.delta.x; + if x.is_finite() { x } else { want } +} + +/// Column guides: with a Selection tool, drag an interior column guide (not over an object) to +/// move its gutter, both guides together; Escape puts it back. One undo step per drag. Returns +/// true while it owns the pointer. +fn column_guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { + let id = egui::Id::new((COLUMN_DRAG, app.pane)); + let (pressed, released, origin, latest, shift, escape) = ui.input(|i| { + ( + i.pointer.primary_pressed(), + // A release this frame, or one missed while something else had the pointer. + i.pointer.primary_released() || !i.pointer.primary_down(), + i.pointer.press_origin(), + i.pointer.latest_pos(), + i.modifiers.shift, + i.key_pressed(egui::Key::Escape), + ) + }); + let mut drag: Option = ui.data(|d| d.get_temp(id)); + if drag.is_none() + && pressed + && resp.hovered() + && let Some(o) = origin.filter(|o| rect.contains(*o)) + && let Some((spread, page, gutter, left)) = column_guide_at(app, xf, o) + // Another gesture or a dialog's preview owns the document until it ends. + && let Some((doc, slot_x)) = app + .session + .active() + .filter(|st| st.interaction.is_none()) + .map(|st| (st.uid, CanvasLayout::new(&st.doc, st.editing_parents).to_spread(spread, xf.to_canvas(o)).x)) + && app.session.begin_interaction("Move Column Guide").is_ok() + { + drag = Some(ColumnDrag { doc, spread, page, gutter, grab: slot_x - left, last: None }); + } + let Some(mut g) = drag else { return false }; + // Another document became active: the drag ends in its own, as it began. + if app.session.active().is_none_or(|st| st.uid != g.doc) { + app.session.cancel_interaction_of(g.doc); + ui.data_mut(|d| d.remove::(id)); + return false; + } + if escape { + app.session.cancel_interaction(); + ui.data_mut(|d| d.remove::(id)); + return true; + } + if let Some(p) = latest + && let Some(x) = app.session.active().map(|st| CanvasLayout::new(&st.doc, st.editing_parents).to_spread(g.spread, xf.to_canvas(p)).x) + && x.is_finite() + { + let want = snap_gutter(app, &g, x - g.grab, shift, xf.zoom); + if g.last != Some(want) && (g.last.is_some() || (p - origin.unwrap_or(p)).length() > 0.0) { + let params = json!({"spread": g.spread, "page": g.page, "gutter": g.gutter, "position": want}); + if let Err(e) = app.session.preview("layout.moveColumnGuide", params) { + app.status(e.to_string()); + } + g.last = Some(want); + } + } + if released { + ui.data_mut(|d| d.remove::(id)); + if let Err(e) = app.session.commit_interaction() { + app.status(e.to_string()); + } + app.after_engine(); + return true; + } + ui.data_mut(|d| d.insert_temp(id, g)); + true +} + /// Presses in a row on the canvas: the last press's count, and where and when it was released /// (`at` is −∞ while the button is down). #[derive(Clone, Copy, Debug)] @@ -3498,6 +3735,138 @@ mod tests { assert!(!g[1].liquid); } + /// Dragging a column guide with the Selection tool moves its gutter (one undo step); with + /// column guides locked the same drag leaves the columns alone. + #[test] + fn the_selection_tool_drags_a_column_guide() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + // Two columns across the 540 pt margin box: the gutter's left edge is at x = 300. + app.run("layout.marginsAndColumns", json!({"parent": "A", "columns": 2, "gutter": 12})).unwrap(); + let mut h = harness(app); + let page = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].clone(); + let screen = |h: &Harness<'_, DesignApp>, x: f64| { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let layout = CanvasLayout::new(&app.session.active().unwrap().doc, false); + let slot = layout.slots.iter().find(|s| s.spread == SpreadRef::Doc(0)).unwrap(); + (xf.to_screen(slot.xf * Point::new(x, 400.0)), xf.zoom as f32) + }; + let (at, zoom) = screen(&h, 300.0); + drag(&mut h, at, at + vec2(72.0 * zoom, 0.0)); + assert_eq!(page(&h).columns.positions, None, "column guides are locked in a new document"); + h.hover_at(at); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + + h.state_mut().run("view.lockColumnGuides", json!({"on": false})).unwrap(); + h.run_steps(2); + let (at, zoom) = screen(&h, 300.0); + drag(&mut h, at, at + vec2(72.0 * zoom, 0.0)); + let pg = page(&h); + let cols = pg.column_rects(); + assert!((cols[0].x1 - 372.0).abs() < 1.0 && (cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9, "{cols:?}"); + assert!(pg.local_margins, "the dragged page's columns are its own"); + // Over the moved guide: a resize cursor. + h.hover_at(screen(&h, 372.0).0); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(page(&h).columns.positions, None, "one undo step"); + + // Escape during the drag puts the gutter back. + h.hover_at(at); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.hover_at(at + vec2(40.0, 0.0)); + h.step(); + assert!(page(&h).columns.positions.is_some(), "the drag previews"); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { + pos: at + vec2(40.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(3); + assert_eq!(page(&h).columns.positions, None); + assert!(!page(&h).local_margins); + + // Space pressed during the drag and the button released under it: the drag ends with the + // release and the gutter stops following the pointer. + let key = |pressed| egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + h.hover_at(at); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.hover_at(at + vec2(40.0, 0.0)); + h.step(); + h.event(key(true)); + h.step(); + h.event(egui::Event::PointerButton { + pos: at + vec2(40.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.step(); + h.event(key(false)); + h.run_steps(2); + let moved = page(&h).columns.positions; + assert!(moved.is_some()); + h.hover_at(at + vec2(120.0, 0.0)); + h.run_steps(3); + assert_eq!(page(&h).columns.positions, moved, "the gutter stays where the drag ended"); + assert!(h.state().session.active().unwrap().interaction.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(page(&h).columns.positions, None, "the drag was one undo step"); + } + + /// A column guide is not picked where the screen mode hides guides, where an object on the + /// parent being edited lies over it, or where its columns cannot move. + #[test] + fn column_guides_are_picked_only_where_a_drag_can_move_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("layout.marginsAndColumns", json!({"parent": "A", "columns": 2, "gutter": 12})).unwrap(); + app.run("view.lockColumnGuides", json!({"on": false})).unwrap(); + let mut h = harness(app); + let hit = |h: &Harness<'_, DesignApp>, r: SpreadRef, x: f64| { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let slot = layout.slots.iter().find(|s| s.spread == r).unwrap(); + column_guide_at(app, &xf, xf.to_screen(slot.xf * Point::new(x, 400.0))) + }; + assert!(hit(&h, SpreadRef::Doc(0), 300.0).is_some()); + h.state_mut().ui.screen_mode = ScreenMode::Preview; + assert!(hit(&h, SpreadRef::Doc(0), 300.0).is_none(), "Preview hides the guides"); + h.state_mut().ui.screen_mode = ScreenMode::Normal; + + // Editing parent A: its right page (the one page 0 follows) and a frame over its gutter. + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let parent = SpreadRef::Parent(0); + let x = h.state().session.active().unwrap().doc.parents[0].pages[1].column_rects()[0].x1; + assert!(hit(&h, parent, x).is_some()); + h.state_mut() + .run("frame.create", json!({"spread": {"kind": "parent", "index": 0}, "rect": [x - 20.0, 300.0, x + 20.0, 500.0], "content": "text"})) + .unwrap(); + h.run_steps(2); + assert!(hit(&h, parent, x).is_none(), "the parent's frame takes the press"); + h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); + h.run_steps(2); + + // Gutters wider than the margin box: the even split cannot be moved. + h.state_mut().run("layout.marginsAndColumns", json!({"pages": [0], "columns": 2, "gutter": 1000})).unwrap(); + h.run_steps(2); + let x = h.state().session.active().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1; + assert!(hit(&h, SpreadRef::Doc(0), x).is_none()); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 587ed775b..31f7fb70d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1606,6 +1606,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "تحريك نقاط الارتساء"), ("Move Column", "نقل عمود"), ("Move Guide", "تحريك دليل"), + ("Move Column Guide", "تحريك دليل العمود"), ("Move Layer", "نقل طبقة"), ("Move Pages…", "نقل صفحات…"), ("Move to Layer", "نقل إلى طبقة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index c052b8f33..4d41c30dc 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -775,6 +775,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Přesunout kotvy"), ("Move Column", "Přesunout sloupec"), ("Move Guide", "Přesunout vodítko"), + ("Move Column Guide", "Přesunout vodítko sloupce"), ("Move Layer", "Přesunout vrstvu"), ("Move Pages…", "Přesunout stránky…"), ("Move to Layer", "Přesunout do vrstvy"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 7065c6f33..d47657b09 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -832,6 +832,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Anker verschieben"), ("Move Column", "Spalte verschieben"), ("Move Guide", "Hilfslinie verschieben"), + ("Move Column Guide", "Spaltenhilfslinie verschieben"), ("Move Layer", "Ebene verschieben"), ("Move Pages…", "Seiten verschieben …"), ("Move to Layer", "Auf Ebene verschieben"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8199e2ebb..755272fe5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -905,6 +905,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Sposta ancoraggi"), ("Move Column", "Sposta colonna"), ("Move Guide", "Sposta guida"), + ("Move Column Guide", "Sposta guida colonna"), ("Move Layer", "Sposta livello"), ("Move Pages…", "Sposta pagine…"), ("Move to Layer", "Sposta su livello"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e79f4ba06..e23ef15c7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1020,6 +1020,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "アンカーポイントを移動"), ("Move Column", "列を移動"), ("Move Guide", "ガイドを移動"), + ("Move Column Guide", "列ガイドを移動"), ("Move Layer", "レイヤーを移動"), ("Move Pages…", "ページを移動…"), ("Move to Layer", "レイヤーへ移動"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index c8405c16e..0300b62d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -837,6 +837,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "საყრდენი წერტილების გადაადგილება"), ("Move Column", "სვეტის გადატანა"), ("Move Guide", "დამხმარე ხაზის გადაადგილება"), + ("Move Column Guide", "სვეტის დამხმარე ხაზის გადაადგილება"), ("Move Layer", "შრის გადაადგილება"), ("Move Pages…", "გვერდების გადატანა…"), ("Move to Layer", "შრეზე გადატანა"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 51bb7fc5c..7d1d12c67 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -788,6 +788,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Przesuń kotwice"), ("Move Column", "Przesuń kolumnę"), ("Move Guide", "Przesuń linię pomocniczą"), + ("Move Column Guide", "Przesuń linię pomocniczą łamu"), ("Move Layer", "Przesuń warstwę"), ("Move Pages…", "Przesuń strony…"), ("Move to Layer", "Przenieś na warstwę"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index bb3d21831..f552e9a63 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1733,6 +1733,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Mover âncoras"), ("Move Column", "Mover coluna"), ("Move Guide", "Mover guia"), + ("Move Column Guide", "Mover guia de coluna"), ("Move Layer", "Mover camada"), ("Move to Layer", "Mover para a camada"), ("New Article", "Novo artigo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 27ce1fed3..3f6ef1be0 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -762,6 +762,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Переместить опорные точки"), ("Move Column", "Переместить столбец"), ("Move Guide", "Переместить направляющую"), + ("Move Column Guide", "Переместить направляющую колонки"), ("Move Layer", "Переместить слой"), ("Move Pages…", "Переместить страницы…"), ("Move to Layer", "Переместить на слой"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index d7ac7ce01..9c7cc855a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -908,6 +908,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Перемістити опорні точки"), ("Move Column", "Перемістити стовпець"), ("Move Guide", "Перемістити напрямну"), + ("Move Column Guide", "Перемістити напрямну колонки"), ("Move Layer", "Перемістити шар"), ("Move Pages…", "Перемістити сторінки…"), ("Move to Layer", "Перемістити до шару"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f61acacba..c7e0678d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -284,6 +284,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Column guides: with the Selection tool, drag an interior column guide to move its gutter (both guides together, keeping its width, stopped by the neighbouring guides and the margins), snapping to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels; one undo step. Refused while Lock Column Guides is on (the default). A document page takes the result as its own columns; on a parent page it reaches the pages that follow it. Objects stay where they are (there is no persistent Layout Adjustment setting yet). `layout.moveColumnGuide`. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. From f3b7c3ee34e402b1e5c1854e7e8a86e876bd286e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:06:48 -0400 Subject: [PATCH 046/117] ROADMAP: progress lines for guide selection, guide snapping and dragged column guides (#1543) --- ROADMAP.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f9be21cc9..8a2352e86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), moved together, nudged, deleted, and placed exactly with the X or Y field; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. @@ -348,9 +348,17 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. +- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. +- **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-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. From fa81009be2450dcfa10ea3b858f5139d6f13a1ab Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:22:08 +0200 Subject: [PATCH 047/117] Keep a literal ':' in style names through an IDML round trip Found while testing crates/idml/src/names.rs:148-155 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/idml/src/names.rs | 27 ++++++++++++++++++++++++--- crates/idml/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 569244470..b9102c9ae 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -144,16 +144,37 @@ pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Tabl pub const OBJECT_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")]; -/// Our style name → IDML `Name` (groups: `A/B` → `A:B`). +/// Our style name → IDML `Name` (groups: `A/B` → `A:B`; a literal `:` or `\` is escaped as `\:` +/// or `\\`, so it is not read back as a group separator). pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String { - builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":")) + builtins + .iter() + .find(|(o, _)| *o == name) + .map(|(_, i)| i.to_string()) + .unwrap_or_else(|| name.replace('\\', "\\\\").replace(':', "\\:").replace('/', ":")) } /// IDML `Name` → our style name. pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String { if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) { return o.to_string(); } - name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/") + let name = name.strip_prefix("$ID/").unwrap_or(name); + let mut out = String::with_capacity(name.len()); + let mut chars = name.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.clone().next() { + Some(next @ ('\\' | ':')) => { + out.push(next); + chars.next(); + } + _ => out.push('\\'), + }, + ':' => out.push('/'), + _ => out.push(c), + } + } + out } /// `ParagraphStyle/…` Self id for one of our names. pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 37fb2af50..d7106ad48 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4368,3 +4368,42 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } + +#[test] +fn style_names_with_a_literal_colon_survive_a_round_trip() { + use designcraft_doc::styles::{CellStyle, CharacterStyle, ObjectStyle, ParagraphStyle, TableStyle}; + let names = ["Title: Big", "Group/Child: X", "Back\\slash: \\:"]; + let mut d = small_doc(); + for n in names { + let st = d.styles_mut(); + st.paragraph.push(ParagraphStyle { + name: n.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }); + st.character.push(CharacterStyle { name: n.into(), based_on: None, chars: Default::default(), shortcut: String::new() }); + st.cell.push(CellStyle { name: n.into(), ..Default::default() }); + st.table.push(TableStyle { name: n.into(), ..Default::default() }); + st.object.push(ObjectStyle { name: n.into(), ..Default::default() }); + } + let back = import_idml(&export_idml(&d)).unwrap(); + for n in names { + assert!( + back.styles.paragraph.iter().any(|s| s.name == n), + "paragraph {n:?}: {:?}", + back.styles.paragraph.iter().map(|s| &s.name).collect::>() + ); + assert!(back.styles.character.iter().any(|s| s.name == n), "character {n:?}"); + assert!(back.styles.cell.iter().any(|s| s.name == n), "cell {n:?}"); + assert!(back.styles.table.iter().any(|s| s.name == n), "table {n:?}"); + assert!(back.styles.object.iter().any(|s| s.name == n), "object {n:?}"); + } + // A group is still written as an IDML `:` group path. + assert_eq!(names::style_name_out(names::PARA_BUILTINS, "Group/Child"), "Group:Child"); + for n in names { + assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n); + } +} From 35e5b39874a40550404c61337f5e0ee3539a24d0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:40:18 +0000 Subject: [PATCH 048/117] Clamp text frame column count before narrowing to u32 Found while testing crates/engine/src/cmd/object.rs:2519 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/object.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..54fa18d31 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2516,7 +2516,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue }; let o = &mut tf.options; if let Some(v) = p.get("columns").and_then(Value::as_u64) { - o.columns = (v as u32).clamp(1, 40); + o.columns = v.clamp(1, 40) as u32; } if let Some(v) = p.get("gutter").and_then(Value::as_f64) { o.gutter = v.max(0.0); @@ -4497,6 +4497,21 @@ mod column_rule_tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(opts(&s), before); } + + /// A column count past `u32::MAX` was cast to `u32` before the clamp, so + /// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40. + #[test] + fn huge_column_count_clamps_to_the_maximum() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns; + for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] { + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap(); + assert_eq!(columns(&s), want, "columns {given}"); + } + } } #[cfg(test)] From 3bdd70a9758d0b47341f310f298b0181226933ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:57:35 +0700 Subject: [PATCH 049/117] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4ca03d4dc..cd0f56479 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+ DesignCraft icon: an engraved cat in a polka-dot scarf on a green tile +

+

DesignCraft

From a473793c07c622b76915a417311808652d25f1a6 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 15:59:33 +0500 Subject: [PATCH 050/117] Rotated cell text is settable and reported, not silently flattened (#1468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cell's text rotation (Cell Options › Text › Rotation) was already read from IDML and written back, but only 0 is composed, so an imported form's narrow header columns of vertical labels came in horizontal and wrapped mid-word ("Eingela / ngt am") — and nothing anywhere said why. The reporter's file has 30 such cells. Composing rotated cell text is a larger change than this. What this does is stop it being silent, and let the value be set from the API: - the IDML import reports it once per file: "2 table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"; - `table.setCell` takes `rotation` (0, 90, 180 or 270, any other angle refused, negatives and multiples normalised), which nothing could set before; - `table.get` reports each cell's rotation, so a round trip can be checked. Tests: the angle through an export and back with the warning counted once for the file (and no warning when no cell is rotated), and the command's accepted and refused angles. Each fails without its own part. --- crates/engine/src/cmd/table.rs | 26 ++++++++++++++++++++++++-- crates/idml/src/import.rs | 14 ++++++++++++++ crates/idml/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7049c2f39..7e8bcc6cb 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -297,7 +297,7 @@ fn table_specs() -> Vec { "Cell Options", [], None, - "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", in_table, set_cell ), @@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { None => None, }; let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; + // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is + // composed so far, which the import says and preflight reports (#1468). + let rotation = match p.get("rotation").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) { + Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d), + _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")), + }, + None => None, + }; let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = match p.get("stroke").filter(|v| !v.is_null()) { Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), @@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { if let Some(v) = first_baseline_min { cell.first_baseline_min = v; } + if let Some(v) = rotation { + cell.rotation = v; + } if let Some(tx) = &text { let len = cell.text.len(); cell.text.replace(0..len, tx); @@ -1548,7 +1560,7 @@ fn get(s: &mut Session, p: &Value) -> Result { .filter_map(|(r, c)| { let cell = t.cell(r, c)?; Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style})) + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) }) .collect(); Ok(json!({ @@ -1832,6 +1844,16 @@ mod style_tests { let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); + // Cell Options › Text › Rotation: carried and written back, composed horizontally so far, + // which the IDML import reports (#1468). + for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] { + s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}")); + let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone(); + assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}"); + } + for bad in [45, 1, 100] { + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}"); + } assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..f9cfed0e2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -295,6 +295,10 @@ struct Importer<'r> { parents: Vec, /// Things read but not kept as they were, for the import report. warnings: Vec, + /// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and + /// written back, but only 0 is composed, so a narrow header column of vertical labels comes + /// in horizontal and wraps mid-word (#1468). + rotated_cells: usize, parent_ids: HashMap, spreads: Vec, page_index: HashMap, @@ -536,6 +540,7 @@ impl<'r> Importer<'r> { story_ids: HashMap::new(), parents: Vec::new(), warnings: Vec::new(), + rotated_cells: 0, parent_ids: HashMap::new(), spreads: Vec::new(), page_index: HashMap::new(), @@ -791,6 +796,12 @@ impl<'r> Importer<'r> { include_prefix: e.get("IncludeSectionPrefix") == Some("true"), }); } + if self.rotated_cells > 0 { + let n = self.rotated_cells; + self.warnings.push(format!( + "{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally" + )); + } self.sections.sort_by_key(|s| s.start); self.sections.dedup_by_key(|s| s.start); if self.sections.first().is_none_or(|s| s.start != 0) { @@ -2232,6 +2243,9 @@ impl<'r> Importer<'r> { cell.first_baseline_min = min.clamp(0.0, 8640.0); } cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); + if cell.rotation != 0.0 { + self.rotated_cells += 1; + } for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { let attrs = self.cell_stroke_attrs(ce, side); attrs.apply_to(&mut cell.strokes[i]); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..9fcbf2d4b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4234,3 +4234,35 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } + +/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is +/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word, +/// and nothing said so (#1468). +#[test] +fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() { + let mut d = Document::new(&NewDocument::default()); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0); + t.cell_mut(0, 0).unwrap().rotation = 90.0; + t.cell_mut(0, 1).unwrap().rotation = 270.0; + d.story_mut(sid).unwrap().insert_table(0, t); + let bytes = export_idml(&d); + let story = zip_text(&bytes, "Stories/"); + assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}"); + + let back = import_idml_report(&bytes, &|_| None).unwrap(); + let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone(); + assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0)); + let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect(); + assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings); + assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]); + assert!(about[0].contains("composed horizontally"), "{}", about[0]); + + // A file with no rotated cell says nothing. + let mut plain = Document::new(&NewDocument::default()); + let (_, sid) = + plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap(); + plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0)); + let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap(); + assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings); +} From e7ef29d9d1826add4ee8a55374898989ccaef4e1 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 16:18:33 +0500 Subject: [PATCH 051/117] A page named twice in an export list is exported once (found while probing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same selection written two ways gave different files: file.exportPdf {"pages": "2,2"} → 1 page file.exportPdf {"pages": [2, 2]} → 2 pages, the second a copy file.exportPdf {"pages": [2,2,2]} → 3 pages `parse_page_range` keeps the first of a repeat (`"2-2,2"` is one page too); the array form appended whatever it was given. A list says which pages to export, not how many times, so the array now drops a repeat as the string does. Both `file.exportPdf` and `file.exportPptx` read the same parameter. The test pins the agreement between the two forms over nine lists, in PDF and PPTX, and the two facts I measured while checking it: the pages come out in the document's order whichever order the list names them in, and `[2, 2]` is now byte for byte the file `"2"` gives. A page that does not exist is still refused, and an empty list still exports nothing. Not from a ticket: I found it probing the page-range parameter. --- crates/engine/src/cmd/export.rs | 56 ++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 36fd3d52a..9db85e7a9 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result= 1 && *n <= page_count) .ok_or_else(|| bad(id, format!("bad page {x}")))?; - v.push(n - 1); + // A page named twice is one page, as `"2,2"` already is: the list says which + // pages to export, not how many times. `[2, 2]` exported two copies of page 2 + // while `"2,2"` exported one. + if !v.contains(&(n - 1)) { + v.push(n - 1); + } } Some(v) } @@ -1326,3 +1331,52 @@ mod pptx_tests { assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); } } + +#[cfg(test)] +mod page_list_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn doc(pages: u64) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); + s + } + + /// The same selection written two ways exported different files: `"2,2"` is one page, because + /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. + /// The list says which pages to export, not how many times. + #[test] + fn a_page_named_twice_is_exported_once_either_way() { + let mut s = doc(4); + for (pages, want) in [ + (json!("2,2"), 1), + (json!([2, 2]), 1), + (json!([2, 2, 2]), 1), + (json!("2-2,2"), 1), + (json!([1, 2, 1]), 2), + (json!("1,2,1"), 2), + (json!([4, 1]), 2), + (json!("4,1"), 2), + (json!("all"), 4), + ] { + let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); + let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); + } + // The two forms now agree byte for byte, and the pages come out in the document's order + // whichever order the list names them in (which is how the export already behaved). + let mut file = |pages: Value| { + let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap(); + r["base64"].as_str().unwrap_or_default().to_string() + }; + assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); + assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); + assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); + // A page that does not exist is still refused, and an empty list still exports nothing. + assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); + assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); + } +} From 2946f2af6202a744dcc2162a2259066b1a4d62ac Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 16:35:47 +0500 Subject: [PATCH 052/117] A page count Insert Pages cannot mean is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found probing `layout.pages.insert` on a two-page document: {"count": 0} → one page inserted (0 clamped up to 1) {"count": -1} → one page (`as_u64` fails, the default applies) {"count": 1.5} → one page (the same way) {"count": 10000} → 9999 pages, quietly capped A count that is given is a number of pages, so each of those is a usage error now: "count 0: 1 to 9999 pages". A count that is absent still means one page. `after` is left alone. My first attempt also refused a position past the last page, and `insert_after_u64_max_appends_at_the_end` caught it: appending is what an out-of-range `after` is meant to do, and the test now says so in the same place. The test covers the five counts, that none of them inserted anything, that a position past the end still appends, and that `{"count": 2, "after": 0}` still inserts two pages after the first. Without the check, `{"count": 0}` inserts a page again. Not from a ticket: I found it probing the command's parameters. --- crates/engine/src/cmd/layout.rs | 48 ++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 615e7c1df..92cf0c6e2 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -112,14 +112,25 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}", + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; + const ID: &str = "layout.pages.insert"; + // A count that is given but is not a number of pages used to insert one page: + // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped. + let count = match p.get("count").filter(|v| !v.is_null()) { + None => 1, + Some(v) => match v.as_u64().map(|n| n as usize).filter(|n| (1..=9999).contains(n)) { + Some(n) => n, + None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))), + }, + }; let st = s.doc()?; let n = st.doc.page_count(); + // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` + // pins deliberately. let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); - let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); + let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { let ids = d.insert_pages(Some(after), count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) @@ -2055,3 +2066,34 @@ mod parent_base_tests { #[cfg(test)] #[path = "parent_delete_tests.rs"] mod parent_delete_tests; + +#[cfg(test)] +mod insert_count_tests { + use serde_json::json; + + use crate::Session; + + /// A count that is given but is not a number of pages used to insert one page anyway: + /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped + /// to 9999. A position past the last page appends, which is deliberate. + #[test] + fn a_page_count_insert_pages_cannot_mean_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap(); + assert_eq!(total(&mut s), 3, "one page added"); + + for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] { + let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string(); + assert!(e.contains("1 to 9999 pages"), "{params}: {e}"); + } + // Nothing was inserted by any of those. + assert_eq!(s.doc().unwrap().doc.page_count(), 3); + // A position past the last page still appends, which `insert_pages_bounds.rs` pins. + let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap(); + assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4))); + // The positions it does have still work: after the first page of three. + let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap(); + assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6))); + } +} From c212c89bab697768c9839292cb3d2e098f474dd3 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 17:00:05 +0500 Subject: [PATCH 053/117] A text anchor's name is the document's, so it is not reused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `anchor.create` took any name, including one another anchor already had, so a document could hold two anchors called "Photo 1". Every reference to the name then means whichever the search finds first: `xref.insert {"anchor": "Photo 1"}` and `hyperlink.create {"anchor": "Photo 1"}` take the first, and an export writes both destinations under the one name. A name nobody can resolve is not a name. A name already in the document is a usage error now, naming it and where to look: "a text anchor named `Photo 1` is already in this document (anchor.list names them)". A name that is empty or only spaces is refused too — a cross-reference built from `` would show nothing. Case still distinguishes names, as it does elsewhere in the document, and nothing about existing anchors changes: the check is on creation. The test covers the same name in the same story and in another one, the empty names, that only one anchor exists afterwards, and that another name and a name differing in case are both fine. Without the check the duplicate is created again. Not from a ticket: found probing `anchor.create`. --- crates/engine/src/cmd/xref.rs | 48 ++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index d462f3d1b..446148095 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "New Hyperlink Destination…", ["Type", "Hyperlinks & Cross-References"], None, - "{name} — a text anchor at the insertion point", + "{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has", in_story_text, create_anchor ), @@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result { fn create_anchor(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string(); + if name.trim().is_empty() { + return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)")); + } + // Two anchors of the same name made every reference to it ambiguous: `xref.insert` and + // `hyperlink.create` take the first they find, and a file writes both under one name. + if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) { + return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)"))); + } s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?; let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; @@ -215,6 +223,44 @@ mod tests { assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } + /// Two text anchors of the same name made every reference to it ambiguous: `xref.insert + /// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an + /// exported file writes both destinations under one name. + #[test] + fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap(); + let select = |s: &mut Session, story: serde_json::Value, from: usize| { + s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap(); + }; + select(&mut s, a["story"].clone(), 0); + s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap(); + + // The same name again, in the same story and in another one. + select(&mut s, a["story"].clone(), 10); + let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string(); + assert!(e.contains("already in this document"), "{e}"); + let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0; + let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap(); + select(&mut s, b["story"].clone(), 0); + assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either"); + // A name of spaces is not a name. + assert!(s.execute("anchor.create", &json!({"name": " "})).is_err()); + assert!(s.execute("anchor.create", &json!({"name": ""})).is_err()); + + // One anchor, so a reference by name can only mean that one. + let anchors = s.execute("anchor.list", &json!({})).unwrap(); + assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}"); + // Another name is fine. + s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap(); + assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + // The check is on the name, not on where it is: a name that differs only in case is a + // different name, as it is everywhere else in the document. + select(&mut s, b["story"].clone(), 0); + s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name"); + } + #[test] fn cross_reference_into_a_story_with_a_running_header_composes() { let mut s = Session::new(); From 80cf719555d9115ee6a130a8b4482b17a6b9ee04 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 17:12:15 +0500 Subject: [PATCH 054/117] A type size outside 0.1 to 1296 pt is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing checked type size. `type.char {"size": 0}` set type with no size at all, a negative size went in the same way, and 1e9 pt asked the composer for a line a million times the height of the page — all of it silent, and all of it saved to the document. The range is the one this engine already applies to a ruby annotation's size, 0.1 to 1296 pt, which is InDesign's: "size 0: 0.1 to 1296 pt". It sits in `validate_char_fill`'s place in the chain, so every command that takes character attributes gets it: `type.char`, `find.change`, the four `style.character` / `style.paragraph` create and edit commands, and the two table paths that already validated a fill. The test covers five refused sizes, the three in range including both ends, and two of the style commands. Without the check, `{"size": 0}` applies again. Not from a ticket: found probing `type.char`. --- crates/engine/src/cmd/text.rs | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 12bd540b7..dbfbcfe15 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result { /// A character fill must name a swatch of the document. pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + validate_char_size(attrs, command_id)?; match &attrs.fill { Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"), None => Ok(()), } } +/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's +/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative +/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page. +pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> { + match attrs.size { + Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => { + Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt"))) + } + _ => Ok(()), + } +} + +/// Type size limits (InDesign's, and the ones `type.ruby` already uses). +pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; +pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; + /// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that /// line, and so the one a line-affecting attribute like leading can land on right away instead of /// waiting for the next typed character (otherwise an empty line could never get its own leading). @@ -3911,3 +3928,42 @@ mod pick_up_format_tests { let _ = ItemId(rect); } } + +#[cfg(test)] +mod size_bounds_tests { + use serde_json::json; + + use crate::Session; + + /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative + /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times + /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt. + #[test] + fn a_type_size_outside_the_range_is_refused() { + 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(); + let select = |s: &mut Session| { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + }; + for size in [0.0, -12.0, 0.05, 1297.0, 1e9] { + select(&mut s); + let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string(); + assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}"); + } + // The sizes in range still apply, including the ends. + for size in [0.1, 12.0, 1296.0] { + select(&mut s); + s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size)); + } + // And the styles that carry character attributes are checked the same way. + for (command, params) in [ + ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})), + ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})), + ] { + let e = s.execute(command, ¶ms).expect_err("refused").to_string(); + assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); + } + } +} From d2bdb0866fb73cf9aac012dc4c08a8854ae2b92e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:26:43 -0400 Subject: [PATCH 055/117] Paste: other apps' text goes into a text caret after copying objects (#1344) * Paste: other apps' text goes into a text caret even after copying objects (#212) * Paste: text that matches a copied frame still pastes; groups with text frames aren't anchored (#212) --- crates/engine/src/cmd/anchored.rs | 2 +- crates/engine/src/cmd/edit.rs | 109 ++++++++++++++++++++++++++++-- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/object.rs | 3 +- crates/ui-egui/src/lib.rs | 31 +-------- 5 files changed, 108 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index dc7127459..6d09f89dd 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { } /// Text frames can't be anchored yet (their stories need frames on a spread). -fn check_item(it: &Item) -> Result<()> { +pub(crate) fn check_item(it: &Item) -> Result<()> { let mut ok = true; it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_))); if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e5a2d2296..fb8848a90 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result> { Ok(Some(text)) } +/// Plain text for the system clipboard after copying objects: the copied text frames' stories, +/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the +/// copied objects", not as text. +pub fn copied_objects_text(s: &Session) -> String { + let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() }; + let mut stories = Vec::new(); + for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() { + it.walk(&mut |i| { + if let Some(tf) = i.text_frame() + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + }); + } + let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::>().join("\n"); + if text.is_empty() { '\u{FFFC}'.to_string() } else { text } +} + +/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and +/// it is their placeholder, so Paste stands for the copied objects. +fn is_objects_placeholder(s: &Session, text: &str) -> bool { + s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n") +} + /// Paste text at the insertion point: the formatted copy when the system clipboard still holds /// what was copied here (or `formatted` is forced), else plain text in the insertion format. +/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their +/// placeholder; other text was copied in another app since. Objects that can't be anchored (text +/// frames, into a table cell) paste as their placeholder text. fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?; let clip = s.text_clipboard.clone(); - // Items copied last (no text copied since) paste into the text as anchored objects. - if formatted - && clip.is_none() + let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x)); + if own && t.cell.is_none() && let Some(items) = s.clipboard.clone() { let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default(); - if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) { + if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) { return s.edit(|d, sel| { let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); st.delete(r.start.min(st.len())..r.end.min(st.len())); - let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?; + // Fresh ids, story and asset copies for every paste, as on the page. + let mut stories = std::collections::HashMap::new(); + let fresh: Vec = list + .iter() + .map(|i| { + let mut i = i.clone(); + super::object::renumber(d, &items, &mut i, &mut stories); + if d.layer(i.layer).is_none() { + i.layer = d.default_layer(); + } + i + }) + .collect(); + let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?; sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "anchored": list.len()})) }); @@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< Ok(json!({"pos": end, "formatted": true})) }); } + // U+FFFC stands for objects with no text. + let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text }; let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; let text = outside_text(&text)?; s.execute("text.insert", &json!({"text": text, "raw": true})) @@ -327,7 +369,8 @@ pub fn specs() -> Vec { if s.doc()?.selection.text.is_some() { return paste_text(s, text.map(str::to_string), true); } - if let Some(text) = text { + // The placeholder of an object copy pastes the copied objects. + if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) { // What our own text copy put on the clipboard keeps its formatting. let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); return paste_text_frame(s, p, text, slice.as_deref()); @@ -538,6 +581,60 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). + #[test] + fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let story = frame(&mut s, "ab"); + let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let marker = copied_objects_text(&s); + s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); + // What the object copy put on the system clipboard still pastes the objects. + assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); + // With no insertion point too: the rectangle, not a text frame holding the marker. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); + let id = ItemId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + // Paste without Formatting inserts the text given; empty text is no text. + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); + } + + /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. + #[test] + fn copied_text_frames_paste_at_a_caret_as_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let target = frame(&mut s, "ab"); + let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"text": text})).unwrap() + }; + let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); + // The same text copied in another app, and the system clipboard read back as absent. + let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); + assert!(r.get("anchored").is_none(), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); + // A group with a text frame inside, its own placeholder on the system clipboard. + let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); + let r = copy_and_paste(&mut s, json!([group]), Value::Null); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn outside_text_never_pastes_markers() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a4d5413bb..1d6294ea6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod endnotes; mod export; mod file; pub(crate) mod find; +pub use edit::copied_objects_text; pub use find::same_find_domain; pub(crate) mod flex; mod fonts; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..4ccb5c823 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId { dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc())) } -fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) { +/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses. +pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) { it.id = ItemId(dst.alloc()); let my_id = it.id; if let Some(m) = it.media.as_mut() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3674b3e81..0c155851f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -810,7 +810,7 @@ impl DesignApp { Some(t.to_string()) } None if !self.native_menu => { - self.object_clip_text = Some(self.copied_objects_text()); + self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session)); self.object_clip_text.clone() } None => { @@ -910,35 +910,6 @@ impl DesignApp { params } - /// Plain text for the system clipboard after copying objects: the copied text frames' stories, - /// or U+FFFC (object replacement character) when none has text. - fn copied_objects_text(&self) -> String { - let mut stories = Vec::new(); - if let Some(clip) = &self.session.clipboard - && let Some(sp) = clip.spreads.first() - { - for it in &sp.items { - it.walk(&mut |i| { - if let Some(tf) = i.text_frame() - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - }); - } - } - let text = stories - .iter() - .filter_map(|id| self.session.clipboard.as_ref()?.story(*id)) - .map(|st| { - st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::() - }) - .filter(|t| !t.is_empty()) - .collect::>() - .join("\n"); - if text.is_empty() { '\u{FFFC}'.to_string() } else { text } - } - /// Handle requests produced by tools/commands (dialogs, view changes, file pickers). pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); From 1500b444b57a20d7d3fa7194faab94f853788bee Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:26:51 -0400 Subject: [PATCH 056/117] Hidden characters: trailing spaces show their dots before the paragraph mark (#1345) * Hidden characters: trailing spaces show their dots before the paragraph mark (#594) * Hidden characters: skip glyphs whose offset isn't a character boundary --- crates/ui-egui/src/canvas.rs | 77 ++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9868d4d8b..ae6394bbd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2716,33 +2716,47 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc for l in &ft.lines { let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; let font = egui::FontId::proportional(size); - for g in &l.glyphs { - if g.len == 0 { - continue; - } - let ch = story.text[g.byte.min(story.text.len())..].chars().next().unwrap_or(' '); - let mark = match ch { - ' ' => "·", - '\t' => "»", - '\u{2028}' => "¬", - '\u{A0}' => "°", - _ => continue, - }; - let p = xf.to_screen(m * Point::new(g.x + if ch == ' ' { g.adv / 2.0 } else { 0.0 }, l.baseline)); - let align = if ch == ' ' { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + for (x, mark, centered) in hidden_marks(&story.text, l) { + let p = xf.to_screen(m * Point::new(x, l.baseline)); + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; painter.text(p, align, mark, font.clone(), col); } - if l.last_in_para { - let end = l.range.end; - let mark = if end >= story.text.len() { "#" } else { "¶" }; - let p = xf.to_screen(m * Point::new(l.end_x + 1.0, l.baseline)); - painter.text(p, egui::Align2::LEFT_BOTTOM, mark, font.clone(), col); - } } } } } +/// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and +/// whether the mark is centered on x (spaces) or starts at it. +fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { + let mut out = Vec::new(); + for g in &l.glyphs { + if g.len == 0 { + continue; + } + let Some(ch) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue }; + let mark = match ch { + ' ' => "·", + '\t' => "»", + '\u{2028}' => "¬", + '\u{A0}' => "°", + _ => continue, + }; + if ch == ' ' { + out.push((g.x + g.adv / 2.0, mark, true)); + } else { + out.push((g.x, mark, false)); + } + } + if l.last_in_para { + // The spaces trimmed off the line's end sit past `end_x`; the paragraph mark follows them. + let end = l.glyphs.iter().filter(|g| g.len > 0 && !g.visible && g.x >= l.end_x - 1e-9).map(|g| g.x + g.adv).fold(l.end_x, f64::max); + let mark = if l.range.end >= text.len() { "#" } else { "¶" }; + out.push((end + 1.0, mark, false)); + } + out +} + /// View › Flattener Preview: red over every object that involves transparency (opacity, blend /// modes, effects, knockout) — what flattening or export rasterising affects. fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { @@ -5560,6 +5574,29 @@ mod tests { assert_eq!(size, 18.0, "{key:?} applies the typed size"); } } + + #[test] + fn trailing_spaces_show_dots_before_the_paragraph_mark() { + let text = "ab \ncd "; + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let rect = DRect::new(0.0, 0.0, 300.0, 100.0); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, Default::default()).unwrap(); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let lines: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).collect(); + assert_eq!(lines.len(), 2); + for (l, end_mark) in lines.iter().zip(["¶", "#"]) { + let marks = hidden_marks(text, l); + let dots: Vec = marks.iter().filter(|m| m.1 == "·").map(|m| m.0).collect(); + assert_eq!(dots.len(), 2, "a dot for each trailing space: {marks:?}"); + let &(para_x, mark, _) = marks.last().unwrap(); + assert_eq!(mark, end_mark); + // The dots sit past the last letter, and the mark comes after both spaces. + let space = l.glyphs.iter().rev().find(|g| g.len > 0).unwrap(); + assert!(dots.iter().all(|&x| x > l.end_x && x < para_x), "{dots:?} then {mark} at {para_x}"); + assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); + } + } } #[cfg(test)] From 291b39a1f30ee7b2df53dab6c18cc78e141468ac Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:27:00 -0400 Subject: [PATCH 057/117] Edit: the Paste in Place shortcut pastes in place (#1346) * Edit: the Paste in Place shortcut pastes in place * Edit: the Paste in Place chord handling is native-only (web gets the key press) --- crates/ui-egui/src/canvas.rs | 71 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 14 +++++++ 2 files changed, 85 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ae6394bbd..318f82388 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1583,6 +1583,21 @@ fn field_focused_id(ctx: &egui::Context) -> egui::Id { pub fn begin_frame(ctx: &egui::Context) { let (id, focused) = (field_focused_id(ctx), ctx.text_edit_focused()); ctx.data_mut(|d| d.insert_temp(id, focused)); + #[cfg(not(target_arch = "wasm32"))] + { + let now = ctx.input(|i| i.modifiers); + // The modifiers last frame ended with are the ones this frame's events began with. + ctx.data_mut(|d| { + let (_, end) = d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id()).unwrap_or((now, now)); + d.insert_temp(modifiers_id(), (end, now)); + }); + } +} + +/// (held when this frame's events began, held after them). +#[cfg(not(target_arch = "wasm32"))] +fn modifiers_id() -> egui::Id { + egui::Id::new("canvas-frame-modifiers") } /// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin). @@ -2546,7 +2561,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // text must not delete the character before it). let composing_at_start = app.session.tool_composing(); let evs = ui.input(|i| i.events.clone()); + // Native only: egui-winit sends a ⌘+V chord as a paste without its key press, so the modifiers + // held at the paste tell Paste in Place from Paste. The web also gets the key press, and the + // shortcut table has already run the command. A chord's modifiers may be released in the same + // frame, hence the modifiers held at each event. + #[cfg(not(target_arch = "wasm32"))] + let mut held = + ui.data(|d| d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id())).map_or_else(|| ui.input(|i| i.modifiers), |(start, _)| start); for e in evs { + #[cfg(not(target_arch = "wasm32"))] + let chord = { + if let egui::Event::ModifiersChanged(m) = &e { + held = *m; + } + if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, held) } else { None } + }; let composing = composing_at_start || app.session.tool_composing(); match e { egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi), @@ -2568,6 +2597,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; let _ = app.run(id, json!({})); } + // Paste in Place's chord (⌘⌥⇧V unless rebound) arrives as a paste too. + #[cfg(not(target_arch = "wasm32"))] + egui::Event::Paste(_) if chord.is_some() => { + if let Some(id) = chord + && crate::menus::enabled(app, id) + { + crate::menus::activate(app, id, &serde_json::Value::Null); + } + } // Without a native menu bar the copy and paste keys arrive only as these events. egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => { let (id, params) = match e { @@ -4278,6 +4316,39 @@ mod tests { assert!((it.bounds().center() - centre).hypot() < 0.01, "{:?} vs {centre:?}", it.bounds()); } + #[test] + fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() { + 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, 200]})).unwrap()["id"].as_u64().unwrap(); + let original = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + app.run("edit.copy", json!({})).unwrap(); + let clip = app.object_clip_text.clone().expect("the copy's placeholder text"); + let mut h = harness(app); + let paste = |h: &mut Harness<'static, DesignApp>, modifiers: egui::Modifiers| { + // As egui-winit delivers ⌃⌥⇧V: the chord's paste and the V release, no V press; here the + // modifiers are released within the same frame. + let release = egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers }; + let none = egui::Event::ModifiersChanged(egui::Modifiers::NONE); + h.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), egui::Event::Paste(clip.clone()), release, none]); + h.run_steps(2); + let st = h.state().session.doc().unwrap(); + let pasted = *st.selection.items.first().expect("the copy is selected"); + assert_ne!(pasted.0, id); + st.doc.item(pasted).unwrap().bounds() + }; + let linux = egui::Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true }; + assert_eq!(paste(&mut h, linux), original, "Paste in Place"); + let moved = paste(&mut h, egui::Modifiers::COMMAND); + assert!((moved.x0 - original.x0 - 12.0).abs() < 1e-9 && (moved.y0 - original.y0 - 12.0).abs() < 1e-9, "{moved:?}"); + // The modifiers went down in an earlier frame than the paste. + h.event_modifiers(egui::Event::Paste(clip.clone()), linux); + h.run_steps(2); + let st = h.state().session.doc().unwrap(); + let pasted = *st.selection.items.first().expect("the copy is selected"); + assert_eq!(st.doc.item(pasted).unwrap().bounds(), original); + } + #[test] fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() { let services = crate::Services { clipboard_text: Some(Box::new(|| Some(ART.to_string()))), ..Default::default() }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb43f8cb9..af2a91fc3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3050,6 +3050,20 @@ fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui matches.then_some(k == key && m.shift == modifiers.shift) } +/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into +/// `Event::Paste` with the clipboard's text and drops the key press, so the shortcut table never +/// sees Paste in Place's chord; the modifiers held when it went down tell them apart. `None` when +/// the chord is another one, or the native menu's accelerator takes it. +#[cfg(not(target_arch = "wasm32"))] +pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Option<&'static str> { + let id = "edit.pasteInPlace"; + if app.native_menu && !app.ui.shortcuts.contains_key(id) { + return None; + } + let (m, k) = shortcut_of(app, id).as_deref().and_then(parse_shortcut)?; + (k == egui::Key::V && key_matches(m, k, egui::Key::V, modifiers) == Some(true)).then_some(id) +} + /// Global keyboard shortcuts: menu commands and single-key tool shortcuts. pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { // Only a focused text field takes the keys: the canvas (or a button) having focus after a click From 8f98f3dc8bfac09d69f949c39bf97f0861621b6c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:28:11 -0400 Subject: [PATCH 058/117] Keyboard Shortcuts: import an InDesign shortcut set (#1296) * #1180: import an InDesign keyboard shortcut set Edit > Keyboard Shortcuts gets Import InDesign Set..., which reads the .txt that InDesign's Show Set writes (window.importShortcuts {path|text}). Matched commands take the set's Default shortcut, or lose theirs when every occurrence says [none defined]; other commands give up keys the set takes. The report lists changes, cleared conflicts, and the items that could not be matched or read. Tool keys stay fixed. * #1180: read the set file through the checked reader * #1180: when two items want the same keys, the later one takes them --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 2 + apps/designcraft/src/main.rs | 1 + crates/ui-egui/src/dialogs.rs | 13 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 19 +- crates/ui-egui/src/shortcut_import.rs | 611 ++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 651 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/shortcut_import.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..d3ef17e47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe0f4a7fa..27d236a2f 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if request.purpose == "shortcuts" { + rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"]) } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index b56878176..13416e4e0 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { match purpose { "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], + "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }], "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..6b60b1296 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if !msg.is_empty() { ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60))); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { - let _ = app.run("window.resetShortcuts", json!({})); - } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { + let _ = app.run("window.resetShortcuts", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked() + && let Some(msg) = crate::shortcut_import::pick_and_import(app) + { + d.fields.insert("message".into(), json!(msg)); + } + }); } /// File › Print, with the printers the system knows. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0c155851f..18fa402a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod prefs; pub mod render_worker; mod rtl; mod section_options; +mod shortcut_import; pub mod story_editor; pub mod taskbar; pub mod theme; @@ -1033,6 +1034,10 @@ impl DesignApp { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); for file in files { + if file.request.purpose == "shortcuts" { + shortcut_import::import_delivered(self, &file.bytes); + continue; + } if self.ui.dialog.is_some() { self.status( crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again") diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index af2a91fc3..2a271324a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"), ("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"), ("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"), + ( + "window.importShortcuts", + "Import InDesign Shortcut Set", + None, + "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", + ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ( @@ -1705,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) { + (Some(text), _) => text.to_string(), + (None, Some(path)) => match crate::shortcut_import::read_file(path) { + Ok(text) => text, + Err(e) => return Some(Err(e)), + }, + (None, None) => return Some(Err("give the set's path or text".into())), + }; + crate::shortcut_import::import(app, &text) + } "view.snapPreferences" => { let b = |k: &str| p.get(k).and_then(Value::as_bool); let ui = &mut app.ui; @@ -3009,7 +3026,7 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } -fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { +pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { let mut m = egui::Modifiers::NONE; let mut key = None; for part in sc.split('+').filter(|s| !s.is_empty()) { diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs new file mode 100644 index 000000000..9ccbef1b9 --- /dev/null +++ b/crates/ui-egui/src/shortcut_import.rs @@ -0,0 +1,611 @@ +//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text +//! (Show Set): `window.importShortcuts`. +//! +//! The file lists items under `Product area : ` headers, one per line: +//! ` --- [none defined]` or ` --- : , : `. Item names +//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no +//! shortcut contexts); a type command with no Default entry takes its Text entry. + +use std::collections::{BTreeSet, HashMap}; + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::menus::{self, Item}; + +/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16). +pub const MAX_BYTES: usize = 8 << 20; +/// Entries read per item: a real item has one or two per context. +const MAX_ENTRIES: usize = 64; + +/// One `: ` entry of an item. +struct Entry { + context: String, + keys: String, +} + +struct SetItem { + area: String, + name: String, + entries: Vec, +} + +struct ParsedSet { + name: String, + items: Vec, +} + +/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or +/// by the zero bytes of ASCII text without one). +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20)); + } + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + Ok(match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, true), + [0xFE, 0xFF, rest @ ..] => utf16(rest, false), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + [a, 0, ..] if *a != 0 => utf16(bytes, true), + [0, b, ..] if *b != 0 => utf16(bytes, false), + _ => String::from_utf8_lossy(bytes).into_owned(), + }) +} + +/// Reads and decodes a set file, refusing one over [`MAX_BYTES`]. +pub fn read_file(path: &str) -> Result { + let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX); + let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?; + decode(&bytes) +} + +/// `Label : value` with any spacing around the colon, when the line starts with `label`. +fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> { + let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?; + rest.trim_start().strip_prefix(':').map(str::trim) +} + +fn parse(text: &str) -> Result { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let mut set = ParsedSet { name: String::new(), items: Vec::new() }; + let mut area = String::new(); + let mut header_seen = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + let trimmed = line.trim(); + if let Some(name) = header(trimmed, "Shortcut Set") { + set.name = name.to_string(); + header_seen = true; + } else if let Some(a) = header(trimmed, "Product area") { + area = a.to_string(); + header_seen = true; + } else if let Some((name, keys)) = trimmed.split_once(" --- ") { + let name = name.trim(); + if name.is_empty() { + continue; + } + set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) }); + } + } + if set.items.is_empty() && !header_seen { + return Err("not a shortcut set: no `Product area` or ` --- ` lines".into()); + } + Ok(set) +} + +/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a +/// piece without `: ` belongs to the entry before it. +fn entries(keys: &str) -> Vec { + let keys = keys.trim(); + if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() { + return Vec::new(); + } + let mut pieces: Vec = Vec::new(); + for piece in keys.split(", ") { + match pieces.last_mut() { + Some(last) if !piece.contains(": ") => { + last.push_str(", "); + last.push_str(piece); + } + _ => pieces.push(piece.to_string()), + } + } + pieces + .into_iter() + .take(MAX_ENTRIES) + .filter_map(|p| { + let (context, keys) = p.split_once(": ")?; + Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() }) + }) + .collect() +} + +/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the +/// command modifier. +fn is_mac_set(set: &ParsedSet) -> bool { + set.items.iter().flat_map(|i| &i.entries).any(|e| { + let k = e.keys.to_lowercase(); + k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥') + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mod { + Command, + Alt, + Shift, + Control, +} + +/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a +/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set. +fn read_keys(keys: &str, mac: bool) -> Result { + const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)]; + const WORDS: &[(&str, Mod)] = &[ + ("command", Mod::Command), + ("cmd", Mod::Command), + ("option", Mod::Alt), + ("opt", Mod::Alt), + ("alt", Mod::Alt), + ("shift", Mod::Shift), + ("control", Mod::Control), + ("ctrl", Mod::Control), + ]; + let mut m = egui::Modifiers::NONE; + let mut set = |md: Mod| match md { + Mod::Command => m.command = true, + Mod::Alt => m.alt = true, + Mod::Shift => m.shift = true, + Mod::Control if mac => m.ctrl = true, + Mod::Control => m.command = true, + }; + let mut rest = keys.trim(); + 'strip: loop { + for (sym, md) in SYMBOLS { + if let Some(r) = rest.strip_prefix(sym) { + set(*md); + rest = r.strip_prefix('+').unwrap_or(r).trim_start(); + continue 'strip; + } + } + for (word, md) in WORDS { + let Some(head) = rest.get(..word.len()) else { continue }; + let Some(after) = rest.get(word.len()..) else { continue }; + // The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name. + let mut chars = after.chars(); + if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() { + set(*md); + rest = chars.as_str().trim_start(); + continue 'strip; + } + } + break; + } + if m.ctrl { + return Err("Control-key shortcuts are not supported"); + } + let key = key_named(rest.trim()).ok_or("unknown key")?; + let sc = menus::shortcut_string(m, key); + // Only a string the shortcut dispatcher reads back as these keys is imported. + match menus::parse_shortcut(&sc) { + Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc), + _ => Err("unknown key"), + } +} + +fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool { + a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl +} + +/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive. +fn key_named(name: &str) -> Option { + use egui::Key; + if name.is_empty() { + return None; + } + let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase(); + // The numeric keypad: egui doesn't tell its keys apart from the main ones. + let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed); + let key = match n { + "up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp, + "down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown, + "left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft, + "right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight, + "pageup" | "pgup" | "⇞" => Key::PageUp, + "pagedown" | "pgdn" | "⇟" => Key::PageDown, + "home" | "↖" => Key::Home, + "end" | "↘" => Key::End, + "esc" | "escape" | "⎋" => Key::Escape, + "tab" | "⇥" => Key::Tab, + "space" | "spacebar" => Key::Space, + "enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter, + "backspace" | "⌫" => Key::Backspace, + "delete" | "del" | "forwarddelete" | "⌦" => Key::Delete, + "insert" | "ins" | "help" => Key::Insert, + _ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)), + }; + Some(key) +} + +/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and +/// no Show/Hide in front (InDesign names a toggle by what choosing it does now). +fn norm(s: &str) -> String { + let s = s.replace('…', "").replace("...", "").replace('&', ""); + let s = s.split_whitespace().collect::>().join(" ").to_lowercase(); + ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string) +} + +/// DesignCraft's commands by menu path and label. +#[derive(Default)] +struct Index { + /// `menu\nsubmenu\nlabel` → ids, in the menu bar. + path: HashMap>, + /// The same from the engine commands' own menu placement, which a UI command (the one with + /// the dialog) may stand in for in the menu bar. + engine_path: HashMap>, + /// (menu, label) → ids, at any depth of that menu. + in_menu: HashMap<(String, String), BTreeSet>, + /// label → ids, anywhere. + label: HashMap>, +} + +impl Index { + fn build() -> Self { + fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { + for it in items { + match it { + Item::Sub(name, children) => { + path.push(norm(name)); + walk(ix, children, path); + path.pop(); + } + // A shortcut runs the command without parameters; a fixed-parameter variant can't hold one. + Item::Cmd { label, id, params, .. } if params.is_null() => { + let label = norm(label); + let top = path.first().cloned().unwrap_or_default(); + add_path(&mut ix.path, path, &label, id); + ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone()); + ix.label.entry(label).or_default().insert(id.clone()); + } + _ => {} + } + } + } + let mut ix = Index::default(); + for (menu, entries) in menus::menu_tree() { + walk(&mut ix, &entries, &mut vec![norm(menu)]); + } + for c in designcraft_engine::command_specs() { + let label = norm(c.label); + if !c.menu.is_empty() { + let path: Vec = c.menu.iter().map(|m| norm(m)).collect(); + add_path(&mut ix.engine_path, &path, &label, c.id); + } + ix.label.entry(label).or_default().insert(c.id.to_string()); + } + for (id, label, _, _) in menus::UI_COMMANDS { + ix.label.entry(norm(label)).or_default().insert((*id).to_string()); + } + ix + } + + /// The command an item names, trying the most specific match first. Matches by label alone + /// only count when exactly one command has that label. + fn find(&self, area: &str, name: &str) -> Option { + let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); + let segments: Vec = name.split(": ").map(norm).collect(); + let last = segments.last().cloned().unwrap_or_default(); + let area = norm(area); + if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) { + let mut key = menu.to_string(); + for s in &segments { + key.push('\n'); + key.push_str(s); + } + if let Some(id) = one(self.path.get(&key)) { + return Some(id); + } + if segments.len() > 1 + && let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" ")))) + { + return Some(id); + } + if let Some(id) = one(self.engine_path.get(&key)) { + return Some(id); + } + if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) { + return Some(id); + } + // Menus are arranged differently: the item's own label, if no other command has it. + return one(self.label.get(&last)); + } + if segments.len() == 1 { one(self.label.get(&last)) } else { None } + } +} + +fn add_path(map: &mut HashMap>, path: &[String], label: &str, id: &str) { + let mut key = path.join("\n"); + key.push('\n'); + key.push_str(label); + map.entry(key).or_default().insert(id.to_string()); +} + +/// What the set says about one DesignCraft command, over every item matched to it. +struct Decision { + id: String, + item: String, + /// The first readable shortcut. + shortcut: Option, + /// Every matched item said `[none defined]`. + all_none: bool, +} + +fn command_label(id: &str) -> String { + menus::ui_label(id) + .map(|(l, _)| l.to_string()) + .or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string())) + .unwrap_or_else(|| id.to_string()) +} + +fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> { + menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k)) +} + +/// Imports a set: every matched command takes the set's shortcut (or none), another command +/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report. +pub fn import(app: &mut DesignApp, text: &str) -> Result { + if text.len() > MAX_BYTES { + return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); + } + let set = parse(text)?; + let mac = is_mac_set(&set); + let ix = Index::build(); + let mut decisions: Vec = Vec::new(); + let mut unmatched = Vec::new(); + let mut unmatched_without_shortcut = 0usize; + let mut unreadable = Vec::new(); + for item in &set.items { + let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) }; + let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", "); + let Some(id) = ix.find(&item.area, &item.name) else { + if item.entries.is_empty() { + unmatched_without_shortcut += 1; + } else { + let tool = norm(&item.area) == "tools" + && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name)); + let mut row = json!({"item": shown, "keys": all_keys}); + if tool { + row["reason"] = json!("tool keys are fixed"); + } + unmatched.push(row); + } + continue; + }; + let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); + let text_command = id.starts_with("type.") || id.starts_with("text."); + let mut considered = in_context("Default"); + if considered.is_empty() && text_command { + considered = in_context("Text"); + } + let mut read = None; + let mut why = ""; + for e in &considered { + match read_keys(&e.keys, mac) { + Ok(sc) => { + read = Some(sc); + break; + } + Err(w) => why = w, + } + } + if read.is_none() && !considered.is_empty() { + unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); + } + let pos = match decisions.iter().position(|d| d.id == id) { + Some(p) => p, + None => { + decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); + decisions.len() - 1 + } + }; + let Some(d) = decisions.get_mut(pos) else { continue }; + d.all_none &= item.entries.is_empty(); + if d.shortcut.is_none() + && let Some(sc) = read + { + d.shortcut = Some(sc); + d.item = shown; + } + } + + let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); + let mut imported = 0usize; + let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); + let mut skipped = Vec::new(); + let mut decided: BTreeSet = BTreeSet::new(); + // When two items in the set want the same keys, the later one takes them (the rule shared by + // the Craft apps' importers). + let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new(); + for (i, d) in decisions.iter().enumerate() { + if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) { + winner.insert(keys, i); + } + } + for (i, d) in decisions.iter().enumerate() { + match &d.shortcut { + Some(sc) => { + let Some(keys) = keys_of(sc) else { continue }; + if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) { + skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); + continue; + } + taken.push((keys, d.id.clone())); + if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { + app.ui.shortcuts.remove(&d.id); + } else { + app.ui.shortcuts.insert(d.id.clone(), sc.clone()); + } + } + None if d.all_none => { + if menus::default_shortcut(&d.id).is_none() { + app.ui.shortcuts.remove(&d.id); + } else { + app.ui.shortcuts.insert(d.id.clone(), String::new()); + } + } + None => continue, + } + imported += 1; + decided.insert(d.id.clone()); + } + let mut conflicts = Vec::new(); + for (id, sc) in menus::effective_shortcuts(app) { + if decided.contains(&id) { + continue; + } + // A command sharing its default keys with the one taking them (file.new and the New + // Document dialog) keeps them: the dispatcher already prefers the other. + let twin = |by: &str| { + let default = |id: &str| menus::default_shortcut(id).and_then(keys_of); + default(&id).is_some() && default(&id) == default(by) + }; + if let Some(keys) = keys_of(&sc) + && taken.iter().any(|(k, by)| *k == keys && !twin(by)) + { + app.ui.shortcuts.insert(id.clone(), String::new()); + conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); + } + } + let changed: Vec = decisions + .iter() + .filter(|d| decided.contains(&d.id)) + .filter_map(|d| { + let from = before.get(&d.id).cloned().flatten(); + let to = menus::shortcut_of(app, &d.id); + (from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to})) + }) + .collect(); + Ok(json!({ + "set": set.name, + "imported": imported, + "changed": changed, + "conflictsCleared": conflicts, + "skipped": skipped, + "unmatched": unmatched, + "unmatchedWithoutShortcut": unmatched_without_shortcut, + "unreadable": unreadable, + })) +} + +/// One line for the Keyboard Shortcuts dialog about an import's report. +pub fn summary(lang: &str, report: &Value) -> String { + let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string(); + let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string(); + crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.") + .replace("{count}", &n("imported")) + .replace("{set}", report["set"].as_str().unwrap_or_default()) + .replace("{unmatched}", &len("unmatched")) + .replace("{unreadable}", &len("unreadable")) +} + +/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and +/// returns the summary; in a browser the chosen file arrives later through the import inbox. +pub fn pick_and_import(app: &mut DesignApp) -> Option { + if let Some(pick) = app.services.pick_open.as_mut() { + let path = pick("shortcuts")?; + return Some(match app.run("window.importShortcuts", json!({"path": path})) { + Ok(report) => summary(&app.ui.language, &report), + Err(e) => e, + }); + } + let request = app.import_request("shortcuts"); + if let Some(open) = app.services.open_async.as_mut() { + open(request); + } + None +} + +/// A set file chosen in the browser: import it and say how it went in the open Keyboard +/// Shortcuts dialog, or the status bar. +pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) { + let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) { + Ok(report) => summary(&app.ui.language, &report), + Err(e) => e, + }; + match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") { + Some(d) => { + d.fields.insert("message".into(), json!(msg)); + } + None => app.status(msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()) + } + + #[test] + fn imports_a_windows_set_and_reports_what_it_could_not_use() { + let mut app = app(); + // Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling. + menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap(); + let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\ + Product area : Edit Menu\r\n\ + \tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tFrobnicate Everything --- Default: Ctrl+F12\r\n\ + \tNever Heard Of It --- [none defined]\r\n\ + Product area : File Menu\r\n\ + \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\ + \tSave --- Default: Ctrl+Hyperspace\r\n\ + \tRevert --- Default: Shift+Ctrl+F9\r\n\ + \tSave As... --- Default: Shift+Ctrl+F9\r\n\ + garbage line without a separator\r\n"; + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); + assert_eq!(r["set"], "My Set"); + // A submenu item, with Ctrl as the command modifier. + assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K")); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5")); + // `[none defined]` in every occurrence clears a matched command. + assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None); + // The command that held the imported keys loses them. + assert_eq!(menus::shortcut_of(&app, "app.palette"), None); + assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}"); + // Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut. + assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}])); + assert_eq!(r["unmatchedWithoutShortcut"], 1); + assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); + assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); + // Two items want the same keys: the later one takes them. + assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9")); + assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}"); + assert_eq!(r["imported"], 4); + // Reset Shortcuts is the way back. + menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap(); + assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return")); + } + + #[test] + fn hostile_input_returns_a_report_or_an_error() { + let mut app = app(); + for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] { + let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap(); + } + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err()); + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err()); + // UTF-16 with a BOM, and Mac modifier spellings. + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes)); + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap(); + assert_eq!(r["set"], "Mac"); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 507358dec..381498fb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in | PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | | Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | | Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) | -| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) | +| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) | | User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) | | Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c7e0678d7..bb7c5eb80 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -417,7 +417,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). -- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. +- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`. - Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). - Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..b66fb585c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux. | Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) | | IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) | | Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope | -| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | | +| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied | | Quick Apply | ✓ | ✓ | ✓ | | | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | From 9bd85a14b2857113259da4950f30736a1166791a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:30:03 -0400 Subject: [PATCH 059/117] i18n: complete the French interface (#1347) * i18n: complete the French interface (#644) * i18n: French uses gabarit for parent pages; fixes * i18n: French for the guide commands and the shortcut-set import --- crates/ui-egui/src/i18n.rs | 174 +++++++++++++++++++++++---- crates/ui-egui/src/i18n/fr.rs | 220 ++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index acf70ff32..fec123a33 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -139,7 +139,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]), ( "Parents on Top", - ["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"], + ["Übergeordnete Seiten oben", "Gabarits en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"], ), ("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]), ("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]), @@ -150,8 +150,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]), ("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]), ("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]), - ("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), - ("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]), + ("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), + ("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]), ( "Bulleted & Numbered Lists", [ @@ -497,7 +497,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("ICF Top", ["ICF oben", "Haut ICF", "Parte superior ICF", "平均字面の上", "平均字面上"]), ("ICF Bottom", ["ICF unten", "Bas ICF", "Parte inferior ICF", "平均字面の下", "平均字面下"]), - ("Gyoudori:", ["Gyoudori:", "Gyoudori:", "Gyoudori:", "行取り:", "行取:"]), + ("Gyoudori:", ["Gyoudori:", "Gyoudori :", "Gyoudori:", "行取り:", "行取:"]), ( "Use Paragraph Gyoudori", ["Absatz-Gyoudori verwenden", "Utiliser le gyoudori de paragraphe", "Usar gyoudori de párrafo", "段落行取りを使用", "使用段落行取"], @@ -734,7 +734,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), - ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), + ("Essentials Classic", ["Essentials Classic", "Les indispensables (classique)", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]), ("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]), ( @@ -873,7 +873,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), - ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ("Parent Pages", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]), ( "Apply Parent to Pages", [ @@ -1388,12 +1388,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Apply None (/)", ["Apply None (/)", "Appliquer Aucun (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]), ( "Default Fill and Stroke", - ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], + ["Default Fill and Stroke", "Fond et contour par défaut", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], ), - ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), + ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Permuter fond et contour", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), ( "Toggle Fill and Stroke", - ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], + ["Toggle Fill and Stroke", "Basculer fond/contour", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], ), ("Reverse the gradient", ["Reverse the gradient", "Inverser le dégradé", "Reverse the gradient", "Reverse the gradient", "反转渐变"]), ("Style source", ["Style source", "Source du style", "Style source", "Style source", "样式源"]), @@ -2061,7 +2061,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ( "Show/Hide Document Grid", - ["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"], + [ + "Show/Hide Document Grid", + "Afficher/Masquer la grille du document", + "Show/Hide Document Grid", + "Show/Hide Document Grid", + "显示/隐藏文档网格", + ], ), ( "Show/Hide Frame Edges", @@ -2713,7 +2719,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Select an object or text to color its fill or stroke.", [ "Select an object or text to color its fill or stroke.", - "Select an object or text to color its fill or stroke.", + "Sélectionnez un objet ou du texte pour colorer son fond ou son contour.", "Select an object or text to color its fill or stroke.", "Select an object or text to color its fill or stroke.", "选择一个对象或文本以设置其填色或描边颜色。", @@ -2922,7 +2928,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cap Height", ["Cap Height", "Hauteur de capitale", "Cap Height", "Cap Height", "大写字母高度"]), ("Leading", ["Leading", "Interligne", "Leading", "Leading", "行距"]), ("x Height", ["x Height", "Hauteur d'x", "x Height", "x Height", "x 字高"]), - ("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]), + ("Em Box Height", ["Em Box Height", "Hauteur du cadratin", "Em Box Height", "Em Box Height", "全角字框高度"]), ("Fixed", ["Fixed", "Fixe", "Fixed", "Fixed", "固定"]), ("Never (continuous)", ["Never (continuous)", "Jamais (continu)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]), ("Page", ["Page", "Page", "Page", "Page", "页面"]), @@ -3505,8 +3511,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Ukrainian", ["Ukrainian", "Ukrainien", "Ukrainian", "Ukrainian", "乌克兰语"]), ("Cell Style", ["Cell Style", "Style de cellule", "Cell Style", "Cell Style", "单元格样式"]), ("Table Style", ["Table Style", "Style de tableau", "Table Style", "Table Style", "表样式"]), - ("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]), - ("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]), + ("Col ←", ["Col ←", "Col. ←", "Col ←", "Col ←", "左侧插入列"]), + ("Col →", ["Col →", "Col. →", "Col →", "Col →", "右侧插入列"]), ("Row ↑", ["Row ↑", "Ligne ↑", "Row ↑", "Row ↑", "上方插入行"]), ("Row ↓", ["Row ↓", "Ligne ↓", "Row ↓", "Row ↓", "下方插入行"]), ("Hide Others", ["Hide Others", "Masquer les autres", "Hide Others", "Hide Others", "隐藏其他图层"]), @@ -3536,7 +3542,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Show Parent Page Overlay", [ "Überlagerung der übergeordneten Seite anzeigen", - "Afficher la superposition de la page parent", + "Afficher la superposition du gabarit", "Mostrar superposición de página principal", "親ページオーバーレイを表示", "显示父页面叠加", @@ -3544,7 +3550,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ( "Controlled by Parent", - ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le gabarit", "Controlado por principal", "親ページで制御", "由父页面控制"], ), ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), ( @@ -4229,8 +4235,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Bullets and Numbering…", ["Aufzählungszeichen und Nummerierung…", "Puces et numérotation…", "Viñetas y numeración…", "箇条書き記号と番号付け…", "项目符号和编号…"], ), - ("List:", ["Liste:", "Liste:", "Lista:", "リスト:", "列表:"]), - ("Mode:", ["Modus:", "Mode:", "Modo:", "モード:", "模式:"]), + ("List:", ["Liste:", "Liste :", "Lista:", "リスト:", "列表:"]), + ("Mode:", ["Modus:", "Mode :", "Modo:", "モード:", "模式:"]), ( "Continue from Previous Number", [ @@ -4717,7 +4723,7 @@ mod tests { // `fr::TABLE` is looked up first: a key there would hide the shared table's French column. let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect(); assert!(both.is_empty(), "French keys also in the shared table: {both:?}"); - let mut keys = std::collections::HashSet::new(); + let mut keys = interface_strings(); for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] { keys.extend(table.iter().map(|(key, _)| *key)); } @@ -4738,6 +4744,121 @@ mod tests { assert_eq!(tr("fr", "Group"), "Associer"); } + /// Interface strings that read the same in French: names, abbreviations, numbering samples, key + /// symbols and words French shares with English. + const SAME_IN_FRENCH: &[&str] = &[ + " OK ", + " (auto)", + "+Bin", + "+LOC", + "A, B, C, D...", + "Angle", + "Articles", + "Bunri-Kinshi", + "Commits", + "Composition", + "Compression", + "Conditions", + "Cyan", + "DesignCraft", + "Direction", + "Distance", + "Document", + "Document…", + "Ellipse", + "Extras", + "Fractions", + "GREP", + "Hindi", + "Horizontal", + "I, II, III, IV...", + "Image", + "Index", + "Interface", + "Justification", + "Justification…", + "Kanji", + "Kenten", + "Lab", + "Ligatures", + "Magenta", + "Maximum", + "Menus", + "Menus…", + "Minimum", + "Mobile", + "Normal", + "Normal (W)", + "Notes", + "OK", + "OpenType", + "Options", + "Orange", + "Orientation", + "PDF/A-2b", + "PDF/X-4", + "Page", + "Pages", + "Pathfinder", + "Picas", + "Portrait", + "Position", + "Radial", + "Rectangle", + "Rensuuji", + "Rotation", + "Ruby", + "Ruby…", + "Satin", + "Saturation", + "Script", + "Scripts", + "Section", + "Serif", + "Shatai", + "Signature", + "Simple", + "Structure", + "Style", + "Styles", + "Tate-Chu-Yoko", + "Triangle", + "Version", + "Vertical", + "Violet", + "Warichu", + "Web", + "a, b, c, d...", + "document", + "i, ii, iii, iv...", + "pages", + "ΔLOC", + "⇧", + "−Bin", + "−LOC", + "⌃", + "⌘", + "⌥", + ]; + + #[test] + fn french_translates_every_interface_string() { + let keys = interface_strings(); + // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships. + let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect(); + untranslated.sort(); + assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}"); + for key in SAME_IN_FRENCH { + assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}"); + assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}"); + } + for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { + assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id); + } + assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection"); + assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer"); + } + #[test] fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() { let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect(); @@ -4937,10 +5058,9 @@ mod tests { assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}"); } - #[test] - fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() { - let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys"); + /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool + /// tables and the list of UI-only labels know them. + fn interface_strings() -> std::collections::HashSet<&'static str> { let mut keys = std::collections::HashSet::new(); keys.extend(TABLE.iter().map(|(key, _)| *key)); for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] { @@ -5033,6 +5153,14 @@ mod tests { "{name}: import cancelled while a dialog is open; choose the file again", "{name}: {count} step(s) done", ]); + keys + } + + #[test] + fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() { + let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect(); + assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys"); + let keys = interface_strings(); // A new English string falls back to English until it is translated, so only stale // entries (keys nothing passes to `tr` any more) fail. let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..681230d84 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -1,57 +1,118 @@ //! French translations of the strings the shared table in `i18n.rs` has no row for, //! written from the English source using ordinary French desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("% of all commits", "% de tous les commits"), + ("(any)", "(quelconque)"), + ("(unchanged)", "(inchangé)"), ("90° CCW", "90° sens antihoraire"), ("90° CW", "90° sens horaire"), + ("About", "À propos"), + ("Activate Panel", "Activer le panneau"), + ("Add Anchor Point Tool", "Outil Ajout de point d'ancrage"), + ("Add Tab", "Ajouter une tabulation"), + ("Align To", "Aligner sur"), ("Alignment", "Alignement"), ("All Documents", "Tous les documents"), ("Alpha Channel", "Couche alpha"), + ("Alternate layout", "Variante de mise en page"), ("Anchored Object Options", "Options d'objet ancré"), + ("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"), + ("Apply Text Attributes (Eyedropper)", "Appliquer les attributs de texte (Pipette)"), ("ArtCraft App Page", "Page de l'application ArtCraft"), ("ArtCraft Website", "Site web d'ArtCraft"), ("Auto Size", "Taille automatique"), + ("Auto Tate-Chu-Yoko", "Tate-Chu-Yoko automatique"), + ("Binary assets added", "Fichiers binaires ajoutés"), + ("Binary assets removed", "Fichiers binaires supprimés"), ("Binding", "Reliure"), + ("Break Thread", "Rompre le chaînage"), ("Bullet Character", "Caractère de puce"), ("CMYK:", "CMJN :"), + ("Cancel Preview", "Annuler l'aperçu"), + ("Cell Number:", "Numéro de mobile :"), + ("Change Panel Layout", "Modifier la disposition des panneaux"), + ("Change Tab", "Modifier la tabulation"), ("Change To", "Remplacer par"), + ("Change fill to:", "Remplacer le fond par :"), + ("Character Style", "Style de caractère"), ("Check Spelling", "Vérifier l'orthographe"), + ("Chinese: Simplified", "Chinois : simplifié"), + ("Chinese: Traditional", "Chinois : traditionnel"), + ("Clear All Tabs", "Effacer toutes les tabulations"), + ("Clear Object Style Overrides", "Effacer les remplacements du style d'objet"), ("Clear Rotation", "Effacer la rotation"), + ("Clipping Path Options…", "Options de masque…"), + ("Close Panel", "Fermer le panneau"), + ("Close Window", "Fermer la fenêtre"), ("Closed Path", "Tracé fermé"), ("Color Blindness – Deuteranopia", "Daltonisme – Deutéranopie"), ("Color Blindness – Protanopia", "Daltonisme – Protanopie"), + ("Color Theme Tool", "Outil Thème de couleur"), + ("Color Theme…", "Thème de couleur…"), ("Colour", "Couleur"), ("Colour Picker", "Sélecteur de couleurs"), ("Column Break", "Saut de colonne"), + ("Commit Preview", "Valider l'aperçu"), ("Community", "Communauté"), + ("Company", "Entreprise"), ("Conditional Text", "Texte conditionnel"), + ("Content Collector Tool", "Outil Collecteur de contenu"), + ("Content Placer Tool", "Outil Placeur de contenu"), + ("Contributors", "Contributeurs"), + ("Convert Direction Point Tool", "Outil Conversion de point directeur"), + ("Convert Point", "Convertir le point"), + ("Convert Rows", "Convertir les lignes"), ("Copyright Symbol", "Symbole du copyright"), ("Corner Options", "Options d'arrondi"), + ("Couldn't write recovery data: {error}", "Impossible d'écrire les données de récupération : {error}"), ("Create Guides", "Créer des repères"), ("Dark", "Foncé"), + ("Data Fields", "Champs de données"), + ("Data Merge Options", "Options de fusion des données"), ("Define Cross-Reference Format", "Définir un format de référence croisée"), ("Define Lists", "Définir des listes"), ("Define Text Variable", "Définir une variable de texte"), + ("Delete Anchor Point Tool", "Outil Suppression de point d'ancrage"), + ("Delete Character Style", "Supprimer le style de caractère"), + ("Delete Document Preset", "Supprimer le paramètre prédéfini de document"), + ("Delete Parent", "Supprimer le gabarit"), ("Delete Type from Path", "Supprimer le texte curviligne"), + ("Deselect Guides", "Désélectionner les repères"), ("DesignCraft Website", "Site web de DesignCraft"), ("DesignCraft on GitHub", "DesignCraft sur GitHub"), + ("DesignCraft page", "Page DesignCraft"), ("Detect Edges", "Détecter les contours"), + ("Direct Selection Tool", "Outil Sélection directe"), ("Discretionary Hyphen", "Trait d'union conditionnel"), + ("Display name", "Nom affiché"), + ("Distribute Rows Evenly", "Répartir les lignes uniformément"), ("Document CMYK", "CMJN du document"), ("Document Endnote Options", "Options de notes de fin de document"), ("Document Footnote Options", "Options de notes de bas de page du document"), ("Document RGB", "RVB du document"), + ("Don't Save", "Ne pas enregistrer"), ("Done", "Terminé"), ("Double Left Quotation Marks", "Guillemets doubles ouvrants"), ("Double Right Quotation Marks", "Guillemets doubles fermants"), + ("Drag Rows or Columns", "Faire glisser des lignes ou des colonnes"), ("Edit/Interface Language", "Édition/Langue de l'interface"), ("Edit/Spelling", "Édition/Orthographe"), ("Edit/Transparency Blend Space", "Édition/Espace de fusion de la transparence"), ("Edit/Transparency Flattener Presets", "Édition/Préréglages d'aplatissement de la transparence"), + ("Ellipse Frame Tool", "Outil Bloc elliptique"), + ("Ellipse Tool", "Outil Ellipse"), ("Ellipsis", "Points de suspension"), ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), + ("Email Address:", "Adresse e-mail :"), + ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), + ( + "Equal fixed heights for the selected rows; cell text may become overset.", + "Hauteurs fixes identiques pour les lignes sélectionnées ; le texte des cellules peut déborder.", + ), + ("Erase Tool", "Outil Gomme"), ("Export EPUB", "Exporter en EPUB"), ("Export EPUB (Fixed Layout)", "Exporter en EPUB (mise en page fixe)"), ("Export EPUB (Reflowable)", "Exporter en EPUB (redistribuable)"), @@ -59,50 +120,99 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Export IDML", "Exporter en IDML"), ("Export PDF (Interactive)", "Exporter en PDF (interactif)"), ("Export Page as PNG", "Exporter la page en PNG"), + ("Export PowerPoint…", "Exporter en PowerPoint…"), ("Export Text", "Exporter le texte"), ("Export XML", "Exporter en XML"), + ( + "Export a PDF, IDML or package to share — no cloud account needed.", + "Exportez un PDF, un fichier IDML ou un assemblage à partager — aucun compte cloud requis.", + ), + ("Extract Color Theme", "Extraire le thème de couleur"), + ("Eyedropper Tool", "Outil Pipette"), ("Figure Space", "Espace chiffre"), ("File/Export", "Fichier/Exporter"), ("Find What", "Rechercher"), + ("Find fill:", "Rechercher le fond :"), + ("First commit", "Premier commit"), ("Flush Space", "Espace sans alinéa"), ("Forced Line Break", "Saut de ligne forcé"), + ("Formatting Affects Text", "La mise en forme agit sur le texte"), + ("Found", "Trouvé"), ("Frame Break", "Saut de bloc"), + ("Free Transform Tool", "Outil Transformation manuelle"), ("GREP", "GREP"), + ("Gap Tool", "Outil Espace"), ("Generate Index", "Générer l'index"), ("Generate Live Caption", "Générer une légende dynamique"), + ("Get Cell Stroke", "Obtenir le contour de cellule"), + ("Get Tabs", "Obtenir les tabulations"), ("Global Light", "Lumière globale"), + ("Grab bag", "Liste"), + ("Gradient Feather Tool", "Outil Contour progressif dégradé"), + ("Gradient Swatch Tool", "Outil Dégradé de nuance"), ("Graphic", "Graphique"), + ("Group with", "Grouper avec"), ("Hair Space", "Espace ultrafine"), + ("Hand Tool", "Outil Main"), ("Hide Guides", "Masquer les repères"), ("High Contrast", "Contraste élevé"), ("High Resolution", "Haute résolution"), ("I, II, III, IV...", "I, II, III, IV..."), + ("Import InDesign Shortcut Set", "Importer un jeu de raccourcis InDesign"), ("Import XML", "Importer XML"), + ("Import cancelled: the originating document has closed", "Importation annulée : le document d'origine a été fermé"), ("Indent", "Retrait"), ("Indent to Here", "Retrait jusqu'à ce point"), ("Ink Limit 300%", "Limite d'encre 300 %"), ("Insert Anchored Object", "Insérer un objet ancré"), ("Insert Cross-Reference", "Insérer une référence croisée"), + ("Insert Field", "Insérer un champ"), ("Insert Math Expression", "Insérer une expression mathématique"), ("Insert Pages", "Insérer des pages"), ("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"), + ("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"), + ("Join", "Joindre"), ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), ("Kerning", "Crénage"), + ("Kerning/Tracking Step", "Pas de crénage/d'approche"), + ("Key Object", "Objet clé"), + ("Last commit", "Dernier commit"), ("Layout/Index", "Page/Index"), ("Layout/Pages", "Page/Pages"), ("Legacy Macintosh RGB", "RVB Macintosh hérité"), ("Light", "Clair"), + ("Line Tool", "Outil Trait"), + ("Line delta", "Différence de lignes"), + ("Lines +/−", "Lignes +/−"), + ("Lines added", "Lignes ajoutées"), + ("Lines deleted", "Lignes supprimées"), + ("List Composite Fonts", "Lister les polices composites"), + ("List Styles in File", "Lister les styles du fichier"), ("Load DTD", "Charger la DTD"), + ("Load Styles…", "Charger les styles…"), ("Load Swatches", "Charger des nuances"), ("Low Resolution", "Basse résolution"), ("Margins:", "Marges :"), + ("Maximize/Restore Window", "Agrandir/Restaurer la fenêtre"), + ("Measure Tool", "Outil Mesure"), ("Medium Dark", "Moyennement foncé"), ("Medium Light", "Moyennement clair"), ("Medium Resolution", "Résolution moyenne"), ("Menus", "Menus"), + ("Merged PRs", "PR fusionnées"), + ("Message:", "Message :"), + ("Minimize Window", "Réduire la fenêtre"), + ("Models", "Modèles"), ("Monitor RGB", "RVB du moniteur"), + ("Move Column Guide", "Déplacer le repère de colonne"), + ("Move Guides", "Déplacer les repères"), ("Move Pages", "Déplacer des pages"), + ("Move Panel", "Déplacer le panneau"), + ("Move Tab", "Déplacer la tabulation"), + ("Move Window", "Déplacer la fenêtre"), + ("Name (A–Z)", "Nom (A–Z)"), + ("New Cell Style", "Nouveau style de cellule"), ("New Condition", "Nouvelle condition"), ("New Hyperlink", "Nouvel hyperlien"), ("New Hyperlink Destination", "Nouvelle destination d'hyperlien"), @@ -110,13 +220,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Page Reference", "Nouvelle référence de page"), ("New Parent", "Nouveau gabarit"), ("New Stroke Style", "Nouveau style de contour"), + ("New Table Style", "Nouveau style de tableau"), ("Next Page Number", "Numéro de la page suivante"), + ("No contributor data was built into this copy.", "Aucune donnée sur les contributeurs n'est intégrée à cette copie."), + ("No matches", "Aucune correspondance"), + ("No model credits were built into this copy.", "Aucun crédit de modèle n'est intégré à cette copie."), ("Nonbreaking Hyphen", "Trait d'union insécable"), ("Nonbreaking Space", "Espace insécable"), ("None (keep transparency)", "Aucun (conserver la transparence)"), + ("Note Tool", "Outil Note"), ("Number of Pages", "Nombre de pages"), ("Numbering & Section Options", "Options de numérotation et de section"), ("OK", "OK"), + ("Object Style", "Style d'objet"), + ("Object Style Options…", "Options de style d'objet…"), ("Object Styles", "Styles d'objet"), ("Object/Anchored Object", "Objet/Objet ancré"), ("Object/Arrange", "Objet/Disposition"), @@ -132,64 +249,130 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Transform", "Objet/Transformation"), ("Object/Transform Again", "Objet/Répéter la transformation"), ("On", "Activé"), + ("Organization:", "Organisation :"), ("Orientation", "Orientation"), + ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), + ("PRs", "PR"), ("Package", "Assemblage"), + ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), + ("Page Options", "Options de page"), + ("Page Tool", "Outil Page"), ("Panels", "Panneaux"), ("Paragraph Return", "Retour de paragraphe"), + ("Paragraph Style", "Style de paragraphe"), ("Paragraph Symbol", "Symbole de paragraphe"), + ("Parent Options…", "Options de gabarit…"), + ("Pen Tool", "Outil Plume"), + ("Pencil Tool", "Outil Crayon"), + ("Phone:", "Téléphone :"), ("Picas", "Picas"), + ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), + ("Polygon Frame Tool", "Outil Bloc polygonal"), + ("Polygon Tool", "Outil Polygone"), ("Preflight", "Contrôle en amont"), ("Preflight Panel…", "Panneau Contrôle en amont…"), + ("Press keys…", "Appuyez sur des touches…"), + ("Preview Change", "Prévisualiser la modification"), + ("Preview Record", "Aperçu de l'enregistrement"), ("Previous Page Number", "Numéro de la page précédente"), ("Print Booklet", "Imprimer le cahier"), ("Punctuation Space", "Espace de ponctuation"), ("Quarter Space", "Quart d'espace"), ("Quick Apply", "Application rapide"), ("RGB:", "RVB :"), + ("Range:", "Plage :"), + ("Real name", "Nom réel"), + ("Recent Files", "Fichiers récents"), + ("Rectangle Frame Tool", "Outil Bloc rectangulaire"), + ("Rectangle Tool", "Outil Rectangle"), + ("Redefine Character Style", "Redéfinir le style de caractère"), + ("Redefine Paragraph Style", "Redéfinir le style de paragraphe"), ("Registered Trademark Symbol", "Symbole de marque déposée"), ("Relink", "Rééditer le lien"), ("Relink to Folder", "Rééditer le lien au dossier"), + ("Relinked {count}", "Liens rétablis : {count}"), + ("Reload Fonts", "Recharger les polices"), + ("Remove Data Source", "Supprimer la source de données"), + ("Remove Field", "Supprimer le champ"), + ("Remove Frame from Thread", "Retirer le bloc du chaînage"), + ("Reorder Objects", "Réorganiser les objets"), ("Report an Issue", "Signaler un problème"), + ("Report an issue", "Signaler un problème"), + ("Reset Bar Position", "Réinitialiser la position de la barre"), + ("Resize Row or Column", "Redimensionner la ligne ou la colonne"), + ("Resize Window", "Redimensionner la fenêtre"), + ("Resize panel window", "Redimensionner la fenêtre du panneau"), + ("Resize panels", "Redimensionner les panneaux"), + ("Reverse the order", "Inverser l'ordre"), ("Right Indent Tab", "Tabulation de retrait à droite"), + ("Rotate Tool", "Outil Rotation"), ("Rotation", "Rotation"), + ("Ruler Zero Point", "Point zéro des règles"), ("Save As", "Enregistrer sous"), ("Save Swatches for Exchange", "Enregistrer les nuances pour échange"), ("Save a Copy", "Enregistrer une copie"), + ("Scale Tool", "Outil Mise à l'échelle"), + ("Scissors Tool", "Outil Ciseaux"), ("Scripts", "Scripts"), ("Section Marker", "Marque de section"), ("Section Symbol", "Symbole de section"), + ("Select Guide", "Sélectionner le repère"), + ("Select Page", "Sélectionner la page"), + ("Select Paragraph", "Sélectionner le paragraphe"), + ("Select Sentence", "Sélectionner la phrase"), + ("Select Story", "Sélectionner l'article"), + ("Select Text Unit", "Sélectionner une unité de texte"), ("Selection", "Sélection"), + ("Selection Tool", "Outil Sélection"), + ("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"), ("Sentence case", "Majuscule en début de phrase"), + ("Set Composite Font", "Définir la police composite"), + ("Set OpenType Features", "Définir les fonctions OpenType"), + ("Shear Tool", "Outil Déformation"), ("Simulate Paper Color", "Simuler la couleur du papier"), ("Single Left Quotation Mark", "Guillemet simple ouvrant"), ("Single Right Quotation Mark", "Guillemet simple fermant"), ("Sixth Space", "Sixième d'espace"), ("Smart Guide Options", "Options des repères commentés"), + ("Smooth Tool", "Outil Lissage"), ("Space After", "Espace après"), ("Space Before", "Espace avant"), ("Start Page #", "N° de la première page"), ("Step and Repeat", "Exécuter et répéter"), + ("Stop Preview", "Arrêter l'aperçu"), ("Story Column Direction", "Sens des colonnes de l'article"), + ("Story Editor — {count} words", "Mode éditeur — {count} mots"), + ("Story {story} ← story {parent}", "Article {story} ← article {parent}"), ("Straight Double Quotation Marks", "Guillemets droits doubles"), ("Straight Single Quotation Mark (Apostrophe)", "Guillemet droit simple (apostrophe)"), + ("Subject:", "Objet :"), ("Subscript:", "Indice :"), ("Superscript:", "Exposant :"), + ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"), ("Tab", "Tabulation"), ("Table of Contents", "Table des matières"), ("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"), ("Table/Delete", "Tableau/Supprimer"), ("Table/Insert", "Tableau/Insérer"), ("Table/Select", "Tableau/Sélectionner"), + ("Text Anchor", "Ancre de texte"), + ("Text:", "Texte :"), ("Thin Space", "Espace fine"), ("Third Space", "Tiers d'espace"), + ("Thread Text Frames", "Chaîner les blocs de texte"), ("Title Case", "Majuscule à chaque mot"), + ("To Body", "En lignes de corps"), + ("To Footer", "En lignes de pied"), + ("To Header", "En lignes d'en-tête"), ("Trademark Symbol", "Symbole de marque"), ("Transform Again Individually", "Répéter la transformation individuellement"), ("Transform Sequence Again", "Répéter la séquence de transformations"), ("Transform Sequence Again Individually", "Répéter la séquence de transformations individuellement"), + ("Type Tool", "Outil Texte"), ("Type on a Path Options", "Options de texte curviligne"), + ("Type on a Path Tool", "Outil Texte curviligne"), ("Type/Bulleted and Numbered Lists", "Texte/Listes à puces et numérotées"), ("Type/Change Case", "Texte/Modifier la casse"), ("Type/Hyperlinks & Cross-References", "Texte/Hyperliens et références croisées"), @@ -210,8 +393,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("URL:", "URL :"), ("Unassigned", "Non attribué"), ("Units", "Unités"), + ("Unsaved Changes", "Modifications non enregistrées"), + ("Update Data Source", "Mettre à jour la source de données"), + ("Username", "Nom d'utilisateur"), + ("Validate from Root Element", "Valider à partir de l'élément racine"), + ("Vertical Type Tool", "Outil Texte vertical"), + ("Vertical Type on a Path Tool", "Outil Texte curviligne vertical"), ("View/Display Performance", "Affichage/Performances d'affichage"), + ("View/Extras", "Affichage/Extras"), ("View/Grids & Guides", "Affichage/Grilles et repères"), + ("View/Proof Setup", "Affichage/Format d'épreuve"), + ("View/Rotate Spread", "Affichage/Faire pivoter la planche"), + ("View/Screen Mode", "Affichage/Mode de l'écran"), + ("View/Separations Preview", "Affichage/Aperçu des séparations"), + ("View/Structure", "Affichage/Structure"), ("Window/Arrange", "Fenêtre/Disposition"), ("Window/Interactive", "Fenêtre/Interactif"), ("Window/Interface Color Theme", "Fenêtre/Thème de couleur de l'interface"), @@ -220,6 +415,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Window/Type & Tables", "Fenêtre/Texte et tableaux"), ("Window/Utilities", "Fenêtre/Utilitaires"), ("Working CMYK", "CMJN de travail"), + ("Zoom Tool", "Outil Zoom"), + ("[No Language]", "[Aucune langue]"), ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("image editing", "retouche d'images"), + ("layer", "calque"), + ("layers", "calques"), ("lowercase", "minuscules"), + ("motion graphics and visual effects", "animation graphique et effets visuels"), + ("photo library and raw development", "photothèque et développement raw"), + ("reading, organizing and protecting PDFs", "lecture, organisation et protection de PDF"), + ("the default printer", "l'imprimante par défaut"), + ("vector illustration", "illustration vectorielle"), + ("video editing, color and sound", "montage vidéo, couleur et son"), + ("{contributors} contributors · {commits} commits", "{contributors} contributeurs · {commits} commits"), + ("{count} conflict(s): {names}", "{count} conflit(s) : {names}"), + ("{count} match(es)", "{count} correspondance(s)"), + ("{count} replacement(s) made", "{count} remplacement(s) effectué(s)"), + ("{label}: {name} (double-click: Color Picker)", "{label} : {name} (double-clic : Sélecteur de couleurs)"), + ( + "{name}: import cancelled while a dialog is open; choose the file again", + "{name} : importation annulée car une boîte de dialogue était ouverte ; choisissez à nouveau le fichier", + ), + ("{name}: {count} step(s) done", "{name} : {count} étape(s) effectuée(s)"), + ( + "“{}” has changes that are not saved. Save them before closing?", + "« {} » contient des modifications non enregistrées. Les enregistrer avant de fermer ?", + ), ]; From e44662305f2367189e0fd11bac9d010e37e3f73b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:25:46 -0400 Subject: [PATCH 060/117] i18n tests: the same checks for every language, none fail on a missing translation (#1576) * i18n: missing French strings don't fail tests * i18n: drop the Czech tool-name translation check * i18n tests: one catalog check for every language, no translated literals One test checks every catalog (each language's table and context table, and the shared tables' columns) for duplicate and stale keys, empty translations, placeholders, padding, ellipses and label colons. One test selects, persists and checks document data for every interface language. Context lookup, fallback, built-in names and RTL are checked over every language against the catalogs. Asserts on particular translated text, and checks that particular strings are translated, are gone. * i18n: drop a stale German entry --- crates/ui-egui/src/chrome.rs | 16 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/guide_selection_tests.rs | 5 - crates/ui-egui/src/i18n.rs | 652 +++++--------------- crates/ui-egui/src/i18n/de.rs | 1 - crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 167 +---- crates/ui-egui/src/panels/properties.rs | 15 +- 8 files changed, 205 insertions(+), 655 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..e853e6793 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1155,7 +1155,7 @@ mod tests { /// The application bar's search field (German interface). fn search_node<'h>(h: &'h egui_kittest::Harness<'static, test_window::Window>) -> egui_kittest::Node<'h> { - h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some("Suchen")) + h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some(crate::i18n::tr("de", "Search"))) } /// A document with two frames, the Selection tool, German menus, and the search field clicked. @@ -1187,12 +1187,12 @@ mod tests { #[test] fn the_search_field_finds_and_runs_menu_commands() { let mut h = searching(); - h.event(egui::Event::Text("gruppier".into())); + let group = crate::i18n::tr("de", "Group"); + h.event(egui::Event::Text(group.to_lowercase())); h.run_steps(4); - assert!(row(&h, "Gruppieren").is_some(), "the German label is listed"); - assert!(row(&h, "Gruppierung aufheben").is_some()); + assert!(row(&h, group).is_some(), "the German label is listed"); // Nothing is selected, so Group is off and Return doesn't run it. - assert!(row(&h, "Gruppieren").is_some_and(|n| n.accesskit_node().is_disabled())); + assert!(row(&h, group).is_some_and(|n| n.accesskit_node().is_disabled())); h.key_press(egui::Key::Enter); h.run_steps(4); assert_eq!(items(&h), (2, 0), "a disabled command doesn't run"); @@ -1205,7 +1205,7 @@ mod tests { h.key_press(egui::Key::Enter); h.run_steps(4); assert_eq!(items(&h).0, 1, "the two frames are grouped"); - assert!(row(&h, "Gruppieren").is_none(), "the matches closed"); + assert!(row(&h, group).is_none(), "the matches closed"); assert!(!h.ctx.memory(|m| m.has_focus(super::search_id())), "the field gave the keyboard back"); } @@ -1227,11 +1227,11 @@ mod tests { assert_eq!(items(&h), (2, 2), "nothing was deleted or deselected"); h.event(egui::Event::Text("zz-no-such-command".into())); h.run_steps(4); - assert!(h.query_by_label("Keine passenden Befehle").is_some()); + assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_some()); // Escape closes the matches and clears the field without touching the document. h.key_press(egui::Key::Escape); h.run_steps(4); - assert!(h.query_by_label("Keine passenden Befehle").is_none()); + assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_none()); assert!(!h.ctx.text_edit_focused()); assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); assert_eq!(items(&h), (2, 2)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6b60b1296..40787954f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8963,7 +8963,7 @@ mod tests { assert_eq!(app.ui.language, "de"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let heading = visible_label(&labels, screen, "Sprache der Oberfläche"); + let heading = visible_label(&labels, screen, crate::i18n::tr("de", "Interface Language")); let combo = visible_label(&labels, screen, "Deutsch"); // The names stay endonyms in the German interface. diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs index 6c41d0122..d69026712 100644 --- a/crates/ui-egui/src/guide_selection_tests.rs +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -190,11 +190,6 @@ fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() { assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G")); crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null); assert_eq!(selected(&h), [0, 1, 2]); - assert_eq!(crate::i18n::tr("de", "Select All Guides"), "Alle Hilfslinien auswählen"); - for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { - assert_ne!(crate::i18n::tr(code, "Select All Guides"), "Select All Guides", "{code}"); - assert_ne!(crate::i18n::tr(code, "Ruler Guide"), "Ruler Guide", "{code}"); - } h.state_mut().ui.guides = false; assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll")); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index fec123a33..6057abc97 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4598,332 +4598,198 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { #[cfg(test)] mod tests { use super::*; + use std::collections::HashSet; + + /// Every per-language table `tr` looks up, by interface language. + const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ + ("ar", ar::TABLE), + ("cs", cs::TABLE), + ("de", de::TABLE), + ("fr", fr::TABLE), + ("it", it::TABLE), + ("ja", ja::TABLE), + ("ka", ka::TABLE), + ("pl", pl::TABLE), + ("pt-br", pt_br::TABLE), + ("ru", ru::TABLE), + ("uk", uk::TABLE), + ]; + + /// Every per-language table `tr_context` looks up. + const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = + &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)]; + + /// The languages of the shared tables' columns, in column order. + const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"]; + + /// The German and Japanese tables translate the whole interface and win over the shared columns. + const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"]; + + #[test] + fn every_interface_language_has_a_catalog() { + for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { + assert_eq!(column(lang), Some(i), "{lang}"); + } + for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) { + assert!(!name.is_empty(), "{code}"); + assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES"); + } + } #[test] fn cached_lookup_preserves_every_translation_across_language_switches() { + let own = |lang: &str, key: &str| { + LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text) + }; for (key, translations) in TABLE { - for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) { - // `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns. - if lang != "de" && lang != "ja" { - assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); - } + for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) { + // A language's own table is looked up before the shared column. + assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}"); } assert_eq!(tr("", key), *key); } - for (key, expected) in de::TABLE { - assert_eq!(tr("de", key), *expected, "de: {key}"); - } - for (key, expected) in ar::TABLE { - assert_eq!(tr("ar", key), *expected, "ar: {key}"); - } - for (key, expected) in pt_br::TABLE { - assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}"); - } - for (key, expected) in it::TABLE { - assert_eq!(tr("it", key), *expected, "it: {key}"); - } - for (key, expected) in uk::TABLE { - assert_eq!(tr("uk", key), *expected, "uk: {key}"); - } - for (key, expected) in ru::TABLE { - assert_eq!(tr("ru", key), *expected, "ru: {key}"); - } - for (key, expected) in cs::TABLE { - assert_eq!(tr("cs", key), *expected, "cs: {key}"); - } - for (key, expected) in fr::TABLE { - assert_eq!(tr("fr", key), *expected, "fr: {key}"); - } - for (key, expected) in pl::TABLE { - assert_eq!(tr("pl", key), *expected, "pl: {key}"); - } - for (key, expected) in ka::TABLE { - assert_eq!(tr("ka", key), *expected, "ka: {key}"); + for (lang, table) in LANGUAGE_TABLES { + for (key, expected) in *table { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + assert_eq!(tr("", key), *key); + } } + // Strings no catalog has stay as they are, in every language. let unknown = String::from("A user-defined untranslated label"); - for lang in ["pl", "cs", "uk", "ru", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] { - assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str())); + for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) { + assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}"); + // Fixed standard names stay identical. + assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}"); } } #[test] - fn translates_known_strings_and_keeps_the_rest() { - assert_eq!(tr("de", "File"), "Datei"); - assert_eq!(tr("ja", "Swatches"), "スウォッチ"); - assert_eq!(tr("fr", "Something new"), "Something new"); - assert_eq!(tr("", "File"), "File"); - assert_eq!(tr("ar", "File"), "ملف"); - assert_eq!(tr("ar", "Unknown label"), "Unknown label"); - assert_eq!(tr("pt-br", "File"), "Arquivo"); - assert_eq!(tr("pt-br", "New Document…"), "Novo documento…"); - assert_eq!(tr("pt-br", "Unknown label"), "Unknown label"); - assert_eq!(tr("it", "Edit"), "Modifica"); - assert_eq!(tr("it", "New Document…"), "Nuovo documento…"); - assert_eq!(tr("it", "Unknown label"), "Unknown label"); - assert!(is_rtl("ar")); - for (i, (en, translation)) in ar::TABLE.iter().enumerate() { - assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); - assert!(!translation.is_empty(), "{en}"); + fn context_captions_come_from_their_context_or_the_plain_translation() { + for (lang, table) in CONTEXT_TABLES { + for (key, scope, text) in *table { + assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + } } - for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() { - assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); - assert!(!translation.is_empty(), "{en}"); + for (key, scope, row) in CONTEXT { + for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) { + // A language's own context table is looked up before the shared one. + let own = CONTEXT_TABLES + .iter() + .filter(|(code, _)| *code == lang) + .flat_map(|(_, table)| table.iter()) + .find(|(k, s, _)| k == key && s == scope) + .map(|(_, _, text)| *text); + assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})"); + } } - for (i, (en, translation)) in it::TABLE.iter().enumerate() { - assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); - assert!(!translation.is_empty(), "{en}"); - } - // Italian entries are for keys the shared or Arabic table knows. A new English string - // falls back to English until it is translated, so it doesn't fail here. - for (en, _) in it::TABLE { - assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}"); - } - // Every row is unique and complete. - for (i, (en, t)) in TABLE.iter().enumerate() { - assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}"); - assert!(t.iter().all(|x| !x.is_empty()), "{en}"); + // An unknown context, or a language without one, keeps the plain translation. + let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key))); + for key in keys { + for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) { + assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}"); + } + assert_eq!(tr_context("", key, "count"), key); } } + /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding, + /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back + /// to English, so missing translations don't fail here. #[test] - fn a_column_count_and_a_list_number_read_differently() { - for (lang, count, numeral) in [ - ("de", "Anzahl:", "Zahl:"), - ("ja", "数:", "番号:"), - ("uk", "Кількість:", "Номер:"), - ("ar", "العدد:", "الرقم:"), - ("fr", "Nombre :", "Numéro:"), - ("zh", "数量:", "编号:"), - ] { - assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}"); + fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() { + // (language, key, translation) of every catalog, and every catalog's keys by name. + let mut entries: Vec<(&str, &str, &str)> = Vec::new(); + let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new(); + for (lang, table) in LANGUAGE_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); + catalogs.push((*lang, keys)); + entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text))); } - assert_eq!(tr_context("en", "Number:", "count"), "Number:"); - assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:"); - // The Pages panel menu. - for key in [ - "Insert Pages…", - "Move Pages…", - "New Parent…", - "Override All Parent Page Items", - "Remove All Local Overrides", - "Detach All Objects from Parent", - "Create Alternate Layout…", - "Allow Selected Spread to Shuffle", - ] { - assert_ne!(tr("pt-br", key), key); + let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); + assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); + catalogs.push(("shared", shared.clone())); + for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) { + entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text)))); + entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text)))); + } + let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())]; + for (lang, table) in CONTEXT_TABLES { + contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect())); + entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text))); } - assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages"); - } - #[test] - fn french_catalog_has_no_duplicate_or_stale_keys_and_keeps_placeholders() { - let catalog: HashMap<_, _> = fr::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), fr::TABLE.len(), "duplicate French keys"); - let shared: std::collections::HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); - // `fr::TABLE` is looked up first: a key there would hide the shared table's French column. - let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect(); - assert!(both.is_empty(), "French keys also in the shared table: {both:?}"); - let mut keys = interface_strings(); - for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] { - keys.extend(table.iter().map(|(key, _)| *key)); + // A per-language table is looked up before the shared table, so a key in both hides the + // shared column, except in the tables that translate the whole interface. + for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) { + let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect(); + assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}"); } - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}"); - let placeholders = |s: &str| { - let mut names = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - names.sort(); + + // A key is current while the interface or another catalog has it. + let interface = interface_strings(); + let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key)); + for (name, keys) in &catalogs { + let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect(); + stale.sort(); + assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}"); + } + for (name, keys) in &contexts { + let unique: HashSet<_> = keys.iter().collect(); + assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys"); + let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect(); + assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}"); + } + + let placeholders = |s: &'static str| { + let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect(); + names.sort_unstable(); names }; - let shared_french = TABLE.iter().filter_map(|(key, row)| row.get(1).map(|text| (*key, *text))); - for (key, translation) in fr::TABLE.iter().copied().chain(shared_french) { - assert!(!translation.trim().is_empty(), "empty {key}"); - assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}"); - assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}"); - assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); + let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("..."); + let colon = |s: &str| s.ends_with(':') || s.ends_with(':'); + // Full-width punctuation carries its own spacing. + let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c); + for (lang, key, text) in entries { + assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}"); + assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}"); + // Dialog ellipses, label colons and padded captions stay as in English. + assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}"); + assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}"); + if !text.starts_with(full_width) { + assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}"); + } + if !text.ends_with(full_width) { + assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}"); + } } - assert_eq!(tr("fr", "Group"), "Associer"); - } - - /// Interface strings that read the same in French: names, abbreviations, numbering samples, key - /// symbols and words French shares with English. - const SAME_IN_FRENCH: &[&str] = &[ - " OK ", - " (auto)", - "+Bin", - "+LOC", - "A, B, C, D...", - "Angle", - "Articles", - "Bunri-Kinshi", - "Commits", - "Composition", - "Compression", - "Conditions", - "Cyan", - "DesignCraft", - "Direction", - "Distance", - "Document", - "Document…", - "Ellipse", - "Extras", - "Fractions", - "GREP", - "Hindi", - "Horizontal", - "I, II, III, IV...", - "Image", - "Index", - "Interface", - "Justification", - "Justification…", - "Kanji", - "Kenten", - "Lab", - "Ligatures", - "Magenta", - "Maximum", - "Menus", - "Menus…", - "Minimum", - "Mobile", - "Normal", - "Normal (W)", - "Notes", - "OK", - "OpenType", - "Options", - "Orange", - "Orientation", - "PDF/A-2b", - "PDF/X-4", - "Page", - "Pages", - "Pathfinder", - "Picas", - "Portrait", - "Position", - "Radial", - "Rectangle", - "Rensuuji", - "Rotation", - "Ruby", - "Ruby…", - "Satin", - "Saturation", - "Script", - "Scripts", - "Section", - "Serif", - "Shatai", - "Signature", - "Simple", - "Structure", - "Style", - "Styles", - "Tate-Chu-Yoko", - "Triangle", - "Version", - "Vertical", - "Violet", - "Warichu", - "Web", - "a, b, c, d...", - "document", - "i, ii, iii, iv...", - "pages", - "ΔLOC", - "⇧", - "−Bin", - "−LOC", - "⌃", - "⌘", - "⌥", - ]; - - #[test] - fn french_translates_every_interface_string() { - let keys = interface_strings(); - // A new English string needs a French entry (or a place on `SAME_IN_FRENCH`) before it ships. - let mut untranslated: Vec<_> = keys.iter().filter(|key| tr("fr", key) == **key && !SAME_IN_FRENCH.contains(key)).collect(); - untranslated.sort(); - assert!(untranslated.is_empty(), "French falls back to English for: {untranslated:?}"); - for key in SAME_IN_FRENCH { - assert!(keys.contains(key), "SAME_IN_FRENCH lists a string the interface no longer has: {key}"); - assert_eq!(tr("fr", key), *key, "SAME_IN_FRENCH lists a string French translates: {key}"); - } - for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { - assert!(tr("fr", tool.label).starts_with("Outil "), "{}", tool.id); - } - assert_eq!(tr("fr", "Selection Tool"), "Outil Sélection"); - assert_eq!(tr("fr", "Don't Save"), "Ne pas enregistrer"); } #[test] - fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() { - let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - keys.extend(ar::TABLE.iter().map(|(key, _)| *key)); - keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key)); - // A new English string falls back to English until it is translated, so only stale - // entries (keys no catalog knows any more) fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in uk::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); + fn only_arabic_lays_out_right_to_left() { + for (code, _) in LANGUAGES { + assert_eq!(is_rtl(code), *code == "ar", "{code}"); + } + } + + /// Built-in style and workspace names follow the interface language; names people give their + /// own styles and workspaces are document data and stay as typed. + #[test] + fn only_built_in_style_and_workspace_names_are_translated() { + for (code, _) in LANGUAGES { + for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] { + assert_eq!(style_name(code, name), tr(code, name), "{code}"); + } + assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}"); + for custom in ["Group", "Columns", "My layout style"] { + assert_eq!(style_name(code, custom), custom, "{code}"); + assert_eq!(workspace_name(code, custom), custom, "{code}"); + } } - assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці"); - assert_eq!(tr("uk", "Columns"), "Колонки"); - assert_eq!(tr_context("uk", "Group", "selection"), "Група"); - // Step and Repeat's grid columns are not a text frame's. - assert_eq!(tr_context("uk", "Columns:", "grid"), "Стовпці:"); - assert_eq!(tr_context("ja", "Columns:", "grid"), "列:"); - assert_eq!(tr("ja", "Columns:"), "段:"); - assert_eq!(tr("uk", "Group"), "Згрупувати"); - assert_eq!(tr_context("", "Columns", "table"), "Columns"); - assert_eq!(tr_context("ja", "Span Columns", "paragraphLayout"), "段抜き"); - assert_eq!(tr("ja", "Span Columns"), "段抜きと段分割"); - assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption"); - assert_eq!(tr("uk", "through"), "включно"); - assert_eq!(tr("uk", "up to"), "не включно"); - assert!(!is_rtl("uk")); - assert_eq!(tr("uk", "Type"), "Текст"); - assert_eq!(tr("uk", "Spread"), "Розворот"); - assert_eq!(tr("uk", "Story"), "Текстовий матеріал"); - assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула"); - assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]"); - assert_eq!(style_name("uk", "My layout style"), "My layout style"); - assert_eq!(workspace_name("uk", "Essentials"), "Основне"); - assert_eq!(workspace_name("uk", "My workspace"), "My workspace"); } #[test] - fn georgian_catalog_preserves_placeholders_and_counts() { - let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys"); - let placeholders = |s: &str| { - s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>() - }; - for (key, translation) in ka::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); - assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); - // Georgian has no capitals in running text: Mtavruli never appears in the interface. - assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}"); - } - assert_eq!(tr("ka", "File"), "ფაილი"); - assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…"); - assert_eq!(tr("ka", "Unknown label"), "Unknown label"); - assert!(!is_rtl("ka")); + fn georgian_counts_use_category_captions_for_every_number() { for n in [1, 2, 5, 21] { assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}")); } @@ -4941,62 +4807,6 @@ mod tests { assert_eq!(count_label("", "objects", 2), "2 objects"); } - #[test] - fn russian_catalog_covers_existing_keys_and_preserves_placeholders() { - let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - keys.extend(ar::TABLE.iter().map(|(key, _)| *key)); - keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key)); - keys.extend(it::TABLE.iter().map(|(key, _)| *key)); - keys.extend(ja::TABLE.iter().map(|(key, _)| *key)); - keys.extend(uk::TABLE.iter().map(|(key, _)| *key)); - // A new English string falls back to English until it is translated, so only stale - // entries (keys no catalog knows any more) fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in ru::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}"); - assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}"); - assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); - } - } - - #[test] - fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() { - assert!(LANGUAGES.contains(&("ru", "Русский"))); - assert!(!is_rtl("ru")); - assert_eq!(tr("ru", "File"), "Файл"); - assert_eq!(tr("ru", "Type"), "Текст"); - assert_eq!(tr("ru", "Spread"), "Разворот"); - assert_eq!(tr("ru", "Story"), "Материал"); - assert_eq!(tr("ru", "Unknown label"), "Unknown label"); - assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы"); - assert_eq!(tr("ru", "Columns"), "Колонки"); - assert_eq!(tr_context("ru", "Group", "selection"), "Группа"); - assert_eq!(tr("ru", "Group"), "Сгруппировать"); - assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption"); - assert_eq!(tr("ru", "through"), "включительно"); - assert_eq!(tr("ru", "up to"), "не включая"); - assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография"); - assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки"); - assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]"); - assert_eq!(style_name("ru", "My layout style"), "My layout style"); - assert_eq!(workspace_name("ru", "Essentials"), "Основное"); - assert_eq!(workspace_name("ru", "My workspace"), "My workspace"); - // Fixed standard names stay identical. - assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4"); - } - #[test] fn russian_counts_use_category_captions_for_every_number() { assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first("")); @@ -5009,45 +4819,6 @@ mod tests { } } - #[test] - fn polish_catalog_covers_existing_keys_and_preserves_placeholders() { - let catalog: HashMap<_, _> = pl::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), pl::TABLE.len(), "duplicate Polish keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - for table in [ar::TABLE, pt_br::TABLE, ja::TABLE, it::TABLE, uk::TABLE, de::TABLE, fr::TABLE, ru::TABLE, cs::TABLE] { - keys.extend(table.iter().map(|(key, _)| *key)); - } - // A new English string falls back to English until it is translated; only stale entries fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in pl::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); - assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); - } - assert!(LANGUAGES.contains(&("pl", "Polski"))); - assert!(!is_rtl("pl")); - assert_eq!(tr("pl", "File"), "Plik"); - assert_eq!(tr("pl", "New Document…"), "Nowy dokument…"); - assert_eq!(tr("pl", "Spread"), "Rozkładówka"); - assert_eq!(tr("pl", "Unknown label"), "Unknown label"); - assert_eq!(style_name("pl", "[Basic Paragraph]"), "[Podstawowy akapit]"); - assert_eq!(workspace_name("pl", "Essentials"), "Podstawowe"); - // A table's columns and grid columns aren't a text frame's; a selection's group isn't the action. - assert_eq!(tr("pl", "Columns"), "Łamy"); - assert_eq!(tr_context("pl", "Columns", "table"), "Kolumny"); - assert_eq!(tr_context("pl", "Columns:", "grid"), "Łamy:"); - assert_eq!(tr("pl", "Group"), "Grupuj"); - assert_eq!(tr_context("pl", "Group", "selection"), "Grupa"); - } - #[test] fn polish_counts_use_category_captions_for_every_number() { for n in [0, 1, 2, 5, 12, 22, 25] { @@ -5058,14 +4829,10 @@ mod tests { assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}"); } - /// Every English string the interface passes to `tr`, as far as the catalogs, command and tool - /// tables and the list of UI-only labels know them. - fn interface_strings() -> std::collections::HashSet<&'static str> { - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] { - keys.extend(table.iter().map(|(key, _)| *key)); - } + /// Every English string the interface passes to `tr`, as far as the command and tool tables and + /// the list of UI-only labels know them. + fn interface_strings() -> HashSet<&'static str> { + let mut keys = HashSet::new(); // Command labels and menu names reach `tr` from the engine and the UI command table. for spec in designcraft_engine::command_specs() { keys.insert(spec.label); @@ -5085,6 +4852,8 @@ mod tests { "(unchanged)", "[No Language]", "Cell Number:", + "Differs from this document", + "Digits:", "Change fill to:", "Chinese: Simplified", "Chinese: Traditional", @@ -5120,6 +4889,7 @@ mod tests { "Found", "Grab bag", "Key Object", + "Load Styles", "layer", "layers", "Lines +/−", @@ -5135,6 +4905,7 @@ mod tests { "Redefine Document Style", "Report an issue", "Reverse the order", + "Sort Entries in Alphabetical Order", "Story {story} ← story {parent}", "Story Editor — {count} words", "Text Anchor", @@ -5156,48 +4927,6 @@ mod tests { keys } - #[test] - fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() { - let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys"); - let keys = interface_strings(); - // A new English string falls back to English until it is translated, so only stale - // entries (keys nothing passes to `tr` any more) fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in cs::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); - // Dialog ellipses, label colons and padded button captions stay as in English. - assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); - assert_eq!(key.ends_with(':'), translation.ends_with(':'), "colon: {key}"); - assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}"); - assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}"); - } - assert!(!is_rtl("cs")); - assert_eq!(tr("cs", "File"), "Soubor"); - assert_eq!(tr("cs", "New Document…"), "Nový dokument…"); - assert_eq!(tr("cs", "Spread"), "Dvoustrana"); - assert_eq!(tr("cs", "Bleed"), "Spadávka"); - assert_eq!(tr("cs", "Leading"), "Proklad"); - assert_eq!(tr("cs", "Unknown label"), "Unknown label"); - assert_eq!(tr_context("cs", "Group", "selection"), "Skupina"); - assert_eq!(tr_context("cs", "Name", "contributors"), "Jméno"); - assert_eq!(tr_context("cs", "Name", "unknown"), "Název"); - assert_eq!(tr("cs", "Group"), "Seskupit"); - assert_eq!(tr_context("cs", "Columns", "table"), tr("cs", "Columns")); - assert_eq!(style_name("cs", "[Basic Paragraph]"), "[Základní odstavec]"); - assert_eq!(style_name("cs", "My layout style"), "My layout style"); - assert_eq!(workspace_name("cs", "Essentials"), "Základní"); - assert_eq!(workspace_name("cs", "My workspace"), "My workspace"); - } - #[test] fn czech_counts_use_the_plural_form_for_the_number() { for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] { @@ -5222,51 +4951,4 @@ mod tests { assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms"); } } - - #[test] - fn czech_translates_every_tool_name() { - for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { - assert_ne!(tr("cs", tool.label), tool.label, "{}", tool.id); - assert_eq!(tr("", tool.label), tool.label); - } - } - - #[test] - fn japanese_vertical_type_commands_are_translated() { - for (en, ja) in [ - ("Story Direction", "組み方向"), - ("Horizontal", "横書き"), - ("Vertical", "縦書き"), - ("Tate-Chu-Yoko", "縦中横"), - ("Ruby…", "ルビ…"), - ("Kenten", "圏点"), - ("Warichu", "割り注"), - ] { - assert_eq!(tr("ja", en), ja); - } - } - - #[test] - fn pdf_export_dialog_strings_are_translated() { - // Translated labels in every supported language. - assert_ne!(tr("zh", "Crop Marks"), "Crop Marks"); - assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF"); - assert_ne!(tr("ar", "Include document bleed"), "Include document bleed"); - assert_ne!(tr("pt-br", "Compress images"), "Compress images"); - // English returns the key unchanged. - assert_eq!(tr("", "Export PDF"), "Export PDF"); - // An unknown string still falls through unchanged. - assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print"); - // Fixed standard names stay identical in every language. - assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4"); - } - - #[test] - fn style_row_menu_strings_are_translated_in_every_language() { - for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { - for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] { - assert_ne!(tr(code, key), key, "{code}: {key}"); - } - } - } } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index d47657b09..4f4177cbd 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -2037,7 +2037,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Print Layer", "Ebene drucken"), ("Suppress Text Wrap When Layer is Hidden", "Textumfluss bei ausgeblendeter Ebene unterdrücken"), // Remaining interface strings. - ("A custom name", "Ein eigener Name"), ("Close Panel", "Bedienfeld schließen"), ("Default Fill and Stroke", "Standardfläche und -kontur"), ("Distribute Rows Evenly", "Zeilen gleichmäßig verteilen"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 18fa402a5..535c87629 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2460,7 +2460,7 @@ mod tests { assert_eq!(app.ui.status, "Table: No table selected"); app.ui.language = "cs".into(); app.status_error("Table", "No table selected"); - assert_eq!(app.ui.status, "Tabulka: No table selected"); + assert_eq!(app.ui.status, format!("{}: No table selected", crate::i18n::tr("cs", "Table"))); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a271324a..87537abd7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3367,16 +3367,12 @@ mod tests { } #[test] - fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { + fn screen_mode_shows_the_current_mode() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m })); assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false))); app.ui.screen_mode = crate::ScreenMode::Preview; assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); - // Every View submenu has its Japanese path (Edit › Menus shows them). - for path in submenu_paths("View") { - assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row"); - } } fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { @@ -3793,8 +3789,8 @@ mod tests { run_ui(&mut app, "app.language", &json!({"lang": "de"})).unwrap().unwrap(); assert_eq!(app.ui.language, "de"); assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true)); - assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster"); assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err()); + assert_eq!(app.ui.language, "de"); // The language is set in Preferences, not from a menu; without `lang` the command opens it. let mut items = Vec::new(); for (_, entries) in menu_tree() { @@ -3804,153 +3800,23 @@ mod tests { run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); assert_eq!(app.ui.language, "de"); - app.ui.dialog = None; - - run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "zh"); - assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件"); - - app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "ar"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("ar", title), title, "{title}"); - } - // Every menu title has a translation (Japanese never matches the English). - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("ja", title), title, "{title}"); - } - - run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "pt-br"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true)); - for (title, _) in menu_tree() { - // "Layout" is the natural Brazilian Portuguese word, so it stays identical - // to English; every other title must be translated. - if title != "Layout" { - assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}"); - } - } - - run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "it"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true)); - for (title, _) in menu_tree() { - // "File" and "Layout" are the usual Italian menu names, so they stay identical - // to English; every other title must be translated. - if title != "File" && title != "Layout" { - assert_ne!(crate::i18n::tr("it", title), title, "{title}"); - } - } - - run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "ka"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true)); - // Every menu title, item and submenu is translated into Georgian. - let mut untranslated = Vec::new(); - for (title, entries) in menu_tree() { - let mut labels = vec![title.to_string()]; - let mut stack: Vec<&Item> = entries.iter().collect(); - while let Some(entry) = stack.pop() { - match entry { - Item::Sub(label, children) => { - labels.push(label.clone()); - stack.extend(children.iter()); - } - Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), - _ => {} - } - } - // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. - let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); - untranslated - .extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); - } - assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); } #[test] - fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() { + fn every_interface_language_is_selectable_persisted_and_keeps_document_data() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова · Zażółć gęślą jaźń"})).unwrap(); let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap(); - assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("uk", title), title, "{title}"); + for (code, _) in crate::i18n::LANGUAGES { + run_ui(&mut app, "app.language", &json!({"lang": code})).unwrap().unwrap(); + assert_eq!(app.ui.language, *code); + assert_eq!(checked(&app, "app.language", &json!({"lang": code})), Some(true), "{code}"); + assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(), "{code}"); + let bytes = serde_json::to_vec(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(restored.language, *code); } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "uk"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); - assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name"); - } - - #[test] - fn russian_language_is_selectable_persisted_and_keeps_document_data() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap(); - assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("ru", title), title, "{title}"); - } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "ru"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); - assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name"); - } - - #[test] - fn czech_language_is_selectable_persisted_and_keeps_document_data() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Příliš žluťoučký kůň"})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "cs"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "cs"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "cs"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("cs", title), title, "{title}"); - } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "cs"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Soubor"); - assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(crate::i18n::LANGUAGES.contains(&("cs", "Čeština")), "Preferences lists Czech by its own name"); - } - - #[test] - fn polish_language_is_selectable_persisted_and_keeps_document_data() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "Zażółć gęślą jaźń"})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "pl"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "pl"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "pl"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("pl", title), title, "{title}"); - } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "pl"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Plik"); - assert!(crate::i18n::LANGUAGES.contains(&("pl", "Polski")), "Preferences lists Polish by its own name"); } #[test] @@ -4993,9 +4859,10 @@ mod tests { fn command_search_matches_labels_in_the_interface_language() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "de".into(); - let items = command_search(&app, "gruppieren"); + let label = crate::i18n::tr("de", "Group"); + let items = command_search(&app, &label.to_lowercase()); let group = items.iter().find(|i| i.id == "object.group").unwrap(); - assert_eq!(group.label, "Gruppieren"); + assert_eq!(group.label, label); assert_eq!(group.shortcut.as_deref(), Some("Cmd+G")); assert!(command_search(&app, "group").iter().any(|i| i.id == "object.group"), "English labels match too"); assert!(command_search(&app, " ").is_empty()); @@ -5652,9 +5519,6 @@ mod tests { // Language follows Settings, as in the suite's other apps, titled in the interface language. assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language)); assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep)); - for (code, _) in crate::i18n::LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { - assert_ne!(crate::i18n::tr(code, "Language"), "Language", "{code}"); - } let label = |command: &str| { MAC_APP_MENU.iter().find_map(|m| match m { AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label), @@ -5662,7 +5526,6 @@ mod tests { }) }; assert_eq!(label("app.preferences"), Some("Settings…")); - assert_ne!(crate::i18n::tr("de", "Settings…"), "Settings…"); let ids = MAC_APP_MENU.iter().filter_map(|m| if let AppMenuItem::Cmd { id, .. } = m { Some(*id) } else { None }).collect::>(); assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "native ids are unique: {ids:?}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 1cbd623a6..fd0094f9b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -3148,8 +3148,19 @@ mod tests { /// are translated, with a sample of each script's figures. #[test] fn digits_choices_use_the_reference_names() { - let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label("fr", *o)).collect(); - assert_eq!(shown, ["Par défaut", "Arabe 0123", "Hindi ٠١٢٣", "Persan ۰۱۲۳", "Natif"].map(String::from)); + assert_eq!(DIGIT_OPTIONS.map(|o| o.1), ["Default", "Arabic", "Hindi", "Farsi", "Native"]); + for (lang, _) in crate::i18n::LANGUAGES { + let tr = |s| crate::i18n::tr(lang, s); + let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label(lang, *o)).collect(); + let expected = [ + tr("Default").to_string(), + format!("{} 0123", tr("Arabic")), + format!("{} ٠١٢٣", tr("Hindi")), + format!("{} ۰۱۲۳", tr("Farsi")), + tr("Native").to_string(), + ]; + assert_eq!(shown, expected, "{lang}"); + } } #[test] From bba31c17b6e3116916b46272cec906a90a054ff7 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:17:34 -0600 Subject: [PATCH 061/117] PDF: report visible unembedded fonts in PDF/X-4 (#909) Track text state through graphics saves and drawn forms; exempt invisible text. Report font names and pages in export warnings and validation without embedding or outlining placed fonts. --- ROADMAP.md | 6 + apps/designcraft-cli/src/main.rs | 2 +- crates/pdf/src/pdfx.rs | 116 ++++++++++++++++++- crates/pdf/src/pdfx/fonts.rs | 172 +++++++++++++++++++++++++++++ crates/pdf/src/pdfx/fonts/tests.rs | 153 +++++++++++++++++++++++++ docs/agents.md | 10 ++ docs/file-format-parity.md | 11 +- docs/gaps.md | 15 ++- docs/target-app-parity.md | 12 +- 9 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 crates/pdf/src/pdfx/fonts.rs create mode 100644 crates/pdf/src/pdfx/fonts/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..b3cc76a63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -354,6 +354,12 @@ Newest first. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded + font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). + Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically. + Repeated forms reuse context-aware font findings; a document-wide operation limit and a form-nesting depth limit + report incomplete checks instead of allowing excessive nested-form traversal. + No readiness percentage change. - **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. - **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. - **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 71e05a480..0c0a15267 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -39,7 +39,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 534886f1d..3303ca879 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -10,6 +10,8 @@ use hayro_syntax::page::Resources; use crate::transitions::{find, int_after, object, rfind}; +mod fonts; + /// Escape a PDF literal string. fn lit(s: &str) -> String { let mut o = String::from("("); @@ -246,6 +248,7 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { issues.push("Type 3 fonts aren't allowed".into()); } issues.extend(device_rgb(pdf)); + issues.extend(fonts::unembedded_fonts(pdf)); issues } @@ -480,7 +483,7 @@ mod tests { use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report}; - fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) { + pub(super) fn place(d: &mut Document, name: &str, data: Vec, size: (f64, f64), at: (f64, f64)) { let asset = AssetId(d.alloc()); let mime = designcraft_images::mime(&data).into(); let pixels = designcraft_images::pixel_size(&data); @@ -579,7 +582,7 @@ mod tests { pdf.finish().unwrap() } - fn x4(d: &Document) -> ExportReport { + pub(super) fn x4(d: &Document) -> ExportReport { export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap() } @@ -645,13 +648,18 @@ mod tests { /// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its /// resources, a group…) and `content`. fn tiny_pdf(entries: &str, content: &str) -> Vec { + tiny_pdf_with(entries, content, "<>\nstream\n\nendstream") + } + + /// [`tiny_pdf`] with `extra` as object 6 (an RGB profile in [`tiny_pdf`]). + pub(super) fn tiny_pdf_with(entries: &str, content: &str, extra: &str) -> Vec { let objects = [ "<>]>>".to_string(), "<>".to_string(), format!("<>"), "<>\nstream\n\nendstream".to_string(), format!("<>\nstream\n{content}\nendstream", content.len()), - "<>\nstream\n\nendstream".to_string(), + extra.to_string(), ]; let mut out = b"%PDF-1.6\n".to_vec(); let mut offsets = Vec::new(); @@ -682,6 +690,108 @@ mod tests { assert!(!cmyk.iter().any(rgb), "{cmyk:?}"); } + /// Every font that draws visible text must be embedded (#909): a base-14 font with no font + /// file, a CID font whose descendant has none, and one drawn from inside a form are reported; + /// an embedded font, an unused one and invisible text (render mode 3, as in OCR layers) are not. + #[test] + fn unembedded_fonts_are_flagged() { + let fonts = |entries: &str, content: &str| -> Vec { + check_pdfx4(&tiny_pdf(entries, content)).into_iter().filter(|i| i.contains("font")).collect() + }; + let helvetica = "/Resources<>>>>>"; + assert_eq!( + fonts(helvetica, "BT /F1 24 Tf 20 50 Td (Base14 text) Tj ET"), + ["the font Helvetica isn't embedded (page 1): PDF/X-4 needs every font used for visible text embedded"] + ); + assert!(fonts(helvetica, "BT /F1 24 Tf 3 Tr 20 50 Td (OCR) Tj ET").is_empty(), "invisible text needs no embedded font"); + assert!(fonts(helvetica, "0 0 0 1 k 0 0 10 10 re f").is_empty(), "an unused font is not reported"); + let restored = fonts(helvetica, "BT /F1 24 Tf q 3 Tr Q 20 50 Td (shown) Tj ET"); + assert_eq!(restored.len(), 1, "Q restores the visible render mode: {restored:?}"); + + let embedded = "/Resources<>>>>>>>"; + assert!(fonts(embedded, "BT /F1 12 Tf (x) Tj ET").is_empty()); + let cid = "/Resources<>>>]>>>>>>"; + assert_eq!(fonts(cid, "BT /F1 12 Tf <0001> Tj ET").len(), 1); + let cid_embedded = cid.replace("/FontName/KozMin>>", "/FontName/KozMin/FontFile3 6 0 R>>"); + assert!(fonts(&cid_embedded, "BT /F1 12 Tf <0001> Tj ET").is_empty()); + + let draw = "BT /T 9 Tf (in a form) Tj ET"; + let form = format!( + "<>>>>>/Length {}>>\nstream\n{draw}\nendstream", + draw.len() + ); + let pdf = tiny_pdf_with("/Resources<>>>", "/X1 Do", &form); + let issues: Vec = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("font")).collect(); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].starts_with("the font Times-Roman isn't embedded"), "{issues:?}"); + } + + /// A placed PDF that draws with an unembedded font: the PDF/X-4 export warns about it (#909). + #[test] + fn placed_pdf_with_an_unembedded_font_is_reported() { + let mut d = Document::new(&NewDocument::default()); + let placed = tiny_pdf( + "/Resources<>>>>>", + "BT /F1 24 Tf 0 0 0 1 k 20 50 Td (Base14 text) Tj ET", + ); + place(&mut d, "helvetica.pdf", placed, (100.0, 100.0), (72.0, 72.0)); + let r = x4(&d); + assert!(r.warnings.iter().any(|w| w.starts_with("PDF/X-4: the font Helvetica isn't embedded (page 1)")), "{:?}", r.warnings); + } + + #[test] + fn unembedded_fonts_are_reported_by_every_text_show_operator() { + let entries = "/Resources<>>>>>"; + for show in ["(x) Tj", "[(x) 20 (y)] TJ", "(x) '", "0 0 (x) \""] { + let pdf = tiny_pdf(entries, &format!("BT /F1 12 Tf {show} ET")); + let issues = check_pdfx4(&pdf); + assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{show}: {issues:?}"); + } + } + + #[test] + fn unembedded_fonts_in_forms_inherit_and_restore_text_state() { + let entries = "/Resources<>>>/XObject<>>>"; + let draw = "BT (inherited font) Tj /F1 10 Tf 3 Tr ET"; + let form = format!("<>\nstream\n{draw}\nendstream", draw.len()); + for (content, count) in [ + ("/F1 12 Tf /X1 Do", 1), + ("/F1 12 Tf 3 Tr /X1 Do", 0), + ("/F1 12 Tf 3 Tr /X1 Do 0 Tr /X1 Do", 1), + ("/F1 12 Tf /X1 Do BT (still visible) Tj ET", 1), + ] { + let pdf = tiny_pdf_with(entries, content, &form); + let issues = check_pdfx4(&pdf); + assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), count, "{content}: {issues:?}"); + } + } + + #[test] + fn unembedded_fonts_in_recursive_forms_are_bounded() { + let draw = "BT /F1 12 Tf (x) Tj ET /X1 Do"; + let form = format!( + "<>>>/XObject<>>>/Length {}>>\nstream\n{draw}\nendstream", + draw.len() + ); + let pdf = tiny_pdf_with("/Resources<>>>", "/X1 Do", &form); + let issues = check_pdfx4(&pdf); + assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{issues:?}"); + } + + #[test] + fn unembedded_fonts_are_reported_after_form_text_state_is_restored() { + let entries = "/Resources<>>>/XObject<>>>"; + let draw = "BT /F2 10 Tf 3 Tr (hidden) Tj ET"; + let form = format!( + "<>>>>>/Length {}>>\nstream\n{draw}\nendstream", + draw.len() + ); + let pdf = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do BT (visible) Tj ET", &form); + let issues: Vec = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("isn't embedded")).collect(); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].starts_with("the font Helvetica isn't embedded"), "{issues:?}"); + } + /// An RGB blending space on the page group and an RGB inline image are device RGB too. #[test] fn rgb_groups_and_inline_images_are_flagged() { diff --git a/crates/pdf/src/pdfx/fonts.rs b/crates/pdf/src/pdfx/fonts.rs new file mode 100644 index 000000000..5dc149b7f --- /dev/null +++ b/crates/pdf/src/pdfx/fonts.rs @@ -0,0 +1,172 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use hayro_syntax::content::UntypedIter; +use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier}; +use hayro_syntax::page::Resources; + +use super::MAX_DEPTH; + +#[cfg(test)] +mod tests; + +const MAX_OPERATIONS: usize = 100_000; +const LIMIT_WARNING: &str = "font scan operation limit reached: PDF/X-4 font embedding checks are incomplete"; +const DEPTH_WARNING: &str = "font scan nesting depth limit reached: PDF/X-4 font embedding checks are incomplete"; + +pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec { + let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() }; + let mut found: BTreeMap> = BTreeMap::new(); + let mut scan = FontScan::default(); + for (i, page) in file.pages().iter().enumerate() { + scan.font = None; + scan.mode = 0; + if let Some(content) = page.page_stream() { + scan.content(content, page.resources(), 0); + } + for name in std::mem::take(&mut scan.found) { + found.entry(name).or_default().push(i + 1); + } + if scan.limited { + break; + } + } + let mut warnings: Vec = found + .into_iter() + .map(|(name, pages)| { + let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let more = if pages.len() > 10 { ", …" } else { "" }; + let s = if pages.len() == 1 { "" } else { "s" }; + format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", ")) + }) + .collect(); + if scan.limited { + warnings.push(LIMIT_WARNING.to_string()); + } + if scan.depth_limited { + warnings.push(DEPTH_WARNING.to_string()); + } + warnings +} + +fn font_embedded(font: &Dict<'_>) -> bool { + let descriptor = match font.get::>(b"Subtype").as_deref() { + Some(b"Type3") => return true, + Some(b"Type0") => { + font.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).and_then(|d| d.get::>(b"FontDescriptor")) + } + _ => font.get::>(b"FontDescriptor"), + }; + descriptor.is_some_and(|d| d.contains_key(b"FontFile") || d.contains_key(b"FontFile2") || d.contains_key(b"FontFile3")) +} + +#[derive(Default)] +struct FontScan<'a> { + found: BTreeSet, + active: HashSet, + path: Vec, + completed: HashMap, BTreeSet>, + font: Option>, + mode: i32, + operations: usize, + limited: bool, + // A form nested too deeply to follow. The scan goes on (unlike `limited`), and the flag is + // never cleared, so a memoized subtree that was cut short can't hide it. + depth_limited: bool, +} + +// Forms inherit text state and missing resource names. Depth and ancestors also affect +// which descendants the depth/cycle guards permit; only identical contexts may share results. +#[derive(Hash, PartialEq, Eq)] +struct FormContext<'a> { + form: ObjectIdentifier, + font: Option<&'a [u8]>, + mode: i32, + resources: Vec<(&'a [u8], &'a [u8])>, + ancestors: Vec, +} + +impl<'a> FontScan<'a> { + fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let mut saved = Vec::new(); + let mut ops = UntypedIter::new(data); + while let Some(op) = ops.next() { + if self.operations == MAX_OPERATIONS { + self.limited = true; + return; + } + self.operations += 1; + let operator: &[u8] = op.operator; + match operator { + b"q" => saved.push((self.font.clone(), self.mode)), + b"Q" => { + if let Some(state) = saved.pop() { + (self.font, self.mode) = state; + } + } + b"Tf" => self.font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)), + b"Tr" => { + if let Some(mode) = op.operands().next().cloned().and_then(|n| i32::try_from(n).ok()) { + self.mode = mode; + } + } + b"Tj" | b"TJ" | b"'" | b"\"" if self.mode != 3 => { + if let Some(font) = self.font.as_ref().filter(|f| !font_embedded(f)) { + let name = font.get::>(b"BaseFont").map_or_else(|| "(unnamed)".to_string(), |n| n.as_str().to_string()); + self.found.insert(name); + } + } + b"Do" => { + let xo = op.operands().last().cloned().and_then(Object::into_name).and_then(|n| res.get_x_object(&n)); + if let Some(form) = xo.filter(|x| x.dict().get::>(b"Subtype").as_deref() == Some(b"Form")) + && (depth < MAX_DEPTH || { + // A form already being scanned is a cycle: it can't hide new content. + self.depth_limited |= !self.active.contains(&form.obj_id()); + false + }) + && self.active.insert(form.obj_id()) + { + let mut resources = Vec::new(); + let mut parent = Some(res); + while let Some(r) = parent { + resources.push((r.fonts.data(), r.x_objects.data())); + parent = r.parent(); + } + let key = FormContext { + form: form.obj_id(), + font: self.font.as_ref().map(Dict::data), + mode: self.mode, + resources, + ancestors: self.path.clone(), + }; + if let Some(found) = self.completed.get(&key) { + self.found.extend(found.iter().cloned()); + self.active.remove(&form.obj_id()); + continue; + } + let inner = Resources::from_parent(form.dict().get::>(b"Resources").unwrap_or_default(), res.clone()); + let state = (self.font.clone(), self.mode); + let outer_found = std::mem::take(&mut self.found); + self.path.push(form.obj_id()); + if let Ok(data) = form.decoded() { + self.content(&data, &inner, depth + 1); + } + self.path.pop(); + if !self.limited { + self.completed.insert(key, self.found.clone()); + } + self.found.extend(outer_found); + (self.font, self.mode) = state; + self.active.remove(&form.obj_id()); + if self.limited { + return; + } + } + } + _ => {} + } + } + } +} diff --git a/crates/pdf/src/pdfx/fonts/tests.rs b/crates/pdf/src/pdfx/fonts/tests.rs new file mode 100644 index 000000000..c148cb076 --- /dev/null +++ b/crates/pdf/src/pdfx/fonts/tests.rs @@ -0,0 +1,153 @@ +use std::time::{Duration, Instant}; + +use super::{DEPTH_WARNING, FontScan, LIMIT_WARNING, MAX_OPERATIONS, unembedded_fonts}; +use crate::check_pdfx4; +use crate::pdfx::tests::{place, tiny_pdf_with, x4}; +use designcraft_doc::{Document, build::NewDocument}; + +fn stream(entries: &str, content: &str) -> String { + format!("<<{entries}/Length {}>>\nstream\n{content}\nendstream", content.len()) +} + +fn fanout_pdf(levels: usize) -> Vec { + let mut objects = vec![ + "<>".to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + stream("", "/Next Do"), + ]; + for level in 0..levels { + let resources = if level + 1 == levels { + "/Font<>>>".to_string() + } else { + format!("/XObject<>", level + 6) + }; + let content = if level + 1 == levels { "BT /F1 12 Tf (bottom) Tj ET".to_string() } else { "/Next Do\n".repeat(10) }; + objects.push(stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{resources}>>"), &content)); + } + pdf_objects(&objects) +} + +fn pdf_objects(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.6\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[test] +fn nested_form_fanout_is_memoized_and_reports_bottom_font() { + let bytes = fanout_pdf(16); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let page = pdf.pages().first().unwrap(); + let mut scan = FontScan::default(); + let start = Instant::now(); + scan.content(page.page_stream().unwrap(), page.resources(), 0); + assert!(start.elapsed() < Duration::from_millis(500)); + assert_eq!(scan.operations, 155); + assert_eq!(scan.completed.len(), 16); + assert!(!scan.limited); + assert!(!scan.depth_limited); + assert_eq!(scan.found.into_iter().collect::>(), ["Helvetica"]); + assert!(check_pdfx4(&bytes).iter().any(|w| w.contains("Helvetica isn't embedded"))); +} + +#[test] +fn placed_pdf_nested_form_fanout_reports_unembedded_font() { + let mut doc = Document::new(&NewDocument::default()); + place(&mut doc, "fanout.pdf", fanout_pdf(14), (100.0, 100.0), (72.0, 72.0)); + let report = x4(&doc); + assert!(report.warnings.iter().any(|w| w.contains("Helvetica isn't embedded")), "{:?}", report.warnings); +} + +#[test] +fn form_memo_resolves_resource_names_in_each_parent_context() { + let wrapper = |font: &str| { + stream( + &format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>>>"), + "/Shared Do", + ) + }; + let bytes = pdf_objects(&[ + "<>".into(), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + stream("", "/A Do /B Do"), + wrapper("Helvetica"), + wrapper("Times-Roman"), + stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", "BT /F1 12 Tf (shared) Tj ET"), + ]); + let issues = unembedded_fonts(&bytes); + assert_eq!(issues.len(), 2, "{issues:?}"); + assert!(issues.iter().any(|w| w.contains("Helvetica isn't embedded"))); + assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded"))); +} + +#[test] +fn form_memo_replays_font_findings_on_each_page() { + let page = "<>>>/Contents 5 0 R>>"; + let bytes = pdf_objects(&[ + "<>".into(), + "<>".into(), + page.into(), + page.into(), + stream("", "/Shared Do"), + stream( + "/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>>>", + "BT /F1 12 Tf (shared) Tj ET", + ), + ]); + let issues = unembedded_fonts(&bytes); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].contains("(pages 1, 2)"), "{issues:?}"); +} + +#[test] +fn form_memo_keeps_inherited_font_and_resource_contexts_distinct() { + let draw = "BT (inherited) Tj /F1 12 Tf (resource) Tj ET"; + let form = stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]", draw); + let entries = "/Resources<>/F2<>>>/XObject<>>>"; + let bytes = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do /F2 12 Tf /X1 Do", &form); + let issues = unembedded_fonts(&bytes); + assert_eq!(issues.len(), 2, "{issues:?}"); + assert!(issues.iter().any(|w| w.contains("Times-Roman isn't embedded"))); +} + +#[test] +fn font_scan_operation_budget_warns_instead_of_silently_truncating() { + let content = "q Q\n".repeat(MAX_OPERATIONS); + let bytes = tiny_pdf_with("/Resources<<>>", &content, "<<>>"); + assert_eq!(unembedded_fonts(&bytes), [LIMIT_WARNING]); +} + +#[test] +fn font_scan_depth_limit_warns_instead_of_silently_truncating() { + // The font is two levels below the deepest form followed, and every level draws the next + // ten times: memo replays of the cut-short subtree still give one warning. + assert_eq!(unembedded_fonts(&fanout_pdf(18)), [DEPTH_WARNING]); +} + +#[test] +fn font_scan_cycles_are_not_reported_as_incomplete() { + let font = "/Font<>>>"; + let bytes = pdf_objects(&[ + "<>".to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + stream("", "/A Do"), + stream(&format!("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<<{font}/XObject<>>>"), "BT /F1 12 Tf (a) Tj ET /B Do"), + stream("/Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<>>>", "/A Do /B Do"), + ]); + let warnings = unembedded_fonts(&bytes); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Helvetica isn't embedded"), "{warnings:?}"); +} diff --git a/docs/agents.md b/docs/agents.md index 2b8d6e51e..566357fbe 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -63,6 +63,16 @@ The image must contain at most 16 million pixels. PDF/SVG graphics are unsupport not Adobe's palette algorithm. In the UI, click an image with the Color Theme Tool (Shift+I), or choose Color Theme… in the Swatches panel menu with one image selected. Cancel keeps the document unchanged. +## PDF/X-4 validation + +`designcraft-cli validate-pdf FILE.pdf` prints JSON with `valid` and `issues`, and exits non-zero +when a built-in check fails. Checks cover PDF version, output intent and profile, Info and XMP +identification, image interpolation, encryption, trim boxes, Type 3 fonts, device RGB under a CMYK +output intent, and unembedded fonts drawing visible text (including placed PDF forms). Render +mode 3 (invisible OCR text) is exempt from the font-embedding check. PDF/X-4 export reports these +issues as warnings; it does not embed or outline placed fonts. These checks are not a complete +external PDF/X certification. + ## Data merge `data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 381498fb6..2a05973af 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -55,6 +55,14 @@ layer survive interchange; literal XML and round-trip tests are in | Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% | | Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% | +PDF/X-4 export warns about unembedded fonts that draw visible text, including inside placed PDF +forms; `designcraft-cli validate-pdf FILE.pdf` reports the same issues, with font names and pages +(#909). Invisible text (render mode 3) is exempt from this font-embedding check. Detection does not +automatically embed or outline fonts; embedding, substitution or outlining of placed-PDF fonts +remains missing. A document-wide operation limit and a form-nesting depth limit report incomplete +font checks rather than traversing pathological nesting. These built-in checks are not complete +external PDF/X certification. + ## Placed and imported content | Format | Ext | InDesign | DesignCraft | Notes | Weight | @@ -114,6 +122,7 @@ up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | PDF/X-4 export and `validate-pdf` report visible unembedded fonts by name and page, including placed forms; invisible text is exempt, operation- or depth-limited checks report incompleteness, and automatic font repair remains missing (#909) | | 2026-10-15 | minor | IDML retains liquid versus ruler guide types on document and parent pages, with literal XML and mixed-guide round-trip coverage; object liquid constraints remain unsupported (#872) | | 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) | | 2026-10-13 | minor | `.indd` and `.indt` read through storytold/indd-utils; INDD remaining work is a real-file corpus (10–30 h); score unchanged until it is measured | diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..1076f6218 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -123,6 +123,18 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · spot, marks and bleed, the options dialog with remembered settings (#196). - **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md) +### 28. Placed-PDF font repair for PDF/X-4 (FF) +- **Missing:** embedding, substitution or outlining of unembedded fonts in placed PDFs; export + does not automatically embed or outline them. +- **Have:** PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible + text, including inside placed PDF forms, with font names and pages (#909). Invisible text + (render mode 3) is exempt from this font-embedding check. A document-wide operation limit and a + form-nesting depth limit report incomplete font checks rather than traversing pathological + nesting. +- **Impact:** detection now ships, but a placed PDF with visible unembedded fonts still needs + font repair before it can meet PDF/X-4 requirements. +- **Doc:** [file-format-parity.md](file-format-parity.md) + ### 10. Printing (H, F) - **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping. - **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through @@ -249,6 +261,7 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Gap 28 added for remaining placed-PDF font embedding, substitution or outlining; visible unembedded font detection now ships in PDF/X-4 export and `validate-pdf`, with invisible text exempt and operation- or depth-limited checks reported incomplete (#909) | | 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) | | 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) | | 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..53c0f2842 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -217,12 +217,19 @@ table is the one summed. | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer | | Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles | | Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback | -| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel | +| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), with visible unembedded font detection (below); no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel | | XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth | | View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) | | Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action | | Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI | +PDF/X-4 export warnings and `validate-pdf` report unembedded fonts drawing visible text, including +inside placed PDF forms, with font names and pages (#909). Invisible text (render mode 3) is exempt +from the font-embedding check. Fonts are not embedded or outlined automatically; embedding, +substitution or outlining of placed-PDF fonts remains missing ([gap 28](gaps.md#28-placed-pdf-font-repair-for-pdfx-4-ff)). +A document-wide operation limit and a form-nesting depth limit report incomplete font checks rather +than traversing pathological nesting. This is detection, not complete external PDF/X certification. + ## Remaining effort and calibration **Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10 @@ -463,6 +470,7 @@ Task notes that headed ROADMAP.md: | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Output assessment records visible unembedded font detection in PDF/X-4 export and `validate-pdf`, the invisible-text exemption, incomplete-check reporting at the operation and nesting-depth limits, and remaining placed-PDF font repair (#909); readiness and hours unchanged | | 2026-10-13 | minor | `.indd` and `.indt` open through storytold/indd-utils; INDD remaining work 10–30 h (real-file corpus); readiness unchanged until it is measured | | 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged | | 2026-10-11 | minor | Spring-loaded Zoom navigation added to the working feature inventory (DC-1); readiness estimates unchanged | From d080a5635a7be3376b129551dd814b11348a23ea Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:13:31 +0200 Subject: [PATCH 062/117] Swatches panel: a gradient dropped on a colour group row is kept too The drop zone held the ungrouped swatches only, so releasing the gradient over a colour group's row did nothing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 130 +++++++++++++++----------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index e5e9a934c..878904a5f 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -18,11 +18,57 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); - // A gradient dragged from the Gradient panel's thumbnail is dropped on the list. + // A gradient dragged from the Gradient panel's thumbnail is dropped on the list, colour + // groups included. let (_, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); } + for g in &doc.color_groups { + let open_id = egui::Id::new(("color_group_open", &g.name)); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name)); + if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + icons::paint( + ui.painter(), + egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)), + if open { "chevron-down" } else { "chevron-right" }, + t.text_dim, + ); + // A folder. + let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim)); + ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text); + // A double click's first click folded the group: it stays as it was. + if resp.clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(open_id, open)); + } + if resp.double_clicked() { + let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); + } + resp.context_menu(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() { + let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() { + let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name})); + ui.close(); + } + }); + if open { + for n in &g.swatches { + if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { + swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref()); + } + } + } + } }); // An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one. if let Some(g) = dropped @@ -30,51 +76,6 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { { let _ = app.run("swatch.new", json!({"swatch": g.0})); } - for g in &doc.color_groups { - let open_id = egui::Id::new(("color_group_open", &g.name)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name)); - if resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); - } - icons::paint( - ui.painter(), - egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)), - if open { "chevron-down" } else { "chevron-right" }, - t.text_dim, - ); - // A folder. - let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0)); - ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside); - ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim)); - ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text); - // A double click's first click folded the group: it stays as it was. - if resp.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } - if resp.double_clicked() { - let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); - } - resp.context_menu(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() { - let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name})); - ui.close(); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() { - let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name})); - ui.close(); - } - }); - if open { - for n in &g.swatches { - if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { - swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref()); - } - } - } - } ui.add_space(6.0); // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). let picked: Option = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); @@ -1054,7 +1055,6 @@ mod gradient_panel_tests { /// which keeps it as a gradient swatch. #[test] fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() { - use egui::{Event, Modifiers, PointerButton}; let mut app = app_with_frame(); app.run("object.gradient", json!({})).unwrap(); app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap(); @@ -1064,10 +1064,23 @@ mod gradient_panel_tests { let before = named(&app); let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); h.run_steps(6); + drag_gradient_to(&mut h, "[Black]"); + let after = named(&h.state().app); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch")); + let d = &h.state().app.session.doc().unwrap().doc; + assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. }))); + let id = h.state().app.session.doc().unwrap().selection.items[0]; + assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name"); + } + + /// Drag the Gradient panel's thumbnail onto the Swatches panel row labelled `label`. + fn drag_gradient_to(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str) { + use egui::{Event, Modifiers, PointerButton}; // The thumbnail sits left of the Type row. let ty = h.get_by_label("Type:").rect(); let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0); - let to = h.get_by_label("[Black]").rect().center(); + let to = h.get_by_label(label).rect().center(); h.event(Event::PointerMoved(from)); h.step(); h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -1078,13 +1091,22 @@ mod gradient_panel_tests { } h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(4); - let after = named(&h.state().app); - assert_eq!(after.len(), before.len() + 1); - assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch")); + } + + /// A colour group's row is part of the list too: a gradient dropped on it becomes a swatch. + #[test] + fn a_gradient_dropped_on_a_colour_group_becomes_a_swatch() { + let mut app = app_with_frame(); + app.run("object.gradient", json!({})).unwrap(); + let group = app.run("swatch.newColorGroup", json!({})).unwrap()["name"].as_str().unwrap().to_string(); + app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap(); + // Tall enough to show the group's row below the swatches. + app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120, "height": 700})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + h.run_steps(6); + drag_gradient_to(&mut h, &group); let d = &h.state().app.session.doc().unwrap().doc; - assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. }))); - let id = h.state().app.session.doc().unwrap().selection.items[0]; - assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name"); + assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })), "dropped on the group's row"); } } From e6d78a71d05c015d39c87ce2b6cbeaeb4e184a09 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 16:09:27 +0500 Subject: [PATCH 063/117] A right-to-left paragraph's first-line indent is at its right edge (#1448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-line indent was added to the left indent whatever the paragraph's direction, so a right-to-left paragraph's first line moved the wrong way. With the hanging indent a numbered list uses — right indent 10 mm, first line −10 mm — the first line went 10 mm outside the frame on the left while the wrapped lines sat where it should have been. It is the paragraph's start-side indent, which is the right one in a right-to-left paragraph; the drop-cap case beside it already made that distinction. Measured on a 200 pt column with that hanging indent: first line right edge 200 (the frame's edge, as InDesign sets it) wrapped right edge 171.65 (10 mm in) every line left edge >= 0 (nothing outside the frame) and the same paragraph left to right is unchanged, first line at 0 and wrapped lines 10 mm in. The test checks both directions; the right-to-left half fails without the change. 359 composition tests pass unchanged. --- crates/compose/src/lib.rs | 8 ++++++- crates/compose/src/tests.rs | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89ea89bd3..aaaee0802 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1674,10 +1674,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // A drop cap sits at the start-side indent; the first-line indent doesn't move it. let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent); + let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 }; let (ind_l, ind_r) = match beside { Some(w) if rtl => (pp.left_indent, pp.right_indent + w), Some(w) => (pp.left_indent + w, pp.right_indent), - None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent), + // The first-line indent is at the paragraph's start side, which is the right + // one in a right-to-left paragraph: applied on the left it moved the line the + // wrong way, out of the frame for the hanging indent a numbered list uses + // (#1448). + None if rtl => (pp.left_indent, pp.right_indent + first_line), + None => (pp.left_indent + first_line, pp.right_indent), }; let lx0 = x0 + ind_l; let lx1 = x1 - ind_r; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 15c412ada..57a545b08 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7310,3 +7310,45 @@ fn rule_above_offset_is_measured_from_the_baseline() { assert!((rule.height() - 1.5).abs() < 1e-6); assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline); } + +/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on +/// the left it moved the first line the wrong way — out of the frame for the hanging indent a +/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where +/// the first line should have been (#1448). +#[test] +fn an_rtl_first_line_indent_is_at_the_right_edge() { + const MM: f64 = 72.0 / 25.4; + // Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the + // placement that is being measured, so no Arabic font has to be present. + let text = LOREM; + let rect = Rect::new(0.0, 0.0, 200.0, 300.0); + let para = |dir, align, first, left, right| ParaAttrs { + direction: Some(dir), + align: Some(align), + first_line_indent: Some(first), + left_indent: Some(left), + right_indent: Some(right), + ..Default::default() + }; + // The hanging indent of a numbered list, right to left. + let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() >= 2, "the text wraps: {}", lines.len()); + let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min); + // The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it. + assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0])); + assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1])); + // And nothing leaves the frame on the left, which is where the first line used to go. + for (i, l) in lines.iter().enumerate() { + assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l)); + } + + // The same paragraph left to right is unchanged: the first line hangs on the left. + let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0])); + assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); +} From 6c251075080b4cdbf8c8d6aa6debaef66cf83d65 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 22 Oct 2026 21:19:02 +0400 Subject: [PATCH 064/117] ui: the Discord button widens to fit its label instead of spilling out The start screen's community card reserves the button's measured width, so the line beside it wraps before the button. Georgian's label overflowed the fixed 190 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/about.rs | 18 +++++++++++++----- crates/ui-egui/src/chrome.rs | 6 ++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index 14745607a..75442b93c 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -77,16 +77,24 @@ pub fn paint_chat_icon(p: &egui::Painter, c: egui::Pos2, s: f32, color: Color32) p.circle_filled(body.center() + vec2(s * 0.18, 0.0), eye, bg); } -/// The big "Join our Discord" button. Returns true when clicked. +/// The Discord button's size for `label`: `min`, widened when the label would not fit inside it. +pub fn discord_button_size(ctx: &egui::Context, label: &str, min: egui::Vec2) -> egui::Vec2 { + let galley = crate::rtl::plain(ctx, label, semibold((min.y * 0.42).max(11.0)), Color32::WHITE); + egui::vec2(min.x.max(min.y * 0.5 + 8.0 + galley.size().x + min.y), min.y) +} + +/// The big "Join our Discord" button. Returns true when clicked. `size` is the minimum: a longer label +/// widens the button (see `discord_button_size`) rather than spilling out of it. pub fn discord_button(ui: &mut egui::Ui, label: &str, size: egui::Vec2) -> bool { - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand); - let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD }; - ui.painter().rect_filled(r, size.y / 2.0, fill); + let size = discord_button_size(ui.ctx(), label, size); let icon = size.y * 0.5; let font = semibold((size.y * 0.42).max(11.0)); let galley = crate::rtl::plain(ui.ctx(), label, font, Color32::WHITE); let total = icon + 8.0 + galley.size().x; + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let resp = resp.on_hover_text(links::DISCORD).on_hover_cursor(egui::CursorIcon::PointingHand); + let fill = if resp.hovered() { Color32::from_rgb(0x47, 0x52, 0xc4) } else { DISCORD }; + ui.painter().rect_filled(r, size.y / 2.0, fill); let x0 = r.center().x - total / 2.0; paint_chat_icon(ui.painter(), pos2(x0 + icon / 2.0, r.center().y), icon, Color32::WHITE); ui.painter().galley(pos2(x0 + icon + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e853e6793..be139a095 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1060,7 +1060,9 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover()); crate::about::paint_mark(ui, r, crate::about::BRAND); ui.add_space(8.0); - let text_width = (ui.available_width() - BUTTON.x - 16.0).max(120.0); + let label = crate::i18n::tr(&app.ui.language, "Join our Discord"); + let button = crate::about::discord_button_size(ui.ctx(), label, BUTTON); + let text_width = (ui.available_width() - button.x - 16.0).max(120.0); ui.vertical(|ui| { ui.set_max_width(text_width); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); @@ -1090,7 +1092,7 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), BUTTON) { + if crate::about::discord_button(ui, label, BUTTON) { let _ = app.run("help.discord", json!({})); } }); From bbb95930a2670b30c4fd4463ad3a79356d540222 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:26:49 +0200 Subject: [PATCH 065/117] Skip control words and symbols used as RTF \uN fallbacks Found while testing crates/textimport/src/rtf.rs:197-243 (the \u handler at :235-242; control words never decrement `pending`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index baf065c30..410455669 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -134,6 +134,11 @@ pub fn import(bytes: &[u8]) -> Result { } if !next.is_ascii_alphabetic() { // Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen). + i += 2; + if pending > 0 { + pending -= 1; + continue; + } let skip = stack.last().is_some_and(|s| s.skip); if !skip { match next { @@ -149,7 +154,6 @@ pub fn import(bytes: &[u8]) -> Result { _ => {} } } - i += 2; continue; } // A control word with an optional numeric parameter and one delimiting space. @@ -172,6 +176,11 @@ pub fn import(bytes: &[u8]) -> Result { k += 1; } i = k; + // A control word is one character of the `\uN` fallback (a following `\u` is not). + if pending > 0 && word != "u" { + pending -= 1; + continue; + } let skip = stack.last().is_some_and(|s| s.skip); if SKIP.contains(&word.as_str()) { flush(&mut st, &mut buf, &fmt); @@ -320,6 +329,16 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab"); } + /// A control word or control symbol is one character of the `\uN` fallback, so it is skipped + /// like a plain character and does not eat the text after it. + #[test] + fn control_fallback_after_unicode_is_skipped() { + let i = import(br"{\rtf1 \u8220\ldblquote Hi\u8221\rdblquote x\u8212\emdash y}").unwrap(); + assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y"); + let i = import(br"{\rtf1 \u8220\{Hi}").unwrap(); + assert_eq!(i.story.text, "\u{201C}Hi"); + } + /// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form /// our own exporter and Word write). Each half was converted alone and dropped. #[test] From 2ddcf6d2a1207cccef8836454a65a4cdfec1e9b2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:08:56 +0200 Subject: [PATCH 066/117] Decode UTF-16 plain text files by BOM on import Found while testing crates/textimport/src/lib.rs:117 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/lib.rs | 12 +++++++++++- crates/textimport/src/tagged.rs | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index ba9cb92e3..56eac1a00 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -114,7 +114,7 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { if l.ends_with(".doc") { return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into())); } - Ok(plain(&String::from_utf8_lossy(bytes))) + Ok(plain(&tagged::decode(bytes))) } /// Plain text: line breaks become paragraphs (CRLF / CR normalised). @@ -136,4 +136,14 @@ mod tests { assert!(is_text_file("Report.DOCX") && !is_text_file("a.png")); assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_)))); } + + #[test] + fn plain_text_utf16() { + let mut le = vec![0xFF, 0xFE]; + le.extend("Hi there".encode_utf16().flat_map(u16::to_le_bytes)); + assert_eq!(import("a.txt", &le).unwrap().story.text, "Hi there"); + let mut be = vec![0xFE, 0xFF]; + be.extend("Hi there".encode_utf16().flat_map(u16::to_be_bytes)); + assert_eq!(import("a.txt", &be).unwrap().story.text, "Hi there"); + } } diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 9bc84b766..18704cbc4 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -72,7 +72,7 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { } /// Text of a Tagged Text file (ASCII, UTF-8 or UTF-16 by byte-order mark). -fn decode(bytes: &[u8]) -> String { +pub(crate) fn decode(bytes: &[u8]) -> String { if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { let le = bytes[0] == 0xFF; let units: Vec = bytes[2..] From 62c71d004a8e6188d9dfc64aaa0479457d3bbae9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:03:00 +0200 Subject: [PATCH 067/117] Align towards/away from spine on parent-page folios by page side (#1577) --- crates/compose/src/lib.rs | 10 +++++++--- crates/compose/src/tests.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89ea89bd3..959f5da37 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -679,10 +679,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let abs = doc.first_page_of_spread(si) + pi; (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) } - (Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => { + (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); - let _ = pi; - (Some(prefix), None, false) + // The side of the parent page under the frame, so a folio there aligns to the spine of its own page. + let left = sp + .page_at_x(item.bounds().center().x) + .and_then(|pi| sp.pages.get(pi)) + .is_some_and(|p| p.side == designcraft_doc::PageSide::Left); + (Some(prefix), None, left) } _ => (None, None, false), }; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 15c412ada..d210a4426 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7310,3 +7310,16 @@ fn rule_above_offset_is_measured_from_the_baseline() { assert!((rule.height() - 1.5).abs() < 1e-6); assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline); } + +#[test] +fn parent_page_frames_know_which_side_of_the_spine_they_are_on() { + // Folios on a facing-pages parent: towards/away from spine must differ between its left and right page. + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let w = d.parents[0].pages[0].width; + let para = ParaFormat { para: ParaAttrs { align: Some(Align::TowardsSpine), ..Default::default() }, ..Default::default() }; + let (_, left_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, w - 36.0, 730.0), lid, "7", para.clone()).unwrap(); + let (_, right_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(w + 36.0, 700.0, 2.0 * w - 36.0, 730.0), lid, "8", para).unwrap(); + assert!(frame_specs(&d, left_story)[0].left_page); + assert!(!frame_specs(&d, right_story)[0].left_page); +} From fd3d5087fc2021a59d12ac436f9e5d7db5736c5e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:02:24 +0200 Subject: [PATCH 068/117] Cursors: paint the tool cursors in the window on Wayland, so they keep the system size (#1544) --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/cursors.rs | 58 +++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..6ffe8166d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5317,7 +5317,7 @@ mod tests { let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type")); let ppp = h.ctx.pixels_per_point(); let images = &mut h.state_mut().cursors; - match crate::cursors::MODE { + match crate::cursors::mode() { crate::cursors::Mode::Os => { assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen"); assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection"); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 566fe65f9..28f98a5e0 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -3,10 +3,10 @@ //! what a click does (add, delete, close, convert, continue), and a curved double arrow for rotating. //! Cursors without a glyph stay system cursors. //! -//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so -//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show -//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The -//! web build keeps the system cursors. +//! Linux (X11) and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, +//! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show +//! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own +//! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors. use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex}; use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2}; @@ -27,13 +27,32 @@ pub enum Mode { System, } -pub const MODE: Mode = if cfg!(target_arch = "wasm32") { - Mode::System -} else if cfg!(target_os = "macos") { - Mode::Painted -} else { - Mode::Os -}; +/// The mode for a platform: `wayland` is true on a Linux Wayland session. +fn mode_for(wasm: bool, macos: bool, wayland: bool) -> Mode { + if wasm { + Mode::System + } else if macos || wayland { + Mode::Painted + } else { + Mode::Os + } +} + +/// Whether the environment describes a Wayland session: a Wayland display, unless the session says X11. +fn is_wayland(wayland_display: Option<&str>, session_type: Option<&str>) -> bool { + wayland_display.is_some_and(|d| !d.is_empty()) && !session_type.is_some_and(|t| t.eq_ignore_ascii_case("x11")) +} + +/// How cursor glyphs are shown on this system. On Wayland the compositor sizes a cursor bitmap by +/// its own scale, so the OS glyph came out far too big; there the canvas paints it, as on macOS. +pub fn mode() -> Mode { + static MODE: std::sync::OnceLock = std::sync::OnceLock::new(); + *MODE.get_or_init(|| { + let wayland = cfg!(target_os = "linux") + && is_wayland(std::env::var("WAYLAND_DISPLAY").ok().as_deref(), std::env::var("XDG_SESSION_TYPE").ok().as_deref()); + mode_for(cfg!(target_arch = "wasm32"), cfg!(target_os = "macos"), wayland) + }) +} /// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts /// through another part's ink. @@ -289,9 +308,9 @@ pub fn system(c: Cursor) -> egui::CursorIcon { } /// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint -/// the glyph per [`MODE`], and return the cursor icon to set. +/// the glyph per [`mode`], and return the cursor icon to set. pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon { - match MODE { + match mode() { Mode::Os => { // The glyphs keep the system cursors' size whatever the interface scale. let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01); @@ -359,6 +378,19 @@ mod tests { assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none()); } + #[test] + fn wayland_paints_the_glyphs_and_x11_keeps_os_cursors() { + assert_eq!(mode_for(false, false, true), Mode::Painted); + assert_eq!(mode_for(false, false, false), Mode::Os); + assert_eq!(mode_for(false, true, false), Mode::Painted); + assert_eq!(mode_for(true, false, true), Mode::System); + assert!(is_wayland(Some("wayland-0"), Some("wayland"))); + assert!(is_wayland(Some("wayland-0"), None)); + assert!(!is_wayland(Some("wayland-0"), Some("x11"))); + assert!(!is_wayland(Some(""), Some("wayland"))); + assert!(!is_wayland(None, Some("wayland"))); + } + #[test] fn the_selection_arrows_differ_in_fill() { // A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct. From ce486fda63d64445bd4ca413b97ebada9b19ec8e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:03:10 +0200 Subject: [PATCH 069/117] Fix slice panics in the PDF post-passes on malformed page trees and metadata (#1591) --- crates/pdf/src/forms.rs | 4 ++-- crates/pdf/src/pdfx.rs | 14 +++++++++++++- crates/pdf/src/transitions.rs | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 39aa597ec..07f4cf04c 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -8,7 +8,7 @@ use std::fmt::Write as _; use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions}; use crate::export::Sheet; -use crate::transitions::{int_after, object, rfind}; +use crate::transitions::{bracketed, int_after, object, rfind}; /// A field placed on an output page: (page index, PDF rectangle, field). pub(crate) type Placed = (usize, [f64; 4], FormField); @@ -94,7 +94,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - let pages_id = int_after(&cat, "/Pages")?; let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string(); let kids_str = &pages[pages.find("/Kids")? + 5..]; - let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?]; + let kids_str = bracketed(kids_str)?; let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let kids: Vec = nums.chunks(2).map(|c| c[0]).collect(); diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 534886f1d..74c385e73 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -94,7 +94,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option> let s0 = find(body, b"stream", 0)? + b"stream".len(); let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count(); let s1 = rfind(body, b"endstream")?; - let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string(); + let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string(); let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string(); if dict.contains("/Filter") { return None; @@ -668,6 +668,18 @@ mod tests { out } + /// A metadata object whose only `stream` is the one inside `endstream` is refused, not a slice panic. + #[test] + fn metadata_with_only_endstream_does_not_panic() { + let objects = ["<>", "<>", "<>endstream"]; + let mut out = b"%PDF-1.6\n".to_vec(); + for (i, o) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n"); + assert!(super::make_pdfx4(&out, "t", None).is_none()); + } + /// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed. #[test] fn default_rgb_makes_device_rgb_managed() { diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index cbfbe27cc..d37581afd 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -43,6 +43,14 @@ pub(crate) fn int_after(s: &str, key: &str) -> Option { rest[..end].parse().ok() } +/// The text between the first `[` and the first `]` after it. `None` when there is no such pair (a `]` +/// before the `[` included). +pub(crate) fn bracketed(s: &str) -> Option<&str> { + let open = s.find('[')?; + let close = s[open..].find(']')? + open; + s.get(open + 1..close) +} + /// Byte range of object `id`'s body (between `obj` and `endobj`). pub(crate) fn object(pdf: &[u8], id: usize) -> Option> { // The latest copy (incremental updates re-state objects further on). @@ -132,7 +140,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option]) -> Option> let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string(); let kids_at = pages.find("/Kids")?; let kids_str = &pages[kids_at + 5..]; - let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?]; + let kids_str = bracketed(kids_str)?; let nums: Vec = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let kids: Vec = nums.chunks(2).map(|c| c[0]).collect(); let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid))); @@ -199,6 +207,31 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option> { Some(out) } +#[cfg(test)] +mod bracket_tests { + use super::{add_page_entries, bracketed}; + + #[test] + fn bracketed_ignores_a_close_before_the_open() { + assert_eq!(bracketed(" [3 0 R 4 0 R] "), Some("3 0 R 4 0 R")); + assert_eq!(bracketed("] ["), None); + assert_eq!(bracketed("] [3 0 R]"), Some("3 0 R")); + assert_eq!(bracketed("[]"), Some("")); + } + + /// `]` before the `[` of /Kids (a spoofed page tree) is refused, not a slice panic. + #[test] + fn kids_closed_before_opened_is_refused() { + let objects = ["<>", "<>"]; + let mut out = b"%PDF-1.6\n".to_vec(); + for (i, o) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + out.extend_from_slice(b"trailer\n<>\nstartxref\n0\n%%EOF\n"); + assert!(add_page_entries(&out, &[Some("/Rotate 0".to_string())]).is_none()); + } +} + #[cfg(test)] mod object_index_tests { use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts}; From d292dd2acd0cff545ab7144bee46c0464abad2fa Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:04:39 +0200 Subject: [PATCH 070/117] IDML export: write the graphic placed in a table cell (#1551) --- crates/idml/src/export.rs | 32 +++++++++++++++++++++++++++++++- crates/idml/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 99dcb383e..eb30d533c 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2797,7 +2797,23 @@ impl<'a> Ex<'a> { ce.set(&format!("{side}StrokePriority"), priority); } } - for psr in self.story_paras(&cell.text) { + let mut paras = self.story_paras(&cell.text); + // A graphic cell holds its image as an inline object in the cell's first paragraph. + if let Some(g) = &cell.graphic + && let Some(object) = self.cell_graphic_el(g) + { + let mut range = self.csr_el(&CharFormat::default()); + range.push(object); + match paras.first_mut() { + Some(first) => first.push(range), + None => { + let mut psr = self.psr_el(&ParaFormat::default()); + psr.push(range); + paras.push(psr); + } + } + } + for psr in paras { ce.push(psr); } el.push(ce); @@ -2806,6 +2822,20 @@ impl<'a> Ex<'a> { el } + /// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None` + /// when the graphic's asset is missing. + fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option { + self.d.assets.get(&g.asset)?; + let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0)); + let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown)); + item.content = Content::Graphic(g.clone()); + let mut el = self.item_el(&item, Affine::IDENTITY); + el.set("Self", self.fresh()); + el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0")); + Some(el) + } + fn psr_el(&mut self, pf: &ParaFormat) -> El { let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style)); let mut props = Vec::new(); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 37fb2af50..7fce003f4 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4368,3 +4368,36 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } + +/// A graphic placed in a table cell is written into the cell, not dropped. +#[test] +fn exports_graphic_in_table_cell() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro", ParaFormat::default()).unwrap(); + let asset = designcraft_doc::AssetId(d.alloc()); + let id = d.add_asset(designcraft_doc::Asset { + id: asset, + name: "tick.png".into(), + mime: "image/png".into(), + data: std::sync::Arc::new(vec![1, 2, 3]), + ..Default::default() + }); + let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0); + t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic { + asset: id, + size: (24.0, 24.0), + xf: designcraft_geom::Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.story_mut(sid).unwrap().insert_table(5, t); + let story = zip_text(&export_idml(&d), "Stories/"); + let cell = story.split("").next().unwrap_or_default(); + assert!(cell.contains(" Date: Thu, 22 Oct 2026 15:38:12 -0400 Subject: [PATCH 071/117] Release 0.12.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b022ecfe..e75e8d5a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,7 +942,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arboard", "designcraft-engine", @@ -964,7 +964,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -980,7 +980,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-cms", "kurbo", @@ -992,7 +992,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1012,7 +1012,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "designcraft-color", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "caseless", "craft-atomic", @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "designcraft-color", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.11.0" +version = "0.12.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1087,7 +1087,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1099,7 +1099,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.11.0" +version = "0.12.0" dependencies = [ "flo_curves", "kurbo", @@ -1112,7 +1112,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1127,7 +1127,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1142,7 +1142,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1155,7 +1155,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "craft-pdf", @@ -1178,7 +1178,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "designcraft-color", @@ -1196,7 +1196,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1215,7 +1215,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-doc", "quick-xml", @@ -1224,7 +1224,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1263,7 +1263,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5814,7 +5814,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 0e728e87f..bc5982898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 2caa39e1304492b6176aa1d4c79e093a322df174 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 22 Oct 2026 22:46:08 +0200 Subject: [PATCH 072/117] Autoflow: fill the pages that follow before adding pages at the end Autoflow (Shift-click, autoflow on Place, text.thread with autoflow) inserted new pages right after the story's last frame, in front of the document's own pages. As in InDesign, it now threads one frame per column on each page that follows, whatever else the page holds, and only then adds pages at the end of the document. It fills whole pages, so an estimate that stops inside a page can't leave its later columns out of the thread. Frames it set past the end of the text are removed again; the pages they were on stay. Smart Text Reflow of the primary text frame keeps adding pages after the story's last page. Shrinks gap 26: flowing into the primary text frame of the following pages is left. --- ROADMAP.md | 3 +- crates/engine/src/cmd/place_text.rs | 192 +++++++++++++++++++++++++--- crates/engine/src/cmd/thread.rs | 2 +- docs/gaps.md | 15 +-- docs/target-app-parity.md | 6 +- 5 files changed, 190 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..89a9bd324 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. -- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads frames on the pages that follow, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`). - Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). - File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI. @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again, and the pages they were on stay. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index c54b55084..1053fafc0 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool { } /// Thread new frames until the story fits: first into the columns after the last frame's column -/// on its page (when it sits in one), then one frame per column on each page it adds. With -/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the -/// live area with the page's columns instead. Empty frames it made at the end of the thread are -/// removed again, except the first on a page it didn't add; a page it added that holds only such -/// a frame goes with it. Returns pages added. +/// on its page (when it sits in one), then one frame per column on each page that follows it, +/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign +/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after +/// the last frame's page, each with one frame on the live area with the page's columns instead. +/// Empty frames it made at the end of the thread are removed again; a page it added that holds +/// only such a frame goes with it. Returns pages added. /// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors /// name `cmd`, the command that autoflows. pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result { @@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s let mut made = Vec::new(); let mut new_pages = Vec::new(); let mut first = true; + // The next page of the document autoflow may set frames on; it only moves forward. + let mut following = 0; loop { let (overset, held, full) = fit(d, sid); let Some(at) = overset else { break }; @@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s let rects = flow_rects(d, dir, vertical, page); let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1); if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) { - for r in rest.iter().take(want) { + // Whole pages: an estimate that stops inside one would leave its later columns out + // of the thread for good. The cleanup below takes the frames the text doesn't reach. + for r in rest { prev = add_flow_frame(d, si, *r, layer, prev)?; made.push(prev); } continue; } } + following = following.max(page + 1); + if !primary && following < d.page_count() { + let mut left = want; + while left > 0 && following < d.page_count() { + let Some((si, _)) = d.page_loc(following) else { break }; + for r in flow_rects(d, dir, vertical, following) { + prev = add_flow_frame(d, si, r, layer, prev)?; + made.push(prev); + left = left.saturating_sub(1); + } + following += 1; + } + continue; + } if added >= max_pages { return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages"))); } + // Past the following pages, `page` is the document's last: autoflow adds pages at its end. let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) }; let pages = want.div_ceil(per_page).clamp(1, max_pages - added); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); @@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s } let page = d.page_of_item(f); if d.page_of_item(*before) != page { - // The first frame on its page: the estimate added more pages than the text needs. - // Drop the page too when autoflow added it and the frame is all it holds. - let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break }; - let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg)); - if !alone { - break; + // The first frame on its page: the estimate asked for more pages than the text needs. + // Drop the page too when autoflow added it and the frame is all it holds; a page the + // document had keeps whatever else is on it. + match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) { + Some(pg) => { + let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg)); + if !alone { + break; + } + d.remove_item(f)?; + d.delete_pages(&[pg])?; + added = added.saturating_sub(1); + } + None => { + d.remove_item(f)?; + } } - d.remove_item(f)?; - d.delete_pages(&[pg])?; - added = added.saturating_sub(1); } else { d.remove_item(f)?; } @@ -568,6 +595,141 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A facing-pages document with two columns, its inside margin wider than the outside one, so + /// left and right pages have their columns in different places. A graphic frame sits on the + /// page `marker` (0-based). Returns the session and the marker's id. + fn facing_document(pages: usize, marker: usize) -> (Session, u64) { + let mut s = Session::new(); + let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36}); + s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins})) + .unwrap(); + let d = &s.doc().unwrap().doc; + let (si, _) = d.page_loc(marker).unwrap(); + let m = d.page(marker).unwrap().margin_rect(); + let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0]; + let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + // Selected, an empty frame would take the placed text. + s.execute("edit.deselectAll", &json!({})).unwrap(); + (s, id) + } + + /// Every frame of the story on the column of its page, the story running through the pages in + /// order, two frames a page. + fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) { + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "frame {i}"); + assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}"); + } + } + + #[test] + fn autoflow_fills_the_pages_that_follow_before_adding_any() { + // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty + // pages the document already had. InDesign threads frames on those first. + let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap(); + let (mut s, marker) = facing_document(6, 2); + let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); + let sid = StoryId(r["story"].as_u64().unwrap()); + let frames = d.story(sid).unwrap().frames.clone(); + // Past page 3, which holds the marker: frames go on a page whatever else it holds. + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len()); + assert_frames_in_columns(d, &frames); + // The text ends before the document does: no empty frame after it. + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()))); + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn autoflow_adds_pages_at_the_end_of_the_document() { + let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap(); + let (mut s, marker) = facing_document(3, 2); + let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + assert!(d.page_count() > 4, "{r}"); + assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}"); + // The document's own pages come first, the added ones after the last of them. + assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); + let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone(); + assert_frames_in_columns(d, &frames); + assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1)); + // One undo step takes the frames, the pages and the story away. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); + assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn autoflow_fills_every_column_of_a_following_page() { + // A first frame across the whole live area holds twice what a column does, so autoflow + // asks for half the frames it needs. Asked page by page, it left the later columns of a + // page out of the thread and went on to the next page. + let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(d.item(frames[0]).unwrap().bounds(), m); + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len()); + for (k, f) in frames[1..].iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, 1 + k / 2, "frame {}", k + 1); + assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() { + // A small first frame makes autoflow ask for more frames than the columns need; the ones + // it set on the following pages past the end of the text go again, the pages stay. + let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), 8); + let sid = StoryId(r["story"].as_u64().unwrap()); + let frames = &d.story(sid).unwrap().frames; + assert!(frames.len() >= 2, "{r}"); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn autoflow_adds_only_the_pages_breaks_ask_for() { // Regression (#262): a frame a break ends early made autoflow take it for a frame that diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index d5056d463..51bab42db 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Thread Text Frames", [], None, - "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}", + "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}", has_doc, thread ), diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..d3587a2aa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -206,13 +206,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · parallel story composition. - **Estimate:** 40–80 h. -### 26. Autoflow fills the following pages before it adds pages (F) -- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the - story's last frame, one margin-sized frame each. InDesign first threads a frame per column on - the existing pages that follow, then adds pages at the end of the document. -- **Impact:** autoflowing into a document that already has pages inserts new pages in the - middle of it instead of using the empty pages. -- **Estimate:** 4–8 h. +### 26. Autoflow flows into the primary text frame of the pages that follow (F) +- **Missing:** autoflow threads a frame per column on the pages that follow, then adds pages at + the end of the document, as InDesign does. On a page whose parent has a primary text frame it + still sets a new frame instead of flowing into the primary one. +- **Estimate:** 2–4 h. ## Lower @@ -249,6 +247,7 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Gap 26 shrunk: autoflow threads a frame per column on the pages that follow the story's last frame, then adds pages at the end of the document | | 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) | | 2026-10-16 | trivial | Gap 13 shrunk: Greek hyphenates, so five languages do (#1077) | | 2026-10-13 | minor | Gap 16: Parent Options dialog and overrides of base items on a parent spread | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..24c94283d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -211,7 +211,7 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer | @@ -299,7 +299,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy; R/G/B, C/M/Y/K and hex fields; a CMYK colour stays CMYK unless R/G/B values are typed); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`. -- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column and adds pages until the text fits; Remove Styles option (`designcraft-textimport`). +- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column, on the pages that follow first, then adds pages at the end of the document until the text fits; Remove Styles option (`designcraft-textimport`). - Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). - File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI. From 079b5bea820291d6f219fb5db2cf3f967481ff69 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 23:31:37 +0000 Subject: [PATCH 073/117] CI: dev profile keeps line tables only --- Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index bc5982898..8a83e0463 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,11 +103,16 @@ todo = "warn" too_many_arguments = "allow" type_complexity = "allow" +# Line tables keep file and line in panics and backtraces; full debug info +# for every test binary and dependency would multiply the target dir size. +# The test profile inherits both settings. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 From 40e86d48a521d15d80f370705e13f9362c8bfb00 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 23:35:30 +0000 Subject: [PATCH 074/117] CI: xtask runs tests with nextest, doctests apart, wasm in one check --- xtask/src/main.rs | 71 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e730feb51..5000860cc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -12,7 +12,7 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask @@ -21,8 +21,10 @@ commands: assets check that every icon/image/font/asset is attributed in ASSETS.md layers enforce the crate dependency layering (plan/architecture.md §3) pdf-deps verify the pinned PDF backend, including Krilla dependencies - wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure) + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call) + test workspace tests (cargo nextest when installed, else cargo test), then doctests + ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm + (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -39,6 +41,7 @@ fn main() -> ExitCode { Some("layers") => cmd_layers(), Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), @@ -134,21 +137,51 @@ fn wasm_set() -> Result, String> { .collect()) } +/// One `cargo check` for the whole set: each crate is checked once, and +/// `--keep-going` still reports every failing crate. Features unify across the +/// set, so each crate is checked with the union of the features the set enables. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; - let mut results = Vec::new(); + if set.is_empty() { + return Err("no wasm-safe crates found in the workspace metadata".into()); + } + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for pkg in &set { - let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok(); - results.push((pkg.clone(), ok)); + c.args(["-p", pkg]); } - println!("\nwasm32-unknown-unknown check:"); - for (p, ok) in &results { - println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); - } - let failed = results.iter().filter(|r| !r.1).count(); - if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } + run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p ")))?; + println!("\nwasm32-unknown-unknown check: ok ({} crates: {})", set.len(), set.join(", ")); + Ok(()) +} + +/// Test targets for the test build. Examples are left out so they aren't +/// linked; clippy `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") } fn cmd_ci() -> Result<(), String> { @@ -171,14 +204,8 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo clippy --workspace --all-targets -- -D warnings") }), ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") - }), - ), + ("test", Box::new(cmd_test)), + ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), From 15e4fdd1243377628a6278cc5e4ecfbe0d9ca81d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 17:57:36 -0600 Subject: [PATCH 075/117] #329: test that the Help menu works on the start screen The Help menu's links were greyed out when no document was open, because UI commands outside app. and window. needed a document. The test checks every help.* UI command is enabled with an empty session while another UI command (view.rulers) stays disabled. Without the help. exemption it fails on "help.discord must work from the start screen". --- crates/ui-egui/src/menus.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..41da5a69e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5642,4 +5642,17 @@ mod tests { assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}"); assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}"); } + + // Regression test for #329: on the start screen (no document) the Help menu's links were greyed out. + #[test] + fn help_links_are_enabled_without_a_document() { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + assert!(app.session.active().is_none()); + let ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).filter(|id| id.starts_with("help.")).collect(); + assert!(ids.contains(&"help.discord") && ids.contains(&"help.github"), "{ids:?}"); + for id in ids { + assert!(menu_enabled(&app, id), "{id} must work from the start screen"); + } + assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document"); + } } From fecf5906c369ccf217fc88cbf01ec8303f40c75a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 17:57:36 -0600 Subject: [PATCH 076/117] #248: test that the start screen offers every print preset including A6 The start screen showed only the first ten presets and had no A6 sheet. The test checks that the start screen paints a card for each print preset, that A6 exists at 105 x 148 mm, and that the Properties page-size list agrees. Without the fix it fails because the B5 card is cut off by take(10) and there is no A6. --- crates/ui-egui/src/menus.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 41da5a69e..91c294521 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5655,4 +5655,19 @@ mod tests { } assert!(!menu_enabled(&app, "view.rulers"), "other UI commands still need a document"); } + + // Regression test for #248: the start screen offered no A6 sheet (it listed only the first ten presets). + #[test] + fn start_screen_offers_every_print_preset_including_a6() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let texts = start_screen_texts(&mut app); + for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { + assert!(texts.iter().any(|t| t == p.name), "start screen lacks the {} card: {texts:?}", p.name); + } + assert!(texts.iter().any(|t| t == "A6"), "{texts:?}"); + let a6 = designcraft_doc::build::PRESETS.iter().find(|p| p.name == "A6").unwrap(); + assert!((a6.width - 105.0 * 72.0 / 25.4).abs() < 0.01 && (a6.height - 148.0 * 72.0 / 25.4).abs() < 0.01, "{a6:?}"); + let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6"); + assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}"); + } } From d9477695e2011372e91d1dceaa049da1edb064f1 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 17:57:36 -0600 Subject: [PATCH 077/117] #249: test that menu rows are selectable across their full width A menu row only reacted over its text because the column used a plain top-down layout. The test renders a menu column with a short Preferences row and clicks near the right end of the row, expecting the dialog to open. Without the justified layout the click misses and the test fails. --- crates/ui-egui/src/menus.rs | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 91c294521..1ddc7a705 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5670,4 +5670,42 @@ mod tests { let page = crate::panels::properties::PAGE_PRESETS.iter().find(|p| p.0 == "A6").expect("Properties page-size list has A6"); assert!((page.1 - a6.width).abs() < 0.01 && (page.2 - a6.height).abs() < 0.01, "{page:?} vs {a6:?}"); } + + // Regression test for #249: a menu row only reacted over its text, not across the whole row. + #[test] + fn menu_rows_are_selectable_across_the_full_width() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let entries = vec![cmd_item("app.preferences", Value::Null)]; + let mut tick = 0; + let mut frame = |app: &mut DesignApp, events: Vec| { + tick += 1; + let mut row = egui::Rect::NOTHING; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + egui::Area::new(egui::Id::new("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| { + let before = ui.cursor().min; + menu_column(app, ui, &[("Test", entries.clone())]); + row = egui::Rect::from_min_max(before, ui.min_rect().max); + }); + }); + output.textures_delta.clear(); + row + }; + frame(&mut app, vec![]); + let row = frame(&mut app, vec![]); + assert!(row.width() >= 239.0, "the column is at least 240 wide: {row:?}"); + let far = egui::pos2(row.right() - 6.0, row.top() + 8.0); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(far), + egui::Event::PointerButton { pos: far, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it"); + } } From 3ee11391aec7434d318654369a7b7b276882f668 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 23:39:10 +0000 Subject: [PATCH 078/117] CI: one integration-test binary per crate --- ASSETS.md | 12 +++++----- apps/designcraft-cli/tests/{ => it}/cli.rs | 0 apps/designcraft-cli/tests/it/main.rs | 1 + crates/engine/tests/{ => it}/book_safety.rs | 2 -- crates/engine/tests/{ => it}/browser_print.rs | 1 - .../character_style_from_selection.rs | 0 .../engine/tests/{ => it}/close_retention.rs | 0 .../{ => it}/collection_state_atomicity.rs | 2 -- .../tests/{ => it}/combined_close_glyph.rs | 0 .../engine/tests/{ => it}/distribute_locks.rs | 0 .../tests/{ => it}/export_page_input.rs | 0 .../tests/{ => it}/insert_pages_bounds.rs | 0 crates/engine/tests/it/main.rs | 23 +++++++++++++++++++ crates/engine/tests/{ => it}/param_keys.rs | 0 crates/engine/tests/{ => it}/pdf_backend.rs | 0 .../tests/{ => it}/preflight_resolution.rs | 0 .../tests/{ => it}/primary_frame_columns.rs | 0 .../tests/{ => it}/save_state_atomicity.rs | 2 -- .../tests/{ => it}/stroke_tint_range.rs | 0 .../engine/tests/{ => it}/text_decorations.rs | 0 .../tests/{ => it}/text_insert_memory.rs | 0 .../tests/{ => it}/text_thread_regressions.rs | 0 .../tests/{ => it}/thread_color_safety.rs | 0 .../epub/tests/{ => it}/export_tag_styles.rs | 0 .../tests/{ => it}/grouped_reading_order.rs | 0 crates/epub/tests/it/main.rs | 2 ++ crates/fonts/tests/it/main.rs | 4 ++++ crates/fonts/tests/{ => it}/system_fonts.rs | 2 -- crates/images/tests/it/main.rs | 1 + .../{ => it}/rgba_conversion_contract.rs | 0 crates/mcp/tests/{ => it}/automation_roots.rs | 2 -- crates/mcp/tests/it/main.rs | 4 ++++ crates/pdf/tests/image_content_cache.rs | 5 ---- .../tests/{ => it}/gradient_color_spaces.rs | 2 +- crates/pdf/tests/it/image_content_cache.rs | 3 +++ crates/pdf/tests/it/main.rs | 6 +++++ .../{ => it}/page_entry_search_contract.rs | 0 .../render/tests/{ => it}/blend_over_paper.rs | 0 crates/render/tests/{ => it}/colorize.rs | 0 crates/render/tests/{ => it}/effects.rs | 2 +- .../render/tests/{ => it}/glyph_retention.rs | 0 crates/render/tests/{ => it}/gradient_inks.rs | 0 crates/render/tests/{ => it}/gradients.rs | 0 crates/render/tests/it/main.rs | 12 ++++++++++ crates/render/tests/{ => it}/pdf_geometry.rs | 0 crates/render/tests/{ => it}/perf.rs | 2 +- crates/render/tests/{ => it}/placed_pdf.rs | 0 .../render/tests/{ => it}/stroke_alignment.rs | 0 .../tests/{ => it}/text_frame_bottom.rs | 0 .../tests/{ => it}/text_stroke_behind.rs | 0 crates/ui-egui/tests/it/main.rs | 2 ++ .../{ => it}/native_job_lifetime_control.rs | 1 - 52 files changed, 67 insertions(+), 26 deletions(-) rename apps/designcraft-cli/tests/{ => it}/cli.rs (100%) create mode 100644 apps/designcraft-cli/tests/it/main.rs rename crates/engine/tests/{ => it}/book_safety.rs (99%) rename crates/engine/tests/{ => it}/browser_print.rs (97%) rename crates/engine/tests/{ => it}/character_style_from_selection.rs (100%) rename crates/engine/tests/{ => it}/close_retention.rs (100%) rename crates/engine/tests/{ => it}/collection_state_atomicity.rs (98%) rename crates/engine/tests/{ => it}/combined_close_glyph.rs (100%) rename crates/engine/tests/{ => it}/distribute_locks.rs (100%) rename crates/engine/tests/{ => it}/export_page_input.rs (100%) rename crates/engine/tests/{ => it}/insert_pages_bounds.rs (100%) create mode 100644 crates/engine/tests/it/main.rs rename crates/engine/tests/{ => it}/param_keys.rs (100%) rename crates/engine/tests/{ => it}/pdf_backend.rs (100%) rename crates/engine/tests/{ => it}/preflight_resolution.rs (100%) rename crates/engine/tests/{ => it}/primary_frame_columns.rs (100%) rename crates/engine/tests/{ => it}/save_state_atomicity.rs (98%) rename crates/engine/tests/{ => it}/stroke_tint_range.rs (100%) rename crates/engine/tests/{ => it}/text_decorations.rs (100%) rename crates/engine/tests/{ => it}/text_insert_memory.rs (100%) rename crates/engine/tests/{ => it}/text_thread_regressions.rs (100%) rename crates/engine/tests/{ => it}/thread_color_safety.rs (100%) rename crates/epub/tests/{ => it}/export_tag_styles.rs (100%) rename crates/epub/tests/{ => it}/grouped_reading_order.rs (100%) create mode 100644 crates/epub/tests/it/main.rs create mode 100644 crates/fonts/tests/it/main.rs rename crates/fonts/tests/{ => it}/system_fonts.rs (86%) create mode 100644 crates/images/tests/it/main.rs rename crates/images/tests/{ => it}/rgba_conversion_contract.rs (100%) rename crates/mcp/tests/{ => it}/automation_roots.rs (99%) create mode 100644 crates/mcp/tests/it/main.rs delete mode 100644 crates/pdf/tests/image_content_cache.rs rename crates/pdf/tests/{ => it}/gradient_color_spaces.rs (98%) create mode 100644 crates/pdf/tests/it/image_content_cache.rs create mode 100644 crates/pdf/tests/it/main.rs rename crates/pdf/tests/{ => it}/page_entry_search_contract.rs (100%) rename crates/render/tests/{ => it}/blend_over_paper.rs (100%) rename crates/render/tests/{ => it}/colorize.rs (100%) rename crates/render/tests/{ => it}/effects.rs (99%) rename crates/render/tests/{ => it}/glyph_retention.rs (100%) rename crates/render/tests/{ => it}/gradient_inks.rs (100%) rename crates/render/tests/{ => it}/gradients.rs (100%) create mode 100644 crates/render/tests/it/main.rs rename crates/render/tests/{ => it}/pdf_geometry.rs (100%) rename crates/render/tests/{ => it}/perf.rs (98%) rename crates/render/tests/{ => it}/placed_pdf.rs (100%) rename crates/render/tests/{ => it}/stroke_alignment.rs (100%) rename crates/render/tests/{ => it}/text_frame_bottom.rs (100%) rename crates/render/tests/{ => it}/text_stroke_behind.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ => it}/native_job_lifetime_control.rs (98%) diff --git a/ASSETS.md b/ASSETS.md index 40e8a97b4..407a26c92 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | -| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | | `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon | | `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | | `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/it/cli.rs similarity index 100% rename from apps/designcraft-cli/tests/cli.rs rename to apps/designcraft-cli/tests/it/cli.rs diff --git a/apps/designcraft-cli/tests/it/main.rs b/apps/designcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..26710c101 --- /dev/null +++ b/apps/designcraft-cli/tests/it/main.rs @@ -0,0 +1 @@ +mod cli; diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/it/book_safety.rs similarity index 99% rename from crates/engine/tests/book_safety.rs rename to crates/engine/tests/it/book_safety.rs index 74c96815a..01c527be5 100644 --- a/crates/engine/tests/book_safety.rs +++ b/crates/engine/tests/it/book_safety.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - use designcraft_engine::Session; use serde_json::json; use std::path::Path; diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/it/browser_print.rs similarity index 97% rename from crates/engine/tests/browser_print.rs rename to crates/engine/tests/it/browser_print.rs index fda224b90..208a6cde8 100644 --- a/crates/engine/tests/browser_print.rs +++ b/crates/engine/tests/it/browser_print.rs @@ -1,5 +1,4 @@ //! Browser Print must reject native spooler operations without trapping the editor. -#![cfg(target_arch = "wasm32")] use designcraft_engine::Session; use serde_json::json; diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/it/character_style_from_selection.rs similarity index 100% rename from crates/engine/tests/character_style_from_selection.rs rename to crates/engine/tests/it/character_style_from_selection.rs diff --git a/crates/engine/tests/close_retention.rs b/crates/engine/tests/it/close_retention.rs similarity index 100% rename from crates/engine/tests/close_retention.rs rename to crates/engine/tests/it/close_retention.rs diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/it/collection_state_atomicity.rs similarity index 98% rename from crates/engine/tests/collection_state_atomicity.rs rename to crates/engine/tests/it/collection_state_atomicity.rs index b73eefa7e..cb6a0db94 100644 --- a/crates/engine/tests/collection_state_atomicity.rs +++ b/crates/engine/tests/it/collection_state_atomicity.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - use designcraft_engine::Session; use serde_json::json; diff --git a/crates/engine/tests/combined_close_glyph.rs b/crates/engine/tests/it/combined_close_glyph.rs similarity index 100% rename from crates/engine/tests/combined_close_glyph.rs rename to crates/engine/tests/it/combined_close_glyph.rs diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/it/distribute_locks.rs similarity index 100% rename from crates/engine/tests/distribute_locks.rs rename to crates/engine/tests/it/distribute_locks.rs diff --git a/crates/engine/tests/export_page_input.rs b/crates/engine/tests/it/export_page_input.rs similarity index 100% rename from crates/engine/tests/export_page_input.rs rename to crates/engine/tests/it/export_page_input.rs diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/it/insert_pages_bounds.rs similarity index 100% rename from crates/engine/tests/insert_pages_bounds.rs rename to crates/engine/tests/it/insert_pages_bounds.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..14555d3ee --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,23 @@ +#[cfg(not(target_arch = "wasm32"))] +mod book_safety; +#[cfg(target_arch = "wasm32")] +mod browser_print; +mod character_style_from_selection; +mod close_retention; +#[cfg(not(target_arch = "wasm32"))] +mod collection_state_atomicity; +mod combined_close_glyph; +mod distribute_locks; +mod export_page_input; +mod insert_pages_bounds; +mod param_keys; +mod pdf_backend; +mod preflight_resolution; +mod primary_frame_columns; +#[cfg(not(target_arch = "wasm32"))] +mod save_state_atomicity; +mod stroke_tint_range; +mod text_decorations; +mod text_insert_memory; +mod text_thread_regressions; +mod thread_color_safety; diff --git a/crates/engine/tests/param_keys.rs b/crates/engine/tests/it/param_keys.rs similarity index 100% rename from crates/engine/tests/param_keys.rs rename to crates/engine/tests/it/param_keys.rs diff --git a/crates/engine/tests/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs similarity index 100% rename from crates/engine/tests/pdf_backend.rs rename to crates/engine/tests/it/pdf_backend.rs diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/it/preflight_resolution.rs similarity index 100% rename from crates/engine/tests/preflight_resolution.rs rename to crates/engine/tests/it/preflight_resolution.rs diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/it/primary_frame_columns.rs similarity index 100% rename from crates/engine/tests/primary_frame_columns.rs rename to crates/engine/tests/it/primary_frame_columns.rs diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/it/save_state_atomicity.rs similarity index 98% rename from crates/engine/tests/save_state_atomicity.rs rename to crates/engine/tests/it/save_state_atomicity.rs index ac0f30f58..2ced20ef0 100644 --- a/crates/engine/tests/save_state_atomicity.rs +++ b/crates/engine/tests/it/save_state_atomicity.rs @@ -1,5 +1,3 @@ -#![cfg(not(target_arch = "wasm32"))] - use std::path::Path; use std::sync::Arc; diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/it/stroke_tint_range.rs similarity index 100% rename from crates/engine/tests/stroke_tint_range.rs rename to crates/engine/tests/it/stroke_tint_range.rs diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/it/text_decorations.rs similarity index 100% rename from crates/engine/tests/text_decorations.rs rename to crates/engine/tests/it/text_decorations.rs diff --git a/crates/engine/tests/text_insert_memory.rs b/crates/engine/tests/it/text_insert_memory.rs similarity index 100% rename from crates/engine/tests/text_insert_memory.rs rename to crates/engine/tests/it/text_insert_memory.rs diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/it/text_thread_regressions.rs similarity index 100% rename from crates/engine/tests/text_thread_regressions.rs rename to crates/engine/tests/it/text_thread_regressions.rs diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/it/thread_color_safety.rs similarity index 100% rename from crates/engine/tests/thread_color_safety.rs rename to crates/engine/tests/it/thread_color_safety.rs diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs similarity index 100% rename from crates/epub/tests/export_tag_styles.rs rename to crates/epub/tests/it/export_tag_styles.rs diff --git a/crates/epub/tests/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs similarity index 100% rename from crates/epub/tests/grouped_reading_order.rs rename to crates/epub/tests/it/grouped_reading_order.rs diff --git a/crates/epub/tests/it/main.rs b/crates/epub/tests/it/main.rs new file mode 100644 index 000000000..69af15eaa --- /dev/null +++ b/crates/epub/tests/it/main.rs @@ -0,0 +1,2 @@ +mod export_tag_styles; +mod grouped_reading_order; diff --git a/crates/fonts/tests/it/main.rs b/crates/fonts/tests/it/main.rs new file mode 100644 index 000000000..1737c00b9 --- /dev/null +++ b/crates/fonts/tests/it/main.rs @@ -0,0 +1,4 @@ +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod system_fonts; diff --git a/crates/fonts/tests/system_fonts.rs b/crates/fonts/tests/it/system_fonts.rs similarity index 86% rename from crates/fonts/tests/system_fonts.rs rename to crates/fonts/tests/it/system_fonts.rs index 62310c34b..66db0f5b1 100644 --- a/crates/fonts/tests/system_fonts.rs +++ b/crates/fonts/tests/it/system_fonts.rs @@ -1,7 +1,5 @@ //! A new session finds the installed system fonts by family name (#22). One test in its own //! process, so the process-wide database starts fresh, as it does when the app starts. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use designcraft_fonts::{FontDb, system_font_dirs}; diff --git a/crates/images/tests/it/main.rs b/crates/images/tests/it/main.rs new file mode 100644 index 000000000..d2f76fb71 --- /dev/null +++ b/crates/images/tests/it/main.rs @@ -0,0 +1 @@ +mod rgba_conversion_contract; diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/it/rgba_conversion_contract.rs similarity index 100% rename from crates/images/tests/rgba_conversion_contract.rs rename to crates/images/tests/it/rgba_conversion_contract.rs diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs similarity index 99% rename from crates/mcp/tests/automation_roots.rs rename to crates/mcp/tests/it/automation_roots.rs index 9380b1987..871945920 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/it/automation_roots.rs @@ -3,8 +3,6 @@ //! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the //! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing //! is written outside. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs new file mode 100644 index 000000000..be744719e --- /dev/null +++ b/crates/mcp/tests/it/main.rs @@ -0,0 +1,4 @@ +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod automation_roots; diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs deleted file mode 100644 index dd3d413d0..000000000 --- a/crates/pdf/tests/image_content_cache.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Run public contracts with the production content-cache hasher. -#[path = "support/image_content_cache_contract.rs"] -mod contracts; -#[path = "support/image_fixtures.rs"] -mod image_fixtures; diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/it/gradient_color_spaces.rs similarity index 98% rename from crates/pdf/tests/gradient_color_spaces.rs rename to crates/pdf/tests/it/gradient_color_spaces.rs index 05806a120..d906413e1 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/it/gradient_color_spaces.rs @@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() { const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD"; if std::env::var_os(CHILD).is_none() { let out = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"]) + .args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"]) .env(CHILD, "1") .output() .unwrap(); diff --git a/crates/pdf/tests/it/image_content_cache.rs b/crates/pdf/tests/it/image_content_cache.rs new file mode 100644 index 000000000..ec0f666c0 --- /dev/null +++ b/crates/pdf/tests/it/image_content_cache.rs @@ -0,0 +1,3 @@ +//! Run public contracts with the production content-cache hasher. +#[path = "../support/image_content_cache_contract.rs"] +mod contracts; diff --git a/crates/pdf/tests/it/main.rs b/crates/pdf/tests/it/main.rs new file mode 100644 index 000000000..e2e092e2f --- /dev/null +++ b/crates/pdf/tests/it/main.rs @@ -0,0 +1,6 @@ +#[path = "../support/image_fixtures.rs"] +mod image_fixtures; + +mod gradient_color_spaces; +mod image_content_cache; +mod page_entry_search_contract; diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/it/page_entry_search_contract.rs similarity index 100% rename from crates/pdf/tests/page_entry_search_contract.rs rename to crates/pdf/tests/it/page_entry_search_contract.rs diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/it/blend_over_paper.rs similarity index 100% rename from crates/render/tests/blend_over_paper.rs rename to crates/render/tests/it/blend_over_paper.rs diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/it/colorize.rs similarity index 100% rename from crates/render/tests/colorize.rs rename to crates/render/tests/it/colorize.rs diff --git a/crates/render/tests/effects.rs b/crates/render/tests/it/effects.rs similarity index 99% rename from crates/render/tests/effects.rs rename to crates/render/tests/it/effects.rs index a9420fbcb..230913173 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/it/effects.rs @@ -3,7 +3,7 @@ //! //! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur //! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended -//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`. +//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`. use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; diff --git a/crates/render/tests/glyph_retention.rs b/crates/render/tests/it/glyph_retention.rs similarity index 100% rename from crates/render/tests/glyph_retention.rs rename to crates/render/tests/it/glyph_retention.rs diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/it/gradient_inks.rs similarity index 100% rename from crates/render/tests/gradient_inks.rs rename to crates/render/tests/it/gradient_inks.rs diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/it/gradients.rs similarity index 100% rename from crates/render/tests/gradients.rs rename to crates/render/tests/it/gradients.rs diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..52000ae2d --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,12 @@ +mod blend_over_paper; +mod colorize; +mod effects; +mod glyph_retention; +mod gradient_inks; +mod gradients; +mod pdf_geometry; +mod perf; +mod placed_pdf; +mod stroke_alignment; +mod text_frame_bottom; +mod text_stroke_behind; diff --git a/crates/render/tests/pdf_geometry.rs b/crates/render/tests/it/pdf_geometry.rs similarity index 100% rename from crates/render/tests/pdf_geometry.rs rename to crates/render/tests/it/pdf_geometry.rs diff --git a/crates/render/tests/perf.rs b/crates/render/tests/it/perf.rs similarity index 98% rename from crates/render/tests/perf.rs rename to crates/render/tests/it/perf.rs index 1f23f88f4..46f33ab62 100644 --- a/crates/render/tests/perf.rs +++ b/crates/render/tests/it/perf.rs @@ -1,6 +1,6 @@ //! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock //! timings are only meaningful on an idle machine — run with -//! `cargo test --release -p designcraft-render --test perf -- --ignored`). +//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`). use std::time::Instant; diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs similarity index 100% rename from crates/render/tests/placed_pdf.rs rename to crates/render/tests/it/placed_pdf.rs diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/it/stroke_alignment.rs similarity index 100% rename from crates/render/tests/stroke_alignment.rs rename to crates/render/tests/it/stroke_alignment.rs diff --git a/crates/render/tests/text_frame_bottom.rs b/crates/render/tests/it/text_frame_bottom.rs similarity index 100% rename from crates/render/tests/text_frame_bottom.rs rename to crates/render/tests/it/text_frame_bottom.rs diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/it/text_stroke_behind.rs similarity index 100% rename from crates/render/tests/text_stroke_behind.rs rename to crates/render/tests/it/text_stroke_behind.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..2d07c9629 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,2 @@ +#[cfg(not(target_arch = "wasm32"))] +mod native_job_lifetime_control; diff --git a/crates/ui-egui/tests/native_job_lifetime_control.rs b/crates/ui-egui/tests/it/native_job_lifetime_control.rs similarity index 98% rename from crates/ui-egui/tests/native_job_lifetime_control.rs rename to crates/ui-egui/tests/it/native_job_lifetime_control.rs index 2cf82e5bd..b7fa87214 100644 --- a/crates/ui-egui/tests/native_job_lifetime_control.rs +++ b/crates/ui-egui/tests/it/native_job_lifetime_control.rs @@ -1,5 +1,4 @@ //! Own a real native Job across last close. This does not run the asynchronous worker. -#![cfg(not(target_arch = "wasm32"))] use std::sync::Arc; From 0fd6e06ad6a7921c0d5df08b3d45434910827d52 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 01:17:55 +0000 Subject: [PATCH 079/117] CI: install nextest for the test step --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72bac7ef7..c27fdfe10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,10 @@ jobs: --target wasm32-unknown-unknown rustup default stable + - uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26 + with: + tool: cargo-nextest@0.9.145 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: save-if: ${{ github.ref == 'refs/heads/main' }} From 62148670bd6ee3522c116cc1b78e2d6be0f16765 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 02:54:21 +0000 Subject: [PATCH 080/117] CI: apt packages through craft-common-actions' cached install --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c27fdfe10..d5b820d24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,10 @@ jobs: persist-credentials: false - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev \ + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - name: Install Rust (stable + clippy, rustfmt, wasm32) From e7d16aeaaae68e61dda386776f62c02f67fc41a4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 11:40:25 +0200 Subject: [PATCH 081/117] docs: autoflow drops the pages it added for unused frames; threading into grouped frames is no longer a gap --- ROADMAP.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 89a9bd324..f46ddb4db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,7 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again, and the pages they were on stay. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. +- **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 24c94283d..9fc2fbfa4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -211,7 +211,7 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow sets new frames on following pages instead of flowing into their primary text frame; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Edit Header/Footer | From bd7fb76d4c6fdda501651df1674bc34dbffc6ec4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:29:39 -0400 Subject: [PATCH 082/117] fix: annotate package output permissions (SonarCloud rust:S2612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages are created with the ordinary 0666 request; the process umask decides the effective mode. Annotated with that reason so the analyzer's world-writable warning does not change the intended sharing behaviour. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- crates/engine/src/cmd/package.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index f2d33ccfa..d0ed58b03 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -135,8 +135,9 @@ impl OutputRoot { { use std::os::unix::fs::PermissionsExt; - // Match fs::write for a new output: 0666 restricted by the caller's umask. - builder.permissions(std::fs::Permissions::from_mode(0o666)); + // Like fs::write for a new output: request 0666 and let the process umask decide the + // effective mode (0644 with the usual 022, 0664 with 002). + builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR } let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?; #[cfg(unix)] From 5344faf9f04f1c4b0094705bb25e74caabe7000c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:08:12 +0200 Subject: [PATCH 083/117] IDML export: clip a table cell's graphic to the cell (#1551) --- crates/idml/src/export.rs | 2 ++ crates/idml/src/tests.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index eb30d533c..df5a1a243 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2802,6 +2802,8 @@ impl<'a> Ex<'a> { if let Some(g) = &cell.graphic && let Some(object) = self.cell_graphic_el(g) { + // The cell clips its graphic on screen and in PDF; IDML does so only when asked. + ce.set("ClipContentToCell", bool_s(true)); let mut range = self.csr_el(&CharFormat::default()); range.push(object); match paras.first_mut() { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7fce003f4..1a9734a6b 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4400,4 +4400,5 @@ fn exports_graphic_in_table_cell() { let cell = story.split("").next().unwrap_or_default(); assert!(cell.contains(" Date: Fri, 23 Oct 2026 16:08:36 +0200 Subject: [PATCH 084/117] Lay out the parent-page side lookup so rustfmt passes (#1577) --- crates/compose/src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 959f5da37..e95ebd4b6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -682,10 +682,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); // The side of the parent page under the frame, so a folio there aligns to the spine of its own page. - let left = sp - .page_at_x(item.bounds().center().x) - .and_then(|pi| sp.pages.get(pi)) - .is_some_and(|p| p.side == designcraft_doc::PageSide::Left); + let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi)); + let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left); (Some(prefix), None, left) } _ => (None, None, false), From c764454ed3f7d6f0db6dac381504699d37854bc2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 16:22:52 +0200 Subject: [PATCH 085/117] Align: a click on one of several selected objects makes it the key object (fixes #1637) --- crates/engine/src/cmd/edit.rs | 13 ++++++ crates/engine/src/cmd/object.rs | 16 +++++-- crates/engine/src/tests.rs | 61 +++++++++++++++++++++++++ crates/tools/src/select.rs | 22 +++++++-- crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/panels/properties.rs | 9 +++- 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fb8848a90..2b89d24f4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -262,12 +262,25 @@ pub fn specs() -> Vec { st.selection.cells = None; if let Some(i) = st.selection.items.iter().position(|x| *x == id) { st.selection.items.remove(i); + if st.selection.key == Some(id) { + st.selection.key = None; + } } else { st.selection.items.push(id); } st.bump_revision(); ok() }), + cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| { + let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?; + let st = s.doc_mut()?; + if !st.selection.items.contains(&id) { + return Err(super::bad("selection.key", "the key object must be selected")); + } + st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) }; + st.bump_revision(); + ok() + }), cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| { let st = s.doc_mut()?; if let Some(t) = st.selection.text { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4ccb5c823..f0606e379 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -686,7 +686,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Object & Layout", "Align"], None, - "{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}", + "{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}", has_selection, align ), @@ -3074,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option) fn align(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let edge = str_param(p, "edge").unwrap_or("left").to_string(); - let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() }); - let key = s.doc()?.selection.key; + // A key object counts only while it is one of the items aligned. With one, InDesign's Align + // To switches to Key Object, so it is also the default target. + let key = s.doc()?.selection.key.filter(|k| ids.contains(k)); + let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| { + if key.is_some() { + "keyObject".into() + } else if ids.len() > 1 { + "selection".into() + } else { + "page".into() + } + }); s.edit(|d, _| { let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?; for id in &ids { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 42f57b3a1..ff5eb7193 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1651,3 +1651,64 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key +/// object, and Align to Key Object leaves it in place. A second click on it drops the key. +#[test] +fn a_click_in_the_selection_makes_the_key_object() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a, b]})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(1.0); + // On the part of the key object that the aligned object never covers, away from the handles. + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap(); + }; + click(&mut s); + assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected"); + // The key is the right-hand object, so aligning left edges to the selection would move it. + s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap(); + let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0; + assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b)); + assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a)); + // The Align panel and the Control panel send no target: the key object is the default one. + s.execute("object.align", &json!({"edge": "top"})).unwrap(); + let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0; + assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b)); + assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a)); + click(&mut s); + assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key"); +} + +/// Dragging one of several selected objects moves them all and makes no key object. +#[test] +fn dragging_the_selection_makes_no_key_object() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a, b]})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(1.0); + s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, None); +} + +/// Shift-clicking the key object out of the selection drops the key. +#[test] +fn deselecting_the_key_object_drops_the_key() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("selection.key", &json!({"id": b})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b))); + s.execute("selection.toggle", &json!({"id": b})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, None); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 866003102..c437484c2 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,6 +1,7 @@ //! Selection tool (V) and Direct Selection tool (A). //! -//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), +//! Click selects the frontmost item (Shift toggles; a click on one of several selected items makes +//! it the key object for Align, a second click drops it), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), //! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no //! object), double-clicking a text frame switches to the Type tool and @@ -223,6 +224,9 @@ pub struct SelectionTool { guides: Vec, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option, + /// One of several selected items to make (or stop being) the key object for Align if this + /// press never becomes a drag. + key_release: Option, /// The loaded text cursor. loaded: Option, /// Frame whose live corners are being edited (diamonds shown). @@ -231,7 +235,7 @@ pub struct SelectionTool { impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None, live: None } + Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, key_release: None, loaded: None, live: None } } /// [`bracket_at`] for this tool. With Direct Selection an anchor under the pointer wins: the @@ -735,6 +739,7 @@ impl Tool for SelectionTool { } PointerKind::Down => { self.shift_release = None; + self.key_release = None; // Type on a path: a bracket moves where the text starts or ends. if !ev.mods.shift && let Some((id, end)) = self.bracket_at(cx, p) @@ -825,6 +830,10 @@ impl Tool for SelectionTool { } } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) { out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); + } else if !self.direct && cx.selection.items.len() > 1 { + // InDesign: a click on one of several selected objects makes it the + // key object, on release so that a drag still moves them all. + self.key_release = Some(id.0); } let content = self.direct || on_selected_content(cx, p); self.drag = Drag::Pending { start: p, hit: true, content }; @@ -852,6 +861,7 @@ impl Tool for SelectionTool { return vec![]; } self.shift_release = None; + self.key_release = None; if hit && !cx.selection.items.is_empty() { // Direct Selection on items without content moves (or duplicates) them whole. let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content)); @@ -1124,6 +1134,7 @@ impl Tool for SelectionTool { PointerKind::Up => { self.guides.clear(); let release = self.shift_release.take(); + let key = self.key_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { Drag::Move { .. } @@ -1135,9 +1146,10 @@ impl Tool for SelectionTool { | Drag::Bracket { .. } => { vec![Action::Commit] } - Drag::Pending { .. } => match release { - Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], - None => vec![], + Drag::Pending { .. } => match (release, key) { + (Some(id), _) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], + (None, Some(id)) => vec![Action::Exec("selection.key".into(), json!({"id": id}))], + (None, None) => vec![], }, Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..a1739b811 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1251,6 +1251,9 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum if sel.items.len() == 1 && sel.text.is_none() { // InDesign draws a 1 pt bounding box around the selection. painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle); + } else if sel.key == Some(*id) { + // The key object for Align: a thicker bounding box, as in InDesign. + painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(3.0, color), StrokeKind::Middle); } let b = xf.rect(a.transform_rect_bbox(it.bounds())); union = Some(union.map_or(b, |u| u.union(b))); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fd0094f9b..a31cfe4ec 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -982,7 +982,9 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; - let to = app.ui.align_to.clone(); + // A key object switches Align To to Key Object; choosing another target drops the key. + let key = app.session.active().and_then(|st| st.selection.key); + let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() }; let to_label = match to.as_str() { "selection" => "Selection", "keyObject" => "Key Object", @@ -1001,7 +1003,10 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { ("Align to Page", "page"), ("Align to Spread", "spread"), ] { - if ui.selectable_label(app.ui.align_to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if ui.selectable_label(to == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + if let Some(k) = key.filter(|_| id != "keyObject") { + let _ = app.run("selection.key", json!({"id": k.0})); + } app.ui.align_to = id.into(); ui.close(); } From 8eab33eba118dbb7f20e9f9a1dbdba0aff12dcd7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 16:54:21 +0200 Subject: [PATCH 086/117] Align: no key object once the selection is down to one object (#1637) --- crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/cmd/object.rs | 6 ++--- crates/engine/src/tests.rs | 30 +++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 2 +- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2b89d24f4..2daa24d5a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -262,7 +262,8 @@ pub fn specs() -> Vec { st.selection.cells = None; if let Some(i) = st.selection.items.iter().position(|x| *x == id) { st.selection.items.remove(i); - if st.selection.key == Some(id) { + // A key object needs another object to align to it. + if st.selection.key == Some(id) || st.selection.items.len() < 2 { st.selection.key = None; } } else { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index f0606e379..65f120622 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3074,9 +3074,9 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option) fn align(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let edge = str_param(p, "edge").unwrap_or("left").to_string(); - // A key object counts only while it is one of the items aligned. With one, InDesign's Align - // To switches to Key Object, so it is also the default target. - let key = s.doc()?.selection.key.filter(|k| ids.contains(k)); + // A key object counts only while it is one of several items aligned. With one, InDesign's + // Align To switches to Key Object, so it is also the default target. + let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k)); let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| { if key.is_some() { "keyObject".into() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ff5eb7193..43bc767c0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1712,3 +1712,33 @@ fn deselecting_the_key_object_drops_the_key() { s.execute("selection.toggle", &json!({"id": b})).unwrap(); assert_eq!(s.doc().unwrap().selection.key, None); } + +/// With the selection down to the key object alone, Align no longer has a key object: a single +/// object aligns to the page, as before. +#[test] +fn a_lone_key_object_aligns_to_the_page() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("selection.key", &json!({"id": b})).unwrap(); + s.execute("selection.toggle", &json!({"id": a})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, None); + s.execute("object.align", &json!({"edge": "left"})).unwrap(); + let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0; + assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge"); +} + +/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes +/// the page. +#[test] +fn aligning_the_key_object_alone_takes_the_page() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("selection.key", &json!({"id": b})).unwrap(); + s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap(); + let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0; + assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge"); +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a31cfe4ec..0422936e3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -983,7 +983,7 @@ fn align_row(app: &mut DesignApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; // A key object switches Align To to Key Object; choosing another target drops the key. - let key = app.session.active().and_then(|st| st.selection.key); + let key = app.session.active().and_then(|st| st.selection.key.filter(|_| st.selection.items.len() > 1)); let to = if key.is_some() { "keyObject".to_string() } else { app.ui.align_to.clone() }; let to_label = match to.as_str() { "selection" => "Selection", From df1db1646b05f4e3f12ca88f3e0f1ed7c2df0164 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:16:49 -0400 Subject: [PATCH 087/117] web: let the browser zoom the page (SonarCloud Web:S7926) (#1641) The viewport no longer sets user-scalable=no, so people who need to zoom the page can (WCAG 1.4.4). The canvas takes touch-action: none, so pinch and pan gestures on it still go to the app's own zoom, as eframe's touch handlers expect, instead of zooming the page. --- apps/designcraft-web/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/designcraft-web/index.html b/apps/designcraft-web/index.html index b05be3936..da7ccc5da 100644 --- a/apps/designcraft-web/index.html +++ b/apps/designcraft-web/index.html @@ -2,12 +2,12 @@ - + DesignCraft From 2e36dcf909627c8f0e5662fe6b4825b1c1c24420 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:16:54 -0400 Subject: [PATCH 088/117] packaging: harden shell scripts (SonarCloud S7688/S7679/S7682/S131) (#1640) - Use bash's [[ ]] instead of [ ] for conditional tests (S7688). - Copy positional parameters into named locals in render(), has() and sha256() (S7679). - End the env.sh helper functions with an explicit return, which keeps the last command's status (S7682). - Give the appimagetool checksum case a default branch for architectures without a pinned download (S131). No behaviour change; shellcheck -x is clean. --- packaging/env.sh | 19 ++++++++++++------- packaging/freebsd/package.sh | 14 +++++++------- packaging/icons.sh | 5 ++++- packaging/linux/flatpak-bundle.sh | 8 ++++---- packaging/linux/package.sh | 23 ++++++++++++++--------- packaging/macos/import-cert.sh | 6 +++--- packaging/macos/package.sh | 24 ++++++++++++------------ packaging/web/package.sh | 4 ++-- 8 files changed, 58 insertions(+), 45 deletions(-) diff --git a/packaging/env.sh b/packaging/env.sh index 0eb58029b..55ff3943c 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,10 +19,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${DESIGNCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${DESIGNCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${DESIGNCRAFT_BUILD_SHA:-}" ]]; then DESIGNCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export DESIGNCRAFT_BUILD_SHA @@ -41,29 +42,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" + return } # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so # the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" f - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi + if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi done } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 1854c284a..d11434be0 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ APP_ID=ai.storyteller.designcraft SKIP_BUILD=0 DRY_RUN=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; @@ -36,7 +36,7 @@ case "$MACHINE" in arm64 | aarch64) ARCH=aarch64 ;; *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; esac -if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 fi @@ -46,7 +46,7 @@ echo "==> DesignCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli) fi WORK="$CARGO_TARGET_DIR/freebsd-package" @@ -56,7 +56,7 @@ mkdir -p "$WORK" BIN="$CARGO_TARGET_DIR/release" OUT_DIR="$DIST" -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. BIN="$WORK/stub-bin" OUT_DIR="$WORK" @@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \ cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/designcraft" for f in NOTICE ATTRIBUTION.md; do - if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/designcraft/"; fi + if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/designcraft/"; fi done for f in bin/designcraft bin/designcraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ share/doc/designcraft/LICENSE-MIT share/doc/designcraft/LICENSE-APACHE; do - if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi + if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi done # ---- .tar.gz ------------------------------------------------------------------------------------ @@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" "$STAGE/bin/designcraft-cli" --version -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" fi diff --git a/packaging/icons.sh b/packaging/icons.sh index 6e06fb9ad..7ac9e2954 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -21,7 +21,10 @@ MAC="$TMP/mac.svg" sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC" grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/designcraft" ] || { echo "error: $TARBALL has no bin/designcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/designcraft" ]] || { echo "error: $TARBALL has no bin/designcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=designcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 12ad8e0f2..c4d843eca 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -22,7 +22,7 @@ APP_ID=ai.storyteller.designcraft SKIP_BUILD=0 FORMATS="appimage deb rpm arch tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -43,8 +43,8 @@ BASENAME="designcraft-$VERSION-linux-$ARCH" echo "==> DesignCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli) @@ -76,7 +76,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local fmt="$1" + case " $FORMATS " in *" $fmt "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -134,6 +137,8 @@ if has appimage; then case "$ARCH" in x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;; aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;; + # No pinned download (e.g. riscv64): set APPIMAGETOOL or put appimagetool on PATH. + *) APPIMAGETOOL_SHA256="" ;; esac APPDIR="$WORK/DesignCraft.AppDir" cp -R "$STAGE" "$APPDIR" @@ -144,9 +149,9 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then + if [[ ! -x "$TOOL" ]]; then download="$TOOL.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" @@ -174,16 +179,16 @@ if has appimage; then # package) appimagetool also writes the .zsync, into its working directory, hence the cd. (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/designcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/designcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 9e549610f..8669d8675 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index c818ae6db..21edfa574 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/designcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/designcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> DesignCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -117,7 +117,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/DesignCraft-notarize.zip" notarize "$WORK/DesignCraft-notarize.zip" xcrun stapler staple "$APP" @@ -151,7 +151,7 @@ hdiutil_retry() { if hdiutil "$@"; then return 0 fi - [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + [[ "$attempt" = 5 ]] && { echo "hdiutil $what failed 5 times" >&2; return 1; } echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 sleep $((attempt * 10)) done @@ -169,7 +169,7 @@ if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; the exit 1 fi hdiutil_retry detach detach "$MOUNT" >/dev/null -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -187,7 +187,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/designcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/designcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 4aafc3e80..62c220f0c 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -11,13 +11,13 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } (cd "$ROOT/apps/designcraft-web" && trunk build --release) fi SITE="$ROOT/dist/web" -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 From 7b91a1eefcf2eab790accff399bdc841c5b5d530 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:32:02 +0200 Subject: [PATCH 089/117] idml: explicit zero cell edge weights hide the default table border (#1623) --- crates/idml/src/import.rs | 9 ++++++++- crates/idml/src/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index ef504065e..dda5ac27e 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2170,9 +2170,12 @@ impl<'r> Importer<'r> { } // Table-local options override the named table style. Cell styles are already // materialized independently, so their edges retain their higher precedence. + let mut border_unset = style.border.is_none() && style.borders.iter().all(|b| b.is_empty()); let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone()); for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) { - self.cell_stroke_attrs(e, side).apply_to(border); + let attrs = self.cell_stroke_attrs(e, side); + border_unset &= attrs.is_empty(); + attrs.apply_to(border); } t.options.border = borders[0].clone(); t.options.borders = borders.map(Some); @@ -2243,6 +2246,10 @@ impl<'r> Importer<'r> { cell.stroke_priorities[i] = priority; // Preserve style-defined edges and #102's positive-priority local overrides. cell.border_overrides[i] |= priority > 0; + // No table or table style border is given anywhere, so the built-in 1 pt default must not + // draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing. + let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE); + cell.border_overrides[i] |= border_unset && hidden; } if let Some(slot) = t.cell_mut(r, c) { *slot = cell; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 37fb2af50..b0119274f 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1100,6 +1100,35 @@ fn imports_and_round_trips_explicit_cell_border_overrides() { assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]); } +#[test] +fn explicitly_hidden_cell_edges_hide_the_default_table_border() { + // No table border or table style is given, and every cell edge is explicitly hidden (issue #1623): + // the first cell by weight 0, the second by the None swatch. + let cell = |name: &str, attr: &str, value: &str| { + format!( + r#" + X"# + ) + }; + let story = format!( + r#" + + + + + {}{} +
"#, + cell("0:0", "StrokeWeight", "0"), + cell("1:0", "StrokeColor", "Swatch/None") + ); + let d = import_idml(&fixture_with_story(&story)).unwrap(); + d.check().unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + for c in 0..2 { + assert_eq!(table.cell(0, c).unwrap().border_overrides, [true; 4], "cell {c} suppresses the table border"); + } +} + #[test] fn round_trips_footnotes_and_options() { let mut d = Document::new(&NewDocument::default()); From f40aca9d376452282b6dec41126a67a94b04fc11 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:31:27 +0200 Subject: [PATCH 090/117] Make the start screen scrollable on short windows (#1655) --- crates/ui-egui/src/chrome.rs | 164 +++++++++++++++++++---------------- crates/ui-egui/src/menus.rs | 41 +++++++++ 2 files changed, 129 insertions(+), 76 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e853e6793..bb1848650 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -852,85 +852,97 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) { egui::Align::Min })), |ui| { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong), - )); - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable.")) - .size(15.0) - .color(t.text_dim), - )); - ui.add_space(24.0); - ui.horizontal(|ui| { - if ui - .add( - egui::Button::new(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE), - )) - .fill(t.accent_strong) - .corner_radius(16.0) - .min_size(vec2(0.0, 32.0)), - ) - .clicked() - { - let _ = app.run("app.newDocumentDialog", json!({})); - } - if ui - .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0))) + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.label(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong), + )); + ui.label(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable.")) + .size(15.0) + .color(t.text_dim), + )); + ui.add_space(24.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE), + )) + .fill(t.accent_strong) .corner_radius(16.0) .min_size(vec2(0.0, 32.0)), - ) - .clicked() - { - let _ = app.run("app.openDialog", json!({})); - } - if ui - .add( - egui::Button::new(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0), - )) - .corner_radius(16.0) - .min_size(vec2(0.0, 32.0)), - ) - .clicked() - { - let _ = app.run("file.newSample", json!({})); - } - }); - ui.add_space(20.0); - community_card(app, ui); - recent_files(app, ui); - ui.add_space(24.0); - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong), - )); - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { - let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click()); - let hov = resp.hovered(); - ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel }); - let k = (80.0 / p.width.max(p.height)) as f32; - let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k)); - ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245)); - ui.painter().text(egui::pos2(cr.center().x, cr.max.y - 26.0), egui::Align2::CENTER_CENTER, p.name, semibold(12.0), t.text_strong); - ui.painter().text( - egui::pos2(cr.center().x, cr.max.y - 11.0), - egui::Align2::CENTER_CENTER, - format!("{} × {}", designcraft_geom::format_measure(p.width, p.units), designcraft_geom::format_measure(p.height, p.units)), - egui::FontId::proportional(10.0), - t.text_dim, - ); - if resp.clicked() { - let _ = app.run("file.new", json!({"preset": p.name})); + ) + .clicked() + { + let _ = app.run("app.newDocumentDialog", json!({})); } - } + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0))) + .corner_radius(16.0) + .min_size(vec2(0.0, 32.0)), + ) + .clicked() + { + let _ = app.run("app.openDialog", json!({})); + } + if ui + .add( + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0), + )) + .corner_radius(16.0) + .min_size(vec2(0.0, 32.0)), + ) + .clicked() + { + let _ = app.run("file.newSample", json!({})); + } + }); + ui.add_space(20.0); + community_card(app, ui); + recent_files(app, ui); + ui.add_space(24.0); + ui.label(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong), + )); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { + let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click()); + let hov = resp.hovered(); + ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel }); + let k = (80.0 / p.width.max(p.height)) as f32; + let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k)); + ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245)); + ui.painter().text( + egui::pos2(cr.center().x, cr.max.y - 26.0), + egui::Align2::CENTER_CENTER, + p.name, + semibold(12.0), + t.text_strong, + ); + ui.painter().text( + egui::pos2(cr.center().x, cr.max.y - 11.0), + egui::Align2::CENTER_CENTER, + format!( + "{} × {}", + designcraft_geom::format_measure(p.width, p.units), + designcraft_geom::format_measure(p.height, p.units) + ), + egui::FontId::proportional(10.0), + t.text_dim, + ); + if resp.clicked() { + let _ = app.run("file.new", json!({"preset": p.name})); + } + } + }); }); }, ); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..70ef4afba 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5642,4 +5642,45 @@ mod tests { assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}"); assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}"); } + + /// On a short window (1366 x 768 laptops) the start screen is taller than the window; the part + /// below the fold has to be reachable by scrolling instead of being cut off. + #[test] + fn start_screen_scrolls_when_the_window_is_too_short() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + let size = egui::vec2(1366.0, 300.0); + let mut tile_y = |events: Vec| { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + // The preset tiles paint their names even when scrolled out of view (labels skip that). + egui::Shape::Text(t) if t.galley.text() == "Tabloid" => Some(t.pos.y), + _ => None, + }) + }; + let at = egui::pos2(600.0, 150.0); + let mut before = None; + for _ in 0..3 { + before = tile_y(vec![egui::Event::PointerMoved(at)]); + } + let before = before.expect("the Tabloid tile is laid out"); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }; + let mut after = tile_y(vec![egui::Event::PointerMoved(at), wheel]); + for _ in 0..30 { + after = tile_y(Vec::new()); + } + let after = after.expect("the Tabloid tile is laid out"); + assert!(after < before - 50.0, "the start screen should scroll: {before} -> {after}"); + } } From 708feab5cd7c86eb72b2adc4a1d3fcef894a47c4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:31:25 +0200 Subject: [PATCH 091/117] find: search and change text inside table cells in document scope (#1624) --- crates/engine/src/cmd/find.rs | 125 ++++++++++++++++++++++++---------- docs/mcp.md | 4 +- 2 files changed, 93 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 3ba6970a1..a8cebf35b 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{Document, Selection, StoryId, TextSel}; +use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,7 +61,7 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)", has_doc, find), cmd!( "find.change", @@ -153,6 +153,9 @@ impl Matcher { } } +/// A searched text: a story, or one table cell of it, and the byte range inside. +type Target = (StoryId, Option, Range); + /// A search owns its original domain, not the selection used to paint the latest hit. /// Weak identity avoids retaining an old document merely for this transient cache. #[derive(Clone)] @@ -163,7 +166,7 @@ pub(crate) struct FindContext { selection: Selection, domain: Domain, matcher: Matcher, - targets: Vec<(StoryId, Range)>, + targets: Vec, order: Vec, target: usize, offset: usize, @@ -184,21 +187,25 @@ impl FindContext { self.target = 0; self.last_match_end = None; self.order = (0..self.targets.len()).collect(); - let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none()); - if self.domain == Domain::Document - && let Some(t) = st.selection.text - && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story) - { + let caret = st.selection.text.filter(|_| st.selection.cells.is_none()); + // A caret in a searched cell starts there; one in a footnote starts at its story. + let start = st.selection.text.and_then(|t| { + let at = |want: Option| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want); + at(t.cell).or_else(|| at(None)) + }); + if let Some(i) = start.filter(|_| self.domain == Domain::Document) { self.order.rotate_left(i); } let first = self.order.first().and_then(|i| self.targets.get(*i)); self.offset = match first { - Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)), - Some((_, r)) => r.start, + Some((sid, cell, r)) if self.domain != Domain::Selection => { + caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end)) + } + Some((_, _, r)) => r.start, None => 0, }; - if let Some((sid, r)) = first { - let text = target_text(&st.doc, *sid, r)?; + if let Some((sid, cell, r)) = first { + let text = target_text(&st.doc, *sid, *cell, r)?; if text.get(self.offset.saturating_sub(r.start)..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } @@ -209,7 +216,7 @@ impl FindContext { fn advance_target(&mut self) { self.target = self.target.saturating_add(1); self.last_match_end = None; - self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start); + self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start); } fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { @@ -222,8 +229,8 @@ impl FindContext { } } -fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range) -> Result<&'a str> { - d.story(sid) +fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option, r: &Range) -> Result<&'a str> { + d.text_story(sid, cell) .ok_or_else(|| bad("find", "no such story"))? .text .get(r.clone()) @@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result { let targets = match domain { Domain::Explicit(sid) => { let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); - vec![(sid, 0..len)] + vec![(sid, None, 0..len)] } Domain::Story | Domain::Selection => { if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { @@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result { let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; - target_text(d, t.story, &r)?; - vec![(t.story, r)] + target_text(d, t.story, None, &r)?; + vec![(t.story, None, r)] } - Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(), + // Table cells hold their own text outside `stories`; search them right after the story that anchors the table. + Domain::Document => d + .stories + .values() + .flat_map(|x| { + let cells = x.tables.iter().flat_map(|(id, t)| { + let cols = t.ncols().max(1); + t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len())) + }); + std::iter::once((x.id, None, 0..x.len())).chain(cells) + }) + .collect(), }; let mut context = FindContext { document: Arc::downgrade(d), @@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result { let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); - for (sid, r) in &context.targets { - let text = target_text(&st.doc, *sid, r)?; + for (sid, cell, r) in &context.targets { + let text = target_text(&st.doc, *sid, *cell, r)?; for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { - out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)})); + let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}); + if let Some(c) = cell { + hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col}); + } + out.push(hit); } } s.find_context = Some(context); @@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result { let targets = scope(s, p)?.targets; s.edit(|d, sel| { let mut count = 0usize; - for (sid, r) in targets { - let Some(story) = d.story(sid) else { continue }; + for (sid, cell, r) in targets { + let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; let edits: Vec<(std::ops::Range, String)> = if grep { @@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result { .into_iter() .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) .collect(); - let Some(st) = d.story_mut(sid) else { continue }; + let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { st.replace(range.clone(), new); if !over.is_empty() && !new.is_empty() { @@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result { } count += 1; } - if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { // Repair byte validity once against the final story, without remapping valid offsets. let range = t.range(); let start = floor_char_boundary(&st.text, range.start); @@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result { let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; - while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { - let text = target_text(&st.doc, sid, &r)?; + while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { + let text = target_text(&st.doc, sid, cell, &r)?; let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); @@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { context.advance_empty(text, m.end, b); continue; } - hit = Some((sid, a, b)); + hit = Some((sid, cell, a, b)); context.last_match_end = Some(b); if m.is_empty() { context.advance_empty(text, m.end, b); @@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result { } context.advance_target(); } - let Some((sid, a, b)) = hit else { + let Some((sid, cell, a, b)) = hit else { s.find_context = Some(context); return Ok(Value::Null); }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); + let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col})); let stm = s.doc_mut()?; - stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); + stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell }); stm.bump_revision(); context.acknowledge(stm); s.find_context = Some(context); - Ok(json!({"story": sid.0, "start": a, "end": b})) + let mut out = json!({"story": sid.0, "start": a, "end": b}); + if let Some(c) = cell_json { + out["cell"] = c; + } + Ok(out) } #[cfg(test)] @@ -1007,6 +1034,31 @@ mod tests { assert_eq!(s.doc().unwrap().history.undo.len(), history); } + #[test] + fn document_scope_searches_and_changes_table_cells() { + let (mut s, sid) = scope_session("Ore 12.15 here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap(); + // The caret sits in the first cell, so Find Next goes to the second cell, then the story. + let p = json!({"find": "Ore 12.15", "scope": "document"}); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 }; + assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}})); + assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell)))); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}"); + let hits = s.execute("find.find", &p).unwrap(); + let hits = hits.as_array().unwrap(); + assert_eq!(hits.len(), 2); + let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect(); + assert_eq!(in_cell.len(), 1); + assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1))); + let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap(); + assert_eq!(n["count"], 2); + assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty()); + assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); + } + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { let (mut s, sid) = scope_session("red"); scope_select(&mut s, sid, 3, 3); @@ -1051,12 +1103,15 @@ mod tests { p["story"] = json!(sid.0); p["scope"] = json!("selection"); } + // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level. + let in_cell = if explicit || note { 0 } else { child.matches("red").count() }; + let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() }; s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell); scope_hit(&mut s, &p, sid, 0, 3); s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); - assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed); assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); p["find"] = json!("blue"); p["change"] = json!("red"); diff --git a/docs/mcp.md b/docs/mcp.md index 7cf8227da..741174e13 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -182,7 +182,9 @@ and `text.move` return, a story's `length` in `get_story` and `inspect_document` `get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15 and its second character starts at 3. An offset inside a character snaps back to the start of that character, except in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end` -is an error that gives the story length in bytes, and the story is left unchanged. +is an error that gives the story length in bytes, and the story is left unchanged. A `find.find` or `find.next` match +that has a `cell` (`{table, row, col}`) is in that table cell's text, and its `start` / `end` count from the start of +the cell. ## Text and image fitting From 55f9e690e341366430c7f1d0dd7f43ccf945b368 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:27:41 +0200 Subject: [PATCH 092/117] Reload ICC profiles loaded in Color Settings at start-up Fixes #1474 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 3 ++ crates/ui-egui/src/lib.rs | 20 ++++++++- .../tests/it/color_profiles_restart.rs | 43 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 2 + 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/it/color_profiles_restart.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..b35acabcb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2456,6 +2456,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { match app.session.execute("color.loadProfile", &json!({"path": path})) { Ok(_) => { + if !app.ui.color_profiles.contains(&path) { + app.ui.color_profiles.push(path); + } let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); d.fields.insert("profiles".into(), f["profiles"].clone()); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 535c87629..59d0d6354 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -223,6 +223,9 @@ pub struct UiState { pub pages_panel: panels::pages::PanelOptions, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, + /// ICC files loaded with Edit › Color Settings › Load Profile… (loaded again at start, before + /// `color_settings`, so a loaded working space stays chosen). + pub color_profiles: Vec, /// View › Proof Colors and View › Proof Setup. pub proof_colors: bool, pub proof_setup: designcraft_color::cms::ProofSetup, @@ -383,6 +386,7 @@ impl Default for UiState { pages_parents_height: None, pages_panel: Default::default(), color_settings: None, + color_profiles: Vec::new(), proof_colors: false, proof_setup: Default::default(), dynamic_spelling: false, @@ -1106,8 +1110,22 @@ impl DesignApp { self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { self.color_applied = true; + for path in self.ui.color_profiles.clone() { + if let Err(e) = self.session.execute("color.loadProfile", &json!({"path": path})) { + log::warn!("Color Settings: {e}"); + self.status_error("Color Settings", e); + } + } if let Some(cs) = self.ui.color_settings.clone() { - let _ = designcraft_color::cms::set_active(&cs.with_known_profiles()); + let known = cs.with_known_profiles(); + for (saved, used) in [(&cs.rgb, &known.rgb), (&cs.cmyk, &known.cmyk)] { + if saved != used { + let e = format!("saved profile {saved} is not available; using {used}"); + log::warn!("Color Settings: {e}"); + self.status_error("Color Settings", e); + } + } + let _ = designcraft_color::cms::set_active(&known); } } let scale = self.ui.ui_scale.clamp(0.5, 3.0); diff --git a/crates/ui-egui/tests/it/color_profiles_restart.rs b/crates/ui-egui/tests/it/color_profiles_restart.rs new file mode 100644 index 000000000..ad7ebcc75 --- /dev/null +++ b/crates/ui-egui/tests/it/color_profiles_restart.rs @@ -0,0 +1,43 @@ +//! A working space loaded with Edit › Color Settings › Load Profile… is active again after a +//! restart (#1474). Colour settings are process-wide, so this lives in the integration tests. + +use designcraft_color::cms::{self, ColorSettings}; +use designcraft_engine::Session; +use designcraft_ui_egui::{DesignApp, Services, UiState}; +use serde_json::json; + +/// The app's first frame, where saved settings are applied. +fn start(app: &mut DesignApp) { + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.logic(&ui.ctx().clone())); + out.textures_delta.clear(); +} + +#[test] +fn loaded_working_space_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("restart-test.icc"); + let mut profile = cms::icc_out::generic_cmyk_profile().unwrap().clone(); + profile.description = "Restart Test CMYK".into(); + std::fs::write(&path, profile.with_encoded_bytes().to_bytes().as_slice()).unwrap(); + + // What ui.json holds after Load Profile… and choosing that profile in a previous session. + let chosen = ColorSettings { cmyk: "Restart Test CMYK".into(), ..Default::default() }; + let saved = json!({"colorProfiles": [path.to_string_lossy()], "colorSettings": chosen}); + + // This process has never loaded the profile: only the built-in ones are known. + assert!(!cms::profiles().iter().any(|p| p.name == chosen.cmyk)); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.ui = serde_json::from_value::(saved).unwrap(); + start(&mut app); + let active = cms::active_settings(); + cms::set_active(&ColorSettings::default()).unwrap(); + assert_eq!(active, chosen, "the loaded working space is active again"); + + // A saved profile that can't be loaded any more is reported, not silently replaced. + let gone = json!({"colorProfiles": [dir.path().join("gone.icc").to_string_lossy()], "colorSettings": ColorSettings { cmyk: "Gone CMYK".into(), ..Default::default() }}); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.ui = serde_json::from_value::(gone).unwrap(); + start(&mut app); + assert_eq!(cms::active_settings(), ColorSettings::default()); + assert!(app.ui.status.contains("Gone CMYK"), "{}", app.ui.status); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2d07c9629..6950fc776 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,2 +1,4 @@ #[cfg(not(target_arch = "wasm32"))] +mod color_profiles_restart; +#[cfg(not(target_arch = "wasm32"))] mod native_job_lifetime_control; From 62a26d9e352e2ea8f58ce239dfc408131a5b2d8c Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 17:47:18 +0100 Subject: [PATCH 093/117] PDF: preserve catalogs and full references in layer variants --- ROADMAP.md | 4 + crates/engine/src/cmd/pdflayers.rs | 61 ++++++++- crates/render/src/pdf_layers.rs | 200 ++++++++++++++++++++++------- docs/gaps.md | 3 + 4 files changed, 221 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..d853936f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,10 @@ 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-23** · PDF layer visibility variants retain catalog/configuration entries and full + object references; bounded incremental updates replace the existing catalog. Synthetic + regression tests cover metadata, output-intent references and nonzero generations. No readiness percentage change. + ## Status (2026-10-01) **Working today** diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index c8badf533..d03aa4e52 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -63,31 +63,84 @@ pub(crate) mod tests { /// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). pub(crate) fn two_layer_pdf() -> Vec { + two_layer_pdf_generation(0) + } + + fn two_layer_pdf_generation(generation: i32) -> Vec { let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; let objs = [ - "<>>>>>".to_string(), + "<>>>>>".to_string(), "<>".to_string(), "<>>>/Contents 4 0 R>>".to_string(), format!("<>\nstream\n{content}\nendstream", content.len()), "<>".to_string(), "<>".to_string(), + "<>\nstream\n\nendstream".to_string(), + "<>".to_string(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes()); } let x = out.len(); let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); for o in offs { - t.push_str(&format!("{o:010} 00000 n\r\n")); + t.push_str(&format!("{o:010} {generation:05} n\r\n")); } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); out.extend_from_slice(t.as_bytes()); out } + #[test] + fn pdf_layer_update_preserves_catalog_configuration_and_generations() { + for generation in [0, 1] { + let original = two_layer_pdf_generation(generation); + let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let appended = std::str::from_utf8(&updated[original.len()..]).unwrap(); + for token in [ + "/Lang(en-GB)".to_string(), + format!("/Metadata 7 {generation} R"), + format!("/OutputIntents[8 {generation} R]"), + format!("/Locked[5 {generation} R]"), + "/Name(Default)".into(), + format!("/Root 1 {generation} R"), + ] { + assert!(appended.contains(&token), "missing {token}: {appended}"); + } + assert!(!appended.contains("9999000")); + let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap(); + assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible)); + } + } + + #[test] + fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let mut stream_pdf = original[..offset].to_vec(); + let mut entries = vec![0, 0, 0, 0, 0, 255, 255]; + for number in 1..=9 { + let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() }; + entries.push(1); + entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes()); + entries.extend_from_slice(&[0, 0]); + } + stream_pdf.extend_from_slice(format!("9 0 obj\n<>\nstream\n", entries.len()).as_bytes()); + stream_pdf.extend_from_slice(&entries); + stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + for unsupported in ["/Encrypt null", "/XRefStm 0"] { + let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9")); + assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none()); + } + } + #[test] fn placed_pdf_layers_hide() { let mut s = Session::new(); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index bc6d864a7..ccf1198d4 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -6,20 +6,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use craft_pdf::syntax::Pdf; -use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier}; -/// (object number, name, on by default) of each layer. -fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { +/// (object reference, name, on by default) of each layer. +fn ocgs(pdf: &Pdf) -> Vec<(ObjRef, String, bool)> { let xref = pdf.xref(); let Some(cat) = xref.get::>(xref.root_id()) else { return vec![] }; let Some(ocp) = cat.get::>(b"OCProperties") else { return vec![] }; let Some(list) = ocp.get::>(b"OCGs") else { return vec![] }; let cfg = ocp.get::>(b"D"); - let ids_in = |key: &[u8]| -> Vec { - cfg.as_ref() - .and_then(|c| c.get::>(key)) - .map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).map(|r| r.obj_number).collect()) - .unwrap_or_default() + let ids_in = |key: &[u8]| -> Vec { + cfg.as_ref().and_then(|c| c.get::>(key)).map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).collect()).unwrap_or_default() }; let base_off = cfg.as_ref().and_then(|c| c.get::>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF"); let (on, off) = (ids_in(b"ON"), ids_in(b"OFF")); @@ -31,8 +28,8 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { .get::>(b"Name") .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) .unwrap_or_default(); - let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; - Some((r.obj_number, name, visible)) + let visible = if base_off { on.contains(&r) } else { !off.contains(&r) }; + Some((r, name, visible)) }) .collect() } @@ -42,62 +39,179 @@ pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() } -/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file -/// has no such layers or can't be read. +// Copy untouched dictionary entries as raw PDF tokens. In particular, do not reinterpret +// strings, numbers or unknown extension keys. The parser bounds container nesting. +fn patch_dict(dict: &Dict<'_>, updates: &[(&[u8], &[u8])]) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + let data = if dict.is_empty() { b"<<>>".as_slice() } else { dict.data() }; + if data.len() > 4 * 1024 * 1024 { + return None; + } + let mut reader = Reader::new(data); + reader.forward_tag(b"<<")?; + let mut out = b"<<".to_vec(); + let mut ended = false; + for _ in 0..100_000 { + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b">>").is_some() { + ended = true; + break; + } + let start = reader.offset(); + let name = reader.read_without_context::>()?; + reader.skip_white_spaces_and_comments(); + reader.skip::>>(false)?; + if !updates.iter().any(|(key, _)| *key == name.as_ref()) { + out.push(b' '); + out.extend_from_slice(data.get(start..reader.offset())?); + } + } + if !ended { + return None; + } + for (key, value) in updates { + if !value.is_empty() { + out.extend_from_slice(b" /"); + out.extend_from_slice(key); + out.push(b' '); + out.extend_from_slice(value); + } + } + out.extend_from_slice(b" >>"); + Some(out) +} + +fn trailer_at(bytes: &[u8], offset: usize) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + let data = bytes.get(offset..)?; + let mut reader = Reader::new(data); + if data.starts_with(b"xref") { + let pos = data.get(..data.len().min(4 * 1024 * 1024))?.windows(7).position(|w| w == b"trailer")?; + reader.skip_bytes(pos.checked_add(7)?)?; + } else { + // An xref stream's dictionary also holds the trailer entries. + reader.read_without_context::()?; + } + reader.skip_white_spaces_and_comments(); + reader.read_without_context::>() +} + +/// `bytes` with exactly the layers named in `hidden` off (the others on). +/// Returns `None` for unreadable, encrypted, hybrid-reference or over-budget updates. +/// Untouched catalog/configuration entries and full object references are preserved. pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { + crate::check_pdf_input_size(bytes.len()).ok()?; let pdf = craft_pdf::open_slice(bytes, None).ok()?; let layers = ocgs(&pdf); if layers.is_empty() { return None; } - let xref = pdf.xref(); - let cat = xref.get::>(xref.root_id())?; - let pages = cat.get_ref(b"Pages")?; - let all: Vec = layers.iter().map(|(n, _, _)| format!("{n} 0 R")).collect(); - let off: Vec = layers.iter().filter(|(_, name, _)| hidden.contains(name)).map(|(n, _, _)| format!("{n} 0 R")).collect(); - // A new catalog under a fresh object number, with the original's page tree. - let root = 9_999_000; - let mut out = bytes.to_vec(); - if !out.ends_with(b"\n") { - out.push(b'\n'); + let tail = String::from_utf8_lossy(bytes.get(bytes.len().saturating_sub(1024)..)?); + let prev = tail.rsplit_once("startxref")?.1.split_whitespace().next()?.parse::().ok()?; + let trailer = trailer_at(bytes, prev)?; + // Do not copy decrypted dictionaries into an encrypted file, or ignore a hybrid + // xref table. Follow bounded Prev links because encryption can be inherited. + let mut offset = Some(prev); + for _ in 0..32 { + let Some(at) = offset else { break }; + let t = trailer_at(bytes, at)?; + if t.contains_key(b"Encrypt") || t.contains_key(b"XRefStm") { + return None; + } + offset = t.get::(b"Prev"); + if offset.is_some_and(|n| n >= at) { + return None; + } } + if offset.is_some() { + return None; + } + let xref = pdf.xref(); + let root = xref.root_id(); + if root.obj_number <= 0 || !(0..=65535).contains(&root.gen_number) || trailer.get::(b"Size")? <= usize::try_from(root.obj_number).ok()? { + return None; + } + let cat = xref.get::>(root)?; + let ocp = cat.get::>(b"OCProperties")?; + let cfg = ocp.get::>(b"D"); + let mut off = b"[".to_vec(); + for (id, name, _) in &layers { + if hidden.contains(name) { + off.extend_from_slice(format!("{} {} R ", id.obj_number, id.gen_number).as_bytes()); + } + } + off.push(b']'); + let empty = Dict::empty(); + let cfg = patch_dict(cfg.as_ref().unwrap_or(&empty), &[(b"BaseState", b"/ON"), (b"ON", b"[]"), (b"OFF", &off)])?; + let ocp = patch_dict(&ocp, &[(b"D", &cfg)])?; + let catalog = patch_dict(&cat, &[(b"OCProperties", &ocp)])?; + let root_ref = format!("{} {} R", root.obj_number, root.gen_number); + let prev_value = prev.to_string(); + let trailer = patch_dict( + &trailer, + &[ + (b"Root", root_ref.as_bytes()), + (b"Prev", prev_value.as_bytes()), + (b"Type", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + (b"W", b""), + (b"Index", b""), + ], + )?; + let mut out = bytes.to_vec(); + out.push(b'\n'); let at = out.len(); - out.extend_from_slice( - format!( - "{root} 0 obj\n<>>>>>\nendobj\n", - pages.obj_number, - pages.gen_number, - all.join(" "), - off.join(" ") - ) - .as_bytes(), - ); + // Redefine the existing catalog: no arbitrary fresh object number or Size increase. + out.extend_from_slice(format!("{} {} obj\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&catalog); + out.extend_from_slice(b"\nendobj\n"); let xref_at = out.len(); - let prev = { - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string(); - tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? - }; - out.extend_from_slice( - format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) - .as_bytes(), - ); + out.extend_from_slice(format!("xref\n{} 1\n{at:010} {:05} n\r\ntrailer\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&trailer); + out.extend_from_slice(format!("\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); Some(out) } /// Shared copies of layer variants (by source address and hidden set). pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Arc> { type Key = (usize, Vec); - static CACHE: Mutex>>>> = Mutex::new(None); + static CACHE: Mutex>, Arc>)>>> = Mutex::new(None); let key = (Arc::as_ptr(data) as usize, hidden.to_vec()); let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); let map = c.get_or_insert_with(HashMap::new); if let Some(v) = map.get(&key) { - return v.clone(); + return v.1.clone(); } if map.len() > 64 { map.clear(); } let v = pdf_hide_layers(data, hidden).map(Arc::new).unwrap_or_else(|| data.clone()); - map.insert(key, v.clone()); + map.insert(key, (data.clone(), v.clone())); v } + +#[cfg(test)] +mod tests { + use super::*; + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + + #[test] + fn dictionary_patch_preserves_raw_values_and_bounds_updates() { + let data = b"<]/Base#53tate/OFF/Name(a(b)c)>>"; + let dict = Reader::new(data).read_without_context::>().unwrap(); + let out = patch_dict(&dict, &[(b"BaseState", b"/ON")]).unwrap(); + let text = std::str::from_utf8(&out).unwrap(); + assert!(text.contains("/Unknown[(x) 12 1 R ]")); + assert!(text.contains("/Name(a(b)c)")); + assert!(!text.contains("/OFF")); + assert!(text.contains("/BaseState /ON")); + assert_eq!(patch_dict(&Dict::empty(), &[(b"BaseState", b"/ON")]).unwrap(), b"<< /BaseState /ON >>"); + let mut many = b"<< /Big (".to_vec(); + many.resize(4 * 1024 * 1024 + 1, b'x'); + many.extend_from_slice(b") >>"); + let dict = Reader::new(&many).read_without_context::>().unwrap(); + assert!(patch_dict(&dict, &[]).is_none()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..8591e39bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -190,6 +190,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation + references, redefining the existing catalog rather than choosing an arbitrary object number. + Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a From 85552ca0f33b81518539f67254d5cc83d2bfcea7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 19:03:26 +0200 Subject: [PATCH 094/117] Selection: a key object needs at least two selected objects --- crates/engine/src/cmd/edit.rs | 3 +++ crates/engine/src/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2daa24d5a..a10e9ae2f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -278,6 +278,9 @@ pub fn specs() -> Vec { if !st.selection.items.contains(&id) { return Err(super::bad("selection.key", "the key object must be selected")); } + if st.selection.items.len() < 2 { + return Err(super::bad("selection.key", "a key object needs at least two selected objects")); + } st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) }; st.bump_revision(); ok() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 43bc767c0..b1792485d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1713,6 +1713,20 @@ fn deselecting_the_key_object_drops_the_key() { assert_eq!(s.doc().unwrap().selection.key, None); } +/// A key object needs another object to align to: with one object selected there is nothing to key +/// against, and the stored key would turn that object into the target as soon as a second one joins. +#[test] +fn a_single_selected_object_cannot_be_the_key_object() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a]})).unwrap(); + assert!(s.execute("selection.key", &json!({"id": a})).is_err()); + assert_eq!(s.doc().unwrap().selection.key, None); + s.execute("selection.toggle", &json!({"id": b})).unwrap(); + assert_eq!(s.doc().unwrap().selection.key, None, "the second object joining must not find a key already set"); +} + /// With the selection down to the key object alone, Align no longer has a key object: a single /// object aligns to the page, as before. #[test] From da8d3a29376c64c7ae627f51a7bdd0b3df546765 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 19:19:20 +0100 Subject: [PATCH 095/117] PDF: accept forward xref links and reject cycles --- ROADMAP.md | 3 ++- crates/engine/src/cmd/pdflayers.rs | 20 ++++++++++++++++++++ crates/render/src/pdf_layers.rs | 7 ++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d853936f2..0b12be183 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,8 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full object references; bounded incremental updates replace the existing catalog. Synthetic - regression tests cover metadata, output-intent references and nonzero generations. No readiness percentage change. + regression tests cover metadata, output-intent references, nonzero generations and + forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change. ## Status (2026-10-01) diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index d03aa4e52..9ef697614 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -141,6 +141,26 @@ pub(crate) mod tests { } } + #[test] + fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let first = original.len(); + let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<>\n"); + let main = first + link(0).len(); + let mut forward = original.clone(); + forward.extend_from_slice(link(main).as_bytes()); + let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes()); + forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let mut cyclic = original; + cyclic.extend_from_slice(link(first).as_bytes()); + cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none()); + } + #[test] fn placed_pdf_layers_hide() { let mut s = Session::new(); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index ccf1198d4..3d90d8083 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -112,16 +112,17 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { // Do not copy decrypted dictionaries into an encrypted file, or ignore a hybrid // xref table. Follow bounded Prev links because encryption can be inherited. let mut offset = Some(prev); + let mut visited = std::collections::HashSet::new(); for _ in 0..32 { let Some(at) = offset else { break }; + if !visited.insert(at) { + return None; + } let t = trailer_at(bytes, at)?; if t.contains_key(b"Encrypt") || t.contains_key(b"XRefStm") { return None; } offset = t.get::(b"Prev"); - if offset.is_some_and(|n| n >= at) { - return None; - } } if offset.is_some() { return None; From ae5a6d549cc22d64332cb82ac2c1b1f993a564b9 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:21:51 -0400 Subject: [PATCH 096/117] CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#1645) ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69. --- .github/workflows/ci.yml | 68 ++++++----------------------- .github/workflows/freebsd.yml | 11 ++--- .github/workflows/windows-arm64.yml | 12 +++-- 3 files changed, 24 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5b820d24..30a02804c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,7 @@ -# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, -# wasm) on every pull request and every push to main. Today only path-filtered workflows run on -# pull requests, so most PRs land with no test run at all. -# -# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -14,58 +11,19 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - # Same craft-fonts commit as release.yml, so the font tests see what a release build embeds. - CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695 - jobs: gate: - name: cargo xtask ci - runs-on: ubuntu-24.04 - timeout-minutes: 90 - env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - RUST_BACKTRACE: "1" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - persist-credentials: false - - - name: Install Linux build dependencies - uses: storytold/craft-common-actions/actions/install-apt-packages@main - with: - packages: >- - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev - libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26 - with: - tool: cargo-nextest@0.9.145 - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + timeout-minutes: 90 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index e60c2bdf3..8e562f8ba 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,16 +2,17 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow itself still run it. on: push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "crates/fonts/**" - .github/workflows/freebsd.yml + schedule: + - cron: "27 2 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index ed7060b99..85856800b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -4,19 +4,17 @@ # out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "3 5 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - .github/workflows/windows-arm64.yml - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up workflow_dispatch: concurrency: From 913bce948f79c5337abf7aff8d7f3db41abe4a4e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:48:11 +0000 Subject: [PATCH 097/117] Keep non-finite IDML numbers out of imported documents Fixes #1589 Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 31 +++++++++++++++++++++---------- crates/idml/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index ef504065e..63e317ad5 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -387,13 +387,10 @@ fn mojikumi_row(e: &El) -> Result { Ok(row) } +/// The numbers in a whitespace-separated list; empty when any number is NaN or infinite (JSON +/// can't store them, and dropping just those would shift the rest). fn nums(s: &str) -> Vec { - s.split_whitespace().filter_map(|v| v.parse().ok()).collect() -} - -/// Like [`nums`], but empty when any number is NaN or infinite. -fn finite_nums(s: &str) -> Vec { - let v = nums(s); + let v: Vec = s.split_whitespace().filter_map(|v| v.parse().ok()).collect(); if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } } @@ -895,6 +892,15 @@ impl<'r> Importer<'r> { _ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32), _ => return None, }; + // Huge values (1e300) overflow `f32` to infinity. + let parts = match c { + Color::Rgb { r, g, b } => [r, g, b, 0.0], + Color::Cmyk { c, m, y, k } => [c, m, y, k], + Color::Gray { k } => [k, 0.0, 0.0, 0.0], + }; + if !parts.iter().all(|v| v.is_finite()) { + return None; + } let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process }; Some((c, ty)) } @@ -947,14 +953,14 @@ impl<'r> Importer<'r> { let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue }; let kind = match e.local() { "StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes { - bands: finite_nums(e.get("StripeArray").unwrap_or("")) + bands: nums(e.get("StripeArray").unwrap_or("")) .as_chunks::<2>() .0 .iter() .map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)) .collect(), }, - "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) }, + "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) }, _ => designcraft_doc::StrokeType::Dotted, }; let _ = id; @@ -2856,7 +2862,7 @@ impl<'r> Importer<'r> { { it.fill.gradient_angle = Some(a); } - let start: Option> = e.prop("GradientFillStart").map(|s| finite_nums(&s)); + let start: Option> = e.prop("GradientFillStart").map(|s| nums(&s)); if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength")) && len > 0.0 { @@ -2874,7 +2880,7 @@ impl<'r> Importer<'r> { { it.stroke.gradient_angle = Some(a); } - let start: Option> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s)); + let start: Option> = e.prop("GradientStrokeStart").map(|s| nums(&s)); if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength")) && len > 0.0 { @@ -3702,6 +3708,11 @@ fn path_capped(pg: &El, max: usize) -> PathData { let closed = g.get("PathOpen") != Some("true"); let mut anchors = Vec::new(); if let Some(arr) = g.find("PathPointArray") { + // A NaN or infinite point drops its subpath (JSON can't store it). + let non_finite = |s: Option<&str>| s.is_some_and(|s| s.split_whitespace().any(|v| v.parse::().is_ok_and(|x| !x.is_finite()))); + if arr.find_all("PathPointType").take(left).any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.get(k)))) { + continue; + } for p in arr.find_all("PathPointType").take(left) { let Some(a) = parse_point(p.get("Anchor")) else { continue }; let hin = parse_point(p.get("LeftDirection")).unwrap_or(a); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 37fb2af50..6999b78b2 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4368,3 +4368,39 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } + +/// Non-finite numbers in colour values, page bounds and item transforms (NaN, infinities and +/// values like 1e300 that overflow `f32`) never reach the model: JSON would write them as null. +#[test] +fn non_finite_number_lists_never_reach_the_model() { + let graphic = GRAPHIC.replace(r#"ColorValue="255 0 0""#, r#"ColorValue="NaN 1e300 inf""#); + let spread = SPREAD + .replace(r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500""#, r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 NaN 500""#) + .replace(r#"ItemTransform="1 0 0 1 50 -300""#, r#"ItemTransform="1 0 0 1 inf -300""#); + let bytes = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", &graphic), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]); + let doc = import_idml(&bytes).unwrap(); + for s in &doc.swatches { + if let SwatchValue::Color { color, .. } | SwatchValue::Paper { color } = &s.value { + let parts: Vec = match *color { + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Gray { k } => vec![k], + }; + assert!(parts.iter().all(|v| v.is_finite()), "swatch {} is not finite: {color:?}", s.name); + } + } + for sp in doc.spreads.iter().chain(&doc.parents) { + for p in &sp.pages { + assert!(p.width.is_finite() && p.height.is_finite() && p.x.is_finite(), "page {p:?}"); + } + } + assert_eq!(doc.non_finite_item(), None); +} From b5342e48cd2efeaa5d337137c26e8015dff29224 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:32:05 +0200 Subject: [PATCH 098/117] Save and export dialogs start in the document's folder, or the last one used (#1653) --- ROADMAP.md | 1 + apps/designcraft/src/main.rs | 28 ++++++++++- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 73 +++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 16 +++--- crates/ui-egui/src/panels/library.rs | 6 +-- docs/user/guides/export-a-pdf.md | 2 +- 7 files changed, 113 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..66c24a7eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 13416e4e0..e694fe84f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -277,12 +277,27 @@ fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path: s.into() } +/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in +/// front (the document's, or the last one used) starts there while that folder still exists; +/// otherwise the system picks, as it did before. +fn save_dialog_start(suggested: &str) -> (Option, String) { + let path = std::path::Path::new(suggested); + let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string()); + let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf); + (folder, name) +} + /// The save dialog: the suggested name's extension as its filter, and the extension added to a /// name typed without it. Adding it names another file than the one the dialog checked, so /// replacing an existing one asks again. -fn pick_save(name: &str) -> Option { +fn pick_save(suggested: &str) -> Option { + let (folder, name) = save_dialog_start(suggested); + let name = name.as_str(); let ext = suggested_extension(name); let mut dialog = rfd::FileDialog::new().set_file_name(name); + if let Some(folder) = folder { + dialog = dialog.set_directory(folder); + } if let Some(ext) = ext { dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]); } @@ -606,6 +621,17 @@ mod tests { assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept"); assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension"); } + + /// #1653: the save dialog opens in the folder the suggestion names, unless it is gone. + #[test] + fn save_dialog_starts_in_the_suggested_folder_while_it_exists() { + let folder = std::env::temp_dir(); + let suggested = folder.join("Brochure.pdf"); + assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string())); + let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf"); + assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own"); + assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own"); + } } #[cfg(all(test, unix))] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..d80f3cd0b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3337,7 +3337,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { params["title"] = json!(title); } let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") }; - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + match app.pick_save(&name) { Some(path) => { params["path"] = json!(path); crate::menus::export_pdf(app, ¶ms) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 535c87629..0895cdf0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,7 +71,8 @@ pub type Inbox = std::sync::Arc>>; pub struct Services { /// Open-file dialog for a purpose (`open`, `place`) → path. pub pick_open: Option, - /// Save dialog with a suggested name → path. + /// Save dialog with a suggested name → path. The name may start with the folder the dialog + /// opens in (see [`DesignApp::pick_save`]). pub pick_save: Option, pub read: Option, pub write: Option, @@ -285,6 +286,10 @@ pub struct UiState { pub custom_workspaces: Vec, /// File › New › Document › Recent: the settings of the last documents made, newest first. pub recent_new_documents: Vec, + /// The folder of the last file saved or exported: where the save dialog starts for a + /// document never saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_save_folder: Option, /// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2). pub font_sample: String, pub font_preview_size: u8, @@ -412,6 +417,7 @@ impl Default for UiState { workspace: "Essentials".into(), custom_workspaces: Vec::new(), recent_new_documents: Vec::new(), + last_save_folder: None, font_sample: "selected".into(), font_preview_size: 1, new_document_preview: false, @@ -751,6 +757,26 @@ impl DesignApp { self.ui.status = s.into(); } + /// The host's save dialog for `name`, started beside the active document once it has been + /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder + /// is remembered for the next one. + pub(crate) fn pick_save(&mut self, name: &str) -> Option { + use std::path::{Path, PathBuf}; + let folder_of = |path: &str| Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf); + let folder = self + .session + .active() + .and_then(|d| d.path.as_deref()) + .and_then(folder_of) + .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from)); + let suggested = folder.map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().to_string()); + let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?; + if let Some(folder) = folder_of(&picked) { + self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); + } + Some(picked) + } + /// Did an IME composition end outside the IME this frame (a click, a tool switch)? egui's /// `should_interrupt_composition` toggles winit's IME, which doesn't reach the macOS input /// context: it keeps the marked text and types it again into the next composition, so the @@ -1834,6 +1860,51 @@ mod tests { assert_eq!(app.view_info().unit, Unit::Inches); } + /// Save and Export PDF dialogs started in the home folder (#1653): they start beside a saved + /// document, and for one never saved in the folder of the last save or export. + #[test] + fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { + use std::{cell::RefCell, rc::Rc}; + let home = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let asked: Rc>> = Rc::default(); + let answer: Rc>> = Rc::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + let (seen, reply) = (asked.clone(), answer.clone()); + app.services.pick_save = Some(Box::new(move |name: &str| { + seen.borrow_mut().push(name.to_string()); + reply.borrow().clone() + })); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + let last = |asked: &Rc>>| asked.borrow().last().cloned().unwrap_or_default(); + let export_pdf = |app: &mut DesignApp| { + app.ui.dialog = Some(dialogs::Dialog::new("pdfExport", json!({}))); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Report")); + dialogs::confirm(app).unwrap(); + }; + + app.run("file.new", json!({})).unwrap(); + let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(last(&asked), untitled, "never saved, nothing remembered: the system's own folder"); + + let saved = home.path().join("Brochure.designcraft"); + *answer.borrow_mut() = Some(saved.to_string_lossy().to_string()); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*saved.to_string_lossy())); + // An export into another folder becomes the last folder used. + *answer.borrow_mut() = Some(elsewhere.path().join("Proof.pdf").to_string_lossy().to_string()); + export_pdf(&mut app); + *answer.borrow_mut() = None; + export_pdf(&mut app); + assert_eq!(last(&asked), home.path().join("Report.pdf").to_string_lossy(), "a saved document exports beside itself"); + + app.run("file.new", json!({})).unwrap(); + let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used"); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..7dfff9016 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1071,7 +1071,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { + if let Some(path) = app.pick_save(&name) { return Some(app.run("file.saveAs", json!({"path": path}))); } Ok(Value::Null) @@ -1082,7 +1082,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { + if let Some(path) = app.pick_save(&name) { return Some(app.run("file.saveACopy", json!({"path": path}))); } Ok(Value::Null) @@ -1156,7 +1156,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + match app.pick_save(&name) { Some(dir) => { let r = app.run("file.package", json!({"dir": dir})); if let Ok(v) = &r { @@ -1217,7 +1217,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str) { Some(s) => Some(s.to_string()), - None => app.services.pick_save.as_mut().and_then(|f| f("Swatches.ase")), + None => app.pick_save("Swatches.ase"), }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let r = match app.run("swatch.save", json!({})) { @@ -2011,7 +2011,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result { Some(s) => Some(s.to_string()), None => { let name = format!("{}-p{}.png", st.doc.title, page + 1); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2029,7 +2029,7 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result { Some(s) => Some(s.to_string()), None => { let name = format!("{}.idml", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2049,7 +2049,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< Some(s) => Some(s.to_string()), None => { let name = format!("{}.{ext}", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2077,7 +2077,7 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result Some(s.to_string()), None => { let name = format!("{}.pdf", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 56d6c8cae..65fb16862 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -33,7 +33,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.separator(); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Library…"))).clicked() { - let path = app.services.pick_save.as_mut().and_then(|f| f("Library.dclib")); + let path = app.pick_save("Library.dclib"); let _ = app.run("library.new", json!({"path": path})); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() @@ -90,7 +90,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() - && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.dcbook")) + && let Some(path) = app.pick_save("Book.dcbook") && let Err(e) = app.run("book.new", json!({"path": path})) { app.status_error("Book", e); @@ -154,7 +154,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("book.syncStyles", json!({})); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() - && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.pdf")) + && let Some(path) = app.pick_save("Book.pdf") && let Err(e) = app.run("book.exportPdf", json!({"path": path})) { app.status_error("Book", e); diff --git a/docs/user/guides/export-a-pdf.md b/docs/user/guides/export-a-pdf.md index 3bec469cd..827f54c4e 100644 --- a/docs/user/guides/export-a-pdf.md +++ b/docs/user/guides/export-a-pdf.md @@ -17,7 +17,7 @@ Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. 4. Under **Compression**, tick or clear **Compress images**. 5. Under **Marks and Bleeds**, tick **Include document bleed**, **Crop Marks**, **Bleed Marks** or **Page Information** as your printer asks. **Weight:** and **Offset:** set the marks. 6. Under **Advanced**, pick a **Standard:** (**None**, **PDF/X-4** or **PDF/A-2b**) and decide on **Tagged PDF**. -7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`. +7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`. The save dialog opens in your document's folder, or, if the document has never been saved, in the folder you last saved or exported to. ## Presets From 2c32ee389b596f66c77298df91e307fa30c48396 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:38:43 -0400 Subject: [PATCH 099/117] Engine: Export PDF and Print take a creation date, so the print test's two exports match (#1680) --- crates/engine/src/cmd/export.rs | 7 ++++++- crates/engine/src/cmd/print.rs | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 36fd3d52a..1b37f526a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", @@ -371,6 +371,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result { Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?, None => Standard::None, }; + let created = match p.get("created") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), + }; Ok(PdfOptions { pages, spreads, @@ -382,6 +386,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result { author: str_param(p, "author").map(str::to_string), tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), media: p.get("media").and_then(Value::as_bool).unwrap_or(false), + created, ..PdfOptions::default() }) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4f79f13ad..0bb280780 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Print…", ["File"], Some("Cmd+P"), - "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}", + "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}", has_doc, print ), @@ -95,7 +95,8 @@ mod tests { fn print_submits_the_selected_export_and_reports_refusal() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 4})).unwrap(); - let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true}); + // A fixed creation date: both exports would otherwise take the clock's second. + let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000}); let expected = { let d = &s.doc().unwrap().doc; let options = super::super::export::options(¶ms, d.page_count()).unwrap(); @@ -118,5 +119,6 @@ mod tests { assert!(refusal.to_string().contains("printer offline")); print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap(); assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err()); + assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err()); } } From 1fd8463af9338e51577106d8293458d2e226d9c9 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:24 -0400 Subject: [PATCH 100/117] i18n: missing translations are reported, never a failure (#1677) A string without a translation shows in English, and an empty catalog row counts as untranslated. Tests keep duplicate-key and placeholder checks and the lookup, context, plural-caption and right-to-left mechanics; coverage, stale-key, wording and per-language literal checks are removed. `cargo xtask i18n-report`, also a `cargo xtask ci` step, runs the ignored missing_translations_report test and prints the missing strings per language. --- ROADMAP.md | 3 + crates/ui-egui/src/i18n.rs | 243 +++++++++++++++++----------------- crates/ui-egui/src/i18n/de.rs | 52 -------- crates/ui-egui/src/i18n/ja.rs | 52 -------- docs/localization-parity.md | 6 +- xtask/src/main.rs | 22 ++- 6 files changed, 146 insertions(+), 232 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..7f6ff40ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -301,6 +301,8 @@ measured); Ready estimated. ## Languages Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md). +A string without a translation shows in English and fails no check; `cargo xtask i18n-report` +(also a `cargo xtask ci` step) lists the missing strings per language. | Language | Code | Status | Translated | |---|---|---|---| @@ -349,6 +351,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..49148723a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4449,6 +4449,12 @@ fn column(lang: &str) -> Option { /// `s` in `lang` (English, or the string itself, when there's no translation). pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { + let text = lookup(lang, s); + // An empty row is a translation nobody has written yet. + if text.trim().is_empty() { s } else { text } +} + +fn lookup<'a>(lang: &str, s: &'a str) -> &'a str { static GERMAN: OnceLock> = OnceLock::new(); static JAPANESE: OnceLock> = OnceLock::new(); static TRANSLATIONS: OnceLock> = OnceLock::new(); @@ -4467,18 +4473,18 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { // The German and Japanese tables cover the whole interface; the shared table's German and Japanese // columns are the fallback for keys added there after them. if lang == "de" - && let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied() + && let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty()) { return text; } if lang == "ja" - && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied() + && let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty()) { return text; } // `fr::TABLE` holds the strings the shared table has no row for. if lang == "fr" - && let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied() + && let Some(text) = FRENCH.get_or_init(|| fr::TABLE.iter().copied().collect()).get(s).copied().filter(|text| !text.trim().is_empty()) { return text; } @@ -4524,11 +4530,13 @@ pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str { "ar" => ar::CONTEXT, _ => &[], }; - if let Some((_, _, text)) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context) { + let written = |text: &&str| !text.trim().is_empty(); + if let Some(text) = contexts.iter().find(|(key, scope, _)| *key == s && *scope == context).map(|(_, _, text)| *text).filter(written) { return text; } if let Some(c) = column(lang) - && let Some(text) = CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)) + && let Some(text) = + CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).and_then(|(_, _, row)| row.get(c)).copied().filter(written) { return text; } @@ -4567,7 +4575,9 @@ pub fn count_label(lang: &str, noun: &str, count: usize) -> String { 2..=4 => few, _ => other, }; - return format!("{count} {form}"); + if !form.trim().is_empty() { + return format!("{count} {form}"); + } } format!("{count} {}", tr(lang, noun)) } @@ -4625,15 +4635,9 @@ mod tests { /// The German and Japanese tables translate the whole interface and win over the shared columns. const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"]; - #[test] - fn every_interface_language_has_a_catalog() { - for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { - assert_eq!(column(lang), Some(i), "{lang}"); - } - for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) { - assert!(!name.is_empty(), "{code}"); - assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES"); - } + /// What `tr` shows for a row: an empty one is still untranslated. + fn or_english<'a>(text: &'a str, english: &'a str) -> &'a str { + if text.trim().is_empty() { english } else { text } } #[test] @@ -4644,13 +4648,16 @@ mod tests { for (key, translations) in TABLE { for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) { // A language's own table is looked up before the shared column. - assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}"); + let expected = own(lang, key).filter(|text| !text.trim().is_empty()).unwrap_or(*shared); + assert_eq!(tr(lang, key), or_english(expected, key), "{lang}: {key}"); } assert_eq!(tr("", key), *key); } for (lang, table) in LANGUAGE_TABLES { for (key, expected) in *table { - assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + if !expected.trim().is_empty() { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + } assert_eq!(tr("", key), *key); } } @@ -4667,7 +4674,9 @@ mod tests { fn context_captions_come_from_their_context_or_the_plain_translation() { for (lang, table) in CONTEXT_TABLES { for (key, scope, text) in *table { - assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + if !text.trim().is_empty() { + assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + } } } for (key, scope, row) in CONTEXT { @@ -4678,8 +4687,10 @@ mod tests { .filter(|(code, _)| *code == lang) .flat_map(|(_, table)| table.iter()) .find(|(k, s, _)| k == key && s == scope) - .map(|(_, _, text)| *text); - assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})"); + .map(|(_, _, text)| *text) + .filter(|text| !text.trim().is_empty()); + let expected = own.or(Some(*shared).filter(|text| !text.trim().is_empty())).unwrap_or_else(|| tr(lang, key)); + assert_eq!(tr_context(lang, key, scope), expected, "{lang}: {key} ({scope})"); } } // An unknown context, or a language without one, keeps the plain translation. @@ -4692,33 +4703,37 @@ mod tests { } } - /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding, - /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back - /// to English, so missing translations don't fail here. - #[test] - fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() { - // (language, key, translation) of every catalog, and every catalog's keys by name. - let mut entries: Vec<(&str, &str, &str)> = Vec::new(); - let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new(); - for (lang, table) in LANGUAGE_TABLES { - let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); - assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); - catalogs.push((*lang, keys)); - entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text))); - } - let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); - assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); - catalogs.push(("shared", shared.clone())); + /// The tables `tr` reads, as (language, key, translation), context captions included. + fn entries() -> Vec<(&'static str, &'static str, &'static str)> { + let mut entries: Vec<_> = LANGUAGE_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, text)| (*lang, *key, *text))).collect(); for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) { entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text)))); entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text)))); } - let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())]; - for (lang, table) in CONTEXT_TABLES { - contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect())); - entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text))); - } + entries.extend(CONTEXT_TABLES.iter().flat_map(|(lang, table)| table.iter().map(|(key, _, text)| (*lang, *key, *text)))); + entries + } + /// Every catalog has one row per key and keeps the English placeholders. A string a catalog + /// doesn't translate shows in English, so a missing or empty row fails nothing here; the + /// `missing_translations_report` lists them. + #[test] + fn every_catalog_has_unique_keys_and_keeps_placeholders() { + for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { + assert_eq!(column(lang), Some(i), "{lang}"); + } + for (lang, table) in LANGUAGE_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); + } + let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); + assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); + let shared_contexts: HashSet<_> = CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(shared_contexts.len(), CONTEXT.len(), "duplicate keys in the shared context table"); + for (lang, table) in CONTEXT_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, scope, _)| (*key, *scope)).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate context keys"); + } // A per-language table is looked up before the shared table, so a key in both hides the // shared column, except in the tables that translate the whole interface. for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) { @@ -4726,41 +4741,58 @@ mod tests { assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}"); } - // A key is current while the interface or another catalog has it. - let interface = interface_strings(); - let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key)); - for (name, keys) in &catalogs { - let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect(); - stale.sort(); - assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}"); - } - for (name, keys) in &contexts { - let unique: HashSet<_> = keys.iter().collect(); - assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys"); - let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect(); - assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}"); - } - let placeholders = |s: &'static str| { let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect(); names.sort_unstable(); names }; - let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("..."); - let colon = |s: &str| s.ends_with(':') || s.ends_with(':'); - // Full-width punctuation carries its own spacing. - let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c); - for (lang, key, text) in entries { - assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}"); + for (lang, key, text) in entries().into_iter().filter(|(_, _, text)| !text.trim().is_empty()) { assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}"); - // Dialog ellipses, label colons and padded captions stay as in English. - assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}"); - assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}"); - if !text.starts_with(full_width) { - assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}"); - } - if !text.ends_with(full_width) { - assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}"); + } + } + + /// The strings each interface language shows in English, for the `cargo xtask ci` log. It + /// always passes: `cargo xtask i18n-report` runs it. + #[test] + #[ignore = "a report, run by `cargo xtask i18n-report`"] + fn missing_translations_report() { + let interface = interface_strings(); + let catalogs: Vec<(&str, HashSet<&str>)> = LANGUAGE_TABLES + .iter() + .map(|(lang, table)| (*lang, table.iter().map(|(key, _)| *key).collect())) + .chain([("shared", TABLE.iter().map(|(key, _)| *key).collect())]) + .collect(); + let mut keys: Vec<&str> = interface.iter().copied().chain(catalogs.iter().flat_map(|(_, keys)| keys.iter().copied())).collect(); + keys.sort_unstable(); + keys.dedup(); + // `tr` hands back the string it was given when it has no translation. + let missing = |lang: &str| -> Vec<&str> { + keys.iter() + .copied() + .filter(|key| { + let english = (*key).to_owned(); + std::ptr::eq(tr(lang, &english), english.as_str()) + }) + .collect() + }; + let report: Vec<(&str, Vec<&str>)> = LANGUAGES.iter().filter(|(code, _)| !code.is_empty()).map(|(code, _)| (*code, missing(code))).collect(); + println!("i18n report: {} interface strings; a missing translation shows in English.", keys.len()); + for (lang, missing) in &report { + println!(" {lang:<6} {:>5} missing", missing.len()); + } + for (lang, missing) in report.iter().filter(|(_, missing)| !missing.is_empty()) { + println!("{lang} ({}): {}", missing.len(), missing.iter().map(|key| format!("{key:?}")).collect::>().join(", ")); + } + // A catalog key neither the interface list nor another catalog knows is stale, or a label + // `interface_strings` doesn't list yet. + for (name, own) in &catalogs { + let mut unknown: Vec<_> = own + .iter() + .filter(|key| !interface.contains(*key) && !catalogs.iter().any(|(other, keys)| other != name && keys.contains(*key))) + .collect(); + unknown.sort_unstable(); + if !unknown.is_empty() { + println!("{name}: {} rows for keys the interface list doesn't have: {unknown:?}", unknown.len()); } } } @@ -4788,47 +4820,21 @@ mod tests { } } + /// Languages with several plural forms put the category first, so every number reads the same. #[test] - fn georgian_counts_use_category_captions_for_every_number() { - for n in [1, 2, 5, 21] { - assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}")); - } - } - - #[test] - fn ukrainian_counts_use_category_captions_for_every_number() { - for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { - assert_eq!(count_label("uk", "pages", n), format!("сторінок: {n}")); - assert_eq!(count_label("uk", "objects", n), format!("об’єктів: {n}")); - assert_eq!(count_label("uk", "errors", n), format!("помилок: {n}")); - let pages = tr("uk", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()); - assert_eq!(pages, format!("Сторінок: {n}; розворотів: {n}")); + fn category_first_counts_put_the_category_before_every_number() { + assert!(!category_first("de") && !category_first("")); + for lang in ["ka", "uk", "ru", "pl"] { + assert!(category_first(lang), "{lang}"); + for noun in ["pages", "objects", "errors"] { + for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] { + assert_eq!(count_label(lang, noun, n), format!("{}: {n}", tr(lang, noun)), "{lang}: {noun}"); + } + } } assert_eq!(count_label("", "objects", 2), "2 objects"); } - #[test] - fn russian_counts_use_category_captions_for_every_number() { - assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first("")); - for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] { - assert_eq!(count_label("ru", "pages", n), format!("страниц: {n}")); - assert_eq!(count_label("ru", "objects", n), format!("объектов: {n}")); - assert_eq!(count_label("ru", "errors", n), format!("ошибок: {n}")); - let pages = tr("ru", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string()); - assert_eq!(pages, format!("Страниц: {n}; разворотов: {n}")); - } - } - - #[test] - fn polish_counts_use_category_captions_for_every_number() { - for n in [0, 1, 2, 5, 12, 22, 25] { - assert_eq!(count_label("pl", "pages", n), format!("Strony: {n}")); - assert_eq!(count_label("pl", "objects", n), format!("Obiekty: {n}")); - assert_eq!(count_label("pl", "errors", n), format!("Błędy: {n}")); - } - assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}"); - } - /// Every English string the interface passes to `tr`, as far as the command and tool tables and /// the list of UI-only labels know them. fn interface_strings() -> HashSet<&'static str> { @@ -4929,26 +4935,13 @@ mod tests { #[test] fn czech_counts_use_the_plural_form_for_the_number() { - for (n, form) in [(0, "chyb"), (1, "chyba"), (2, "chyby"), (4, "chyby"), (5, "chyb"), (11, "chyb"), (22, "chyb")] { - assert_eq!(count_label("cs", "errors", n), format!("{n} {form}")); + for (noun, [one, few, other]) in cs::PLURALS { + for (n, form) in [(0, other), (1, one), (2, few), (4, few), (5, other), (11, other), (22, other)] { + let expected = if form.trim().is_empty() { format!("{n} {}", tr("cs", noun)) } else { format!("{n} {form}") }; + assert_eq!(count_label("cs", noun, n), expected, "{noun}"); + } } - // The status bar passes the singular noun for one error. - assert_eq!(count_label("cs", "error", 1), "1 chyba"); - assert_eq!(count_label("cs", "pages", 1), "1 stránka"); - assert_eq!(count_label("cs", "pages", 3), "3 stránky"); - assert_eq!(count_label("cs", "pages", 5), "5 stránek"); - assert_eq!(count_label("cs", "stories", 2), "2 články"); - assert_eq!(count_label("cs", "items", 1), "1 položka"); - assert_eq!(count_label("cs", "objects", 7), "7 objektů"); - assert_eq!(count_label("cs", "warnings", 3), "3 varování"); - assert_eq!(count_label("cs", "layer", 1), "1 vrstva"); - assert_eq!(count_label("cs", "layers", 3), "3 vrstvy"); - assert_eq!(count_label("cs", "layers", 8), "8 vrstev"); // A noun without plural forms keeps the plain translation. assert_eq!(count_label("cs", "a new noun", 3), "3 a new noun"); - for (noun, forms) in cs::PLURALS { - assert!(forms.iter().all(|form| !form.is_empty()), "{noun}"); - assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms"); - } } } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 4f4177cbd..367ca52f8 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -2075,55 +2075,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Update Data Source", "Datenquelle aktualisieren"), ("Validate from Root Element", "Ab Stammelement prüfen"), ]; - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::TABLE; - use crate::i18n::tr; - - fn placeholders(text: &str) -> Vec<&str> { - let mut found = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(len) = rest[start..].find('}') else { break }; - found.push(&rest[start..start + len + 1]); - rest = &rest[start + len + 1..]; - } - found.sort_unstable(); - found - } - - #[test] - fn rows_are_unique_and_complete() { - let mut seen = HashSet::new(); - for (en, de) in TABLE { - assert!(seen.insert(*en), "duplicate {en}"); - assert!(!de.is_empty(), "{en}"); - } - } - - #[test] - fn rows_keep_placeholders_padding_and_endings() { - let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("..."); - for (en, de) in TABLE { - assert_eq!(placeholders(en), placeholders(de), "placeholders of {en}"); - assert_eq!(en.len() - en.trim_start().len(), de.len() - de.trim_start().len(), "leading space of {en}"); - assert_eq!(en.len() - en.trim_end().len(), de.len() - de.trim_end().len(), "trailing space of {en}"); - assert_eq!(ellipsis(en), ellipsis(de), "ellipsis of {en}"); - // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`. - assert!(!de.contains("...") || de == en, "ASCII ellipsis in {en}"); - assert_eq!(en.trim_end().ends_with(':'), de.trim_end().ends_with(':'), "colon of {en}"); - } - } - - #[test] - fn lookup_uses_this_table_and_leaves_unknown_text_alone() { - assert_eq!(tr("de", "File"), "Datei"); - assert_eq!(tr("de", "Cancel"), "Abbrechen"); - assert_eq!(tr("de", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1); - let unknown = String::from("A user-defined untranslated label"); - assert!(std::ptr::eq(tr("de", &unknown), unknown.as_str())); - } -} diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e23ef15c7..37b14ac5c 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -2124,55 +2124,3 @@ pub(super) const TABLE: &[(&str, &str)] = &[ /// Captions whose Japanese depends on where they appear: (English, context, Japanese). pub(super) const CONTEXT: &[(&str, &str, &str)] = &[("Span Columns", "paragraphLayout", "段抜き"), ("Columns:", "grid", "列:")]; - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::TABLE; - use crate::i18n::tr; - - fn placeholders(text: &str) -> Vec<&str> { - let mut found = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(len) = rest[start..].find('}') else { break }; - found.push(&rest[start..start + len + 1]); - rest = &rest[start + len + 1..]; - } - found.sort_unstable(); - found - } - - #[test] - fn rows_are_unique_and_complete() { - let mut seen = HashSet::new(); - for (en, ja) in TABLE { - assert!(seen.insert(*en), "duplicate {en}"); - assert!(!ja.is_empty(), "{en}"); - } - } - - #[test] - fn rows_keep_placeholders_padding_and_endings() { - let ellipsis = |text: &str| text.trim_end().ends_with('…') || text.trim_end().ends_with("..."); - for (en, ja) in TABLE { - assert_eq!(placeholders(en), placeholders(ja), "placeholders of {en}"); - assert_eq!(en.len() - en.trim_start().len(), ja.len() - ja.trim_start().len(), "leading space of {en}"); - assert_eq!(en.len() - en.trim_end().len(), ja.len() - ja.trim_end().len(), "trailing space of {en}"); - assert_eq!(ellipsis(en), ellipsis(ja), "ellipsis of {en}"); - // The numbering samples (`a, b, c, d...`) stay as they are; everything else uses `…`. - assert!(!ja.contains("...") || ja == en, "ASCII ellipsis in {en}"); - assert_eq!(en.trim_end().ends_with(':'), ja.trim_end().ends_with(':'), "colon of {en}"); - } - } - - #[test] - fn lookup_uses_this_table_and_leaves_unknown_text_alone() { - assert_eq!(tr("ja", "File"), "ファイル"); - assert_eq!(tr("ja", "Cancel"), "キャンセル"); - assert_eq!(tr("ja", "{count} glyphs — click to insert at the text cursor").matches("{count}").count(), 1); - let unknown = String::from("A user-defined untranslated label"); - assert!(std::ptr::eq(tr("ja", &unknown), unknown.as_str())); - } -} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index fa75a20d8..bc209c118 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Polish and Georgian interfaces; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are reported, never a failure; catalog counts remain the 2026-10-10 measurement) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -19,6 +19,10 @@ in the UI crate appear in no catalog (some are test data or proper names), so th somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no help content in any language (Help ▸ opens the project's pages). +A missing translation never fails a test, a build or a release. `cargo xtask i18n-report` (also +a step of `cargo xtask ci`, so the list is in every gate log) prints how many interface strings +each language lacks, then the strings. + InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es, fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew come as Middle East editions with an English or French interface and right-to-left features. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5000860cc..c1d08071b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,8 +23,9 @@ commands: pdf-deps verify the pinned PDF backend, including Krilla dependencies wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call) test workspace tests (cargo nextest when installed, else cargo test), then doctests - ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm - (stops at first failure) + i18n-report list the interface strings each language shows in English (always passes) + ci fmt --check, pdf-deps, clippy -D warnings, test, i18n-report, doctest, assets, + layers, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -42,6 +43,7 @@ fn main() -> ExitCode { Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_doctest()), + Some("i18n-report") => cmd_i18n_report(), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), @@ -177,6 +179,21 @@ fn cmd_test() -> Result<(), String> { run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) } +/// Runs the ignored `missing_translations_report` test with its output shown. It takes the test +/// step's targets, so it reuses that build. +fn cmd_i18n_report() -> Result<(), String> { + const REPORT: &str = "i18n::tests::missing_translations_report"; + let mut c = cargo(); + if nextest_available() { + c.args(["nextest", "run", "--cargo-quiet"]).args(TEST_TARGETS); + c.args(["--run-ignored", "only", "--no-capture", "--status-level", "fail", "--final-status-level", "fail", "-E"]); + c.arg(format!("package(designcraft-ui-egui) & test(={REPORT})")); + } else { + c.args(["test", "-q"]).args(TEST_TARGETS).args(["--", "--ignored", "--exact", REPORT, "--nocapture"]); + } + run(c, &format!("missing translations ({REPORT})")) +} + /// nextest does not run doctests. fn cmd_doctest() -> Result<(), String> { let mut c = cargo(); @@ -205,6 +222,7 @@ fn cmd_ci() -> Result<(), String> { }), ), ("test", Box::new(cmd_test)), + ("i18n-report", Box::new(cmd_i18n_report)), ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), From 005030404cb0bf6e26a2968fe40b8b006f27db9a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:41:16 +0200 Subject: [PATCH 101/117] Save dialogs: only a file name goes after the folder, and no name keeps no folder (#1653) --- crates/ui-egui/src/lib.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0895cdf0c..4186aa2f1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -769,7 +769,13 @@ impl DesignApp { .and_then(|d| d.path.as_deref()) .and_then(folder_of) .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from)); - let suggested = folder.map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().to_string()); + // Only the last part of a name goes after the folder: a title may hold a slash, and an + // empty name would make the folder itself the suggested file. + let file = Path::new(name).file_name(); + let suggested = match (folder, file) { + (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(), + _ => name.to_string(), + }; let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?; if let Some(folder) = folder_of(&picked) { self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); @@ -1905,6 +1911,25 @@ mod tests { assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used"); } + /// No name (Save with no document) suggested the remembered folder itself as the file, and a + /// title with a slash moved the suggestion out of the document's folder. + #[test] + fn save_dialog_suggestions_keep_the_folder_and_only_a_file_name() { + use std::{cell::RefCell, rc::Rc}; + let folder = tempfile::tempdir().unwrap(); + let asked: Rc>> = Rc::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + let seen = asked.clone(); + app.services.pick_save = Some(Box::new(move |name: &str| { + seen.borrow_mut().push(name.to_string()); + None + })); + app.ui.last_save_folder = Some(folder.path().join("Docs").to_string_lossy().to_string()); + app.pick_save(""); + app.pick_save("Q1/Q2.pdf"); + assert_eq!(*asked.borrow(), ["".to_string(), folder.path().join("Docs").join("Q2.pdf").to_string_lossy().to_string()]); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); From 13d17e7a6d57d9349b8d01d54eaa110e8615e3a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 16:58:25 -0400 Subject: [PATCH 102/117] RTF: a group opening ends the \uN fallback skip Co-Authored-By: Claude Opus 5.5 --- crates/textimport/src/rtf.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 410455669..3060d7ea3 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -95,6 +95,8 @@ pub fn import(bytes: &[u8]) -> Result { stack.push(top); } first = false; + // A group is not a character: the `\uN` fallback skip ends here too. + pending = 0; // `{\*\dest …}`: an ignorable destination. if chars.get(i + 1) == Some(&'\\') && chars.get(i + 2) == Some(&'*') @@ -337,6 +339,8 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y"); let i = import(br"{\rtf1 \u8220\{Hi}").unwrap(); assert_eq!(i.story.text, "\u{201C}Hi"); + let i = import(br"{\rtf1 \u8220{\b Hi}}").unwrap(); + assert_eq!(i.story.text, "\u{201C}Hi"); } /// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form From 45e30914fcf6093b5660c7ce911a21acb6694ee4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 16:59:41 -0400 Subject: [PATCH 103/117] Color Settings: reloading a profile moves it to the end of the replay list Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b35acabcb..359dc9b1a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2456,9 +2456,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { match app.session.execute("color.loadProfile", &json!({"path": path})) { Ok(_) => { - if !app.ui.color_profiles.contains(&path) { - app.ui.color_profiles.push(path); - } + // Replayed in load order at start-up, so the latest load of a path comes last. + app.ui.color_profiles.retain(|p| p != &path); + app.ui.color_profiles.push(path); let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); d.fields.insert("profiles".into(), f["profiles"].clone()); } From e14123e83ed9eddfaf01a4ec68cd5a163b0ef2c5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 17:00:31 -0400 Subject: [PATCH 104/117] ci(sonar): don't cancel superseded runs on main, so coverage is published (#1685) --- .github/workflows/sonarcloud.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: From 2aa2ef4b7c6f513e96522284715910555542ada2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:02:49 -0400 Subject: [PATCH 105/117] find: move the table id into the cell iterator (E0373) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index a8cebf35b..a40e8da51 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -270,7 +270,7 @@ fn scope(s: &Session, p: &Value) -> Result { .stories .values() .flat_map(|x| { - let cells = x.tables.iter().flat_map(|(id, t)| { + let cells = x.tables.iter().flat_map(move |(id, t)| { let cols = t.ncols().max(1); t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len())) }); From 0d139559bd721d1bd2b206c3e5b512c5c1ae6c4b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:11:13 -0400 Subject: [PATCH 106/117] Place PDF: the page field takes one whole page number of 1 or more Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 28 ++++++++++++++++++---------- crates/ui-egui/src/lib.rs | 2 +- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1c0bfcfce..7651cfffa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3268,21 +3268,29 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); - if !d.s("page").trim().is_empty() && d.n("page").is_none() { - // A range or list ("4-5", "2,5") is not a page number: refuse it rather than place page 1. - let e = "Enter a single page number; one page is placed at a time".to_string(); - let mut d = d; - d.fields.insert("status".into(), json!(e)); - app.ui.dialog = Some(d); - return Err(e); - } + // One page number, 1 or more; a blank field is page 1. A range or list ("4-5", "2,5"), a + // fraction or a negative is refused rather than placing some other page. + let raw = if d.fields.get("page").is_none_or(Value::is_null) { String::new() } else { d.s("page") }; + let page = match raw.trim() { + "" => 1, + t => match t.parse::() { + Ok(n) if n >= 1 => n, + _ => { + let e = "Enter a single page number; one page is placed at a time".to_string(); + let mut d = d; + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + return Err(e); + } + }, + }; if d.b("async") { - return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop }); + return app.confirm_pdf_import(page, if crop.is_empty() { "crop".into() } else { crop }); } if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.place_file(json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.place_file(json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bea9fbfdc..7f7a650b7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2953,7 +2953,7 @@ mod browser_import_tests { #[test] fn pdf_options_refuse_a_page_range_instead_of_placing_page_one() { - for spec in ["4-5", "2,5", "2-3"] { + for spec in ["4-5", "2,5", "2-3", "2.5", "-3", "0", "nan", "inf"] { let mut app = app(); let request = app.import_request("place"); deliver(&mut app, request, "three.pdf", pdf()); From 50161d0d42c6c64e8b4d865514e07ab40d78d935 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:15:22 -0400 Subject: [PATCH 107/117] Index: next-paragraph ranges saturate and read the paragraph list with get Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/index.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs index 6ca36ee7d..38a6bc49d 100644 --- a/crates/engine/src/cmd/index.rs +++ b/crates/engine/src/cmd/index.rs @@ -105,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option) -> Vec<(StoryId, us IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)), IndexRange::NextParagraphs(n) => { let ranges = st.para_ranges(); - let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1); - page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start)) + let n = usize::try_from(*n).unwrap_or(usize::MAX); + let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1)); + ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start))) } _ => start, }; From 96a3290021f3c2faa7daf85870da515b6697d360 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:16:26 -0400 Subject: [PATCH 108/117] Tagged Text: decode only the escapes we write; other backslashes stay literal Co-Authored-By: Claude Opus 5.5 --- crates/textimport/src/tagged.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index ffb106ea9..b5f7815bb 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -37,14 +37,20 @@ fn escape_value(v: &str) -> String { out } -/// A tag value with backslash escapes removed. +/// A tag value with the escapes `escape_value` writes (`\\`, `\<`, `\>`) decoded. Any other +/// backslash, including a trailing one, stays literal, as older files and InDesign's `\:` expect. fn unescape_value(v: &str) -> String { let mut out = String::with_capacity(v.len()); - let mut it = v.chars(); + let mut it = v.chars().peekable(); while let Some(c) = it.next() { - match c { - '\\' => out.extend(it.next()), - c => out.push(c), + if c == '\\' + && let Some(&n) = it.peek() + && matches!(n, '\\' | '<' | '>') + { + out.push(n); + it.next(); + } else { + out.push(c); } } out @@ -312,6 +318,15 @@ mod tests { } } + /// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal. + #[test] + fn unknown_backslashes_in_style_names_stay_literal() { + assert_eq!(unescape_value(r"x\y"), r"x\y"); + assert_eq!(unescape_value(r"a\:b"), r"a\:b"); + assert_eq!(unescape_value("end\\"), "end\\"); + assert_eq!(unescape_value(r"\\\"), r"\"); + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; From 253be57a856b22768e311a0d223171854c7e0ed0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:18:46 -0400 Subject: [PATCH 109/117] layout.pages.insert: range-check the count before narrowing to usize Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 92cf0c6e2..9b43de012 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -120,7 +120,7 @@ pub fn specs() -> Vec { // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped. let count = match p.get("count").filter(|v| !v.is_null()) { None => 1, - Some(v) => match v.as_u64().map(|n| n as usize).filter(|n| (1..=9999).contains(n)) { + Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) { Some(n) => n, None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))), }, From 1e86a669cab3301684f7ef993c97f11c6a27643d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:18:56 -0400 Subject: [PATCH 110/117] table: the rotation comment names only the IDML import warning Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 7e8bcc6cb..04a805568 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -972,7 +972,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { }; let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is - // composed so far, which the import says and preflight reports (#1468). + // composed so far, which the IDML import reports (#1468). let rotation = match p.get("rotation").filter(|v| !v.is_null()) { Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) { Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d), From 80d13911b2d20fbb5cd9398696627bb3a715417b Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 24 Oct 2026 03:47:45 +0400 Subject: [PATCH 111/117] fonts: Georgian UI text from craft-fonts' Noto Sans Georgian (#1270) * fonts: Georgian UI text from craft-fonts' Noto Sans Georgian font_definitions registers the Geor face and appends it to every family, like Arabic (and to the Arabic families after the CJK faces); the wasm filter in crates/fonts/build.rs keeps the face. The installed-font probe for Geor stays skipped whenever craft-fonts covers the script. Bump craft-fonts-ref to a71c20e (storytold/craft-fonts#27). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF * Release: build the web version with craft-fonts too (web-craft-fonts) The shared release workflow builds the web release without CRAFT_FONTS_DIR unless web-craft-fonts is set, so the web version would still have no Georgian face (CodeRabbit on vectorcraft#2934). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF * test: the Arabic New Document test scrolls to Height like the other fields With craft-fonts' Noto Sans Arabic (from a71c20e3) the rows above Height are taller, so at 392x271 the field starts partly below the dialog body's clip and the test's unscrolled visible_label check failed (FreeBSD jobs). It stays reachable: scroll to it as the test already does for Gutter and the margins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF * CI: same craft-fonts pin as release (a71c20e3), so the gate checks the Georgian face Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 3 ++- crates/fonts/build.rs | 4 ++-- crates/ui-egui/src/dialogs.rs | 6 +++++- crates/ui-egui/src/theme.rs | 15 ++++++++++----- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a02804c..d0d1fd873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8e562f8ba..d6b897cec 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,4 +27,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57eb9bb53..2af7a9e3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,8 @@ jobs: with: app-name: designcraft app-title: DesignCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 651fed196..497c41f83 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -1,7 +1,7 @@ //! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its //! `docs/integration.md`). With `CRAFT_FONTS_DIR=` the fonts in its //! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web -//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size +//! (wasm32) builds embed only the UI faces, BIZ UDPGothic Regular and Noto Sans Georgian Regular, to stay within the web size //! budget. It only reads the local checkout: no network. use std::fmt::Write as _; use std::path::PathBuf; @@ -38,7 +38,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result { let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") { + if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Georgian", "Regular")) { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 53a2b350b..b412a06f0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -9149,7 +9149,11 @@ mod tests { for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } - let height = visible_label(&dialog_frame(&mut app, &ctx, screen, vec![]), screen, "66p1"); + // With craft-fonts' Noto Sans Arabic the rows above are taller, so at the smallest size the + // Height field starts partly below the body's clip: scroll to it like the other fields. + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let (_, _, body) = labels.iter().find(|(text, _, _)| text.trim() == "66p1").expect("66p1 is painted"); + let height = scroll_to_label(&mut app, &ctx, screen, body.center(), "66p1"); click_dialog(&mut app, &ctx, screen, height.center()); dialog_frame( &mut app, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7b2364ffe..666394e09 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -446,9 +446,9 @@ pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[System /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without /// `CRAFT_FONTS_DIR`) as fallbacks at the end of every family: Japanese (BIZ UDPGothic first) and -/// Simplified Chinese, in the interface language's order, then Arabic. The `arabic` families +/// Simplified Chinese, in the interface language's order, then Arabic and Georgian. The `arabic` families /// put the Arabic face first for right-to-left runs. egui has no system-font discovery, so -/// without craft-fonts CJK and Arabic UI text has no glyphs. +/// without craft-fonts CJK, Arabic and Georgian UI text has no glyphs. pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, data: &'static [u8]| { @@ -466,12 +466,13 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l japanese.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != "Regular")); let chinese = pick("Hans"); let arabic = pick("Arab"); + let georgian = pick("Geor"); let name = |f: &designcraft_fonts::CraftFont| format!("craft-{}-{}", f.family, f.style); for f in japanese.iter().chain(&chinese) { // CJK faces sit on the Latin baseline (see `baseline_tweak`). fonts.font_data.insert(name(f), Arc::new(FontData { tweak: baseline_tweak(f.bytes, 0), ..FontData::from_static(f.bytes) })); } - for f in &arabic { + for f in arabic.iter().chain(&georgian) { add(&mut fonts, &name(f), f.bytes); } // Han characters take the forms of the interface language: Chinese faces first for Chinese. @@ -483,12 +484,16 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l ja.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != "Bold")); } let (first, second) = if zh { (&chinese, &ja) } else { (&ja, &chinese) }; - stack.extend(first.iter().chain(second).chain(&arabic).map(|f| name(f))); + stack.extend(first.iter().chain(second).chain(&arabic).chain(&georgian).map(|f| name(f))); } // Arabic runs (rtl.rs): the Arabic face first, so letters and spaces shape as one run. for family in ["arabic", "arabic-semibold"] { let ui = if family == "arabic" { "ui" } else { "ui-semibold" }; - let stack = arabic.iter().map(|f| name(f)).chain([ui.to_string(), "ui".to_string()]).chain(japanese.iter().chain(&chinese).map(|f| name(f))); + let stack = arabic + .iter() + .map(|f| name(f)) + .chain([ui.to_string(), "ui".to_string()]) + .chain(japanese.iter().chain(&chinese).chain(&georgian).map(|f| name(f))); let mut seen = std::collections::HashSet::new(); fonts.families.insert(FontFamily::Name(family.into()), stack.filter(|n| seen.insert(n.clone())).collect()); } From 3b71900097620dbe93f397c9c8c88ac707bb6c67 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:19:50 -0400 Subject: [PATCH 112/117] Links: resolve stored paths with craft-docpath's resolve_absolute (#1399) * Links: use resolve_absolute for stored paths * Links: check for a missing linked file with is_ok_and * CI: release only with git dependencies from storytold The winit patch from skylartaylor/winit at its pinned rev is the one other source allowed. * Links: Relink to Folder only uses a name that stays a file name in the folder * CI: the git-sources check reads Cargo.lock as TOML, needs it current and refuses config patches * CI: the git-sources check parses .cargo/config and .cargo/config.toml for source replacement, patches and paths * Links: rustfmt * Links: Relink to Folder only takes a file directly in the chosen folder * Links: Relink to Folder compares folders as the file system resolves them --- .github/workflows/release.yml | 42 +++++++++++++++ crates/engine/src/cmd/links.rs | 16 ++++-- crates/engine/src/io.rs | 3 +- crates/engine/src/tests_linked_paths.rs | 70 +++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2af7a9e3e..480b94e5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,49 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 6623b6cb8..b2cb88fd2 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -144,7 +144,7 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool let Some(link) = &a.link else { return false }; #[cfg(not(target_arch = "wasm32"))] { - crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path)) + !crate::io::document_path(link, document).is_ok_and(crate::io::exists) } #[cfg(target_arch = "wasm32")] { @@ -490,8 +490,18 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { Some(dir) => Some(dir.clone()), None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)), }; - let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n)); - let cand = folder.filter(|_| plain_name).map(|f| f.join(&name)); + let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\'])); + // A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`, + // a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b` + // on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name + // itself, directly in the folder as the file system resolves it, counts. The link keeps + // the folder as given. + let cand = folder.zip(plain_name).and_then(|(f, n)| { + let c = crate::io::folder_path(n, &f).ok()?; + let real = |p: &Path| crate::io::canonicalize(p).ok(); + let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p)); + (c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c) + }); if let Some(cand) = cand.filter(|c| crate::io::exists(c)) { todo.push((a.id, cand)); } else { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5378a411e..b493edf35 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -21,8 +21,7 @@ use std::path::{Path, PathBuf}; /// refused path is never handed to the file system; callers treat it as a missing file. pub fn document_path(stored: &str, document: Option<&Path>) -> Result { let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); - let path = craft_docpath::resolve(stored, document.as_deref())?; - if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) } + craft_docpath::resolve_absolute(stored, document.as_deref()) } /// [`document_path`] for a path stored in a file inside `folder` (a data file's image names). diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests_linked_paths.rs index eb4c953ec..448ffedb9 100644 --- a/crates/engine/src/tests_linked_paths.rs +++ b/crates/engine/src/tests_linked_paths.rs @@ -105,3 +105,73 @@ fn a_relative_link_resolves_beside_the_document() { assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_relative_link_in_an_unsaved_document_is_missing() { + // With no document path there is nothing to resolve against; the working directory is not used. + let mut s = document_with_links(Path::new("doc.designcraft"), &["logo.png", "img/logo.png"]); + s.doc_mut().unwrap().path = None; + let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap()); + assert!(seen.is_empty(), "{seen:?}"); + for row in rows.as_array().unwrap() { + assert_eq!(row["status"], "missing", "{row}"); + assert_eq!(row["refused"], "the path is not valid", "{row}"); + } +} + +#[cfg(unix)] +#[test] +fn relink_folder_never_leaves_the_chosen_folder() { + // A stored name with no separator can still be a `file:` URL that decodes to a path elsewhere. + let dir = folder("relink-outside"); + let chosen = dir.join("chosen"); + std::fs::create_dir_all(&chosen).unwrap(); + let outside = dir.join("outside.png"); + std::fs::write(&outside, png()).unwrap(); + let encoded = format!("file:{}", outside.to_string_lossy().replace('/', "%2F")); + let mut s = document_with_links(&dir.join("doc.designcraft"), &[&encoded]); + let (out, seen) = + crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap()); + assert_eq!(out["relinked"], 0, "{out}"); + assert!(!seen.contains(&outside), "{seen:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn relink_folder_takes_the_chosen_folder_as_one_folder() { + // `a\b` is one folder name on Unix; read as a stored path it would be `a/b`. + let dir = folder("relink-backslash"); + let chosen = dir.join("a\\b"); + std::fs::create_dir_all(&chosen).unwrap(); + std::fs::create_dir_all(dir.join("a/b")).unwrap(); + let elsewhere = dir.join("a/b/x.png"); + std::fs::write(&elsewhere, png()).unwrap(); + let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]); + let (out, seen) = + crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap()); + assert_eq!(out["relinked"], 0, "{out}"); + assert!(!seen.contains(&elsewhere), "{seen:?}"); + + // A folder given with `..` is still that folder. + let real = dir.join("real"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("x.png"), png()).unwrap(); + let roundabout = real.join("..").join("real"); + let out = s.execute("links.relinkFolder", &json!({"dir": roundabout.to_string_lossy(), "missingOnly": false})).unwrap(); + assert_eq!(out["relinked"], 1, "{out}"); + + // Through a symlink, `alias/..` is the link target's parent, not the folder holding `alias`. + let inner = dir.join("inner"); + std::fs::create_dir_all(inner.join("sub")).unwrap(); + std::os::unix::fs::symlink(inner.join("sub"), dir.join("alias")).unwrap(); + let beside_alias = dir.join("x.png"); + std::fs::write(&beside_alias, png()).unwrap(); + let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]); + let (out, seen) = crate::io::touched(|| { + s.execute("links.relinkFolder", &json!({"dir": dir.join("alias").join("..").to_string_lossy(), "missingOnly": false})).unwrap() + }); + assert_eq!(out["relinked"], 0, "{out}"); + assert!(!seen.contains(&beside_alias), "{seen:?}"); + let _ = std::fs::remove_dir_all(&dir); +} From 203529ab3659872160d3230fdd6bd7e434952803 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 22:03:59 -0400 Subject: [PATCH 113/117] ci: move release workflow write permission to job level (SonarCloud S8233) (#1697) --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 480b94e5f..f65960f27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: default: "" permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} @@ -61,6 +61,8 @@ jobs: release: needs: git-sources + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: From 4823b5949285f43d60e2edb11f1aab57963ecae9 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:03:36 -0400 Subject: [PATCH 114/117] PDF: shared reader from craft-libs 9f75d3dc (input bounds and crash fixes) (#1701) * PDF: craft-pdf, krilla and hayro from craft-libs main (input bounds and crash fixes) * PDF: craft-libs 9f75d3dc, with hayro-cmap from its vendored copy --- Cargo.lock | 15 +++++++-------- Cargo.toml | 13 +++++++------ NOTICE | 11 ++++++----- docs/pdf-backend.md | 4 ++-- xtask/src/pdf_deps.rs | 3 ++- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e75e8d5a5..beb1f6f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,7 +841,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "bytemuck", "hayro", @@ -2123,7 +2123,7 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "bytemuck", "hayro-interpret", @@ -2143,8 +2143,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "brotli", "hayro-postscript", @@ -2153,7 +2152,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2171,7 +2170,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2195,7 +2194,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "flate2", "hayro-ccitt", @@ -2559,7 +2558,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 8a83e0463..0245708f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -122,9 +122,10 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } -hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" } +hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" } diff --git a/NOTICE b/NOTICE index f1e997156..71abff776 100644 --- a/NOTICE +++ b/NOTICE @@ -92,14 +92,15 @@ No Adobe icons, images or artwork are included. PDF backend source -The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 -are fetched from https://github.com/storytold/craft-libs at revision -89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred +The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and +hayro-cmap 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision +9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef. The first four source snapshots were transferred from https://github.com/storytold/pdfcraft at revision -7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is +7437bde378f43856eada40af25b983812e615e8a and hayro-cmap at revision +a2ec27b4775f53c40cad3456936d9406611e3a8d, then patched in craft-libs. Their upstream project is https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each crate's upstream licence files. The patch ledger is at -https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef/vendor/README.craft-reader.md. Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset origins and unchanged per-file hashes are recorded separately in ASSETS.md. diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 4cbc985d3..bf36708ad 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,7 +1,7 @@ # Shared PDF backend -DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the four Hayro reader crates to -craft-libs revision `89dcbb3060377a26c99a6d80caa16084366704c4`. Existing `craft-ui` and `craft-print` revisions +DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the five Hayro reader crates (with +`hayro-cmap`) to craft-libs revision `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Existing `craft-ui` and `craft-print` revisions remain unchanged. The reader migration is reviewed on top of the separate writer change. ## Writing diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index bb2a4f01d..7e9677d52 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -4,7 +4,7 @@ use std::process::Command; use serde_json::Value; -const REV: &str = "89dcbb3060377a26c99a6d80caa16084366704c4"; +const REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"; const PACKAGES: &[(&str, &str)] = &[ ("craft-pdf", "0.1.0"), ("krilla", "0.8.2"), @@ -12,6 +12,7 @@ const PACKAGES: &[(&str, &str)] = &[ ("hayro-interpret", "0.7.0"), ("hayro-syntax", "0.7.2"), ("hayro-jbig2", "0.3.0"), + ("hayro-cmap", "0.1.0"), ]; pub fn run(root: &Path) -> Result<(), String> { From d8d78851729a2c1a10e5bfad7c591f3de78651a0 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:34:05 -0400 Subject: [PATCH 115/117] Anchored objects: Relative to Spine mirrors custom positions on right-hand pages (#1702) * Anchored objects: Relative to Spine mirrors custom positions on right-hand pages * Anchored objects: page side from the frame's spread position; footnote cache keyed by page side --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 41 ++++++++++----- crates/compose/src/notes.rs | 6 ++- crates/compose/src/table.rs | 10 ++-- crates/compose/src/tests.rs | 86 +++++++++++++++++++++++++++++++ crates/doc/src/anchored.rs | 5 ++ crates/engine/src/cmd/anchored.rs | 29 ++++++++--- crates/idml/src/export.rs | 4 +- crates/idml/src/import.rs | 1 + crates/idml/src/tests.rs | 39 ++++++++++++++ docs/target-app-parity.md | 4 +- 11 files changed, 197 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4d94421d..2204ca58d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -350,6 +350,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ebf1e377b..a3fac1eab 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -583,6 +583,9 @@ pub struct FrameSpec { pub grid: Option<(f64, f64)>, /// The frame is on a left page (for towards/away-from-spine alignment). pub left_page: bool, + /// The frame is on a right page of a facing spread (spine-relative anchored objects mirror + /// there; a single page has no spine). + pub right_page: bool, /// The page and its margins in the frame's inner space (custom anchored objects). pub page_rect: Option<(Rect, Rect)>, } @@ -662,7 +665,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let loc = doc.find(fid); let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf; + let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY); + let xf = parent * item.xf; + // Where the frame is on the spread (a group's transform included), to find its page. + let center_x = parent.transform_rect_bbox(item.bounds()).center().x; let inv = xf.inverse(); let mut exclusions = Vec::new(); let spread_ref = loc.as_ref().map(|l| l.spread); @@ -686,27 +692,27 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 }); } - let (page_name, page, left_page) = match (spread_ref, spread) { + let (page_name, page, side) = match (spread_ref, spread) { (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { - let c = item.bounds().center(); - let pi = sp.page_at_x(c.x).unwrap_or(0); + let pi = sp.page_at_x(center_x).unwrap_or(0); let abs = doc.first_page_of_spread(si) + pi; - (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) + (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side)) } (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); // The side of the parent page under the frame, so a folio there aligns to the spine of its own page. - let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi)); - let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left); - (Some(prefix), None, left) + let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)); + (Some(prefix), None, under.map(|p| p.side)) } - _ => (None, None, false), + _ => (None, None, None), }; + let left_page = side == Some(designcraft_doc::PageSide::Left); + let right_page = side == Some(designcraft_doc::PageSide::Right); let section_marker = page.map(|abs| section_marker(doc, abs)); // The page under the frame and its margins, in inner space (bounding boxes). let page_rect = match (spread_ref, spread) { (_, Some(sp)) => sp - .page_at_x(item.bounds().center().x) + .page_at_x(center_x) .and_then(|pi| sp.pages.get(pi)) .map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))), _ => None, @@ -773,6 +779,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { page, grid, left_page, + right_page, page_rect, }); } @@ -2004,6 +2011,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C object_point, ref_point, keep_within_column, + spine_relative, } => { use designcraft_doc::anchored::AnchorRelative as R; let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area); @@ -2015,15 +2023,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)), }; let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]); - let (rfx, rfy) = frac(*ref_point); - let (ofx, ofy) = frac(*object_point); + let (mut rfx, rfy) = frac(*ref_point); + let (mut ofx, ofy) = frac(*object_point); + let mut dx = *x_offset; + // The settings describe a left-hand page; a right-hand page mirrors them + // across the spine. + if *spine_relative && f.right_page { + (rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx); + } let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx); let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy); let mut y = ry + y_offset - h * ofy; if *keep_within_column { y = y.clamp(col.y0, (col.y1 - h).max(col.y0)); } - Point::new(rx + x_offset - w * ofx, y) + Point::new(rx + dx - w * ofx, y) } }; } @@ -4649,6 +4663,7 @@ mod column_allocation_safety_tests { page: None, grid: None, left_page: false, + right_page: false, page_rect: None, outline: None, } diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index e72ecde02..2a465fbfa 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -72,7 +72,7 @@ pub(crate) struct Notes { pub key: Option, pub placed: Vec, /// Composed footnote text by (index, label, width). - cache: HashMap<(usize, String, u64), (Arc, NoteMetrics)>, + cache: HashMap<(usize, String, u64, bool, bool), (Arc, NoteMetrics)>, notes: Vec>, /// Footnotes span the columns of their frame. span: bool, @@ -153,7 +153,8 @@ impl Notes { fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc, NoteMetrics) { let o = &doc.footnote_options; let label = format!("{}{}", o.label(self.numbers[k], true), o.separator); - let key = (k, label.clone(), w.to_bits()); + // The page side sets towards/away-from-spine alignment and spine-relative anchored objects. + let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page); if let Some(c) = self.cache.get(&key) { return c.clone(); } @@ -169,6 +170,7 @@ impl Notes { page: f.page, grid: None, left_page: f.left_page, + right_page: f.right_page, page_rect: None, }; let nopts = ComposeOptions { diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 940e0aa77..39219e43b 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -121,7 +121,7 @@ impl CellLineExtent { } /// Compose a cell story at width `w` (content space, top-left at the origin). -fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout { +fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout { let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), @@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left page: opts.page, grid: None, left_page, + right_page, page_rect: None, }; let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); @@ -200,7 +201,7 @@ pub(crate) fn place_table( let (nr, nc) = (table.nrows(), table.ncols()); let owners = table.owners(); let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c); - let left_page = frames[cur.fi].left_page; + let side = (frames[cur.fi].left_page, frames[cur.fi].right_page); // Compose every owner cell. let mut layouts: Vec> = Vec::with_capacity(nr * nc); for r in 0..nr { @@ -212,7 +213,7 @@ pub(crate) fn place_table( let cell = &table.cells[r * nc + c]; let cs = (cell.col_span.max(1) as usize).min(nc - c); let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3]; - layouts.push(Some(compose_cell(doc, cell, w, opts, left_page))); + layouts.push(Some(compose_cell(doc, cell, w, opts, side))); } } // Row heights. @@ -813,7 +814,7 @@ mod height_tests { } fn measure(cell: &Cell, width: f64) -> CellLayout { - compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false) + compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false)) } fn compose_table(table: Table, height: f64) -> ComposedStory { @@ -1061,6 +1062,7 @@ mod height_tests { object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }), ); assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b73806d27..f9e8ba0fd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2752,6 +2752,7 @@ fn custom_anchors_do_not_change_bottom_baseline_fitting() { object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }; d.story_mut(sid).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, pos)); let cs = compose_story(&d, sid, &ComposeOptions::default()); @@ -6740,6 +6741,7 @@ fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel: object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }; d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position)); } @@ -6808,6 +6810,7 @@ fn anchored_object_wraps_with_its_contour_and_side() { object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }; d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, position)); let cs = compose_story(&d, b, &ComposeOptions::default()); @@ -6821,6 +6824,70 @@ fn anchored_object_wraps_with_its_contour_and_side() { assert!(top > mid + 5.0, "top {top}, middle {mid}"); } +/// Relative to Spine: a marginal note set up for a left-hand page (its right edge 12 pt left of +/// the column) mirrors on the right-hand page (its left edge 12 pt right of the column), so the +/// notes of a story that runs across a spread all sit in the outside margin. Placed as on a +/// left-hand page, the right page's note sat in the inside margin and reached across the spine. +#[test] +fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() { + use designcraft_doc::anchored::AnchorRelative as R; + let compose_spread = |spine_relative: bool| { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + let sp = &d.spreads[1]; + let (l, r) = (sp.pages[0].clone(), sp.pages[1].clone()); + assert_eq!((l.side, r.side), (designcraft_doc::PageSide::Left, designcraft_doc::PageSide::Right)); + // Body frames leave a 120 pt outside margin for the notes. + let left_rect = Rect::new(l.x + 120.0, 36.0, l.x + l.width - 36.0, 120.0); + let right_rect = Rect::new(r.x + 36.0, 36.0, r.x + r.width - 120.0, 600.0); + let text = [LOREM; 3].join(" "); + let (lf, sid) = d.add_text_frame(SpreadRef::Doc(1), left_rect, lid, &text, ParaFormat::default()).unwrap(); + let (rf, _) = d.add_text_frame(SpreadRef::Doc(1), right_rect, lid, "", ParaFormat::default()).unwrap(); + d.thread(lf, rf).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let second_line = |fid| plain.frame(fid).unwrap().lines[1].range.start; + let (at_left, at_right) = (second_line(lf), second_line(rf)); + // Top-right corner of the note 12 pt left of the column's top-left, on the anchor's baseline. + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::ColumnEdge, + y_relative: R::Anchor, + x_offset: -12.0, + y_offset: 0.0, + object_point: 2, + ref_point: 0, + keep_within_column: false, + spine_relative, + }; + for at in [at_right, at_left] { + let note = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 72.0, 40.0)), + ); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position.clone())); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Per page: the note's left and right edges, the column, and the page's left and right edges. + let placed = |fid, page: &designcraft_doc::Page| { + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.objects.len(), 1); + let o = &ft.objects[0]; + ((o.origin.x, o.origin.x + o.size.0), ft.columns[0], (page.x, page.x + page.width)) + }; + (placed(lf, &l), placed(rf, &r)) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (((lx0, lx1), lcol, (lp0, _)), ((rx0, rx1), rcol, (_, rp1))) = compose_spread(true); + assert!(near(lx1, lcol.x0 - 12.0) && lx0 >= lp0, "left page: note {lx0}..{lx1}, column {lcol:?}"); + assert!(near(rx0, rcol.x1 + 12.0) && rx1 <= rp1, "right page: note {rx0}..{rx1}, column {rcol:?}"); + + // Without Relative to Spine both pages take the settings as written. + let (_, ((rx0, rx1), rcol, _)) = compose_spread(false); + assert!(near(rx1, rcol.x0 - 12.0), "right page as written: note {rx0}..{rx1}, column {rcol:?}"); +} + const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8(); #[test] @@ -7324,6 +7391,25 @@ fn parent_page_frames_know_which_side_of_the_spine_they_are_on() { assert!(!frame_specs(&d, right_story)[0].left_page); } +#[test] +fn a_grouped_frame_takes_the_side_of_the_page_its_group_moves_it_to() { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + let (l, r) = (d.spreads[1].pages[0].clone(), d.spreads[1].pages[1].clone()); + let rect = Rect::new(l.x + 36.0, 36.0, l.x + l.width - 36.0, 300.0); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, "Text", ParaFormat::default()).unwrap(); + // The frame's own coordinates are on the left page; its group's transform puts it on the right one. + let mut g = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default()); + g.xf = designcraft_geom::Affine::translate((r.x - l.x, 0.0)); + let sp = Arc::make_mut(&mut d.spreads[1]); + let at = sp.items.iter().position(|it| it.id == fid).unwrap(); + g.content = designcraft_doc::Content::Group { items: vec![sp.items.remove(at)] }; + sp.items.push(Arc::new(g)); + let spec = &frame_specs(&d, sid)[0]; + assert!(spec.right_page && !spec.left_page); + assert_eq!(spec.page, Some(2)); +} + /// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on /// the left it moved the first line the wrong way — out of the frame for the hanging indent a /// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where diff --git a/crates/doc/src/anchored.rs b/crates/doc/src/anchored.rs index c1392f313..74fc8818f 100644 --- a/crates/doc/src/anchored.rs +++ b/crates/doc/src/anchored.rs @@ -60,6 +60,11 @@ pub enum AnchorPosition { /// Keep Within Top/Bottom Column Boundaries. #[serde(default)] keep_within_column: bool, + /// Relative to Spine: the settings describe a left-hand page, and on a right-hand page + /// the reference point, the object point and the X offset mirror horizontally, so an + /// object in the outside margin stays in the outside margin. + #[serde(default)] + spine_relative: bool, }, } diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 6d09f89dd..1e00585ca 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Insert Anchored Object…", ["Object", "Anchored Object"], None, - "{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text", + "{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text", has_doc, insert ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Anchored Object Options…", ["Object", "Anchored Object"], None, - "{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}", + "{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}", has_doc, options ), @@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { } ("custom", b) => { use designcraft_doc::anchored::AnchorRelative as R; - let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b { - AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => { - (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column) - } - _ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false), + let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b { + AnchorPosition::Custom { + x_relative, + y_relative, + x_offset, + y_offset, + object_point, + ref_point, + keep_within_column, + spine_relative, + } => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative), + _ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false), }; let rel = |k: &str, d: R| -> Result { match p.get(k) { @@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { object_point: pt("objectPoint", op0), ref_point: pt("refPoint", rp0), keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0), + spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0), } } (o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))), @@ -316,5 +324,12 @@ mod custom_tests { matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0), "IDML Anchored position" ); + // Relative to Spine keeps the other custom settings. + s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(matches!( + st.objects[0].position, + designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0 + )); } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index df5a1a243..d3587c943 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2414,6 +2414,7 @@ impl<'a> Ex<'a> { object_point, ref_point, keep_within_column, + spine_relative, } => El::new("AnchoredObjectSetting") .attr("AnchoredPosition", "Anchored") .attr("AnchorPoint", names::anchor_out(*object_point)) @@ -2424,7 +2425,8 @@ impl<'a> Ex<'a> { // IDML measures a left-aligned object's X offset leftwards. .attr("AnchorXoffset", num(if *ref_point % 3 == 0 { 0.0 - *x_offset } else { *x_offset })) .attr("AnchorYoffset", num(*y_offset)) - .attr("PinPosition", bool_s(*keep_within_column)), + .attr("PinPosition", bool_s(*keep_within_column)) + .attr("SpineRelative", bool_s(*spine_relative)), }; e.push(setting); out.push(Node::El(e)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index b57210576..039d1e562 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2449,6 +2449,7 @@ impl<'r> Importer<'r> { object_point: names::anchor_in(s("AnchorPoint")), ref_point: row * 3 + col, keep_within_column: s("PinPosition") == "true", + spine_relative: s("SpineRelative") == "true", } } _ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") }, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 35374a5e0..b72ba1c86 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1354,6 +1354,7 @@ fn anchored_objects_take_unset_settings_from_object_styles() { object_point, ref_point: 6, keep_within_column, + spine_relative: false, }; let positions: Vec<_> = s.objects.iter().map(|o| o.position.clone()).collect(); assert_eq!( @@ -1366,6 +1367,44 @@ fn anchored_objects_take_unset_settings_from_object_styles() { ); } +/// Relative to Spine comes from the object style chain like the other anchored settings, an +/// item's own setting wins, and export writes it back. +#[test] +fn anchored_spine_relative_comes_from_object_styles_and_round_trips() { + use designcraft_doc::AnchorPosition; + let styles = STYLES.replace( + r#""#, + r#" + + ObjectStyle/Side"#, + ); + let rect = |style: &str, setting: &str| { + format!( + r#" + + + + + "# + ) + }; + let story = format!( + r#" + a{}{}{} + "#, + rect("Note", ""), + rect("Note", r#"SpineRelative="false""#), + rect("$ID/[None]", ""), + ); + let spine = |d: &Document| -> Vec { + let s = d.stories.values().find(|s| !s.objects.is_empty()).unwrap(); + s.objects.iter().map(|o| matches!(o.position, AnchorPosition::Custom { spine_relative: true, .. })).collect() + }; + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + assert_eq!(spine(&d), [true, false, false]); + assert_eq!(spine(&import_idml(&export_idml(&d)).unwrap()), [true, false, false]); +} + /// IDML measures a left-aligned anchored object's X offset leftwards; the document model and /// composition measure every X offset rightwards. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ffb2ac993..8ae902a21 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -343,7 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. Essentials Classic shows the Control panel and InDesign's iconic panel groups with names. The Window menu follows InDesign's: Arrange, Workspace, then the panels alphabetically with their Color, Editorial, Interactive, Object & Layout, Output, Styles, Type & Tables and Utilities groups. - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. -- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. +- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column, Relative to Spine (mirrored on right-hand pages); IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). From 00d5fa5cddb955eebece398eb13a6f62681d2074 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 00:40:33 -0600 Subject: [PATCH 116/117] M6: reject unreadable PDF trailer history links (#1670) Reject a non-null Prev entry that cannot be read as an offset instead of silently treating it as the end of the guarded trailer chain. Keep absent/null end markers and readable numeric offsets unchanged. Reproduced CodeRabbit discussion_r4366245854 with an independent probe and a failing native regression before the fix. The synthetic regression covers seven unreadable value types plus absent/null and integer/real history controls. Validation: all 9 cargo xtask ci gates passed (3063 tests passed, 6 skipped; 16 WASM-safe crates). One unrelated GPU test was labelled leaky by nextest in that run and passed cleanly in an isolated rerun. Independent checks: 17 original supported PDF variants still pass qpdf, logical preservation and Poppler pixels; 6 original refusal cases remain refused. All 11 additional Prev controls pass (7 refused, 4 accepted). --- crates/engine/src/cmd/pdflayers.rs | 22 ++++++++++++++++++++++ crates/render/src/pdf_layers.rs | 3 +++ 2 files changed, 25 insertions(+) diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index e5cca3821..937fe81c0 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -263,6 +263,28 @@ pub(crate) mod tests { s.execute("file.exportPdf", &json!({})).unwrap(); } + #[test] + fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { + for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { + let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}")); + assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}"); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}"); + } + for trailer in ["", "/Prev null"] { + let bytes = two_layer_pdf_options(0, "", trailer); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}"); + } + let mut revised = two_layer_pdf(); + let prev = + std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::().unwrap(); + add_pdf_revision(&mut revised); + let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>")); + assert!(real_prev.contains(&format!("/Prev {prev}.0>>"))); + for bytes in [revised, real_prev.into_bytes()] { + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work"); + } + } + #[test] fn fake_null_trailer_cannot_bypass_hybrid_rejection() { for real_guard in ["", "/XRefStm 0"] { diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index 47f93c06e..2573598d9 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -176,6 +176,9 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { return None; } offset = t.get::(b"Prev"); + if offset.is_none() && has_non_null_entry(&t, b"Prev") { + return None; + } } if offset.is_some() { return None; From f90d2867215777f96a62f363c9af296487c2678d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 03:54:24 -0400 Subject: [PATCH 117/117] chore: resolve SonarCloud maintainability issues (#1698) * chore: resolve SonarCloud maintainability issues - Replace lambda closures with method references (S1612) - Replace wildcard imports with explicit imports (S2208) - Refactor complex functions with extracted helpers (S3776) - Replace OR pattern with range pattern in math parser (S9047) * style: apply rustfmt to warichu helper * fix(compose): revert slice conversion in tests and remove unused var in warichu * Group warichu row layout scalars to satisfy S107 parameter limit * style: satisfy rustfmt in warichu.rs --------- Co-authored-by: Dylan Taylor --- apps/designcraft-cli/src/main.rs | 61 ++++++++++++++++-------------- crates/color/src/cms/mod.rs | 20 +++++----- crates/compose/src/breaker.rs | 2 +- crates/compose/src/cache.rs | 26 ++++++------- crates/compose/src/warichu.rs | 25 ++++++++++-- crates/engine/src/cmd/flex.rs | 65 +++++++++++++++++--------------- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/math.rs | 4 +- crates/engine/src/recovery.rs | 10 ++--- crates/fonts/src/fontdb.rs | 60 ++++++++++++++--------------- crates/render/src/lib.rs | 14 +++---- 11 files changed, 159 insertions(+), 130 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 0c0a15267..3d18dcaee 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -124,7 +124,7 @@ fn main() -> ExitCode { Some("bench") => report(perf::bench(&args[1..])), Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))), Some("links") => { - use designcraft_engine::links::*; + use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); ExitCode::SUCCESS } @@ -345,33 +345,7 @@ fn run(args: &[String]) -> Result<(), String> { } "--export" => { let out = val()?; - match Export::of(&out)? { - Export::Image => export(&mut s, &out, page, scale)?, - Export::Pdf => { - let mut p = pdf_opts.clone(); - p["path"] = json!(out); - let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]); - for w in r["warnings"].as_array().into_iter().flatten() { - eprintln!("warning: {}", w.as_str().unwrap_or_default()); - } - } - Export::Document => { - s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("saved {out}"); - } - Export::Pptx => { - let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]); - for w in r["warnings"].as_array().into_iter().flatten() { - eprintln!("warning: {}", w.as_str().unwrap_or_default()); - } - } - Export::Command(cmd) => { - let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} bytes)", r["bytes"]); - } - } + handle_export(&mut s, &out, page, scale, &pdf_opts)?; } "--all-pages" => { let dir = val()?; @@ -387,6 +361,37 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } +fn handle_export(s: &mut Session, out: &str, page: usize, scale: f64, pdf_opts: &Value) -> Result<(), String> { + match Export::of(out)? { + Export::Image => export(s, out, page, scale)?, + Export::Pdf => { + let mut p = pdf_opts.clone(); + p["path"] = json!(out); + let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + } + Export::Document => { + s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("saved {out}"); + } + Export::Pptx => { + let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + } + Export::Command(cmd) => { + let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} bytes)", r["bytes"]); + } + } + Ok(()) +} + /// A page as PNG, or as JPEG for `.jpg` and `.jpeg`. fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> { let st = s.doc().map_err(|e| e.to_string())?; diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 3be294ae5..1a1374cd3 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -17,7 +17,7 @@ pub mod lab; use std::borrow::Cow; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; use serde::{Deserialize, Serialize}; @@ -201,7 +201,7 @@ static USER: RwLock>> = RwLock::new(Vec::new()); fn builtin_rgb_cache(name: &str) -> Option> { static CACHE: Mutex>> = Mutex::new(Vec::new()); - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner); if let Some(p) = c.iter().find(|p| p.name == name) { return Some(p.clone()); } @@ -211,7 +211,7 @@ fn builtin_rgb_cache(name: &str) -> Option> { } fn user_profile(name: &str) -> Option> { - USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned() + USER.read().unwrap_or_else(PoisonError::into_inner).iter().find(|p| p.name == name).cloned() } /// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable. @@ -255,7 +255,7 @@ pub fn profiles() -> Vec { .map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true }) .chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true })) .collect(); - for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() { + for p in USER.read().unwrap_or_else(PoisonError::into_inner).iter() { v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false }); } v @@ -275,7 +275,7 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Arc { let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper); - let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner); if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) { return l.clone(); } @@ -538,7 +538,7 @@ impl Cms { static PROOF_CACHE: Mutex)>> = Mutex::new(Vec::new()); fn clear_proof_cache() { - PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear(); + PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner).clear(); } fn cmyk4(v: Option>) -> Option<[f32; 4]> { @@ -704,10 +704,10 @@ pub fn generation() -> u64 { /// The active colour-management context (Edit → Color Settings). pub fn active() -> Arc { - if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(c) = ACTIVE.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return c.clone(); } - let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner()); + let mut w = ACTIVE.write().unwrap_or_else(PoisonError::into_inner); w.get_or_insert_with(|| Arc::new(Cms::default())).clone() } @@ -721,7 +721,7 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { let changed = active().settings != *settings; RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed); CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed); - *ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms)); + *ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(cms)); if changed { GENERATION.fetch_add(1, Ordering::Relaxed); } diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 0dff34702..85b3a0dc2 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -295,7 +295,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool { } pub fn is_forced(c: char) -> bool { - use designcraft_doc::story::*; + use designcraft_doc::story::{FORCED_LINE_BREAK, is_break_char}; c == FORCED_LINE_BREAK || is_break_char(c) } diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 77bb30f95..6ca7c7366 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -7,7 +7,7 @@ //! can't be reused by other allocations while the entry lives. use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use designcraft_doc::{Document, Item, Story, StoryId, Styles}; @@ -196,7 +196,7 @@ impl Cache { sig.push(std::hash::Hasher::finish(&h) as usize); } { - let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); g.1 += 1; let stamp = g.1; if let Some(e) = g.0.get_mut(&key) @@ -216,7 +216,7 @@ impl Cache { &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, &others.iter().map(|o| &**o).collect::>(), )); - let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); let stamp = g.1; if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); @@ -228,7 +228,7 @@ impl Cache { && cross && let Some(old) = replaced { - self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out)); + self.previous.lock().unwrap_or_else(PoisonError::into_inner).insert(sid, (old.sig, old.out)); } out } @@ -241,7 +241,7 @@ impl Cache { // Only the geometry/story part of the signature (variables etc. add to it). let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..]; { - let g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let g = self.map.lock().unwrap_or_else(PoisonError::into_inner); for cross in [true, false] { if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross)) && matches(&e.sig) @@ -250,7 +250,7 @@ impl Cache { } } } - let g = self.previous.lock().unwrap_or_else(|e| e.into_inner()); + let g = self.previous.lock().unwrap_or_else(PoisonError::into_inner); g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone()) } @@ -306,34 +306,34 @@ impl Cache { } pub fn clear(&self) { - self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear(); - *self.running.lock().unwrap_or_else(|e| e.into_inner()) = None; - *self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = None; + self.map.lock().unwrap_or_else(PoisonError::into_inner).0.clear(); + *self.running.lock().unwrap_or_else(PoisonError::into_inner) = None; + *self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = None; } /// Anchor pages for cross-references (rebuilt when the document changes). pub fn xref_index(&self, doc: &Document) -> Arc { let sig = doc_signature(doc); - if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) + if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) && *s == sig { return r.clone(); } let r = Arc::new(crate::xref::XrefIndex::build(doc, &|sid| self.get(doc, sid, None))); - *self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone())); + *self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone())); r } /// The running-header index for `doc` (rebuilt when the document changes). pub fn running_index(&self, doc: &Document) -> Arc { let sig = doc_signature(doc); - if let Some((s, r)) = &*self.running.lock().unwrap_or_else(|e| e.into_inner()) + if let Some((s, r)) = &*self.running.lock().unwrap_or_else(PoisonError::into_inner) && *s == sig { return r.clone(); } let r = Arc::new(RunningIndex::build(doc, &|sid| self.get(doc, sid, None))); - *self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone())); + *self.running.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone())); r } } diff --git a/crates/compose/src/warichu.rs b/crates/compose/src/warichu.rs index 133e392ee..14b732e5e 100644 --- a/crates/compose/src/warichu.rs +++ b/crates/compose/src/warichu.rs @@ -217,13 +217,31 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f restore(line, members, &saved); return 0.0; } - let rows = counts.len().max(1); let small = st.size * scale; // 0 is the default (one small em). Any other value, including a negative one, is added to // that em and then clamped so the rows cannot swap order. // Bounded, so a hostile value can't move a row to an infinite or huge coordinate. let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 }; let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) }; + position_warichu_rows(line, members, &counts, &widths, RowLayout { block, x0: old_x0, gap }, st); + let delta = block - old_span; + shift_non_member_glyphs(line, members, &old_xs, old_x0, old_right, delta, scale); + delta +} + +#[derive(Clone, Copy)] +struct RowLayout { + block: f64, + x0: f64, + gap: f64, +} + +fn position_warichu_rows(line: &mut [PlacedGlyph], members: &[usize], counts: &[usize], widths: &[f64], layout: RowLayout, st: &RunStyle) { + let block = layout.block; + let old_x0 = layout.x0; + let gap = layout.gap; + + let rows = counts.len().max(1); let mut off = 0_usize; for (li, &c) in counts.iter().enumerate() { let w = widths[li]; @@ -242,7 +260,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f } off += c; } - let delta = block - old_span; +} + +fn shift_non_member_glyphs(line: &mut [PlacedGlyph], members: &[usize], old_xs: &[f64], old_x0: f64, old_right: f64, delta: f64, scale: f64) { let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect(); placed.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut member_at = vec![false; line.len()]; @@ -265,7 +285,6 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f g.x = anchor.1 + (g.x - anchor.0) * scale; } } - delta } #[cfg(test)] diff --git a/crates/engine/src/cmd/flex.rs b/crates/engine/src/cmd/flex.rs index 9b0f56b16..8d7117858 100644 --- a/crates/engine/src/cmd/flex.rs +++ b/crates/engine/src/cmd/flex.rs @@ -82,44 +82,49 @@ pub(crate) fn sync(d: &mut Document) { } } -/// InDesign's detection when objects become a container: objects in a row (or column) keep it, -/// with Auto size and Space Between at their own spacing; others wrap in a row of their width. -fn detect(bounds: &[Rect]) -> FlexLayout { - let tol = 0.5; - let in_line = |row: bool| { - let mut v: Vec<&Rect> = bounds.iter().collect(); - v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) }); - let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol }); - let gaps: Vec = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect(); - (apart && bounds.len() > 1).then(|| gaps.iter().sum::() / gaps.len().max(1) as f64) - }; +fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option { + let mut v: Vec<&Rect> = bounds.iter().collect(); + v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) }); + let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol }); + let gaps: Vec = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect(); + (apart && bounds.len() > 1).then(|| gaps.iter().sum::() / gaps.len().max(1) as f64) +} + +fn detect_align(bounds: &[Rect], row: bool, tol: f64) -> FlexAlign { let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol); - let align = |row: bool| { - let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) = - if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) }; - if same(a) { - FlexAlign::Start - } else if same(c) { - FlexAlign::Center - } else if same(e) { - FlexAlign::End - } else { - FlexAlign::Start - } - }; - let auto = |gap: f64, row: bool| FlexLayout { + let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) = + if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) }; + if same(a) { + FlexAlign::Start + } else if same(c) { + FlexAlign::Center + } else if same(e) { + FlexAlign::End + } else { + FlexAlign::Start + } +} + +fn auto_flex(gap: f64, row: bool, align: FlexAlign) -> FlexLayout { + FlexLayout { direction: if row { FlexDirection::Row } else { FlexDirection::Column }, justify: FlexJustify::SpaceBetween, - align: align(row), + align, width: FlexSize::Auto, height: FlexSize::Auto, gap_h: if row { gap.max(0.0) } else { 0.0 }, gap_v: if row { 0.0 } else { gap.max(0.0) }, ..Default::default() - }; - match (in_line(true), in_line(false)) { - (Some(gap), _) => auto(gap, true), - (None, Some(gap)) => auto(gap, false), + } +} + +/// InDesign's detection when objects become a container: objects in a row (or column) keep it, +/// with Auto size and Space Between at their own spacing; others wrap in a row of their width. +fn detect(bounds: &[Rect]) -> FlexLayout { + let tol = 0.5; + match (in_line_gap(bounds, true, tol), in_line_gap(bounds, false, tol)) { + (Some(gap), _) => auto_flex(gap, true, detect_align(bounds, true, tol)), + (None, Some(gap)) => auto_flex(gap, false, detect_align(bounds, false, tol)), _ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() }, } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index dbfbcfe15..3a40844be 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2105,7 +2105,7 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { continue; } let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); - let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None }; + let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None }; let (applied, available) = measure(&tag); sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); } diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 224420953..16ef7e234 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -333,7 +333,7 @@ impl Parser<'_> { '+' => Node::Sym("+".into(), Class::Bin), '-' => Node::Sym("−".into(), Class::Bin), '*' => Node::Sym("∗".into(), Class::Bin), - '=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel), + '<'..='>' => Node::Sym(c.to_string(), Class::Rel), ',' | ';' => Node::Sym(c.to_string(), Class::Punct), '\'' => Node::Sym("′".into(), Class::Plain), c => Node::Sym(c.to_string(), Class::Plain), @@ -390,7 +390,7 @@ impl Parser<'_> { if self.peek().is_none() { break; } - if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(|c| c.is_ascii_alphabetic()) { + if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) { self.i += 6; break; } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index eed263f04..7ec62d073 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -408,7 +408,7 @@ fn remove_emptied(dir: &Path) { let path = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); - let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::().is_ok()); + let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::().is_ok()); // Metadata whose document has gone, and a file a crash left half written. if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) { left.push(path); @@ -726,8 +726,8 @@ fn occupied_uids(dir: &Path) -> Result> { for entry in entries { let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; let path = entry.path(); - if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft")) - && let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) + if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft")) + && let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok()) { ids.insert(uid); } @@ -756,10 +756,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { let Ok(rd) = std::fs::read_dir(dir) else { return out }; for e in rd.flatten() { let p = e.path(); - if p.extension().and_then(|x| x.to_str()) != Some("designcraft") { + if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") { continue; } - let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; + let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. let meta = std::fs::read_to_string(p.with_extension("json")) .ok() diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 352e0952b..ef5314b32 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; use kurbo::BezPath; use skrifa::instance::{Location, LocationRef, Size}; @@ -150,10 +150,10 @@ impl FaceRef { pub fn of(face: &Arc) -> FaceRef { static LEAKED: RwLock>> = RwLock::new(Vec::new()); let id = face.id as usize; - if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) { + if let Some(Some(f)) = LEAKED.read().unwrap_or_else(PoisonError::into_inner).get(id) { return FaceRef(f); } - let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner()); + let mut w = LEAKED.write().unwrap_or_else(PoisonError::into_inner); if w.len() <= id { w.resize(id + 1, None); } @@ -1037,7 +1037,7 @@ pub fn same_style(a: &str, b: &str) -> bool { } fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() } /// Weight implied by a style name. @@ -1107,7 +1107,7 @@ impl FontDb { pub fn set_system_fallback(&self, on: bool) { #[cfg(not(target_arch = "wasm32"))] { - self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on; + self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on; } #[cfg(target_arch = "wasm32")] let _ = on; @@ -1123,20 +1123,20 @@ impl FontDb { } fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { - self.faces.read().unwrap_or_else(|e| e.into_inner()) + self.faces.read().unwrap_or_else(PoisonError::into_inner) } /// The system font catalog, scanned first if it hasn't been yet. #[cfg(not(target_arch = "wasm32"))] fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { self.ensure_catalog(); - self.catalog.read().unwrap_or_else(|e| e.into_inner()) + self.catalog.read().unwrap_or_else(PoisonError::into_inner) } /// What the document with font scope `scope` sees: its own fonts ahead of the shared ones. /// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts. pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> { - let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() }; + let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() }; ScopedFonts { db: self, own } } @@ -1187,7 +1187,7 @@ impl FontDb { /// family's bold without its regular), skipping a family and style another thread has added /// meanwhile. Returns the number added. fn insert_faces(&self, new: Vec) -> usize { - let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner); let mut added = 0; for f in new { if !has_face(&faces, &f.family, &f.style) { @@ -1238,7 +1238,7 @@ impl FontDb { let count = first.unwrap_or_else(|| self.scan_font_dirs()); // Retry characters and CJK chain families that newly installed fonts can now cover. // Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan. - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); sys.generation = sys.generation.wrapping_add(1); sys.misses.clear(); sys.chain_tried.clear(); @@ -1257,7 +1257,7 @@ impl FontDb { // Rescan first: a lookup after the faces are dropped finds the new files. let cataloged = self.load_system_fonts(); let changed: Vec = { - let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner()); + let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner); let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect(); changed.sort(); for p in &changed { @@ -1268,15 +1268,15 @@ impl FontDb { if !changed.is_empty() { let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p)); let mut dropped = std::collections::HashSet::new(); - self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| { + self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| { let keep = !stale(f); if !keep { dropped.insert(f.id); } keep }); - self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); - self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face)); + self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); + self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face)); log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len()); } ReloadedFonts { cataloged, changed } @@ -1291,7 +1291,7 @@ impl FontDb { fn read_installed(&self, path: &std::path::Path) -> Option> { let stamp = file_stamp(path)?; let data = std::fs::read(path).ok()?; - self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp); + self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp); Some(data) } @@ -1351,7 +1351,7 @@ impl FontDb { } let n = found.len(); log::debug!("cataloged {n} system font faces"); - *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + *self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found; n } @@ -1421,7 +1421,7 @@ impl FontDb { let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); // The listed length skips oversized files unread. let meta = std::fs::metadata(path).ok(); - let listed = meta.as_ref().map_or(0, |m| m.len()); + let listed = meta.as_ref().map_or(0, std::fs::Metadata::len); let faces = if listed > MAX_DOCUMENT_FONT_BYTES { Err(too_large()) } else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { @@ -1449,7 +1449,7 @@ impl FontDb { if !own.is_empty() { out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed); out.faces = own.len(); - self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into()); + self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into()); } out } @@ -1466,7 +1466,7 @@ impl FontDb { room: u64, ) -> Result<(Vec>, u64), String> { use std::io::Read; - if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path) + if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path) && (*len, *m) == (listed, modified) { return Ok((faces.clone(), listed)); @@ -1494,14 +1494,14 @@ impl FontDb { if faces.is_empty() { return Err("not a font DesignCraft can read".into()); } - self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone())); + self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone())); Ok((faces, len)) } /// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their /// files stay read, for the document opening again. pub fn close_scope(&self, scope: u32) { - self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope); + self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope); } /// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]). @@ -1542,7 +1542,7 @@ impl FontDb { return false; } let generation = { - let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); if !sys.enabled || sys.misses.contains(&c) { return false; } @@ -1558,12 +1558,12 @@ impl FontDb { } } } - let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + let mut paths: Vec = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect(); paths.sort(); paths.dedup(); #[cfg(all(test, target_os = "linux"))] { - let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(|e| e.into_inner()).take(); + let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take(); if let Some(hook) = hook { let _ = hook.reached.send(()); hook.resume.wait(); @@ -1580,7 +1580,7 @@ impl FontDb { return true; } } - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); if sys.generation == generation { sys.misses.insert(c); } @@ -1619,12 +1619,12 @@ impl FontDb { /// disappears. pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); - if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { return p.clone(); } let drawn = font_outline(face, gid); let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn }); - let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner); if cache.len() >= OUTLINE_CACHE_MAX { cache.clear(); } @@ -1986,7 +1986,7 @@ impl ScopedFonts<'_> { return Some(base); } let key = (base.id, style.to_string()); - if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) { return Some(f.clone()); } let mut coords = base.coords.clone(); @@ -2004,7 +2004,7 @@ impl ScopedFonts<'_> { } } let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; - Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone()) + Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone()) } fn find(&self, family: &str, style: &str) -> Option> { @@ -2077,7 +2077,7 @@ impl ScopedFonts<'_> { #[cfg(not(target_arch = "wasm32"))] if !self.db.is_loaded(family) { // Held while loading, so a parallel lookup waits for the font instead of passing it by. - let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner()); + let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner); if sys.enabled && sys.chain_tried.insert(family) { self.db.load_cataloged(family); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5159da0ab..b239df164 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -888,7 +888,7 @@ impl Renderer { let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h); let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return }; // A coloured image is already in the plate's inks (its colours come from `color_of`). - let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(|p| p.get())) { + let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) { (Some(coloured), _) => coloured, (None, Some(plate)) => plate_pixmap(&pm, plate), (None, None) => pm, @@ -919,7 +919,7 @@ pub(crate) fn cmyk_table() -> std::sync::Arc> { type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = LUT.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match g.as_ref() { Some((s, t)) if *s == settings => t.clone(), _ => { @@ -967,7 +967,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let key = (std::sync::Arc::as_ptr(pm) as usize, plate); let settings = designcraft_color::cms::active_settings(); { - let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((previous, map, _)) = guard.as_ref() { if *previous == settings { if let Some((_, derived)) = map.get(&key) { @@ -1000,7 +1000,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())); let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8); if bytes <= BUDGET { - let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0)); if *previous != settings || used.saturating_add(bytes) > BUDGET { map.clear(); @@ -1030,7 +1030,7 @@ thread_local! { /// Screen indicators and placeholders are display sRGB, independent of the document's RGB /// working profile. Encode their inks only while drawing a process plate or CMYK blend pass. pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { - if PLATE.with(|plate| plate.get()).is_none() { + if PLATE.with(std::cell::Cell::get).is_none() { return peniko::Color::from_rgba8(rgb[0], rgb[1], rgb[2], alpha); } let [c, m, y, k] = cmyk_in(&cmyk_table(), rgb.map(|v| v as f32 / 255.0)); @@ -1038,7 +1038,7 @@ pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { } pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { - if let Some(p) = PLATE.with(|p| p.get()) { + if let Some(p) = PLATE.with(std::cell::Cell::get) { let cmyk = match *c { designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k], _ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric), @@ -1051,7 +1051,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { let v = ink(p.min(3) as usize); return peniko::Color::from_rgba8(v, v, v, a); } - if RICH_BLACK.with(|r| r.get()) + if RICH_BLACK.with(std::cell::Cell::get) && let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c && cc <= 1e-3 && m <= 1e-3