From 62a26d9e352e2ea8f58ce239dfc408131a5b2d8c Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 17:47:18 +0100 Subject: [PATCH 01/11] 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 da8d3a29376c64c7ae627f51a7bdd0b3df546765 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 19:19:20 +0100 Subject: [PATCH 02/11] 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 80d13911b2d20fbb5cd9398696627bb3a715417b Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 24 Oct 2026 03:47:45 +0400 Subject: [PATCH 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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 From bfd95b258b0e9df5ea0191372c604f195f8005fd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:30:39 -0400 Subject: [PATCH 10/11] chore: drop redundant 'static lifetimes in statics and consts (Sonar S8863) (#1713) Co-authored-by: Claude Sonnet 5.5 --- crates/doc/src/attrs.rs | 2 +- crates/fonts/src/fontdb.rs | 4 ++-- crates/images/src/lib.rs | 4 ++-- crates/ui-egui/src/i18n.rs | 24 ++++++++++++------------ 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..7c57736ef 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -371,7 +371,7 @@ fn locale_code(words: &[&str]) -> Option { /// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`). fn intern_tag(tag: String) -> Option<&'static str> { const MAX_TAGS: usize = 512; - static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(t) = tags.iter().find(|t| **t == tag) { return Some(t); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index ef5314b32..0c3d229c3 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -148,7 +148,7 @@ pub struct FaceRef(&'static FontFace); impl FaceRef { /// The handle for a loaded face (memoised per face). pub fn of(face: &Arc) -> FaceRef { - static LEAKED: RwLock>> = RwLock::new(Vec::new()); + static LEAKED: RwLock>> = RwLock::new(Vec::new()); let id = face.id as usize; if let Some(Some(f)) = LEAKED.read().unwrap_or_else(PoisonError::into_inner).get(id) { return FaceRef(f); @@ -579,7 +579,7 @@ fn script_chain(c: char) -> Option<&'static [&'static str]> { /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { - static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); CHAIN.get_or_init(|| { let mut chain: Vec<&'static str> = Vec::new(); for f in crate::japanese_document_fonts() { diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 1723145ea..3a8facf08 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -738,8 +738,8 @@ mod tests { impl tiff::encoder::colortype::ColorType for Palette { type Inner = u8; const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette; - const BITS_PER_SAMPLE: &'static [u16] = &[BITS]; - const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; + const BITS_PER_SAMPLE: &[u16] = &[BITS]; + const SAMPLE_FORMAT: &[tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; fn horizontal_predict(row: &[u8], result: &mut Vec) { result.extend_from_slice(row); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index be5f43c97..bdca83589 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4465,18 +4465,18 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { } 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(); - static ARABIC: OnceLock> = OnceLock::new(); - static UKRAINIAN: OnceLock> = OnceLock::new(); - static PORTUGUESE_BR: OnceLock> = OnceLock::new(); - static ITALIAN: OnceLock> = OnceLock::new(); - static POLISH: OnceLock> = OnceLock::new(); - static RUSSIAN: OnceLock> = OnceLock::new(); - static CZECH: OnceLock> = OnceLock::new(); - static FRENCH: OnceLock> = OnceLock::new(); - static GEORGIAN: OnceLock> = OnceLock::new(); + static GERMAN: OnceLock> = OnceLock::new(); + static JAPANESE: OnceLock> = OnceLock::new(); + static TRANSLATIONS: OnceLock> = OnceLock::new(); + static ARABIC: OnceLock> = OnceLock::new(); + static UKRAINIAN: OnceLock> = OnceLock::new(); + static PORTUGUESE_BR: OnceLock> = OnceLock::new(); + static ITALIAN: OnceLock> = OnceLock::new(); + static POLISH: OnceLock> = OnceLock::new(); + static RUSSIAN: OnceLock> = OnceLock::new(); + static CZECH: OnceLock> = OnceLock::new(); + static FRENCH: OnceLock> = OnceLock::new(); + static GEORGIAN: OnceLock> = OnceLock::new(); if lang == "ar" { return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } From 8299d264004f7355d67ba5f9eaa757e15151295d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:30:43 -0400 Subject: [PATCH 11/11] chore: replace trivial closures with method paths (Sonar S1612) (#1714) Co-authored-by: Claude Sonnet 5.5 --- apps/designcraft-cli/src/main.rs | 2 +- apps/designcraft-cli/src/perf.rs | 4 ++-- apps/designcraft/src/main.rs | 2 +- crates/compose/src/lib.rs | 4 ++-- crates/compose/src/shape.rs | 4 ++-- crates/compose/src/tests.rs | 2 +- crates/doc/src/otf.rs | 2 +- crates/engine/src/cmd/book.rs | 2 +- crates/engine/src/cmd/datamerge/fill.rs | 2 +- crates/engine/src/cmd/datamerge/mod.rs | 2 +- crates/engine/src/cmd/datamerge/parse.rs | 2 +- crates/engine/src/cmd/file.rs | 4 ++-- crates/engine/src/cmd/links.rs | 2 +- crates/engine/src/guard.rs | 2 +- crates/fonts/src/fontdb.rs | 4 ++-- crates/geom/src/trace.rs | 4 ++-- crates/idml/src/export.rs | 2 +- crates/images/src/psd.rs | 2 +- crates/pdf/src/lib.rs | 2 +- crates/pdf/src/pdfx.rs | 2 +- crates/pdf/src/pdfx/fonts.rs | 2 +- crates/render/src/blendspace.rs | 2 +- crates/render/src/images.rs | 2 +- crates/render/src/pdf_layers.rs | 2 +- crates/textimport/src/xlsx.rs | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 2 +- xtask/src/assets.rs | 2 +- xtask/src/layers.rs | 2 +- 31 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 3d18dcaee..faa4f4af0 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -263,7 +263,7 @@ impl Export { /// A name with another extension is an error: it used to get a PNG of the page. One with no /// extension still does (`--export /dev/stdout`). fn of(out: &str) -> Result { - let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase); + let ext = std::path::Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase); Ok(match ext.as_deref() { None | Some("png" | "jpg" | "jpeg") => Export::Image, Some("pdf") => Export::Pdf, diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index bed57cfcc..0c665f7e7 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -335,7 +335,7 @@ fn row(rows: &mut Vec, name: impl Into, ms: f64, budget: Option Result, String> { let mut rows = vec![]; - let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); let chars: usize = doc.stories.values().map(|s| s.text.len()).sum(); // Budget: 250 ms per 300k characters (MT), scaled to this document. let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0); @@ -532,7 +532,7 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> { } fn header() -> (usize, bool) { - let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); let load = load_average(); let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); outln!( diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index e694fe84f..195a2dc6e 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -256,7 +256,7 @@ const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "* /// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. /// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. fn suggested_extension(name: &str) -> Option<&str> { - std::path::Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) + std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) } /// The file a save dialog's answer writes: `picked` with the suggested extension added when it has diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a3fac1eab..15202de7a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -4040,7 +4040,7 @@ fn hyphenation_points( // Words repeat a lot: remember their points per set of limits. static CACHE: Mutex), Box<[usize]>>)>> = Mutex::new(Vec::new()); let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized); - 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 idx = match guard.iter().position(|e| e.0 == key) { Some(i) => i, None => { @@ -4541,7 +4541,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { // count as still inside the note. let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01; let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y }); - if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) } + if up { candidates.map(|r| r.0).max_by(f64::total_cmp) } else { candidates.map(|r| r.0).min_by(f64::total_cmp) } } #[cfg(test)] diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index ffbc4857d..0f739e16e 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -950,10 +950,10 @@ static WORD_CACHE: RwLock>)>> = RwLock::new(Ve const WORD_CACHE_MAX: usize = 50_000; fn word_map(key: WordKey) -> Arc> { - if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) { + if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|e| e.0 == key) { return m.clone(); } - let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner()); + let mut w = WORD_CACHE.write().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((_, m)) = w.iter().find(|e| e.0 == key) { return m.clone(); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f9e8ba0fd..3fc1c6ccb 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1150,7 +1150,7 @@ fn nonbreaking_space_alone_fills_a_justified_line() { /// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame. fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec) { let mut parts: Vec = (0..fillers).map(|k| format!("Filler {k}")).collect(); - parts.extend(extra.iter().map(|s| s.to_string())); + parts.extend(extra.iter().map(ToString::to_string)); let text = parts.join("\n"); let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, height), ParaAttrs::default()); d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; diff --git a/crates/doc/src/otf.rs b/crates/doc/src/otf.rs index 5e246b8bf..64c976bad 100644 --- a/crates/doc/src/otf.rs +++ b/crates/doc/src/otf.rs @@ -53,7 +53,7 @@ pub fn figures(list: &[String]) -> &'static str { pub fn set_figures(list: &mut Vec, id: &str) -> bool { let Some((_, _, tags, _)) = FIGURES.iter().find(|f| f.0 == id) else { return false }; list.retain(|t| !matches!(t.as_str(), "tnum" | "pnum" | "lnum" | "onum")); - list.extend(tags.iter().map(|t| t.to_string())); + list.extend(tags.iter().map(ToString::to_string)); true } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 4a65137cf..18f554d57 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -130,7 +130,7 @@ impl ChapterBatch { fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> { use std::path::Path; - if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { + if Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); } let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index b0165d9d3..d4b2235e9 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -254,7 +254,7 @@ fn find_markers(text: &str) -> Vec<(usize, usize, String)> { continue; } } - i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1); + i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1); } out } diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 38b543ff7..c07e2fff4 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -617,7 +617,7 @@ fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> { i = end; continue; } - i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1)); + i = i.saturating_add(text[i..].chars().next().map(char::len_utf8).unwrap_or(1)); } out } diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 61ad6224e..c76c37f1e 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -379,7 +379,7 @@ fn sniff(name: &str, text: &str) -> Delimiter { } pub fn extension(name: &str) -> String { - Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() + Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } struct HeaderDelimiterCandidate { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c644284d1..bc6928362 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { /// the file opens from the file manager. A path without a file name is left for the write to fail. pub fn with_document_extension(path: &str) -> String { let p = std::path::Path::new(path); - let ext = p.extension().and_then(|e| e.to_str()); + let ext = p.extension().and_then(std::ffi::OsStr::to_str); if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { return path.to_string(); } @@ -469,7 +469,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result { let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; // The content decides first: a file's extension may be missing or wrong. The extension // then routes files whose content isn't recognised to the reader that explains why. - let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) { return super::interchange::open_idml(s, p); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index b2cb88fd2..c39e05668 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -480,7 +480,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { continue; } let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone())); - let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into()); + let mut name = old.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| a.name.clone().into()); if let Some(e) = &ext { name = std::path::Path::new(&name).with_extension(e).into_os_string(); } diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 2ac6bb63c..6395c3594 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session}; pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { payload .downcast_ref::<&str>() - .map(|s| s.to_string()) + .map(ToString::to_string) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "unknown panic".into()) } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 0c3d229c3..1c1e55bd1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1314,7 +1314,7 @@ impl FontDb { stack.push(p); continue; } - let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } @@ -1399,7 +1399,7 @@ impl FontDb { let Ok(entries) = std::fs::read_dir(folder) else { return out }; let mut files: Vec = Vec::new(); for path in entries.flatten().map(|e| e.path()) { - let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } diff --git a/crates/geom/src/trace.rs b/crates/geom/src/trace.rs index b2d850ea9..54809fbb5 100644 --- a/crates/geom/src/trace.rs +++ b/crates/geom/src/trace.rs @@ -36,12 +36,12 @@ pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec< let mut starts: Vec<(isize, isize)> = next.keys().copied().collect(); starts.sort(); for s in starts { - while let Some(first) = next.get_mut(&s).and_then(|v| v.pop()) { + while let Some(first) = next.get_mut(&s).and_then(Vec::pop) { let mut ring = vec![s]; let mut cur = first; while cur != s { ring.push(cur); - match next.get_mut(&cur).and_then(|v| v.pop()) { + match next.get_mut(&cur).and_then(Vec::pop) { Some(n) => cur = n, None => break, } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d3587c943..ef692bfa4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -177,7 +177,7 @@ impl<'a> Ex<'a> { } fn assign_swatch_ids(&mut self) { - let mut used: HashSet = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect(); + let mut used: HashSet = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(ToString::to_string).collect(); self.swatch_ids.insert(swatch::NONE.into(), "Swatch/None".into()); self.swatch_ids.insert(swatch::PAPER.into(), "Color/Paper".into()); self.swatch_ids.insert(swatch::BLACK.into(), "Color/Black".into()); diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs index db11eef5a..980f802c5 100644 --- a/crates/images/src/psd.rs +++ b/crates/images/src/psd.rs @@ -298,7 +298,7 @@ impl<'a> ImageData<'a> { } let mut counts = self.counts.chunks_exact(self.count_size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b))); // This channel starts after every earlier channel's rows. - let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, |at, n| at.checked_add(n))?; + let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, usize::checked_add)?; for _ in 0..self.height { let n = counts.next()?; let end = at.checked_add(n)?; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..66512ac50 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -56,7 +56,7 @@ pub enum Standard { impl Standard { /// `none`, `x4` / `pdfx4` / `PDF/X-4`, `a2b` / `pdfa2b` / `PDF/A-2b`. pub fn parse(s: &str) -> Option { - let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); match k.as_str() { "" | "none" => Some(Self::None), "x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4), diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 2be38f97a..9aaeb445a 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -285,7 +285,7 @@ fn device_rgb(pdf: &[u8]) -> Vec { found .into_iter() .map(|(what, pages)| { - let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let list: Vec = pages.iter().take(10).map(ToString::to_string).collect(); let more = if pages.len() > 10 { ", …" } else { "" }; let s = if pages.len() == 1 { "" } else { "s" }; format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", ")) diff --git a/crates/pdf/src/pdfx/fonts.rs b/crates/pdf/src/pdfx/fonts.rs index 5dc149b7f..83178682a 100644 --- a/crates/pdf/src/pdfx/fonts.rs +++ b/crates/pdf/src/pdfx/fonts.rs @@ -33,7 +33,7 @@ pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec { let mut warnings: Vec = found .into_iter() .map(|(name, pages)| { - let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let list: Vec = pages.iter().take(10).map(ToString::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(", ")) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 79c476343..f6c2fa328 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -240,7 +240,7 @@ fn srgb_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(), _ => { diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4a6052781..37606097d 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -56,7 +56,7 @@ fn size_of(pm: &Pixmap) -> usize { } fn with(f: impl FnOnce(&mut Cache) -> R) -> R { - let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let c = g.get_or_insert_with(Cache::default); // Decoding converts CMYK through the working space: new colour settings, new pixels. let colour = designcraft_color::cms::generation(); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index 2573598d9..3b323a505 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -236,7 +236,7 @@ pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Option); static CACHE: Mutex>, Option>>)>>> = 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 mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let map = c.get_or_insert_with(HashMap::new); if let Some(v) = map.get(&key) { return v.1.clone(); diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 3f20f3dee..83bea3c87 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -11,7 +11,7 @@ use crate::{ImportError, Imported}; /// "B12" → (row 11, column 1). fn cell_ref(r: &str) -> Option<(usize, usize)> { - let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let letters: String = r.chars().take_while(char::is_ascii_alphabetic).collect(); let row: usize = r[letters.len()..].parse().ok()?; let col = letters.chars().try_fold(0usize, |acc, c| { let digit = (c.to_ascii_uppercase() as u8).checked_sub(b'A')? as usize + 1; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b149cea64..6a60da26e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1071,7 +1071,7 @@ 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())); + let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); for file in files { if file.request.purpose == "shortcuts" { shortcut_import::import_delivered(self, &file.bytes); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d97dcdeba..3c052b783 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1933,7 +1933,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(TYPOGRAPHY_GROUPS), _ => None, }; - app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(|p| p.to_string()).collect()).collect()); + app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(ToString::to_string).collect()).collect()); app.ui.dock_expanded = groups.is_none(); let dock_tab = match name { "Interactive for PDF" | "Digital Publishing" => Some("pages"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f2229b604..85058d7a8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -264,7 +264,7 @@ pub fn preflight_errors(app: &DesignApp) -> usize { static MEMO: std::sync::Mutex> = std::sync::Mutex::new(None); let Some(st) = app.session.active() else { return 0 }; let key = std::sync::Arc::as_ptr(&st.doc) as usize; - let mut g = MEMO.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((k, n)) = *g && k == key { diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 4b2360f8f..534eb4aa1 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -28,7 +28,7 @@ const MAX_THUMB_W: f64 = 1024.0; fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option { let st = app.session.active()?; let key = (st.uid, st.revision, of, h.round() as u32); - let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let map = g.get_or_insert_with(HashMap::new); if let Some(t) = map.get(&key) { return Some(t.clone()); diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 1aef2dbac..e0074b9e0 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -42,7 +42,7 @@ const ASSET_EXT: &[&str] = &[ const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "examples/"]; pub fn is_asset(path: &str) -> bool { - let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default(); + let ext = Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str()) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index dbfbfb5c8..8355fb95d 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -194,7 +194,7 @@ pub fn check(crates: &[Crate]) -> Vec { } } } - out.sort_by_key(|v| v.to_string()); + out.sort_by_key(ToString::to_string); out.dedup(); out }