From e585f9495d46e9b309b6aa2dd5938ef89f464cb6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 00:02:57 -0600 Subject: [PATCH 1/7] Preserve browser import intent and document targets (#119) * #89: preserve browser import context and PDF options * #91: reject PDF pages before narrowing on Wasm --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/designcraft-web/src/web.rs | 13 +- crates/engine/src/cmd/file.rs | 9 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 16 +- crates/ui-egui/src/lib.rs | 371 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 3 +- 8 files changed, 383 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6473b84f8..c683910c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,7 @@ dependencies = [ "designcraft-engine", "designcraft-fonts", "designcraft-geom", + "designcraft-idml", "designcraft-render", "designcraft-tools", "egui", diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index f5659863f..fe7cca4ae 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -2,7 +2,7 @@ use super::browser_policy::{has_query_flag, mime_for}; use designcraft_engine::Session; -use designcraft_ui_egui::{DesignApp, Inbox, Services}; +use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services}; use wasm_bindgen::JsCast as _; const DOC_EXTS: &[&str] = &["designcraft", "idml"]; @@ -88,13 +88,14 @@ impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { + let request = self.app.import_request("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -117,12 +118,12 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { - open_async: Some(Box::new(move |purpose: &str| { + open_async: Some(Box::new(move |request: ImportRequest| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); - let dialog = if purpose == "swatches" { + let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) - } else if purpose == "place" { + } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS) @@ -132,7 +133,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { return; }; let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name: file.file_name(), bytes }); ctx.request_repaint(); }); })), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d475cafc5..f871a43e8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -297,10 +297,13 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { Some(n) if designcraft_render::is_pdf(&bytes) => { let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); - if n == 0 || n as usize > count { - return Err(bad("file.place", format!("the PDF has {count} page(s)"))); + let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); + let one_based = usize::try_from(n).map_err(|_| invalid_page())?; + if one_based == 0 || one_based > count { + return Err(invalid_page()); } - n as u32 - 1 + let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; + u32::try_from(zero_based).map_err(|_| invalid_page())? } _ => 0, }; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 369b101b7..52cd054b8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +designcraft-idml = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e81e4c190..33e7c1b97 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,6 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { + app.cancel_pdf_import(); app.ui.dialog = None; ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7af1a32b9..503c2c9ff 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1072,7 +1072,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "preferences" => preferences(app, ui, &mut d), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { - ui.label(egui::RichText::new(d.s("path")).size(11.0)); + ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string())); text_field(ui, &mut d, "page", 60.0); @@ -1414,7 +1414,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => app.ui.dialog = None, + Some(false) => { + app.ui.dialog = None; + app.cancel_pdf_import(); + } None => {} } } @@ -1422,6 +1425,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + if d.id != "pdfImport" || !d.b("async") { + app.cancel_pdf_import(); + } match d.id.as_str() { "newDocument" => { let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)}); @@ -1520,6 +1526,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); + if d.b("async") { + return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop }); + } + if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { + app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; + } app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 16c4e1ee9..704a52eda 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,10 +38,21 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; -pub type OpenAsyncFn = Box; +/// Context captured before any host file-picker or read await. +#[derive(Clone, Debug)] +pub struct ImportRequest { + pub purpose: String, + pub target: Option, +} +pub struct ImportedFile { + pub request: ImportRequest, + pub name: String, + pub bytes: Vec, +} +pub type OpenAsyncFn = Box; pub type DownloadFn = Box; -/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). -pub type Inbox = std::sync::Arc)>>>; +/// Files delivered asynchronously with their original operation and document identity. +pub type Inbox = std::sync::Arc>>; /// Platform services injected by the host (desktop or web). #[derive(Default)] @@ -58,8 +69,7 @@ pub struct Services { /// Hand bytes to the user as a named file (browser download). When set, Save uses it /// instead of writing to a path. pub download: Option, - /// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) → - /// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`. + /// Files delivered asynchronously, drained every frame with their captured request context. pub inbox: Option, } @@ -386,6 +396,7 @@ pub struct Perf { } pub struct DesignApp { + pending_pdf: Option, pub session: Session, pub ui: UiState, pub services: Services, @@ -434,6 +445,7 @@ impl DesignApp { // The font menus and the first file opened need the installed fonts: catalog them now. designcraft_fonts::FontDb::global().scan_in_background(); DesignApp { + pending_pdf: None, session, ui: UiState::default(), services, @@ -537,6 +549,7 @@ impl DesignApp { /// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the /// command right away; asynchronous ones (web) deliver the file through the inbox. pub fn pick_and_open(&mut self, purpose: &str) -> Result { + let target = self.session.active().map(|d| d.uid); let cmd = if purpose == "place" { "file.place" } else { "file.open" }; if let Some(pick) = self.services.pick_open.as_mut() { return match pick(purpose) { @@ -545,7 +558,8 @@ impl DesignApp { let pages = self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); if pages > 1 { - self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages}))); + self.ui.dialog = + Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); Ok(Value::Null) } else { self.open_file(cmd, json!({"path": path})) @@ -555,33 +569,99 @@ impl DesignApp { None => Ok(Value::Null), }; } + let request = self.import_request(purpose); if let Some(open) = self.services.open_async.as_mut() { - open(purpose); + open(request); } Ok(Value::Null) } - /// Open or place files delivered through the inbox. + pub fn import_request(&self, purpose: &str) -> ImportRequest { + ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) } + } + + pub(crate) fn activate_import_target(&mut self, request: &ImportRequest) -> Result<(), String> { + let Some(index) = request.target.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) else { + let message = "Import cancelled: the originating document has closed"; + self.status(message); + return Err(message.into()); + }; + self.run("file.activate", json!({"index": index}))?; + Ok(()) + } + + pub(crate) fn cancel_pdf_import(&mut self) { + self.pending_pdf = None; + } + + pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result { + let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?; + self.activate_import_target(&file.request)?; + self.open_file( + "file.place", + json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}), + ) + } + + /// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than + /// replacing its options. Cancellation, replacement and target closure release its payload. fn drain_inbox(&mut self) { + if let Some(file) = &self.pending_pdf { + let target_exists = self.session.documents().iter().any(|d| Some(d.uid) == file.request.target); + let owns_dialog = + self.ui.dialog.as_ref().is_some_and(|d| d.id == "pdfImport" && d.fields.get("async").and_then(Value::as_bool) == Some(true)); + if !target_exists || !owns_dialog { + self.pending_pdf = None; + if !target_exists { + if owns_dialog { + self.ui.dialog = None; + } + self.status("PDF import cancelled: the originating document has closed"); + } + } + } let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); - for (name, bytes) in files { - let b64 = designcraft_engine::cmd::base64_encode(&bytes); - let lower = name.to_ascii_lowercase(); - let r = if lower.ends_with(".ase") { - self.run("swatch.load", json!({"base64": b64})) - } else if opens_as_document(&name) { - let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); - self.open_file("file.openBytes", json!({"name": title, "base64": b64})) - } else { - self.open_file("file.place", json!({"name": name, "base64": b64})) - }; - if let Err(e) = r { + for file in files { + if self.ui.dialog.is_some() { + self.status(format!("{}: import cancelled while a dialog is open; choose the file again", file.name)); + continue; + } + let name = file.name.clone(); + if let Err(e) = self.import_file(file) { self.status(format!("{name}: {e}")); } } } + fn import_file(&mut self, file: ImportedFile) -> Result { + let lower = file.name.to_ascii_lowercase(); + let layout = opens_as_document(&file.name); + let place = + file.request.purpose == "place" || (matches!(file.request.purpose.as_str(), "drop" | "open") && !layout && !lower.ends_with(".ase")); + if place || lower.ends_with(".ase") || file.request.purpose == "swatches" { + self.activate_import_target(&file.request)?; + } + if place && lower.ends_with(".pdf") { + let pages = designcraft_render::pdf_page_count(&file.bytes).unwrap_or(1); + if pages > 1 { + self.ui.dialog = + Some(dialogs::Dialog::new("pdfImport", json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages}))); + self.pending_pdf = Some(file); + return Ok(Value::Null); + } + } + let b64 = designcraft_engine::cmd::base64_encode(&file.bytes); + if lower.ends_with(".ase") || file.request.purpose == "swatches" { + self.run("swatch.load", json!({"base64": b64})) + } else if place { + self.open_file("file.place", json!({"name": file.name, "base64": b64})) + } else { + let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem); + self.open_file("file.openBytes", json!({"name": title, "base64": b64})) + } + } + pub fn select_tool(&mut self, id: &str) { let _ = self.run("tool.select", json!({"tool": id})); self.ui.flyout = None; @@ -1108,3 +1188,254 @@ pub(crate) mod test_window { h.run_steps(4); } } + +#[cfg(test)] +mod browser_import_tests { + use super::*; + + fn app() -> DesignApp { + let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() }); + app.run("file.new", json!({"title": "A"})).unwrap(); + app + } + fn deliver(app: &mut DesignApp, request: ImportRequest, name: &str, bytes: Vec) { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes }); + app.drain_inbox(); + } + fn image() -> Vec { + br##""##.to_vec() + } + fn pdf_pages(count: usize) -> Vec { + let kids = (0..count).map(|i| format!("{} 0 R", 3 + i * 2)).collect::>().join(" "); + let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {count} >>")]; + for (i, color) in ["1 0 0", "0 1 0", "0 0 1"].iter().take(count).enumerate() { + let content = format!("{color} rg 0 0 200 200 re f\n"); + objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents {} 0 R >>", 4 + i * 2)); + objects.push(format!("<< /Length {} >>\nstream\n{content}endstream", content.len())); + } + let mut result = "%PDF-1.4\n".to_string(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(result.len()); + result.push_str(&format!("{} 0 obj\n{object}\nendobj\n", i + 1)); + } + let xref = result.len(); + result.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1)); + for offset in offsets { + result.push_str(&format!("{offset:010} 00000 n \n")); + } + result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1)); + result.into_bytes() + } + fn pdf() -> Vec { + pdf_pages(3) + } + + #[test] + fn picker_receives_context_before_returning_and_cancel_does_not_edit() { + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let copy = captured.clone(); + let mut app = app(); + app.services.open_async = Some(Box::new(move |request| *copy.lock().unwrap() = Some(request))); + let uid = app.session.doc().unwrap().uid; + app.pick_and_open("place").unwrap(); + app.run("file.new", json!({"title": "B"})).unwrap(); + let request = captured.lock().unwrap().take().unwrap(); + assert_eq!(request.purpose, "place"); + assert_eq!(request.target, Some(uid)); + assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty())); + deliver(&mut app, request, "broken.pdf", b"not a PDF".to_vec()); + assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty())); + assert!(app.pending_pdf.is_none()); + } + + #[test] + fn delayed_place_activates_original_document_and_undo_belongs_to_it() { + let mut app = app(); + let request = app.import_request("place"); + app.run("file.new", json!({"title": "B"})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.active_index(), Some(0)); + assert_eq!(app.session.documents()[0].doc.assets.len(), 1); + assert!(app.session.documents()[1].doc.assets.is_empty()); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1); + } + + #[test] + fn closed_target_is_not_replaced_by_a_reused_tab_index() { + let mut app = app(); + let request = app.import_request("place"); + app.run("file.close", json!({})).unwrap(); + app.run("file.new", json!({"title": "replacement"})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + } + + #[test] + fn closing_earlier_tab_does_not_shift_import_target() { + let mut app = app(); + app.run("file.new", json!({"title": "target"})).unwrap(); + let request = app.import_request("place"); + app.run("file.new", json!({"title": "other"})).unwrap(); + app.run("file.close", json!({"index": 0})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.doc().unwrap().doc.title, "target"); + assert_eq!(app.session.documents()[0].doc.assets.len(), 1); + assert!(app.session.documents()[1].doc.assets.is_empty()); + } + + #[test] + fn layout_place_and_open_preserve_explicit_intent() { + let mut source = Session::new(); + source.execute("file.new", &json!({"title": "source"})).unwrap(); + source.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "text", "text": "placed"})).unwrap(); + let native = designcraft_engine::cmd::to_bytes(&source.doc().unwrap().doc); + let idml = designcraft_idml::export_idml(&source.doc().unwrap().doc); + for (name, bytes) in [("source.designcraft", native), ("source.idml", idml)] { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request, name, bytes.clone()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.doc().unwrap().doc.title, "A"); + assert!(app.session.doc().unwrap().doc.stories.values().any(|s| s.text == "placed")); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.doc().unwrap().doc.stories.is_empty()); + app.run("edit.redo", json!({})).unwrap(); + assert!(!app.session.doc().unwrap().doc.stories.is_empty()); + let request = app.import_request("open"); + deliver(&mut app, request, name, bytes); + assert_eq!(app.session.documents().len(), 2); + } + } + + #[test] + fn pdf_options_keep_bytes_target_and_selected_page() { + let mut app = app(); + let bytes = pdf(); + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", bytes.clone()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport"); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active_index(), Some(0)); + let asset = app.session.doc().unwrap().doc.assets.values().next().unwrap(); + assert_eq!(asset.page, 1); + assert_eq!(*asset.data, bytes); + assert!(app.pending_pdf.is_none()); + assert!(app.session.documents()[1].doc.assets.is_empty()); + let exported = app.run("file.exportPdf", json!({})).unwrap(); + let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap()); + let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap(); + assert!(pixels.data().iter().filter(|p| p.g > 240 && p.r < 10 && p.b < 10).count() > 1000); + assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0); + } + + #[test] + fn pdf_options_reject_oversized_page_without_editing() { + let mut app = app(); + for page in [0_u64, 4, 4_294_967_296, 4_294_967_297, u64::MAX] { + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", pdf()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(page.to_string())); + // The native dialog clamps zero to one; positive invalid pages must fail. + if page == 0 { + assert!(app.confirm_pdf_import(page, "crop".into()).is_err()); + } else { + assert!(dialogs::confirm(&mut app).is_err()); + } + app.ui.dialog = None; + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + assert!(app.pending_pdf.is_none()); + } + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", pdf()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1); + } + + #[test] + fn drop_routing_and_nonlayout_open_remain_compatible() { + let mut app = app(); + let native = designcraft_engine::cmd::to_bytes(&app.session.doc().unwrap().doc); + let request = app.import_request("drop"); + deliver(&mut app, request, "document.designcraft", native); + assert_eq!(app.session.documents().len(), 2); + let request = app.import_request("drop"); + app.run("file.activate", json!({"index": 0})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1); + let request = app.import_request("open"); + deliver(&mut app, request, "single.pdf", pdf_pages(1)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.pending_pdf.is_none()); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn ase_swatches_keep_purpose_and_target() { + let mut source = Session::new(); + source.execute("file.new", &json!({})).unwrap(); + source.execute("swatch.create", &json!({"name": "Import Teal", "color": "#108080"})).unwrap(); + let data = source.execute("swatch.save", &json!({"names": ["Import Teal"]})).unwrap(); + let bytes = designcraft_engine::cmd::base64_decode(data["base64"].as_str().unwrap()); + for (purpose, name) in [("swatches", "picker-file"), ("drop", "palette.ase")] { + let mut app = app(); + let request = app.import_request(purpose); + app.run("file.new", json!({"title": "B"})).unwrap(); + deliver(&mut app, request, name, bytes.clone()); + assert_eq!(app.session.active_index(), Some(0)); + assert!(app.session.documents()[0].doc.swatches.iter().any(|s| s.name == "Import Teal")); + assert!(!app.session.documents()[1].doc.swatches.iter().any(|s| s.name == "Import Teal")); + } + } + + #[test] + fn native_pdf_dialog_keeps_path_and_uid_at_confirmation() { + let mut app = app(); + let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid)); + std::fs::write(&path, pdf()).unwrap(); + let pick_path = path.to_string_lossy().to_string(); + app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone()))); + app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string()))); + app.pick_and_open("place").unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport"); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active_index(), Some(0)); + assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1); + assert!(app.session.documents()[1].doc.assets.is_empty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn pending_pdf_cancel_close_supersession_and_second_delivery_release_bytes() { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request.clone(), "first.pdf", pdf()); + deliver(&mut app, request.clone(), "second.svg", image()); + assert_eq!(app.pending_pdf.as_ref().unwrap().name, "first.pdf"); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + app.ui.dialog = None; + app.drain_inbox(); + assert!(app.pending_pdf.is_none()); + deliver(&mut app, request.clone(), "first.pdf", pdf()); + app.ui.dialog = Some(dialogs::Dialog::new("newDocument", json!({}))); + app.drain_inbox(); + assert!(app.pending_pdf.is_none()); + app.ui.dialog = None; + deliver(&mut app, request, "first.pdf", pdf()); + app.run("file.close", json!({})).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(app.pending_pdf.is_none()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 53bee42e6..72c3abe7b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -858,8 +858,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Ok(Value::Null), }); } + let request = app.import_request("swatches"); if let Some(open) = app.services.open_async.as_mut() { - open("swatches"); + open(request); } Ok(Value::Null) } From b9febf68c1466167a9b7a85cd93e2f09375d0f51 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 02:13:04 -0400 Subject: [PATCH 2/7] i18n: a new English string doesn't fail the Ukrainian catalog test (#294) Untranslated strings fall back to English, as in every other language; only stale Ukrainian entries fail. Otherwise every PR adding UI text breaks the build until someone translates it. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04f1c8889..6eee4c29a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2878,7 +2878,10 @@ mod tests { keys.extend(TABLE.iter().map(|(key, _)| *key)); keys.extend(ar::TABLE.iter().map(|(key, _)| *key)); keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key)); - assert_eq!(keys, catalog.keys().copied().collect()); + // A new English string falls back to English until it is translated, so only stale + // entries (keys no catalog knows any more) fail. + let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); + assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}"); let placeholders = |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); for (key, translation) in uk::TABLE { From 617e487dff5e5b29de5acd5d6cc04cc3e188ff9a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:30 -0400 Subject: [PATCH 3/7] Styles: Character Style Options dialog (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Styles: editing a character style renames it everywhere it is used, changes its Based On and unsets attributes; [None] can't be edited * UI: Character Style Options edits a character style's name, Based On and character formats, opened by double-clicking the style, Edit in its menu, or New Character Style… --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/doc/src/styles.rs | 27 + crates/engine/src/cmd/style.rs | 157 ++++- crates/ui-egui/src/dialogs.rs | 787 +++++++++++++++++++++--- crates/ui-egui/src/i18n.rs | 32 + crates/ui-egui/src/i18n/ar.rs | 23 + crates/ui-egui/src/i18n/pt_br.rs | 23 + crates/ui-egui/src/menus.rs | 14 + crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/styles.rs | 26 +- crates/ui-egui/src/widgets.rs | 41 +- docs/control-protocol.md | 2 +- 11 files changed, 1034 insertions(+), 100 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 478cf0452..7a700fbce 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -336,6 +336,15 @@ impl Styles { cp } + /// The attributes character style `name` sets, with those of its Based On chain beneath them. + pub fn char_style_attrs(&self, name: &str) -> CharAttrs { + let mut a = CharAttrs::default(); + for s in self.char_chain(name) { + a.merge(&s.chars); + } + a + } + /// Would making `name` based on `parent` create a cycle? pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool { name == parent || self.para_chain(parent).iter().any(|s| s.name == name) @@ -402,6 +411,24 @@ mod tests { assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE)); } + #[test] + fn character_style_attrs_include_its_based_on_chain_only() { + let mut st = Styles::default(); + let cs = |name: &str, based_on: Option<&str>, chars| CharacterStyle { + name: name.into(), + based_on: based_on.map(Into::into), + chars, + shortcut: String::new(), + }; + st.character.push(cs("Base", None, CharAttrs { font_style: Some("Bold".into()), size: Some(9.0), ..Default::default() })); + st.character.push(cs("Strong", Some("Base"), CharAttrs { size: Some(14.0), ..Default::default() })); + st.character.push(cs("Loop", Some("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() })); + assert_eq!(st.char_style_attrs("Strong"), CharAttrs { font_style: Some("Bold".into()), size: Some(14.0), ..Default::default() }); + assert_eq!(st.char_style_attrs("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() }); + assert!(st.char_style_attrs(NO_CHAR_STYLE).is_empty()); + assert!(st.char_style_attrs("Missing").is_empty()); + } + #[test] fn unique_names() { let names = ["Body", "Body 2"]; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 3fb86fb6c..92641e4b9 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -100,8 +100,24 @@ pub fn specs() -> Vec { edit_para ), cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para), - cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char), - cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char), + cmd!( + "style.character.create", + "New Character Style…", + [], + None, + "{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)", + has_doc, + create_char + ), + cmd!( + "style.character.edit", + "Character Style Options…", + [], + None, + "{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use", + has_doc, + edit_char + ), cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| { let st = s.doc()?; Ok(json!({ @@ -507,6 +523,12 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & s.based_on = Some(to.to_string()); } } + for s in &mut st.paragraph { + rename_char_refs(&mut s.para, from, to); + } + if d.footnote_options.ref_char_style == from { + d.footnote_options.ref_char_style = to.to_string(); + } } for sid in d.stories.keys().copied().collect::>() { let Some(story) = d.story_mut(sid) else { continue }; @@ -519,12 +541,26 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & for r in st.chars.iter_mut().filter(|r| r.format.style == from) { r.format.style = to.to_string(); } + for f in &mut st.paras { + rename_char_refs(&mut f.para, from, to); + } } st.rev += 1; }); } } +/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style. +fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { + let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); + let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); + for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) { + if n == from { + *n = to.to_string(); + } + } +} + fn group_styles(s: &mut Session, p: &Value) -> Result { let para = match str_param(p, "kind").unwrap_or("paragraph") { "paragraph" => true, @@ -583,8 +619,14 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - let based_on = str_param(p, "basedOn").map(str::to_string); + // Based on [None] is based on nothing. + let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { + if let Some(b) = &based_on + && d.styles.char_style(b).is_none() + { + return Err(bad("style.character.create", format!("no character style `{b}`"))); + } let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base); d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }); Ok(json!({"name": name})) @@ -593,10 +635,50 @@ fn create_char(s: &mut Session, p: &Value) -> Result { fn edit_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); + if name == designcraft_doc::NO_CHAR_STYLE { + return Err(bad("style.character.edit", "[None] can't be edited")); + } let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // `null` unsets an attribute (the style no longer sets it). + let unset: Vec = p + .get("chars") + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); + let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string()); + let based: Option> = match p.get("basedOn") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None), + Some(Value::String(b)) => Some(Some(b.clone())), + Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")), + }; s.edit(|d, _| { - let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?; + if let Some(Some(b)) = &based { + if d.styles.char_style(b).is_none() { + return Err(bad("style.character.edit", format!("no character style `{b}`"))); + } + if d.styles.char_based_on_cycles(&name, b) { + return Err(bad("style.character.edit", "based-on would create a cycle")); + } + } + if let Some(n) = &rename + && (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some()) + { + return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + } + let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; st.chars.merge(&chars); + for k in &unset { + st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?; + } + if let Some(b) = based.clone() { + st.based_on = b; + } + if let Some(n) = &rename { + // Every use: other styles, stories, table cells, nested and GREP styles, footnotes. + rename_style(d, false, &name, n); + } ok() }) } @@ -977,6 +1059,73 @@ mod color_tests { assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); } + #[test] + fn character_style_edit_sets_unsets_rebases_and_renames_every_use() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap(); + s.execute( + "style.paragraph.create", + &json!({"name": "Lead", "para": { + "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], + "nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}), + ) + .unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + + s.execute( + "style.character.edit", + &json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.styles.char_style("Emphasis").is_none()); + let st = d.styles.char_style("Strong").unwrap(); + assert_eq!(st.based_on.as_deref(), Some("Base")); + // Exactly the attributes set: `null` unset the font style, the rest stayed unset. + assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() }); + let story = d.story(designcraft_doc::StoryId(sid)).unwrap(); + assert!(story.chars.iter().any(|r| r.format.style == "Strong")); + assert!(story.chars.iter().all(|r| r.format.style != "Emphasis")); + let lead = d.styles.para("Lead").unwrap(); + assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(d.footnote_options.ref_char_style, "Strong"); + + // Based On [None] (null or the name) clears it. + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); + + // Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None]. + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); + assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty()); + } + + #[test] + fn new_character_style_checks_its_based_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err()); + s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap(); + let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap(); + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); + } + #[test] fn swatches_save_and_load_ase() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 503c2c9ff..a2dc5c189 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -50,7 +50,7 @@ impl Dialog { fields.insert("separator".into(), json!(sep.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>"))); fields.entry("tab".to_string()).or_insert(json!("numbering")); } - if id == "paragraphStyleOptions" { + if id == "paragraphStyleOptions" || id == "characterStyleOptions" { fields.entry("section".to_string()).or_insert(json!("general")); } if id == "frameSize" { @@ -821,6 +821,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), + "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), @@ -1065,6 +1066,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), "footnoteOptions" => footnote_options(app, ui, &mut d), "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), @@ -1492,24 +1494,71 @@ pub fn confirm(app: &mut DesignApp) -> Result { chars.insert(a.into(), v.clone()); } } - let mut params = json!({"name": name, "para": para, "chars": chars}); let based = d.s("basedOn"); - if !based.is_empty() { - params["basedOn"] = if based == "[No Paragraph Style]" { Value::Null } else { json!(based) }; - } let rename = d.s("rename"); - if !rename.is_empty() && rename != name { - params["rename"] = json!(rename); - } - let r = app.run("style.paragraph.edit", params)?; + let (r, style) = if d.b("new") { + let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); + if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { + params["basedOn"] = json!(based); + } + let r = app.run("style.paragraph.create", params)?; + let style = r["name"].as_str().unwrap_or_default().to_string(); + (r, style) + } else { + let mut params = json!({"name": name, "para": para, "chars": chars}); + if !based.is_empty() { + params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; + } + if !rename.is_empty() && rename != name { + params["rename"] = json!(rename); + } + let style = if !rename.is_empty() && rename != name { rename } else { name }; + (app.run("style.paragraph.edit", params)?, style) + }; if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; - let style = if !rename.is_empty() && rename != name { rename } else { name }; app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?; } Ok(r) } + "characterStyleOptions" => { + let name = d.s("name"); + let mut chars = Map::new(); + for (k, v) in &d.fields { + if let Some(a) = k.strip_prefix("c.") { + chars.insert(a.into(), v.clone()); + } + } + let rename = d.s("rename").trim().to_string(); + let based = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string); + let r = if d.b("new") { + // A new style sets only what was filled in. + chars.retain(|_, v| !v.is_null()); + let mut params = json!({"name": if rename.is_empty() { "Character Style 1" } else { rename.as_str() }, "chars": chars}); + if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) { + params["basedOn"] = json!(b); + } + app.run("style.character.create", params) + } else { + let mut params = json!({"name": name, "chars": chars}); + if let Some(b) = based { + params["basedOn"] = if b == designcraft_doc::NO_CHAR_STYLE { Value::Null } else { json!(b) }; + } + if !rename.is_empty() && rename != name { + params["rename"] = json!(rename); + } + app.run("style.character.edit", params) + }; + if let Err(e) = &r { + // Keep the dialog open with the reason (a taken name, a based-on loop). + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + d.fields.insert("section".into(), json!("general")); + app.ui.dialog = Some(d); + } + r + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -1751,13 +1800,29 @@ fn regex_ok(p: &str) -> Result<(), ()> { fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); + let new = d.b("new"); let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return }; - let Some(style) = doc.styles.para(&name).cloned() else { - ui.label(format!("No style named {name}")); - return; + let style = match doc.styles.para(&name) { + _ if new => designcraft_doc::ParagraphStyle { + name: String::new(), + based_on: None, + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }, + Some(s) => s.clone(), + None => { + ui.label(format!("No style named {name}")); + return; + } }; - let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect(); - let (pp, cp) = doc.styles.resolve_para_style(&name); + if new && !d.fields.contains_key("rename") { + d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); + } + let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); + // A new style shows what it would inherit from its Based On. + let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -1767,9 +1832,11 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo // A fixed height: the separator would otherwise stretch the dialog to the window. ui.set_min_height(380.0); ui.set_max_height(380.0); - ui.vertical(|ui| { - ui.set_width(170.0); - for (id, label) in [ + section_list( + &app.ui.language, + ui, + d, + &[ ("general", "General"), ("chars", "Basic Character Formats"), ("indents", "Indents and Spacing"), @@ -1779,12 +1846,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ("grep", "GREP Style"), ("color", "Character Color"), ("export", "Export Tagging"), - ] { - if ui.selectable_label(d.s("section") == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("section".into(), json!(id)); - } - } - }); + ], + ); ui.separator(); let cnames: Vec = app.session.active().map(|s| s.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); ui.vertical(|ui| match d.s("section").as_str() { @@ -2030,44 +2093,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("p.grepStyles".into(), Value::Array(list)); } } - "chars" => { - let fonts = crate::panels::fonts(app); - egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); - let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string(); - if let Some(f) = crate::panels::font_combo(app, ui, "psfam", &fam, 200.0) { - d.fields.insert("c.fontFamily".into(), json!(f)); - } - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); - let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { - for s in fonts.styles(&fam) { - if ui.selectable_label(s == sty, &s).clicked() { - d.fields.insert("c.fontStyle".into(), json!(s)); - } - } - }); - ui.end_row(); - for (label, key, suffix) in [("Size:", "size", " pt"), ("Tracking:", "tracking", "")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("c.{key}"), &cv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, suffix, 80.0, 2) { - d.fields.insert(format!("c.{key}"), json!(n)); - } - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Leading:")); - let lv = match cur(d, "c.leading", &cv["leading"]) { - v if v["kind"] == "points" => v["value"].as_f64(), - _ => None, - }; - if let Some(n) = crate::widgets::number(ui, "pslead", lv, " pt", 80.0, 2) { - d.fields.insert("c.leading".into(), json!({"kind": "points", "value": n})); - } - ui.end_row(); - }); - } + "chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }), "indents" => { egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:")); @@ -2164,22 +2190,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.end_row(); }); } - "color" => { - let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string(); - let swatches: Vec = doc.swatches.iter().map(|s| s.name.clone()).collect(); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for sw in swatches { - let (c, g) = crate::widgets::swatch_colors(&doc, &sw, 1.0); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); - crate::widgets::paint_chip(ui.painter(), r, c, g); - if ui.selectable_label(sw == cur_fill, &sw).clicked() { - d.fields.insert("c.fill".into(), json!(sw)); - } - }); - } - }); - } + "color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc), _ => { egui::Grid::new("psg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style Name:")); @@ -2228,6 +2239,489 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } +/// The section list on the left of the style options dialogs. +fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) { + ui.vertical(|ui| { + ui.set_width(170.0); + for (id, label) in sections { + if ui.selectable_label(d.s("section") == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert("section".into(), json!(id)); + } + } + }); +} + +/// "Paragraph Style n" / "Character Style n": the first name no style has. +fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { + let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; + let taken = |n: &str| if para { styles.para(n).is_some() } else { styles.char_style(n).is_some() }; + // Among count + 1 numbers at least one is free. + (1..=count + 1).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| format!("{base} {}", count + 1)) +} + +/// Paragraph or Character Style Options for an existing style (a double-click on it in the styles +/// panel, or Edit in its menu). [None] and [No Paragraph Style] have no options. +pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { + if name == if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { + return; + } + let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; + app.ui.dialog = Some(Dialog::new(id, json!({"name": name}))); +} + +/// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named +/// with the next free "Paragraph Style n" / "Character Style n". +pub fn open_new_style(app: &mut DesignApp, para: bool) { + let Some(st) = app.session.active() else { return }; + let name = next_style_name(&st.doc.styles, para); + let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; + app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); +} + +/// Character Style Options: General, then the character sections it shares with Paragraph Style +/// Options. A character style sets only some attributes: the fields show those it and its Based +/// On set and leave the rest blank. Edits are `c.` fields (`null` = no longer set), applied +/// on OK; `new` makes the style instead of editing `name`. +fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + let new = d.b("new"); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return }; + let styles = &doc.styles; + let style = match styles.char_style(&name) { + _ if new => designcraft_doc::CharacterStyle { name: String::new(), based_on: None, chars: Default::default(), shortcut: String::new() }, + Some(s) if name != designcraft_doc::NO_CHAR_STYLE => s.clone(), + _ => { + crate::rtl::label(ui, format!("No style named {name}")); + return; + } + }; + if !d.fields.contains_key("rename") { + d.fields.insert("rename".into(), json!(if new { next_style_name(styles, false) } else { name.clone() })); + } + let based = d + .fields + .get("basedOn") + .and_then(Value::as_str) + .map(str::to_string) + .or(style.based_on.clone()) + .unwrap_or_else(|| designcraft_doc::NO_CHAR_STYLE.into()); + // The fields show the style's own attributes over those its Based On gives it. + let mut shown = styles.char_style_attrs(&based); + shown.merge(&style.chars); + let base = serde_json::to_value(&shown).unwrap_or_default(); + let cf = CharFields { base: &base, sparse: true }; + let lang = app.ui.language.clone(); + ui.set_min_width(560.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list( + &lang, + ui, + d, + &[ + ("general", "General"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), + ], + ); + ui.separator(); + ui.vertical(|ui| match d.s("section").as_str() { + "chars" => basic_character_formats(app, ui, d, &cf), + "advanced" => advanced_character_formats(&lang, ui, d, &cf), + "color" => character_color(&lang, ui, d, &cf, &doc), + "openType" => open_type_features(&lang, ui, d, &cf), + "underline" => line_options(&lang, ui, d, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"), + _ => { + egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Style Name:")); + text_field(ui, d, "rename", 220.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&lang, "Based On:")); + egui::ComboBox::from_id_salt("csbased") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&lang, &based))) + .width(220.0) + .show_ui(ui, |ui| { + // Not itself, nor a style based on it. + for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) { + if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(&lang, &s.name))).clicked() { + d.fields.insert("basedOn".into(), json!(s.name)); + } + } + }); + ui.end_row(); + }); + let status = d.s("status"); + if !status.is_empty() { + crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Style Settings:")).font(semibold(12.0))); + // What OK would leave the style setting, after its Based On. + let mut own = serde_json::to_value(&style.chars).unwrap_or_default(); + if let Some(o) = own.as_object_mut() { + for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) { + if v.is_null() { + o.remove(k); + } else { + o.insert(k.to_string(), v.clone()); + } + } + } + let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()]; + parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v)))); + ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); + } + }); + }); +} + +/// An attribute's name as Style Settings shows it: the field's label where the name reads poorly. +fn setting_label(lang: &str, key: &str) -> String { + match key { + "hScale" => crate::i18n::tr(lang, "Horizontal Scale:").trim_end_matches([':', ':']).to_string(), + "vScale" => crate::i18n::tr(lang, "Vertical Scale:").trim_end_matches([':', ':']).to_string(), + "capitalization" => crate::i18n::tr(lang, "Case:").trim_end_matches([':', ':']).to_string(), + "otfFeatures" => crate::i18n::tr(lang, "OpenType Features").to_string(), + _ => crate::i18n::tr(lang, &humanize(key)).to_string(), + } +} + +/// An attribute value as Style Settings shows it. +fn setting_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), |x| format!("{}", (x * 1000.0).round() / 1000.0)), + Value::Bool(b) => if *b { "on" } else { "off" }.into(), + Value::Array(a) => a.iter().map(setting_text).collect::>().join(", "), + Value::Object(o) => o.get("value").or_else(|| o.get("kind")).map(setting_text).unwrap_or_default(), + Value::Null => String::new(), + } +} + +/// Where the character sections of the style options dialogs get the values they show: `base` +/// holds them by attribute name (resolved values for a paragraph style; for a character style the +/// attributes it sets, the others absent). `sparse` (a character style) lets a field be blank: +/// emptying it, or picking the blank entry, unsets the attribute. +struct CharFields<'a> { + base: &'a Value, + sparse: bool, +} + +impl CharFields<'_> { + /// The value shown for attribute `key`: the dialog's edit, else the base value. + fn get(&self, d: &Dialog, key: &str) -> Value { + d.fields.get(&format!("c.{key}")).cloned().unwrap_or_else(|| self.base.get(key).cloned().unwrap_or(Value::Null)) + } + fn set(&self, d: &mut Dialog, key: &str, v: Value) { + d.fields.insert(format!("c.{key}"), v); + } +} + +/// A number attribute shown multiplied by `scale` (100 for a fraction shown as a percentage). +fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) { + let v = cf.get(d, key).as_f64().map(|x| x * scale); + let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1); + let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; + if let Some(n) = edit { + cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale))); + } +} + +/// An attribute chosen from `opts` (value, label); a character style also has a blank entry. +fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { + let cur = cf.get(d, key); + let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); + egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { + cf.set(d, key, Value::Null); + } + for (v, label) in opts { + if ui.selectable_label(*v == cur, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + cf.set(d, key, v.clone()); + } + } + }); +} + +/// An on/off attribute; for a character style a third, mixed state leaves it unset (clicks go +/// unset → on → off → unset). +fn char_check(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, label: &str) { + let cur = cf.get(d, key); + let mut on = cur.as_bool().unwrap_or(false); + let unset = cf.sparse && cur.is_null(); + if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, label)).indeterminate(unset)).clicked() { + let next = match cur.as_bool() { + _ if !cf.sparse => json!(on), + None => json!(true), + Some(true) => json!(false), + Some(false) => Value::Null, + }; + cf.set(d, key, next); + } +} + +/// A swatch attribute from a menu; `text_color` names the "" entry (the text's own colour). +fn char_swatch(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document, key: &str, text_color: Option<&str>) { + let mut opts: Vec<(Value, &str)> = text_color.map(|l| (json!(""), l)).into_iter().collect(); + opts.extend(doc.swatches.iter().map(|w| (json!(w.name), w.name.as_str()))); + char_choice(lang, ui, d, cf, key, &opts); +} + +/// Basic Character Formats: font, size, leading, kerning, tracking, case, position and the +/// underline, strikethrough, ligatures and no-break switches. +fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) { + let lang = app.ui.language.clone(); + let lang = lang.as_str(); + let fonts = crate::panels::fonts(app); + let menu = crate::panels::font_menu(app); + egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); + let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); + let shown = if fam.is_empty() { "—".to_string() } else { crate::panels::font_label(app, &menu, &fam) }; + egui::ComboBox::from_id_salt("psfam") + .selected_text(shown) + .width(200.0) + .height(440.0) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { + if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() { + cf.set(d, "fontFamily", Value::Null); + } + if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) { + cf.set(d, "fontFamily", json!(f)); + } + }); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:")); + let sty = cf.get(d, "fontStyle").as_str().unwrap_or("").to_string(); + if cf.sparse && fam.is_empty() { + // No family to list styles of: type the style ("Italic" in whatever font the text has). + let mut s = sty.clone(); + if ui.add(egui::TextEdit::singleline(&mut s).desired_width(200.0)).changed() { + cf.set(d, "fontStyle", if s.trim().is_empty() { Value::Null } else { json!(s) }); + } + } else { + egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { + if cf.sparse && ui.selectable_label(sty.is_empty(), " ").clicked() { + cf.set(d, "fontStyle", Value::Null); + } + for s in fonts.styles(&fam) { + if ui.selectable_label(s == sty, &s).clicked() { + cf.set(d, "fontStyle", json!(s)); + } + } + }); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); + char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); + let lv = match cf.get(d, "leading") { + v if v["kind"] == "points" => v["value"].as_f64(), + _ => None, + }; + let field = crate::widgets::NumField::number("pslead", lv, " pt", 2).width(80.0).range(0.0, 5000.0); + let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; + if let Some(n) = edit { + cf.set(d, "leading", n.map_or(Value::Null, |n| json!({"kind": "points", "value": n}))); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Kerning:")); + char_choice( + lang, + ui, + d, + cf, + "kerning", + &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); + char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Case:")); + char_choice( + lang, + ui, + d, + cf, + "capitalization", + &[ + (json!("normal"), "Normal"), + (json!("allCaps"), "All Caps"), + (json!("smallCaps"), "Small Caps"), + (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), + ], + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); + char_choice( + lang, + ui, + d, + cf, + "position", + &[ + (json!("normal"), "Normal"), + (json!("superscript"), "Superscript"), + (json!("subscript"), "Subscript"), + (json!("otSuperscript"), "OpenType Superscript"), + (json!("otSubscript"), "OpenType Subscript"), + (json!("otNumerator"), "OpenType Numerator"), + (json!("otDenominator"), "OpenType Denominator"), + ], + ); + ui.end_row(); + }); + ui.add_space(4.0); + egui::Grid::new("pscb").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| { + char_check(ui, d, cf, "underline", crate::i18n::tr(lang, "Underline")); + char_check(ui, d, cf, "ligatures", crate::i18n::tr(lang, "Ligatures")); + ui.end_row(); + char_check(ui, d, cf, "strikethrough", crate::i18n::tr(lang, "Strikethrough")); + char_check(ui, d, cf, "noBreak", crate::i18n::tr(lang, "No Break")); + ui.end_row(); + }); +} + +/// Advanced Character Formats: scaling, baseline shift, skew and language. +fn advanced_character_formats(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) { + egui::Grid::new("csa").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Horizontal Scale:")); + char_number(ui, d, cf, "hScale", "%", 100.0, (1.0, 1000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Vertical Scale:")); + char_number(ui, d, cf, "vScale", "%", 100.0, (1.0, 1000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Baseline Shift:")); + char_number(ui, d, cf, "baselineShift", " pt", 1.0, (-5000.0, 5000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Skew:")); + char_number(ui, d, cf, "skew", "°", 1.0, (-85.0, 85.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Language:")); + let opts: Vec<(Value, &str)> = crate::panels::properties::LANGUAGES.iter().map(|l| (json!(l), *l)).collect(); + char_choice(lang, ui, d, cf, "language", &opts); + ui.end_row(); + }); +} + +/// Character Color: the fill or stroke swatch, its tint and the stroke weight. +fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document) { + let stroke = d.s("colorTarget") == "stroke"; + ui.horizontal(|ui| { + for (target, label) in [("fill", "Fill"), ("stroke", "Stroke")] { + if ui.selectable_label((target == "stroke") == stroke, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert("colorTarget".into(), json!(target)); + } + } + }); + let key = if stroke { "stroke" } else { "fill" }; + let cur = cf.get(d, key).as_str().unwrap_or("").to_string(); + egui::ScrollArea::vertical().id_salt(("cscolor", key)).max_height(220.0).show(ui, |ui| { + for sw in &doc.swatches { + let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + crate::widgets::paint_chip(ui.painter(), r, c, g); + if ui.selectable_label(sw.name == cur, &sw.name).clicked() { + // A character style's chosen swatch, clicked again, is no longer set. + cf.set(d, key, if cf.sparse && sw.name == cur { Value::Null } else { json!(sw.name) }); + } + }); + } + }); + egui::Grid::new("cscg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); + char_number(ui, d, cf, if stroke { "strokeTint" } else { "fillTint" }, "%", 100.0, (0.0, 100.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); + char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0)); + ui.end_row(); + }); +} + +/// OpenType Features: the feature switches, figure style and stylistic sets (one `otfFeatures` +/// list: changing any feature sets the whole list). +fn open_type_features(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) { + use designcraft_doc::otf; + let mut list: Vec = + cf.get(d, "otfFeatures").as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let mut changed = false; + egui::Grid::new("csot").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| { + for (i, (tag, label, _)) in otf::TOGGLES.iter().enumerate() { + let mut on = otf::is_on(&list, tag); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() { + otf::set(&mut list, tag, on); + changed = true; + } + if i % 2 == 1 { + ui.end_row(); + } + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Figure Style:")); + let fig = otf::figures(&list); + let shown = otf::FIGURES.iter().find(|f| f.0 == fig).map_or("", |f| f.1); + egui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui(ui, |ui| { + for (id, label, ..) in otf::FIGURES { + if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() && otf::set_figures(&mut list, id) + { + changed = true; + } + } + }); + }); + ui.add_space(4.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "Stylistic Sets")); + let mask = otf::stylistic_sets(&list); + egui::Grid::new("csss").num_columns(10).spacing([2.0, 2.0]).show(ui, |ui| { + for n in 1..=20u32 { + let bit = 1u32 << (n - 1); + if ui.add_sized([28.0, 20.0], egui::Button::selectable(mask & bit != 0, format!("{n}"))).clicked() { + otf::set_stylistic_sets(&mut list, mask ^ bit); + changed = true; + } + if n % 10 == 0 { + ui.end_row(); + } + } + }); + if changed { + cf.set(d, "otfFeatures", json!(list)); + } +} + +/// Underline Options / Strikethrough Options (`key` is `underline` or `strikethrough`). +fn line_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document, key: &str) { + char_check(ui, d, cf, key, crate::i18n::tr(lang, if key == "underline" { "Underline On" } else { "Strikethrough On" })); + ui.add_space(4.0); + egui::Grid::new(("csline", key)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); + char_number(ui, d, cf, &format!("{key}Weight"), " pt", 1.0, (0.0, 800.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); + char_number(ui, d, cf, &format!("{key}Offset"), " pt", 1.0, (-800.0, 800.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); + char_swatch(lang, ui, d, cf, doc, &format!("{key}Color"), Some("(Text Color)")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); + char_number(ui, d, cf, &format!("{key}Tint"), "%", 100.0, (0.0, 100.0)); + ui.end_row(); + }); +} + fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); @@ -2806,6 +3300,143 @@ mod tests { } } + use designcraft_doc::{CharAttrs, CharacterStyle}; + + /// A document with character styles Base (bold) and Emphasis (italic) on the first two letters. + fn styled_app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.character.create", json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("style.character.create", json!({"name": "Emphasis", "chars": {"fontStyle": "Italic"}})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 2})).unwrap(); + app.run("style.character.apply", json!({"name": "Emphasis"})).unwrap(); + app + } + + /// One control-channel request, as the control port or MCP would send it. + fn ctl(app: &mut DesignApp, method: &str, params: Value) -> Value { + let (req, _reply) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &egui::Context::default(), &req) { + crate::control::Outcome::Done(v) => v, + crate::control::Outcome::Screenshot { .. } => Value::Null, + } + } + + fn char_style(app: &DesignApp, name: &str) -> Option { + app.session.active().and_then(|st| st.doc.styles.char_style(name).cloned()) + } + + #[test] + fn character_style_options_set_exactly_the_edited_attributes() { + let mut app = styled_app(); + assert_eq!(ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Emphasis"}}))["ok"], true); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 14})); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.fill", "value": "[Paper]"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "basedOn", "value": "Base"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert!(app.ui.dialog.is_none()); + let st = char_style(&app, "Emphasis").unwrap(); + assert_eq!(st.based_on.as_deref(), Some("Base")); + assert_eq!(st.chars, CharAttrs { font_style: Some("Italic".into()), size: Some(14.0), fill: Some("[Paper]".into()), ..Default::default() }); + } + + #[test] + fn character_style_options_unset_cleared_fields_and_rename_every_use() { + let mut app = styled_app(); + ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Emphasis"}})); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.fontStyle", "value": null})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rename", "value": "Strong"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert!(char_style(&app, "Emphasis").is_none()); + assert!(char_style(&app, "Strong").unwrap().chars.is_empty()); + let st = app.session.active().unwrap(); + let story = st.doc.stories.values().next().unwrap(); + assert!(story.chars.iter().any(|r| r.format.style == "Strong")); + // A taken name keeps the dialog open with the reason. + ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Strong"}})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rename", "value": "Base"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert!(app.ui.dialog.as_ref().is_some_and(|d| !d.s("status").is_empty())); + assert!(char_style(&app, "Strong").is_some()); + } + + #[test] + fn new_character_style_opens_its_options_and_creates_the_style() { + let mut app = styled_app(); + crate::menus::activate(&mut app, "style.character.create", &Value::Null); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.b("new"), d.s("rename").as_str()), ("characterStyleOptions", true, "Character Style 1")); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let st = char_style(&app, "Character Style 1").unwrap(); + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); + crate::menus::activate(&mut app, "style.character.create", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); + } + + #[test] + fn new_paragraph_style_opens_its_options_and_creates_the_style() { + let mut app = styled_app(); + crate::menus::activate(&mut app, "style.paragraph.create", &Value::Null); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.b("new"), d.s("rename").as_str()), ("paragraphStyleOptions", true, "Paragraph Style 1")); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 20})); + ctl(&mut app, "ui.dialog.set", json!({"field": "basedOn", "value": designcraft_doc::BASIC_PARAGRAPH})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); + assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() }); + } + + #[test] + fn style_rows_open_their_options_except_none() { + let mut app = styled_app(); + open_style_options(&mut app, false, "Emphasis"); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.id.as_str(), d.s("name").as_str()), ("characterStyleOptions", "Emphasis")); + open_style_options(&mut app, false, designcraft_doc::NO_CHAR_STYLE); + assert!(app.ui.dialog.is_none()); + open_style_options(&mut app, true, designcraft_doc::BASIC_PARAGRAPH); + assert_eq!(app.ui.dialog.take().unwrap().id, "paragraphStyleOptions"); + } + + #[test] + fn style_options_sections_draw() { + let mut app = styled_app(); + let ctx = egui::Context::default(); + let frame = |app: &mut DesignApp| { + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + for lang in ["", "ar"] { + app.run("app.language", json!({"lang": lang})).unwrap(); + for fields in [json!({"name": "Emphasis"}), json!({"new": true}), json!({"name": "Emphasis", "c.size": null, "c.underline": false})] { + for section in ["general", "chars", "advanced", "color", "openType", "underline", "strikethrough"] { + let mut f = fields.clone(); + f["section"] = json!(section); + app.ui.dialog = Some(Dialog::new("characterStyleOptions", f)); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "{section} stays open"); + } + } + for section in ["general", "chars", "color"] { + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"new": true, "section": section}))); + frame(&mut app); + } + } + } + #[test] fn shortcut_recorder_waits_for_the_key_after_its_modifiers() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6eee4c29a..36a33d8a2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2262,6 +2262,38 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Justify", ["Justify", "Justify", "Justify", "Justify", "两端对齐"]), ("Underline Options", ["Underline Options", "Underline Options", "Underline Options", "Underline Options", "下划线选项"]), ("Strikethrough Options", ["Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "删除线选项"]), + ( + "Character Style Options", + ["Character Style Options", "Character Style Options", "Character Style Options", "Character Style Options", "字符样式选项"], + ), + ( + "Advanced Character Formats", + ["Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "高级字符格式"], + ), + ("OpenType Features", ["OpenType Features", "OpenType Features", "OpenType Features", "OpenType Features", "OpenType 功能"]), + ("Style Settings:", ["Style Settings:", "Style Settings:", "Style Settings:", "Style Settings:", "样式设置:"]), + ("Kerning:", ["Kerning:", "Kerning:", "Kerning:", "Kerning:", "字偶间距:"]), + ("Case:", ["Case:", "Case:", "Case:", "Case:", "大小写:"]), + ( + "OpenType All Small Caps", + ["OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType 全部小型大写字母"], + ), + ("Subscript", ["Subscript", "Subscript", "Subscript", "Subscript", "下标"]), + ("OpenType Subscript", ["OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType 下标"]), + ("OpenType Numerator", ["OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType 分子"]), + ("OpenType Denominator", ["OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType 分母"]), + ("Ligatures", ["Ligatures", "Ligatures", "Ligatures", "Ligatures", "连字"]), + ("No Break", ["No Break", "No Break", "No Break", "No Break", "不换行"]), + ("Horizontal Scale:", ["Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "水平缩放:"]), + ("Vertical Scale:", ["Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "垂直缩放:"]), + ("Baseline Shift:", ["Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "基线偏移:"]), + ("Skew:", ["Skew:", "Skew:", "Skew:", "Skew:", "倾斜:"]), + ("Language:", ["Language:", "Language:", "Language:", "Language:", "语言:"]), + ("Tint:", ["Tint:", "Tint:", "Tint:", "Tint:", "色调:"]), + ("Figure Style:", ["Figure Style:", "Figure Style:", "Figure Style:", "Figure Style:", "数字样式:"]), + ("Underline On", ["Underline On", "Underline On", "Underline On", "Underline On", "启用下划线"]), + ("Strikethrough On", ["Strikethrough On", "Strikethrough On", "Strikethrough On", "Strikethrough On", "启用删除线"]), + ("Edit \"{name}\"…", ["„{name}“ bearbeiten…", "Modifier « {name} »…", "Editar \"{name}\"…", "\"{name}\" を編集…", "编辑\"{name}\"…"]), ( "Discretionary Ligatures", ["Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "自由连字"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 34a50b57e..8625093e9 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -570,6 +570,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Justify", "ضبط"), ("Underline Options", "خيارات التسطير"), ("Strikethrough Options", "خيارات الخط المتوسط"), + ("Character Style Options", "خيارات نمط الأحرف"), + ("Advanced Character Formats", "تنسيقات الأحرف المتقدمة"), + ("OpenType Features", "ميزات OpenType"), + ("Style Settings:", "إعدادات النمط:"), + ("Kerning:", "تقنين الأزواج:"), + ("Case:", "حالة الأحرف:"), + ("OpenType All Small Caps", "كل الأحرف الكبيرة المصغّرة OpenType"), + ("Subscript", "النص المنخفض"), + ("OpenType Subscript", "منخفض OpenType"), + ("OpenType Numerator", "بسط OpenType"), + ("OpenType Denominator", "مقام OpenType"), + ("Ligatures", "الحروف المركبة"), + ("No Break", "بدون فصل"), + ("Horizontal Scale:", "المقياس الأفقي:"), + ("Vertical Scale:", "المقياس الرأسي:"), + ("Baseline Shift:", "إزاحة خط الأساس:"), + ("Skew:", "الإمالة:"), + ("Language:", "اللغة:"), + ("Tint:", "الصبغة:"), + ("Figure Style:", "نمط الأرقام:"), + ("Underline On", "تشغيل التسطير"), + ("Strikethrough On", "تشغيل الخط المتوسط"), + ("Edit \"{name}\"…", "تحرير \"{name}\"…"), ("Discretionary Ligatures", "روابط اختيارية"), ("Fractions", "كسور"), ("Ordinal", "ترتيبي"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index fa7973a71..bb754dd10 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -569,6 +569,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Justify", "Justificar"), ("Underline Options", "Opções de sublinhado"), ("Strikethrough Options", "Opções de tachado"), + ("Character Style Options", "Opções de estilo de caractere"), + ("Advanced Character Formats", "Formatos avançados de caracteres"), + ("OpenType Features", "Recursos OpenType"), + ("Style Settings:", "Configurações do estilo:"), + ("Kerning:", "Kerning:"), + ("Case:", "Caixa:"), + ("OpenType All Small Caps", "Todas em versalete OpenType"), + ("Subscript", "Subscrito"), + ("OpenType Subscript", "Subscrito OpenType"), + ("OpenType Numerator", "Numerador OpenType"), + ("OpenType Denominator", "Denominador OpenType"), + ("Ligatures", "Ligaturas"), + ("No Break", "Sem quebra"), + ("Horizontal Scale:", "Escala horizontal:"), + ("Vertical Scale:", "Escala vertical:"), + ("Baseline Shift:", "Deslocamento da linha de base:"), + ("Skew:", "Inclinação:"), + ("Language:", "Idioma:"), + ("Tint:", "Matiz:"), + ("Figure Style:", "Estilo de algarismos:"), + ("Underline On", "Sublinhado ativado"), + ("Strikethrough On", "Tachado ativado"), + ("Edit \"{name}\"…", "Editar \"{name}\"…"), ("Discretionary Ligatures", "Ligaduras discricionárias"), ("Fractions", "Frações"), ("Ordinal", "Ordinal"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 72c3abe7b..37db2fbb6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1773,6 +1773,20 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); return; } + // New Paragraph/Character Style…: the style options for the new style. + if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") { + crate::dialogs::open_new_style(app, id == "style.paragraph.create"); + return; + } + // Paragraph/Character Style Options…: those of the selected text's style. + if params.is_null() && matches!(id, "style.paragraph.edit" | "style.character.edit") { + let para = id == "style.paragraph.edit"; + let cur = crate::panels::text_attrs(app).and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string)); + if let Some(name) = cur { + crate::dialogs::open_style_options(app, para, &name); + return; + } + } if params.is_null() && ui_label(id).is_none() && let Some(c) = designcraft_engine::find_command(id) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 5d81e295c..09f759c66 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1178,7 +1178,7 @@ fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { } /// Languages offered for text (InDesign-style names). -const LANGUAGES: &[&str] = &[ +pub(crate) const LANGUAGES: &[&str] = &[ "[No Language]", "English: USA", "English: UK", diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index c3412095e..82dc1282a 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -78,8 +78,8 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } -/// One style in the list: click applies (⌥ clears overrides), double-click edits (paragraph), -/// right-click: apply, break link, move to group. +/// One style in the list: click applies (⌥ clears overrides), double-click edits, +/// right-click: edit, apply, break link, move to group. fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if current == Some(n) { @@ -96,8 +96,9 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren egui::FontId::proportional(12.5), t.text, ); - if resp.double_clicked() && para { - app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE }; + if resp.double_clicked() && editable { + crate::dialogs::open_style_options(app, para, n); } else if resp.clicked() { let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; let clear = ui.input(|i| i.modifiers.alt); @@ -105,17 +106,12 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } let kind = if para { "paragraph" } else { "character" }; resp.context_menu(|ui| { - if ui - .button(crate::rtl::widget( - ui, - format!( - "{} \"{}\"", - crate::i18n::tr(&app.ui.language, "Apply"), - if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) } - ), - )) - .clicked() - { + let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }; + if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit \"{name}\"…").replace("{name}", label))).clicked() { + crate::dialogs::open_style_options(app, para, n); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&app.ui.language, "Apply"), label))).clicked() { let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; let _ = app.run(cmd, json!({"name": n})); ui.close(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index dd7f3d8b7..b13627c9c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -134,12 +134,19 @@ impl<'a> NumField<'a> { /// Draw the field; returns the new value when the user commits an edit (Enter, focus loss, /// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset). pub fn show(self, ui: &mut Ui) -> Option { + self.show_or_clear(ui).flatten() + } + + /// Like [`NumField::show`]; a field emptied while it had a value commits `Some(None)` (for + /// values that may be unset, like a character style's attributes). + pub fn show_or_clear(self, ui: &mut Ui) -> Option> { let t = Tokens::get(ui.ctx()); let key = ui.id().with(self.id); let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover()); let step = self.step.unwrap_or_else(|| self.kind.step()); let clamp = |v: f64| v.clamp(self.range.0, self.range.1); let mut out: Option = None; + let mut cleared = false; let mut text_rect = rect; let focused_id = key.with("te"); let focused = ui.memory(|m| m.has_focus(focused_id)); @@ -218,6 +225,8 @@ impl<'a> NumField<'a> { && self.value.is_none_or(|old| (old - v).abs() > 1e-9) { out = Some(v); + } else if buf.trim().is_empty() && self.value.is_some() { + cleared = true; } ui.data_mut(|d| d.remove::(key)); } else if !r.has_focus() { @@ -246,7 +255,7 @@ impl<'a> NumField<'a> { } }); } - out + if cleared { Some(None) } else { out.map(Some) } } } @@ -662,6 +671,36 @@ mod tests { assert_eq!(got, vec![12.0]); } + #[test] + fn emptying_a_clearable_field_clears_it() { + let ctx = egui::Context::default(); + let field = |ui: &mut Ui| NumField::number("c", Some(12.0), " pt", 2).width(80.0).show_or_clear(ui); + let mut rect = Rect::NOTHING; + run(&ctx, vec![], egui::Modifiers::NONE, &mut |ui| { + rect = ui.available_rect_before_wrap(); + field(ui); + }); + let key = |k, m| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }; + let mut frames: Vec> = click(rect.min + vec2(40.0, 10.0), egui::Modifiers::NONE); + frames.push(vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + frames.push(vec![key(egui::Key::Backspace, egui::Modifiers::NONE)]); + frames.push(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + let mut got = vec![]; + for ev in frames { + let m = if ev.iter().any(|e| matches!(e, egui::Event::Key { modifiers, .. } if modifiers.command)) { + egui::Modifiers::COMMAND + } else { + egui::Modifiers::NONE + }; + run(&ctx, ev, m, &mut |ui| { + if let Some(v) = field(ui) { + got.push(v); + } + }); + } + assert_eq!(got, vec![None]); + } + #[test] fn number_kind_formats_and_parses() { let k = NumKind::Number { suffix: "%", decimals: 0 }; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9d5308301..d3b6f62a1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | -| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) | +| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | From 096ddf6085bbedc7b011caa818d0f850b4df8423 Mon Sep 17 00:00:00 2001 From: vpkoskinen <46873698+vpkoskinen@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:16:24 +0300 Subject: [PATCH 4/7] Swatch menus: make the whole row (chip included) clickable and show chips in the Contextual Task Bar (#199) Add one shared swatch_menu_row (full-width clickable chip + name, current highlight, hover) and use it in the Properties Appearance/text menus, the shared swatch_picker (Control bar, Stroke, Character, Paragraph Border/ Shading) and both Contextual Task Bar fill/stroke menus. The Task Bar popups gain a colour chip per swatch, and every swatch menu now applies on a click anywhere on the row, including the chip. Cap the popups at 280 pt so a full-width row keeps the menus InDesign-sized. Label the chip controls and rows for accessibility. Test: clicking a swatch chip in the Appearance Fill menu applies it (the Task Bar uses the same swatch_menu_row helper). Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels/mod.rs | 37 ++++++++++++++---- crates/ui-egui/src/panels/properties.rs | 50 +++++++++++++++++++++---- crates/ui-egui/src/taskbar.rs | 12 +++++- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 301556741..05a73395b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -153,6 +153,34 @@ pub fn preflight_errors(app: &DesignApp) -> usize { n } +/// Height of one swatch-menu row. +const SWATCH_MENU_ROW_H: f32 = 22.0; + +/// One swatch-menu row: a chip and the swatch name; the whole row is clickable (the chip included). +/// `name == current` highlights it. Returns true when the row was clicked. +pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SWATCH_MENU_ROW_H), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, name == current, name)); + if name == current { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let (c, g) = crate::widgets::swatch_colors(doc, name, 1.0); + let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0)); + crate::widgets::paint_chip(ui.painter(), chip, c, g); + crate::rtl::paint( + ui.painter(), + row.min + vec2(28.0, SWATCH_MENU_ROW_H / 2.0), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(12.5), + t.text, + ); + resp.clicked() +} + /// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name. pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Option, on_pick: impl FnOnce(&mut DesignApp, String)) { let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; @@ -175,15 +203,10 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: ); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(200.0); + ui.set_max_width(280.0); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for sw in &doc.swatches { - let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0); - let row = ui.horizontal(|ui| { - let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); - crate::widgets::paint_chip(ui.painter(), cr, c, g); - ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur)) - }); - if row.inner.clicked() { + if swatch_menu_row(ui, &doc, &sw.name, &cur) { picked = Some(sw.name.clone()); ui.close(); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 09f759c66..90fbed0a8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -572,6 +572,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { widgets::paint_chip(ui.painter(), chip, fc, fg); ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); let resp = ui.interact(chip, ui.id().with("fillchip"), Sense::click()).on_hover_text(format!("Fill: {}", i.fill)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); swatch_menu(app, &resp, &doc, &i.fill, |app, n| { let _ = app.run("object.fill", json!({"swatch": n})); }); @@ -591,6 +592,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { widgets::paint_stroke_chip(ui.painter(), chip, sc, egui::Color32::from_gray(20)); ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); let resp = ui.interact(chip, ui.id().with("strokechip"), Sense::click()).on_hover_text(format!("Stroke: {}", i.stroke)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); swatch_menu(app, &resp, &doc, &i.stroke, |app, n| { let _ = app.run("object.stroke", json!({"swatch": n})); }); @@ -667,15 +669,10 @@ fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc let mut picked = None; egui::Popup::menu(resp).show(|ui| { ui.set_min_width(200.0); + ui.set_max_width(280.0); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for sw in &doc.swatches { - let (c, g) = widgets::swatch_colors(doc, &sw.name, 1.0); - let row = ui.horizontal(|ui| { - let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); - widgets::paint_chip(ui.painter(), cr, c, g); - ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur)) - }); - if row.inner.clicked() { + if super::swatch_menu_row(ui, doc, &sw.name, cur) { picked = Some(sw.name.clone()); ui.close(); } @@ -2159,4 +2156,43 @@ mod tests { h.run_steps(2); assert_eq!(language(h.state_mut()), "French"); } + + #[test] + fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + // A graphic frame: the Appearance section shows a single Fill chip. + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let before = app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); + assert_ne!(before, "[Paper]"); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + // Open the Fill menu from the Appearance chip. + h.get_by_label("Fill swatch").click(); + h.run_steps(3); + // Click the *chip* (the row's left edge), not the name. + let name = "[Paper]".to_string(); + let row = h.get_by_label(&name).rect(); + // The row spans the menu (chip + name), not just the name text. + assert!(row.width() > 150.0, "the row is the full menu width, chip included: {row:?}"); + let p = row.min + vec2(8.0, row.height() / 2.0); + h.hover_at(p); + h.drag_at(p); + h.drop_at(p); + h.run_steps(4); + let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); + assert_eq!(after, name, "clicking the swatch chip applies it"); + } } diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 98815423f..9b25afd43 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -160,11 +160,14 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let sw = c["fill"].as_str().unwrap_or("[Black]").to_string(); let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0); let resp = widgets::swatch_chip(ui, 17.0, col, g).on_hover_text(format!("Fill: {sw}")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Task Bar fill")); let mut picked = None; egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(180.0); + ui.set_max_width(280.0); egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for s in &doc.swatches { - if ui.selectable_label(s.name == sw, &s.name).clicked() { + if panels::swatch_menu_row(ui, &doc, &s.name, &sw) { picked = Some(s.name.clone()); ui.close(); } @@ -228,11 +231,16 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { widgets::paint_stroke_chip(ui.painter(), r, col, egui::Color32::from_gray(20)); } let resp = resp.on_hover_text(format!("{}: {sw}", if k == 0 { "Fill" } else { "Stroke" })); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Task Bar {}", if k == 0 { "fill" } else { "stroke" })) + }); let mut picked = None; egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(180.0); + ui.set_max_width(280.0); egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for s in &doc.swatches { - if ui.selectable_label(s.name == sw, &s.name).clicked() { + if panels::swatch_menu_row(ui, &doc, &s.name, &sw) { picked = Some(s.name.clone()); ui.close(); } From 06b4e4e860b7946d6cdc376df0ea3c9b45b7f413 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 10 Oct 2026 08:18:47 +0200 Subject: [PATCH 5/7] Add A6 sheet preset to the welcome page (#248) Co-authored-by: moppeeer <339764117+moppeeer@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 +++ crates/doc/src/build.rs | 1 + crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index c480bd68a..f074153b8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -22,5 +22,8 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.moppeeer] +sync_github_name = true + [people.thaynej] real_name = "Jeffrey Thayne" diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 4d6c3e0b0..c274f2130 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -34,6 +34,7 @@ pub const PRESETS: &[Preset] = &[ Preset { name: "A3", intent: Intent::Print, width: 297.0 * MM, height: 420.0 * MM, units: Unit::Millimeters }, Preset { name: "A4", intent: Intent::Print, width: 210.0 * MM, height: 297.0 * MM, units: Unit::Millimeters }, Preset { name: "A5", intent: Intent::Print, width: 148.0 * MM, height: 210.0 * MM, units: Unit::Millimeters }, + Preset { name: "A6", intent: Intent::Print, width: 105.0 * MM, height: 148.0 * MM, units: Unit::Millimeters }, Preset { name: "B5", intent: Intent::Print, width: 176.0 * MM, height: 250.0 * MM, units: Unit::Millimeters }, Preset { name: "Business Card", intent: Intent::Print, width: 252.0, height: 144.0, units: Unit::Picas }, Preset { name: "Postcard", intent: Intent::Print, width: 432.0, height: 288.0, units: Unit::Picas }, diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index d27390bc9..84a3ccbfc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -678,7 +678,7 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) { )); ui.add_space(8.0); ui.horizontal_wrapped(|ui| { - for p in designcraft_doc::build::PRESETS.iter().take(10) { + for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) { let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click()); let hov = resp.hovered(); ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel }); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 90fbed0a8..68452d954 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -164,6 +164,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("A3", 841.89, 1190.55), ("A4", 595.276, 841.89), ("A5", 419.528, 595.276), + ("A6", 297.638, 419.528), ("B5", 498.898, 708.661), ]; From e161878e4c7744fa06f9a2eede8103cb4c296355 Mon Sep 17 00:00:00 2001 From: ROSSA AI <132106533+rossaai@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:21:25 -0500 Subject: [PATCH 6/7] Hyphenation: Spanish text is hyphenated with Javier Bezos's patterns (#222) Text set in Spanish was not hyphenated at all. It now uses Javier Bezos's Liang patterns, hyph-es.tex 5.0 from hyph-utf8 under MIT, through the hypher crate 0.1.8 with only its alloc and spanish features. The full feature, which embeds 48 languages under several licences, stays off. - hyphen::Lang names the hyphenator for a run: English, or a hypher language. Lang::for_language maps a language name or locale code. - word_breaks_in and hyphen_points_in take the language. word_breaks and hyphen_points stay as the English entry points. - Composition hyphenates a non-English run whose language has patterns and skips the rest. The word cache key includes the language. - NOTICE carries the Bezos copyright line and the MIT text. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 7 +++ NOTICE | 14 ++++++ ROADMAP.md | 2 +- crates/compose/Cargo.toml | 1 + crates/compose/src/hyphen/mod.rs | 80 ++++++++++++++++++++++++++++++-- crates/compose/src/lib.rs | 32 +++++++++---- crates/compose/src/tests.rs | 21 +++++++++ 7 files changed, 141 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c683910c2..3fc1e3274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -911,6 +911,7 @@ dependencies = [ "designcraft-doc", "designcraft-fonts", "designcraft-geom", + "hypher", "log", "miniz_oxide 0.8.9", "proptest", @@ -2085,6 +2086,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hypher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f2b4f6b5da34d400d4c112eadcc14512cc5d04307a8eee182586d9bebd7ce6" + [[package]] name = "icu_collections" version = "2.3.0" diff --git a/NOTICE b/NOTICE index b1d382b04..8b67a99e1 100644 --- a/NOTICE +++ b/NOTICE @@ -18,6 +18,20 @@ list (author, source, license) is in ASSETS.md. - English hyphenation word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns (assets/hyphenation/en-us.pat) are trained from it by DesignCraft and released as CC0-1.0. +- Spanish hyphenation patterns, embedded through the hypher crate (feature "spanish"): + hyph-es.tex 5.0 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1993, 1997 Javier Bezos, 2001-2019 Javier Bezos, CervanTeX. MIT License: + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: The above copyright notice + and this permission notice shall be included in all copies or substantial portions of the + Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE + AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, diff --git a/ROADMAP.md b/ROADMAP.md index bb517b5a3..eaa813d99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,7 +91,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Hyphenation dictionaries for other languages aren't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 83ba81835..012868835 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -18,6 +18,7 @@ designcraft-fonts = { workspace = true } log = { workspace = true } regex = { workspace = true } miniz_oxide = "0.8" +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index 23d182aeb..22644eb70 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -1,6 +1,8 @@ -//! English hyphenation. +//! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the +//! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's +//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE). //! -//! Lookup order for each word: +//! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list //! by Grady Ward (~160k words), stored compactly in `assets/hyphenation/en-us.dic`; simple //! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem; @@ -61,6 +63,33 @@ impl Default for Limits { } } +/// The hyphenator for a run of text. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Lang { + English, + Patterns(hypher::Lang), +} + +impl Lang { + /// The hyphenator for a language name or locale code (see [`designcraft_doc::language_tag`]). + /// None when the language has no bundled hyphenation. + pub fn for_language(language: &str) -> Option { + if language.starts_with("English") { + return Some(Lang::English); + } + let tag = designcraft_doc::language_tag(language)?; + let sub = designcraft_doc::language_subtag(tag); + if sub == "en" { + return Some(Lang::English); + } + let b = sub.as_bytes(); + if b.len() != 2 { + return None; + } + hypher::Lang::from_iso([b[0], b[1]]).map(Lang::Patterns) + } +} + /// Where a word's break points came from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Source { @@ -79,11 +108,25 @@ fn is_word_char(c: char) -> bool { } /// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`). -fn core_breaks(core: &[char]) -> (Vec, Source) { +fn core_breaks(core: &[char], lang: Lang) -> (Vec, Source) { let n = core.len(); if n < 2 { return (vec![], Source::None); } + if let Lang::Patterns(l) = lang { + if core.contains(&'\'') { + return (vec![], Source::None); + } + let s: String = core.iter().collect(); + let mut out = Vec::new(); + let mut at = 0; + let syl: Vec<&str> = hypher::hyphenate_bounded(&s, l, 1, 1).collect(); + for part in &syl[..syl.len().saturating_sub(1)] { + at += part.chars().count(); + out.push(at); + } + return (out, Source::Patterns); + } let dict = Dictionary::en_us(); let s: String = core.iter().collect(); let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::>(); @@ -112,6 +155,11 @@ fn core_breaks(core: &[char]) -> (Vec, Source) { /// case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter run is /// hyphenated separately). No break is ever placed next to an apostrophe or non-letter. pub fn word_breaks(word: &str) -> (Vec, Source) { + word_breaks_in(word, Lang::English) +} + +/// [`word_breaks`] for a language. +pub fn word_breaks_in(word: &str, lang: Lang) -> (Vec, Source) { let chars: Vec = word.chars().collect(); let mut out = Vec::new(); let mut src = Source::None; @@ -133,7 +181,7 @@ pub fn word_breaks(word: &str) -> (Vec, Source) { b -= 1; } let core: Vec = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect(); - let (pts, s) = core_breaks(&core); + let (pts, s) = core_breaks(&core, lang); if src == Source::None { src = s; } @@ -146,8 +194,13 @@ pub fn word_breaks(word: &str) -> (Vec, Source) { /// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with /// the paragraph limits applied to each letter run. pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { + hyphen_points_in(word, lim, Lang::English) +} + +/// [`hyphen_points`] for a language. +pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec { let chars: Vec = word.chars().collect(); - let (pts, _) = word_breaks(word); + let (pts, _) = word_breaks_in(word, lang); if pts.is_empty() { return pts; } @@ -319,6 +372,23 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } + #[test] + fn spanish_uses_the_bezos_patterns() { + let es = Lang::for_language("Spanish").unwrap(); + assert_eq!(es, Lang::for_language("es_ES").unwrap()); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), es); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("transición"), "tran-si-ción"); + assert_eq!(h("pingüino"), "pin-güino"); + assert_eq!(h("Construcción"), "Cons-truc-ción"); + assert_eq!(h("desesperación"), "de-ses-pe-ra-ción"); + assert_eq!(h("teatro"), "tea-tro", "a vowel run stays whole"); + assert_eq!(h("calle"), "ca-lle", "ll stays whole"); + assert!(Lang::for_language("French").is_none(), "only languages built in"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8f34d1177..b48531d93 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2284,9 +2284,14 @@ pub fn is_english(language: &str) -> bool { language.starts_with("English") || designcraft_doc::language_tag(language).is_some_and(|t| designcraft_doc::language_subtag(t) == "en") } -/// Byte ranges of paragraph `prange` set in a language other than English (no English -/// hyphenation there). -fn foreign_ranges(doc: &Document, story: &designcraft_doc::Story, prange: Range, base: &designcraft_doc::CharProps) -> Vec> { +/// Byte ranges of paragraph `prange` set in a language other than English, with the hyphenator +/// for that language (None: not hyphenated). +fn foreign_ranges( + doc: &Document, + story: &designcraft_doc::Story, + prange: Range, + base: &designcraft_doc::CharProps, +) -> Vec<(Range, Option)> { if is_english(&base.language) && story.runs().all(|(_, f)| f.over.language.as_deref().is_none_or(is_english) && f.style == designcraft_doc::story::NO_CHAR_STYLE) { @@ -2295,12 +2300,19 @@ fn foreign_ranges(doc: &Document, story: &designcraft_doc::Story, prange: Range< story .runs() .filter(|(r, _)| r.start < prange.end && r.end > prange.start) - .filter(|(_, f)| !is_english(&doc.styles.resolve_char(base, f).language)) - .map(|(r, _)| r) + .map(|(r, f)| (r, doc.styles.resolve_char(base, f).language.clone())) + .filter(|(_, l)| !is_english(l)) + .map(|(r, l)| (r, hyphen::Lang::for_language(&l))) .collect() } -fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: &HashMap>, foreign: &[Range]) -> Vec { +fn hyphenation_points( + text: &str, + glyphs: &[Glyph], + pp: &ParaProps, + exceptions: &HashMap>, + foreign: &[(Range, Option)], +) -> Vec { let mut out = vec![false; glyphs.len()]; if !pp.hyphenate { return out; @@ -2312,7 +2324,7 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: capitalized: pp.hyph_capitalized, }; // Words repeat a lot: remember their points per set of limits. - static CACHE: Mutex, Box<[usize]>>)>> = Mutex::new(Vec::new()); + 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 idx = match guard.iter().position(|e| e.0 == key) { @@ -2351,13 +2363,13 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: let word = &text[a..b]; // Do not hyphenate the paragraph's last word unless allowed. let is_last_word = !pp.hyph_last_word && glyphs[j..].iter().all(|g| !g.is_letter()); - let in_foreign = foreign.iter().any(|r| r.contains(&a)); - if !is_last_word && !in_foreign { + let lang = foreign.iter().find(|(r, _)| r.contains(&a)).map_or(Some(hyphen::Lang::English), |(_, l)| *l); + if !is_last_word && let Some(lang) = lang { let user = (!exceptions.is_empty()).then(|| exceptions.get(&word.to_lowercase())).flatten(); let pts: &[usize] = match user { // User dictionary exceptions win over the patterns and the built-in list. Some(v) => v, - None => cache.entry(word.into()).or_insert_with(|| hyphen::hyphen_points(word, &lim).into_boxed_slice()), + None => cache.entry((lang, word.into())).or_insert_with(|| hyphen::hyphen_points_in(word, &lim, lang).into_boxed_slice()), }; for &p in pts.iter() { // char index p → byte → glyph whose cluster ends at that byte. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 849d3a4e4..70b0198ff 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1107,6 +1107,27 @@ fn only_english_text_gets_english_hyphenation() { assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } +#[test] +fn spanish_text_gets_spanish_hyphenation() { + let text = "La transición democrática fue una construcción colectiva extraordinariamente compleja y desesperadamente necesaria."; + let narrow = Rect::new(36.0, 36.0, 120.0, 700.0); + let (mut d, sid, _) = doc_with(text, narrow, ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some("Spanish".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let hy: Vec<&str> = lines.iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect(); + assert!(!hy.is_empty(), "Spanish hyphenates"); + for l in &hy { + let last: String = l.chars().rev().take_while(|c| c.is_alphabetic()).collect::>().into_iter().rev().collect(); + let whole = text[text.find(l).unwrap() + l.len() - last.len()..].split(|c: char| !c.is_alphabetic()).next().unwrap(); + let es = hyphen::Lang::for_language("Spanish").unwrap(); + let pts = hyphen::hyphen_points_in(whole, &hyphen::Limits::default(), es); + assert!(pts.contains(&last.chars().count()), "break {last}- in {whole} is a Bezos point"); + } +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); From 464a442802a6d3af21c2e33b3adddcb868642ffe Mon Sep 17 00:00:00 2001 From: ROSSA AI <132106533+rossaai@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:22:52 -0500 Subject: [PATCH 7/7] PDF/X-4: RGB images carry sRGB, no image interpolation, pdf:Trapped in XMP (#223) - An RGB image with no ICC profile of its own was written as DeviceRGB under the CMYK output intent, and the export only warned. In PDF/X-4 it now carries the sRGB profile (assets/icc/sRGB-v2-magic.icc, ICC v2.1, CC0). JPEGs stay passed through. Untagged PNG, GIF and WebP, and the decoded TIFF, BMP and PSD fallback, go in as RGB with the profile. A re-encoded JPEG keeps the source's RGB profile or gets sRGB. Grey images and images with their own profile are unchanged. - Every image constructor asked for interpolation. PDF/X-4 and PDF/A exports now write images without it, which also removes the ImageInterpolation error that stopped PDF/A exports with a placed image. Plain exports keep it. - make_pdfx4 writes pdf:Trapped False into the XMP, matching /Trapped/False in the document information. - check_pdfx4 reports an image that asks for interpolation. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ASSETS.md | 1 + ROADMAP.md | 2 +- assets/icc/sRGB-v2-magic.icc | Bin 0 -> 736 bytes crates/pdf/src/export.rs | 137 ++++++++++++++++++++++++++++++---- crates/pdf/src/lib.rs | 4 +- crates/pdf/src/pdfx.rs | 140 +++++++++++++++++++++++++++++++++-- 6 files changed, 262 insertions(+), 22 deletions(-) create mode 100644 assets/icc/sRGB-v2-magic.icc diff --git a/ASSETS.md b/ASSETS.md index 66479bd7b..76052b5d1 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -14,6 +14,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `crates/ui-egui/src/i18n/uk.rs` (15 common phrases) | DeckCraft contributors | https://github.com/storytold/deckcraft/blob/3a45c614a409349fc13d449f35bd98f3321f0df5/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary; validation dictionaries are external and are not bundled | | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `https://www.gutenberg.org/files/3204/files/mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`) | Public Domain ("Public Domain material by grant from the author, January, 2001"; Project Gutenberg: "Public domain in the USA") | ~165k words with break points, front-coded + deflate; regenerate with `cargo run --release -p designcraft-compose --example hyphgen -- mhyph.txt assets/hyphenation` | | `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from `en-us.dic`'s source list by `crates/compose/src/hyphen/patgen.rs` (`examples/hyphgen.rs`) | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX/hyph-utf8 patterns used) | +| `assets/icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/saucecontrol/Compact-ICC-Profiles, `profiles/sRGB-v2-magic.icc` at `abb1b6f48570b4eb0fabd8566e517dca07e617d4`, SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | Public Domain (CC0-1.0) | ICC v2.1 sRGB profile, 736 bytes. PDF/X-4 export tags RGB images that have no profile of their own with it | | `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface | | `assets/fonts/Inter-Regular.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface | | `assets/fonts/Inter-SemiBold.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface | diff --git a/ROADMAP.md b/ROADMAP.md index eaa813d99..5c5a7a99a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,7 +109,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left. - Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage. - Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update. -- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. +- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. - Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections. - Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. diff --git a/assets/icc/sRGB-v2-magic.icc b/assets/icc/sRGB-v2-magic.icc new file mode 100644 index 0000000000000000000000000000000000000000..b29f729dbff1d6eae55aa62418f79b42b1fce9e8 GIT binary patch literal 736 zcmZQzV0w^~oLkH!z`&53S5g$@?xYYA8KuDfh=G|wgn^TRk3lvuxwybL*e!$s2#OQS zi<3)=azJbZ_;!tf5yZQ!n~|88GI^89O1=$BT{Hg9TE95Gb2dU9Ru*SUYH>19<^vGN zCl?fzFfcIk0NFg{B?Tb11duIK1auUTtpj4GL)Z==b`pdQa+^p|NRTs7O$HEGq$9DD zkl0YWfXW$Ii-Dd6F<_u1wE}1#1B0`(0Rt!uAmX1xzU%w5_dnW^f#4UuF3Q!$?D+9x}Bgv&jWf1ciwgG7w1`CD|h608jh7}A)7#=b*Fe)*+ zG3GK(Wjx6Efk}ZWl&PEP2s0zIJ#!QDF%~|SNS5U+KUsZPm$3e0i(%WxuE^fS{)Ho% z<0_{o=V2~;uKnCj+$VWLcpmbW@Nx1j;&e!5TP-eaZJj{f8ogEe_YB31 zf{dq_+%!`$FSa;lrC?oSbKlO@euJZ;(=-Q*XRMyhH@9N`^F`51t}PE+d3#Ozy6+qNw`grUv$JH6;=Zc~dyn`W v7dUzA% { let asset = self.doc.assets.get(&id)?.clone(); let data = asset.data.clone(); let fmt = image::guess_format(&data).ok(); + let mut mode = RasterMode::for_standard(self.opts.standard); + mode.interpolate &= self.interpolate; let img = match fmt { - Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(), + Some(image::ImageFormat::Jpeg) => jpeg(data.clone().into(), || embedded_icc(&data), mode), // A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print // as four-colour black). PDF/A exports are RGB only. - Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) { + Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, mode) { Some(img) => Some(img), None => { self.warn(format!( "{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)", asset.name )); - lossless(&data, fmt, self.interpolate) + lossless(&data, fmt, mode) } }, - _ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)), - _ => lossless(&data, fmt, self.interpolate), + _ if self.opts.compress_images => recompress(&data, mode).or_else(|| lossless(&data, fmt, mode)), + _ => lossless(&data, fmt, mode), }; if img.is_none() { self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); @@ -1301,24 +1303,130 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { Some(out) } -fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option { +/// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4. +pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc"); + +/// How raster images are written for the export's standard. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RasterMode { + /// `/Interpolate true`: smoother upscaling in viewers. PDF/X-4 and PDF/A don't allow it. + interpolate: bool, + /// Give RGB images with no ICC profile of their own the sRGB profile: under PDF/X-4's CMYK + /// output intent, RGB must be colour-managed. + tag_rgb: bool, +} + +impl RasterMode { + fn for_standard(standard: Standard) -> Self { + Self { interpolate: standard == Standard::None, tag_rgb: standard == Standard::PdfX4 } + } +} + +/// The ICC profile embedded in an image file, if it has one. +fn embedded_icc(data: &[u8]) -> Option> { + use image::ImageDecoder; + let mut dec = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_decoder().ok()?; + dec.icc_profile().ok().flatten() +} + +/// Whether an ICC profile describes RGB (its colour space signature, header bytes 16 to 20). +fn is_rgb_icc(icc: &[u8]) -> bool { + icc.get(16..20) == Some(b"RGB ".as_slice()) +} + +/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets +/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG +/// without a profile stays DeviceGray or DeviceCMYK. +fn jpeg(data: krilla::Data, icc: impl FnOnce() -> Option>, mode: RasterMode) -> Option { + if !mode.tag_rgb { + return Image::from_jpeg(data, mode.interpolate).ok(); + } + let icc: krilla::Data = match icc() { + Some(own) => own.into(), + None => SRGB_ICC.into(), + }; + Image::from_jpeg_with_icc(data, Some(icc), mode.interpolate).ok() +} + +/// Whether krilla would write this PNG, GIF or WebP as untagged RGB: not grey, and no profile of +/// its own. +fn untagged_rgb(data: &[u8]) -> bool { + use image::ImageDecoder; + let Some(mut dec) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok().and_then(|r| r.into_decoder().ok()) else { + return false; + }; + let grey = matches!(dec.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16); + !grey && dec.icc_profile().ok().flatten().is_none() +} + +fn lossless(data: &Arc>, fmt: Option, mode: RasterMode) -> Option { + let i = mode.interpolate; // krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole // export when the PDF is written. Decoding it first means it is skipped, like any other // image that can't be decoded. let rgba = designcraft_images::decode_rgba(data)?; let direct = match fmt { - Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(), - Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(), - Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::Png | image::ImageFormat::Gif | image::ImageFormat::WebP) if mode.tag_rgb && untagged_rgb(data) => None, + Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), i).ok(), + Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), i).ok(), + Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), i).ok(), _ => None, }; direct.or_else(|| { - // TIFF, BMP, PSD composites… + // TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB. + if mode.tag_rgb { + return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok()); + } let (w, h) = rgba.dimensions(); Some(Image::from_rgba8(rgba.into_raw(), w, h)) }) } +/// An RGB raster tagged with the sRGB profile, for PDF/X-4. +#[derive(Clone, Hash)] +struct SrgbImage { + rgb: Arc>, + /// Only when some pixel is not opaque. + alpha: Option>>, + size: (u32, u32), +} + +impl SrgbImage { + fn new(rgba: image::RgbaImage) -> Option { + let size = rgba.dimensions(); + let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?; + let mut rgb = Vec::with_capacity(pixels.checked_mul(3)?); + let mut alpha = Vec::with_capacity(pixels); + for p in rgba.pixels() { + rgb.extend_from_slice(&p.0[..3]); + alpha.push(p.0[3]); + } + let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha)); + Some(Self { rgb: Arc::new(rgb), alpha, size }) + } +} + +impl krilla::image::CustomImage for SrgbImage { + fn color_channel(&self) -> &[u8] { + &self.rgb + } + fn alpha_channel(&self) -> Option<&[u8]> { + self.alpha.as_deref().map(Vec::as_slice) + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + self.size + } + fn icc_profile(&self) -> Option<&[u8]> { + Some(SRGB_ICC) + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Rgb + } +} + /// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a /// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for /// the output intent's press, and tagging them with another CMYK profile would let a RIP @@ -1348,18 +1456,19 @@ impl krilla::image::CustomImage for CmykImage { } /// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK. -fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option { +fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option { let r = designcraft_images::decode_cmyk_tiff(data)?; // krilla panics when the channel lengths don't match the size. let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?; if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) { return None; } - Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok() + Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok() } /// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -fn recompress(data: &[u8], interpolate: bool) -> Option { +/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB. +fn recompress(data: &[u8], mode: RasterMode) -> Option { let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -1368,5 +1477,5 @@ fn recompress(data: &[u8], interpolate: bool) -> Option { let mut buf = Vec::new(); let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90); image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; - Image::from_jpeg(buf.into(), interpolate).ok() + jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index d2fbba7ba..2881ede67 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -15,7 +15,9 @@ //! Gradients with mixed output colour spaces are converted to RGB with an export warning; //! homogeneous gradients keep their existing colour space (subject to PDF/A conversion). //! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded -//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). +//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). PDF/X-4 and PDF/A +//! images are written without interpolation. In PDF/X-4 an RGB image without a profile of its own +//! carries sRGB (`assets/icc/sRGB-v2-magic.icc`). //! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a //! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the //! right characters), so text is selectable and searchable. diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index ad3a140ce..3218ffc76 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -84,7 +84,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { i } }; - // XMP: the PDF/X identification schema. + // XMP: the PDF/X identification schema, and the trapping state that Info also states. if let Some(meta) = int_after(&cat, "/Metadata") { let r = object(pdf, meta)?; let body = &pdf[r]; @@ -96,7 +96,10 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { if dict.contains("/Filter") { return None; } - let add = "PDF/X-4"; + let add = concat!( + "PDF/X-4", + "False" + ); let xml = xml.replacen("", &format!("{add}"), 1); let dict = { let i = dict.find("/Length")?; @@ -213,6 +216,9 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") { issues.push("the XMP metadata doesn't name PDF/X-4".into()); } + if text.contains("/Interpolate true") { + issues.push("an image asks for interpolation".into()); + } if text.contains("/Encrypt") { issues.push("the file is encrypted".into()); } @@ -538,12 +544,16 @@ mod tests { /// A one-page PDF 1.6 holding a CMYK image, as another app would hand over a placed graphic. fn pdf_with_cmyk_image() -> Vec { + pdf_with_image(krilla::image::Image::from_custom(CmykImage([0u8, 0, 0, 255].repeat(8)), false).unwrap()) + } + + /// A one-page PDF 1.6 holding `img`. + fn pdf_with_image(img: krilla::image::Image) -> Vec { let configuration = krilla::configure::ConfigurationBuilder::new().with_version(krilla::configure::PdfVersion::Pdf16).finish().unwrap(); let mut pdf = krilla::Document::new_with(krilla::SerializeSettings { configuration, ..Default::default() }); let size = krilla::geom::Size::from_wh(100.0, 50.0).unwrap(); let mut page = pdf.start_page_with(krilla::page::PageSettings::new(size)); let mut s = page.surface(); - let img = krilla::image::Image::from_custom(CmykImage([0u8, 0, 0, 255].repeat(8)), false).unwrap(); s.draw_image(img, size); s.finish(); page.finish(); @@ -572,12 +582,13 @@ mod tests { out } - /// A placed RGB PNG went into PDF/X-4 as DeviceRGB under the CMYK output intent with no - /// warning (#35's fallback). + /// A DeviceRGB image under the CMYK output intent is reported. A placed PDF carries its + /// images into the export unchanged. #[test] fn device_rgb_image_is_flagged() { let mut d = Document::new(&NewDocument::default()); - place(&mut d, "photo.png", png(false, None), (4.0, 2.0), (72.0, 72.0)); + let photo = krilla::image::Image::from_png(png(false, None).into(), false).unwrap(); + place(&mut d, "photo.pdf", pdf_with_image(photo), (100.0, 50.0), (72.0, 72.0)); let r = x4(&d); assert_eq!(image_spaces(&r.bytes), ["DeviceRGB"]); let rgb: Vec<&String> = r.warnings.iter().filter(|w| w.contains("RGB")).collect(); @@ -665,4 +676,121 @@ mod tests { let grey = check_pdfx4(&tiny_pdf("/Resources<<>>", "q BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 80> EI Q")); assert!(!grey.iter().any(|i| i.contains("RGB")), "{grey:?}"); } + + /// A 4 × 2 RGB image in every raster path the exporter has, none with a profile. Each has + /// its own colour, so no two share an image XObject. + fn rgb_rasters() -> Vec<(&'static str, Vec)> { + let px = |c: [u8; 3]| c.repeat(8); + let encode = |f: &dyn Fn(&mut Vec)| { + let mut b = Vec::new(); + f(&mut b); + b + }; + let jpeg = encode(&|b| image::codecs::jpeg::JpegEncoder::new(b).write_image(&px([0, 0, 255]), 4, 2, ExtendedColorType::Rgb8).unwrap()); + let gif = encode(&|b| image::codecs::gif::GifEncoder::new(b).encode(&[0u8, 255, 0, 255].repeat(8), 4, 2, ExtendedColorType::Rgba8).unwrap()); + let webp = + encode(&|b| image::codecs::webp::WebPEncoder::new_lossless(b).write_image(&px([255, 255, 0]), 4, 2, ExtendedColorType::Rgb8).unwrap()); + let tiff = encode(&|b| { + image::codecs::tiff::TiffEncoder::new(std::io::Cursor::new(b)).write_image(&px([0, 255, 255]), 4, 2, ExtendedColorType::Rgb8).unwrap() + }); + vec![("photo.png", png(false, None)), ("photo.jpg", jpeg), ("photo.gif", gif), ("photo.webp", webp), ("photo.tif", tiff)] + } + + /// The decoded `/ICCBased` profile of every image XObject that has one. + fn image_profiles(bytes: &[u8]) -> Vec> { + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + pdf.objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::(b"Subtype").is_some_and(|n| n.as_str() == "Image")) + .filter_map(|s| match s.dict().get::(b"ColorSpace")?.iter::().nth(1)? { + Object::Stream(p) => Some(p.decoded().ok()?.into_owned()), + _ => None, + }) + .collect() + } + + /// PDF/X-4 wrote placed RGB images as DeviceRGB under the CMYK output intent, and only warned. + /// Each raster path now tags them with sRGB, re-encoded as JPEG or not. A plain export keeps + /// DeviceRGB. + #[test] + fn rgb_images_carry_srgb_in_pdfx4() { + let mut d = Document::new(&NewDocument::default()); + for (i, (name, data)) in rgb_rasters().into_iter().enumerate() { + place(&mut d, name, data, (4.0, 2.0), (72.0, 72.0 + 60.0 * i as f64)); + } + for compress_images in [false, true] { + let opts = PdfOptions { standard: Standard::PdfX4, compress_images, ..Default::default() }; + let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap(); + assert!(r.warnings.is_empty(), "compress_images {compress_images}: {:?}", r.warnings); + assert_eq!(image_spaces(&r.bytes), ["[ICCBased"; 5], "compress_images {compress_images}"); + let profiles = image_profiles(&r.bytes); + assert_eq!(profiles.len(), 5); + assert!(profiles.iter().all(|p| p == crate::export::SRGB_ICC), "every image carries the sRGB profile"); + } + let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert_eq!(image_spaces(&plain.bytes), ["DeviceRGB"; 5]); + } + + /// An image's own profile and a grey image are left as they are in PDF/X-4. + #[test] + fn own_profiles_and_grey_stay() { + let mut d = Document::new(&NewDocument::default()); + place(&mut d, "grey.png", png(true, None), (4.0, 2.0), (72.0, 72.0)); + place(&mut d, "tagged.png", png(false, Some(rgb_icc())), (4.0, 2.0), (72.0, 172.0)); + let r = x4(&d); + assert_eq!(image_spaces(&r.bytes), ["DeviceGray", "[ICCBased"]); + assert_eq!(image_profiles(&r.bytes), [rgb_icc()]); + } + + /// Every image constructor asked for interpolation, which PDF/X-4 and PDF/A forbid: PDF/A + /// exports with a placed image failed (#71). Plain exports keep it. + #[test] + fn no_interpolation_in_pdfx4_or_pdfa() { + let mut d = Document::new(&NewDocument::default()); + for (i, (name, data)) in rgb_rasters().into_iter().enumerate() { + place(&mut d, name, data, (4.0, 2.0), (72.0, 72.0 + 60.0 * i as f64)); + } + let interpolated = |b: &[u8]| String::from_utf8_lossy(b).matches("/Interpolate true").count(); + for compress_images in [false, true] { + for standard in [Standard::PdfX4, Standard::PdfA2b] { + let opts = PdfOptions { standard, compress_images, ..Default::default() }; + let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap_or_else(|e| panic!("{standard:?}: {e:?}")); + assert_eq!(interpolated(&r.bytes), 0, "{standard:?}, compress_images {compress_images}"); + } + let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + // The TIFF goes through the decoded fallback, which never asked for interpolation, + // unless it is re-encoded as JPEG. + assert_eq!(interpolated(&plain.bytes), if compress_images { 5 } else { 4 }, "compress_images {compress_images}"); + assert!(check_pdfx4(&plain.bytes).iter().any(|i| i == "an image asks for interpolation")); + } + let tif = cmyk_tiff(); + let mut c = Document::new(&NewDocument::default()); + place(&mut c, "scan.tif", tif, (4.0, 2.0), (72.0, 72.0)); + assert_eq!(interpolated(&x4(&c).bytes), 0, "CMYK TIFF"); + } + + /// A 4 × 2 CMYK TIFF, 100% K. + fn cmyk_tiff() -> Vec { + let mut b = Vec::new(); + tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)) + .unwrap() + .write_image::(4, 2, &[0u8, 0, 0, 255].repeat(8)) + .unwrap(); + b + } + + /// The XMP states the trapping state as the document information does. + #[test] + fn xmp_states_trapped() { + let r = x4(&Document::new(&NewDocument::default())); + let pdf = hayro_syntax::Pdf::new(r.bytes.clone()).unwrap(); + let xref = pdf.xref(); + let catalog = xref.get::(xref.root_id()).unwrap(); + let xmp = catalog.get::(b"Metadata").unwrap().decoded().unwrap().into_owned(); + let xmp = String::from_utf8_lossy(&xmp); + assert!(xmp.contains("xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">False"), "{xmp}"); + assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } }