collective

Browse / github.com/storytold/cadcraft / patch

Batch Plot across open drawings, AutoCAD .ctb/.stb plot style tables, Plot Preview zoom, paper units

Batch Plot across open drawings, AutoCAD .ctb/.stb plot style tables, Plot Preview zoom, paper units Split out of #2034 (the AutoCAD-parity UI work), as asked in review: one theme per PR. #2034 stays open as the source. **Builds on the icons (`split/icons`) and Hebrew interface (`split/rtl-fields`) PRs.** Until they merge this PR also shows their commits; this part is the last commit. Feature overview with screenshots (PDF): https://github.com/storytold/cadcraft/pull/2034#issuecomment-6436084948 ## What Printing real projects: - **Batch Plot** (`PUBLISH`, `batchplot.rs`): a print manager. Sheets of any open drawing in a list that can be reordered (drag or the Move buttons), repeated or r

Status
open
Score
0.00
Adopters
0 other operators
Author
luminatiguy (imported from pull request #2328, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+1882 −107 in 29 files
Review
pending
Shared
2026-10-28
Id
6af5adfd8d7989a590df92ea

How it went

No other operator has reported using it yet.

Diff

raw · apps/cadcraft-cli/src/main.rs apps/cadcraft/src/main.rs crates/engine/src/cmd/exchange.rs crates/engine/src/cmd/file.rs crates/engine/src/cmd/layout/plotstyle.rs crates/engine/src/cmd/view.rs crates/io/src/dxf_read/entities.rs crates/io/src/dxf_read/tables.rs crates/io/src/lib.rs crates/io/src/pdf.rs crates/io/src/pia.rs crates/io/src/plotter.rs crates/io/tests/it/paperview_marker.rs crates/io/tests/it/tables_objects_roundtrip.rs crates/render/src/paper.rs crates/ui-egui/src/batchplot.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/closing.rs crates/ui-egui/src/cmdline.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/exchange.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/palettes.rs crates/ui-egui/tests/it/clipboard_between_drawings.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/rtl_names.rs

diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs--- a/apps/cadcraft-cli/src/main.rs+++ b/apps/cadcraft-cli/src/main.rs@@ -177,6 +177,8 @@ fn install_io() {         write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),         plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),     });+    cadcraft_engine::cmd::file::set_plot_style_reader(cadcraft_io::pia::read);+    cadcraft_engine::cmd::file::set_plot_book(|items, opts| cadcraft_io::plotter::plot_book(items, opts).map_err(|e| e.to_string()));     cadcraft_engine::cmd::file::set_write_as(|d, name, o| {         cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())     });diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs--- a/apps/cadcraft/src/main.rs+++ b/apps/cadcraft/src/main.rs@@ -139,6 +139,8 @@ pub fn install_io() {         write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),         plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),     });+    cadcraft_engine::cmd::file::set_plot_style_reader(cadcraft_io::pia::read);+    cadcraft_engine::cmd::file::set_plot_book(|items, opts| cadcraft_io::plotter::plot_book(items, opts).map_err(|e| e.to_string()));     cadcraft_engine::cmd::file::set_write_as(|d, name, o| {         cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())     });diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs--- a/crates/engine/src/cmd/exchange.rs+++ b/crates/engine/src/cmd/exchange.rs@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {             .noundo(),         CommandSpec::new("publish", "Batch Publish...", run_publish)             .menu(&["File", "Batch Publish..."])-            .params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")+            .params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order), sheets?: [{drawing?: index of an open drawing (default the current one), layout: name | \"Model\"}] (in place of layouts; a sheet may repeat), combine?: bool (one PDF of all the sheets, at `path` itself)} → {sheets: [{layout, bytes, path? | data}]}")             .noundo(),         CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook)             .menu(&["File", "Sheet Books to PDF..."])@@ -384,7 +384,154 @@ fn file_part(name: &str) -> String {     name.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') { c } else { '_' }).collect() } +/// One sheet of a PUBLISH list: an open drawing (index in [`Session::docs`]) and a layout of it.+struct PubSheet {+    doc: usize,+    space: Space,+}++/// The `sheets` list of PUBLISH: each names an open drawing and one of its layouts.+fn publish_sheets(s: &Session, v: &Value) -> Result<Vec<PubSheet>> {+    let a = v.as_array().ok_or_else(|| bad("publish", "`sheets` must be a list of {drawing?, layout}"))?;+    if a.len() > MAX_SHEETS {+        return Err(bad("publish", format!("at most {MAX_SHEETS} sheets can be published at once")));+    }+    a.iter()+        .map(|it| {+            let doc = match it.get("drawing") {+                None | Some(Value::Null) => s.active,+                Some(n) => n+                    .as_u64()+                    .and_then(|n| usize::try_from(n).ok())+                    .ok_or_else(|| bad("publish", "`drawing` must be the index of an open drawing"))?,+            };+            let d = s.docs.get(doc).ok_or_else(|| bad("publish", format!("no open drawing {doc}")))?;+            let name = it.get("layout").and_then(Value::as_str).ok_or_else(|| bad("publish", "each sheet needs a `layout`"))?;+            let space = if name.eq_ignore_ascii_case("model") {+                Space::Model+            } else {+                Space::Paper(d.doc.layout(name).map(|l| l.name.clone()).ok_or_else(|| bad("publish", format!("{}: no layout `{name}`", d.title)))?)+            };+            Ok(PubSheet { doc, space })+        })+        .collect()+}++/// Write `bytes` to `path` through a temporary file, as PLOT does.+fn write_pdf(path: &str, bytes: &[u8]) -> Result<()> {+    #[cfg(not(target_arch = "wasm32"))]+    {+        // Through an unpredictable temporary file, as files are saved: a planted symlink at a+        // fixed temporary name can't redirect the write (CWE-59).+        crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("publish", format!("{path}: {e}")))+    }+    #[cfg(target_arch = "wasm32")]+    {+        let _ = (path, bytes);+        Err(bad("publish", "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))+    }+}++/// PUBLISH of a `sheets` list: sheets of any open drawing, in order, into one PDF (`combine`) or+/// one PDF each.+fn publish_list(s: &mut Session, p: &Value, list: Vec<PubSheet>) -> Result<Value> {+    if list.is_empty() {+        return Err(bad("publish", "there are no sheets to publish"));+    }+    let path = str_param(p, "path").map(str::to_string);+    let several = list.iter().any(|x| x.doc != list[0].doc);+    if p.get("combine").and_then(Value::as_bool) == Some(true) {+        let hook = super::file::plot_book().ok_or_else(|| bad("publish", "combining sheets is not available in this build"))?;+        let items: Vec<(&Drawing, Space)> = list.iter().map(|x| (&*s.docs[x.doc].doc, x.space.clone())).collect();+        let bytes = hook(&items, &json!({})).map_err(|e| bad("publish", e))?;+        let r = match &path {+            Some(path) => {+                write_pdf(path, &bytes)?;+                json!({ "path": path, "bytes": bytes.len(), "pages": list.len() })+            }+            None => json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "pages": list.len() }),+        };+        let message = match &path {+            Some(path) => format!("Published {} sheet(s) to {path}", list.len()),+            None => format!("Published {} sheet(s)", list.len()),+        };+        return Ok(json!({ "sheets": [r], "message": message }));+    }+    let base = path.as_deref().map(|path| {+        let pb = std::path::Path::new(path);+        let stem = pb.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Drawing".into());+        (pb.parent().map(std::path::Path::to_path_buf).unwrap_or_default(), stem)+    });+    let back = s.active;+    let mut used = std::collections::HashSet::new();+    let mut out = Vec::new();+    let mut failed = None;+    for x in &list {+        let name = x.space.tab_name().to_string();+        let mut q = json!({ "layout": name });+        if let Some((dir, stem)) = &base {+            // Sheets of other drawings carry the drawing's name; a repeated sheet is numbered.+            let mut file = if several {+                let title = std::path::Path::new(&s.docs[x.doc].title).file_stem().map(|t| t.to_string_lossy().to_string()).unwrap_or_default();+                format!("{stem}-{}-{}", file_part(&title), file_part(&name))+            } else {+                format!("{stem}-{}", file_part(&name))+            };+            let first = file.clone();+            let mut n = 1;+            while !used.insert(file.clone()) {+                n += 1;+                file = format!("{first}-{n}");+            }+            q["path"] = json!(dir.join(format!("{file}.pdf")).to_string_lossy());+        }+        s.active = x.doc;+        let r = s.execute("plot", &q);+        s.active = back;+        match r {+            Ok(mut r) => {+                if let Some(o) = r.as_object_mut() {+                    o.insert("layout".into(), json!(name));+                    o.insert("drawing".into(), json!(s.docs[x.doc].title));+                }+                out.push(r);+            }+            Err(e) => {+                failed = Some(bad("publish", format!("{}: {name}: {e}", s.docs[x.doc].title)));+                break;+            }+        }+    }+    if let Some(e) = failed {+        return Err(e);+    }+    let message = match &base {+        Some(_) => format!("Published {} sheet(s): {}", out.len(), out.iter().filter_map(|r| r["path"].as_str()).collect::<Vec<_>>().join(", ")),+        None => format!("Published {} sheet(s)", out.len()),+    };+    Ok(json!({ "sheets": out, "message": message }))+}+ fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {+    if let Some(v) = p.get("sheets") {+        let list = publish_sheets(s, v)?;+        return publish_list(s, p, list);+    }+    if p.get("combine").and_then(Value::as_bool) == Some(true) {+        let names: Vec<Value> = match p.get("layouts") {+            Some(Value::Array(a)) => a.iter().map(|n| json!({ "layout": n })).collect(),+            Some(_) => return Err(bad("publish", "`layouts` must be a list of layout names")),+            None => {+                let d = s.doc()?;+                let mut layouts: Vec<_> = d.layouts.iter().collect();+                layouts.sort_by_key(|l| l.tab_order);+                let model = (!d.model.is_empty()).then(|| json!({ "layout": "Model" }));+                model.into_iter().chain(layouts.iter().map(|l| json!({ "layout": l.name }))).collect()+            }+        };+        let list = publish_sheets(s, &Value::Array(names))?;+        return publish_list(s, p, list);+    }     let d = s.doc()?;     let sheets: Vec<String> = match p.get("layouts") {         Some(v) => {@@ -794,6 +941,10 @@ mod tests {         for p in [json!({ "layouts": [] }), json!({ "layouts": ["Nope"] }), json!({ "layouts": "Layout1" }), json!({ "layouts": [1] })] {             assert!(s.execute("publish", &p).is_err(), "{p}");         }+        // Combined into one PDF: the same check, not every layout instead.+        let path = temp("combined.pdf");+        assert!(s.execute("publish", &json!({ "combine": true, "layouts": "Layout1", "path": path.to_string_lossy() })).is_err());+        assert!(!path.exists());     }      #[test]diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -42,6 +42,81 @@ pub struct IoHooks {  static IO: OnceLock<IoHooks> = OnceLock::new(); +/// Plot sheets of several drawings into one PDF, one page each in order, with JSON options for+/// the whole file (installed by the app; PUBLISH with `combine` across open drawings).+pub type PlotBookHook = fn(&[(&Drawing, cadcraft_doc::Space)], &Value) -> std::result::Result<Vec<u8>, String>;++static PLOT_BOOK: OnceLock<PlotBookHook> = OnceLock::new();++/// Read an AutoCAD plot style table file (`.ctb`/`.stb` bytes, the file name) into a table+/// (installed by the app; the io layer reads the format).+pub type PlotStyleReader = fn(&[u8], &str) -> std::result::Result<cadcraft_doc::PlotStyleTable, String>;++static PLOT_STYLE_READER: OnceLock<PlotStyleReader> = OnceLock::new();++pub fn set_plot_style_reader(h: PlotStyleReader) {+    let _ = PLOT_STYLE_READER.set(h);+}+pub fn plot_style_reader() -> Option<PlotStyleReader> {+    PLOT_STYLE_READER.get().copied()+}++/// Whether `bytes` are an AutoCAD plot style table file.+pub fn is_pia(bytes: &[u8]) -> bool {+    bytes.starts_with(b"PIAFILEVERSION")+}++/// The folders a missing plot style table is looked for in: `extra` (the drawing's folder), then+/// AutoCAD's plot style folders of this user (Windows: under `%APPDATA%\Autodesk`).+#[cfg(not(target_arch = "wasm32"))]+fn plot_style_dirs(extra: Vec<std::path::PathBuf>) -> Vec<std::path::PathBuf> {+    let mut dirs = extra;+    if let Some(appdata) = std::env::var_os("APPDATA") {+        // Autodesk\<product>\<release>\<language>\Plotters\Plot Styles+        let list = |p: &std::path::Path| -> Vec<std::path::PathBuf> {+            std::fs::read_dir(p).map(|r| r.flatten().map(|e| e.path()).filter(|p| p.is_dir()).take(64).collect()).unwrap_or_default()+        };+        for product in list(&std::path::Path::new(&appdata).join("Autodesk")) {+            for release in list(&product) {+                for lang in list(&release) {+                    let ps = lang.join("Plotters").join("Plot Styles");+                    if ps.is_dir() {+                        dirs.push(ps);+                    }+                }+            }+        }+    }+    dirs+}++/// Find the table file `name` (case-insensitive) in `dirs` and read it.+#[cfg(not(target_arch = "wasm32"))]+fn find_plot_style(name: &str, dirs: &[std::path::PathBuf]) -> Option<cadcraft_doc::PlotStyleTable> {+    let reader = plot_style_reader()?;+    for dir in dirs {+        let Ok(rd) = std::fs::read_dir(dir) else { continue };+        for e in rd.flatten().take(5000) {+            let p = e.path();+            if p.file_name().is_some_and(|f| f.to_string_lossy().eq_ignore_ascii_case(name.trim()))+                && std::fs::metadata(&p).is_ok_and(|m| m.len() <= cadcraft_doc::MAX_TABLE_FILE as u64)+                && let Ok(bytes) = std::fs::read(&p)+                && let Ok(t) = reader(&bytes, name.trim())+            {+                return Some(t);+            }+        }+    }+    None+}++pub fn set_plot_book(h: PlotBookHook) {+    let _ = PLOT_BOOK.set(h);+}+pub fn plot_book() -> Option<PlotBookHook> {+    PLOT_BOOK.get().copied()+}+ pub fn set_io(h: IoHooks) {     let _ = IO.set(h); }@@ -298,6 +373,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {     let ReadFile { drawing, binary_dxf, name, path } = f;     let i = add_read(s, drawing, binary_dxf, &name, path);     super::blocks::xref::after_open(s, i);+    find_plot_styles(s, i);     Ok(opened(s, i, true)) } @@ -350,6 +426,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {                 crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;             let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;             super::blocks::xref::after_open(s, i);+            find_plot_styles(s, i);             return Ok(opened(s, i, true));         }         #[cfg(target_arch = "wasm32")]@@ -363,6 +440,52 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {     Ok(opened(s, i, false)) } +/// The plot style tables the drawing's layouts name that it doesn't hold and CADCraft doesn't+/// have built in (`SIVAN.ctb`): read from the drawing's folder or AutoCAD's plot style folders,+/// so the layouts plot with their colours and lineweights.+pub(crate) fn find_plot_styles(s: &mut Session, i: usize) {+    #[cfg(not(target_arch = "wasm32"))]+    {+        let Some(st) = s.docs.get_mut(i) else { return };+        let mut wanted: Vec<String> = Vec::new();+        for l in &st.doc.layouts {+            let n = l.page.plot_style_table.trim();+            if !cadcraft_doc::no_plot_style_table(n)+                && cadcraft_doc::plot_style_table(&st.doc, n).is_none()+                && !wanted.iter().any(|w| w.eq_ignore_ascii_case(n))+            {+                wanted.push(n.to_string());+            }+        }+        if wanted.is_empty() {+            return;+        }+        let mut extra = Vec::new();+        if let Some(dir) = st.path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {+            extra.push(dir.to_path_buf());+        }+        let dirs = plot_style_dirs(extra);+        let found: Vec<cadcraft_doc::PlotStyleTable> = wanted.iter().filter_map(|n| find_plot_style(n, &dirs)).collect();+        let missing: Vec<&String> = wanted.iter().filter(|n| !found.iter().any(|t| t.name.eq_ignore_ascii_case(n))).collect();+        let names: Vec<String> = found.iter().map(|t| t.name.clone()).collect();+        if !found.is_empty() {+            let d = std::sync::Arc::make_mut(&mut st.doc);+            d.plot_style_tables.extend(found);+            st.saved = st.doc.clone();+        }+        for n in names {+            s.echo(format!("Plot style table {n} found and used for plotting."));+        }+        for n in missing {+            s.echo(format!(+                "Plot style table {n} not found: layouts plot with the objects' own colours and lineweights. Put the file next to the drawing or load it in the Plot Style Manager."+            ));+        }+    }+    #[cfg(target_arch = "wasm32")]+    let _ = (s, i);+}+ /// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error. fn run_close(s: &mut Session, p: &Value) -> Result<Value> {     let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs--- a/crates/engine/src/cmd/layout/plotstyle.rs+++ b/crates/engine/src/cmd/layout/plotstyle.rs@@ -307,7 +307,21 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {                         if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {                             return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));                         }-                        std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?+                        let bytes = std::fs::read(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;+                        // AutoCAD's own .ctb/.stb files, read as they are.+                        if super::super::file::is_pia(&bytes) {+                            let reader = super::super::file::plot_style_reader()+                                .ok_or_else(|| bad(cmd, "AutoCAD plot style table files can't be read in this build"))?;+                            let file = std::path::Path::new(_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+                            let mut t = reader(&bytes, &file).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;+                            if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {+                                t.name = n.chars().take(255).collect();+                            }+                            let out = table_summary(s.doc()?, &t);+                            store(s.doc_mut()?, t);+                            return Ok(out);+                        }+                        String::from_utf8(bytes).map_err(|_| bad(cmd, format!("{_path}: not a plot style table file")))?                     }                     #[cfg(target_arch = "wasm32")]                     return Err(bad(cmd, "files can't be read here: pass `text`"));diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs--- a/crates/engine/src/cmd/view.rs+++ b/crates/engine/src/cmd/view.rs@@ -283,6 +283,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {         Space::Paper(l.name.clone())     };     s.pending_window = None;+    let (px_w, px_h) = s.viewport_px;     let st = s.state_mut()?;     st.space = space.clone();     st.mspace = None;@@ -290,11 +291,17 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {     if let Space::Paper(n) = &space         && !st.views.iter().any(|(sp, _)| sp == &space)     {-        // Default paper view: the sheet.-        let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();-        let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };-        let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };-        st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });+        // A layout shown for the first time: the whole sheet fits the screen, with a margin.+        let b = cadcraft_render::sheet(&st.doc, n).map(|sh| sh.bounds()).filter(|b| !b.is_empty() && b.width() > 0.0 && b.height() > 0.0);+        let b = b.unwrap_or_else(|| {+            let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();+            let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };+            let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };+            cadcraft_geom::Bounds2::new(Vec2::ZERO, Vec2::new(w * unit, h * unit))+        });+        let aspect = if px_w > 1.0 && px_h > 1.0 { px_w / px_h } else { 1.5 };+        let height = b.height().max(b.width() / aspect).max(1e-9) * 1.08;+        st.set_view_quiet(View { center: b.center(), height });     }     s.touch();     ok()diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs--- a/crates/io/src/dxf_read/entities.rs+++ b/crates/io/src/dxf_read/entities.rs@@ -230,7 +230,13 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef             center: t.p(10),             width: t.fd(40, 1.0),             height: t.fd(41, 1.0),-            view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)),+            // The view centre (12/22) is relative to the view target (17/27): AutoCAD often keeps+            // the centre near zero and the target where the drawing is.+            view_center: {+                let target = Vec2::new(t.fd(17, 0.0), t.fd(27, 0.0));+                let target = if target.is_finite() { target } else { Vec2::ZERO };+                Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)) + target+            },             view_height: t.fd(45, 1.0),             id: t.i(69).unwrap_or(0) as u32,             locked: t.i(90).unwrap_or(0) & 16384 != 0,diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs--- a/crates/io/src/dxf_read/tables.rs+++ b/crates/io/src/dxf_read/tables.rs@@ -235,7 +235,9 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {                 if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS {                     continue;                 }-                let center = t.p(10).xy();+                // The centre is relative to the view target (12).+                let target = t.p(12).xy();+                let center = t.p(10).xy() + if target.is_finite() { target } else { Vec2::ZERO };                 if !(center.x.is_finite() && center.y.is_finite()) {                     continue;                 }@@ -307,7 +309,9 @@ const MAX_VPORT_TILES: usize = 64; /// are fractions of the drawing area; unusable tiles are skipped. fn vport_record(d: &mut Drawing, t: &T) {     let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default();-    let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy());+    // The view centre (12) is relative to the view target (17).+    let target = t.p(17).xy();+    let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy() + if target.is_finite() { target } else { Vec2::ZERO });     let height = t.fd(40, 0.0);     let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v);     let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit)diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs--- a/crates/io/src/lib.rs+++ b/crates/io/src/lib.rs@@ -13,6 +13,7 @@ mod dxf_table; mod dxf_write; mod dxf_xref; pub mod pdf;+pub mod pia; pub mod plotter; pub mod svg; mod svg_read;diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs--- a/crates/io/src/pdf.rs+++ b/crates/io/src/pdf.rs@@ -491,6 +491,52 @@ impl Groups {     } } +/// Write a vector PDF of sheets from several drawings, one page per `(drawing, space)` in order+/// (at most [`MAX_PAGES`]). Each drawing has its own layers, so none become PDF layers.+pub fn pdf_book(items: &[(&Drawing, Space)], o: &PdfOptions) -> Result<Vec<u8>> {+    if items.is_empty() {+        return Err(IoError::Format("there are no sheets to plot".into()));+    }+    if items.len() > MAX_PAGES {+        return Err(IoError::Format(format!("at most {MAX_PAGES} sheets fit in one PDF")));+    }+    let mut po = o.clone();+    po.layers = false;+    match write_book(items, &po, po.text_as_geometry) {+        // As for one drawing: a font the writer can't embed plots the text as outlines instead.+        Err(Failure::Write(e)) if !po.text_as_geometry && font_failure(&e) => write_book(items, &po, true).map_err(Failure::into_io),+        r => r.map_err(Failure::into_io),+    }+}++/// [`write_pdf`] for sheets of several drawings, each with its own links; no PDF layers.+fn write_book(items: &[(&Drawing, Space)], o: &PdfOptions, text_as_geometry: bool) -> std::result::Result<Vec<u8>, Failure> {+    write::guard(|| {+        let title = if o.title.is_empty() { items.first().map(|(_, s)| sheet_name(s)).unwrap_or_default() } else { o.title.clone() };+        let metadata = Metadata {+            creator: "CADCraft".into(),+            producer: "CADCraft".into(),+            title: Some(title.chars().take(MAX_TEXT).collect()),+            ..Metadata::default()+        };+        let settings = kr::SerializeSettings {+            compress_content_streams: o.compress,+            xmp_metadata: false,+            enable_tagging: false,+            ..kr::SerializeSettings::default()+        };+        let mut writer = Writer::new(settings, metadata);+        let mut fonts = fonts::Fonts::new(!text_as_geometry);+        let groups = Groups::default();+        for (d, s) in items {+            let links = all_links(d, o);+            let page = sheet(d, s, o, &links)?;+            write_page(writer.document_mut(), &page, &groups, &mut fonts)?;+        }+        Ok(writer.finish()?)+    })+}+ /// One plotted sheet, ready to be written as a page. struct SheetPage {     /// Page size in points.diff --git a/crates/io/src/pia.rs b/crates/io/src/pia.rsnew file mode 100644--- /dev/null+++ b/crates/io/src/pia.rs@@ -0,0 +1,265 @@+//! Plot style table files as AutoCAD writes them (`.ctb` colour-dependent, `.stb` named): a+//! `PIAFILEVERSION_2.0,CTBVER1,compress` header line, `pmzlibcodec`, two 32-bit words (a check+//! value and the text length), the compressed text length, then zlib-compressed text of nested+//! `key=value` and `name{ … }` blocks. Read into CADCraft's [`PlotStyleTable`] so a drawing whose+//! layouts name the user's own table plots with its colours and lineweights.++use cadcraft_color::Rgb;+use cadcraft_doc::{EndStyle, JoinStyle, PlotStyle, PlotStyleKind, PlotStyleTable};++/// Largest file and decompressed text we accept.+const MAX_FILE: usize = 4 << 20;+const MAX_TEXT: usize = 16 << 20;+/// "Use the object's colour" values of a style's `color`.+const OBJECT_COLOR: i64 = -1;+const OBJECT_COLOR2: i64 = -1_006_632_961;++/// Whether `bytes` look like an AutoCAD plot style table file.+pub fn is_pia(bytes: &[u8]) -> bool {+    bytes.starts_with(b"PIAFILEVERSION")+}++/// A node of the text: a value or a block of children, in file order.+enum Node {+    Value(String),+    Block(Vec<(String, Node)>),+}++/// The decompressed text of a PIA file.+fn text(bytes: &[u8]) -> Result<String, String> {+    if bytes.len() > MAX_FILE {+        return Err("the plot style table file is too large".into());+    }+    if !is_pia(bytes) {+        return Err("not a plot style table file".into());+    }+    let marker = b"pmzlibcodec";+    let at = bytes.windows(marker.len()).position(|w| w == marker).ok_or("the plot style table is not compressed as expected")?;+    let data = bytes.get(at + marker.len() + 12..).ok_or("the plot style table file is cut short")?;+    let out = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, MAX_TEXT)+        .map_err(|_| "the plot style table can't be decompressed".to_string())?;+    Ok(String::from_utf8_lossy(&out).into_owned())+}++/// Parse the nested text. Lines are `key=value` (a value starting with `"` runs to the end of+/// the line, without a closing quote), `key{` opening a block and `}` closing it.+fn parse(text: &str) -> Vec<(String, Node)> {+    let mut stack: Vec<(String, Vec<(String, Node)>)> = vec![(String::new(), Vec::new())];+    for line in text.lines().take(200_000) {+        let l = line.trim();+        if l.is_empty() {+            continue;+        }+        if l == "}" {+            if stack.len() > 1+                && let Some((name, kids)) = stack.pop()+                && let Some(top) = stack.last_mut()+            {+                top.1.push((name, Node::Block(kids)));+            }+            continue;+        }+        if let Some(name) = l.strip_suffix('{') {+            if stack.len() < 8 {+                stack.push((name.trim().to_string(), Vec::new()));+            }+            continue;+        }+        if let Some((k, v)) = l.split_once('=')+            && let Some(top) = stack.last_mut()+        {+            let v = v.strip_prefix('"').unwrap_or(v);+            top.1.push((k.trim().to_string(), Node::Value(v.to_string())));+        }+    }+    while stack.len() > 1 {+        if let Some((name, kids)) = stack.pop()+            && let Some(top) = stack.last_mut()+        {+            top.1.push((name, Node::Block(kids)));+        }+    }+    stack.pop().map(|(_, k)| k).unwrap_or_default()+}++fn value<'a>(nodes: &'a [(String, Node)], key: &str) -> Option<&'a str> {+    nodes.iter().find_map(|(k, n)| match n {+        Node::Value(v) if k == key => Some(v.as_str()),+        _ => None,+    })+}++fn block<'a>(nodes: &'a [(String, Node)], key: &str) -> Option<&'a [(String, Node)]> {+    nodes.iter().find_map(|(k, n)| match n {+        Node::Block(b) if k == key => Some(b.as_slice()),+        _ => None,+    })+}++fn int(nodes: &[(String, Node)], key: &str) -> Option<i64> {+    value(nodes, key).and_then(|v| v.trim().parse().ok())+}++/// One style: its colour (object or RGB, grey, screened), lineweight (`0` = the object's, else+/// an index into the file's lineweight table, in millimetres), ends and joins.+fn style(nodes: &[(String, Node)], weights: &[f64]) -> PlotStyle {+    let name = value(nodes, "name").unwrap_or("").to_string();+    let mut s = PlotStyle::object(&name);+    s.description = value(nodes, "description").unwrap_or("").to_string();+    let policy = int(nodes, "color_policy").unwrap_or(1);+    if let Some(c) = int(nodes, "color").or_else(|| int(nodes, "mode_color"))+        && c != OBJECT_COLOR+        && c != OBJECT_COLOR2+    {+        let v = c as u32;+        s.color = Some(Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8));+    }+    s.grayscale = policy & 2 != 0;+    s.screening = int(nodes, "screen").map_or(100, |v| v.clamp(0, 100) as u8);+    s.lineweight = match int(nodes, "lineweight") {+        Some(i) if i > 0 => usize::try_from(i).ok().and_then(|i| weights.get(i)).map(|mm| (mm * 100.0).round().clamp(0.0, 211.0) as u16),+        _ => None,+    };+    s.end = match int(nodes, "end_style") {+        Some(0) => EndStyle::Butt,+        Some(1) => EndStyle::Square,+        Some(2) => EndStyle::Round,+        Some(3) => EndStyle::Diamond,+        _ => EndStyle::Object,+    };+    s.join = match int(nodes, "join_style") {+        Some(0) => JoinStyle::Miter,+        Some(1) => JoinStyle::Bevel,+        Some(2) => JoinStyle::Round,+        Some(3) => JoinStyle::Diamond,+        _ => JoinStyle::Object,+    };+    s+}++/// Read a `.ctb` or `.stb` file into a table named `name` (the file's name, as page setups+/// name it).+pub fn read(bytes: &[u8], name: &str) -> Result<PlotStyleTable, String> {+    let t = text(bytes)?;+    let nodes = parse(&t);+    // A named table says it has no colour (ACI) table; both kinds share the CTBVER1 header.+    let named = match value(&nodes, "aci_table_available") {+        Some(v) => v.trim().eq_ignore_ascii_case("FALSE"),+        None => bytes.get(..60).is_some_and(|h| h.windows(6).any(|w| w == b"STBVER")),+    };+    // Lineweights are stored in millimetres; `custom_lineweight_display_units` only says how the+    // plot style editor shows them.+    let mut weights: Vec<(usize, f64)> = block(&nodes, "custom_lineweight_table")+        .unwrap_or(&[])+        .iter()+        .filter_map(|(k, n)| match n {+            Node::Value(v) => Some((k.parse().ok()?, v.trim().parse::<f64>().ok()?)),+            Node::Block(_) => None,+        })+        .filter(|(_, v)| v.is_finite() && *v >= 0.0)+        .collect();+    weights.sort_by_key(|(i, _)| *i);+    let weights: Vec<f64> = weights.into_iter().map(|(_, v)| v).collect();+    let mut styles: Vec<(usize, PlotStyle)> = block(&nodes, "plot_style")+        .unwrap_or(&[])+        .iter()+        .filter_map(|(k, n)| match n {+            Node::Block(b) => Some((k.parse().ok()?, style(b, &weights))),+            Node::Value(_) => None,+        })+        .collect();+    styles.sort_by_key(|(i, _)| *i);+    if styles.is_empty() {+        return Err("the plot style table has no styles".into());+    }+    let table = PlotStyleTable {+        name: name.to_string(),+        description: value(&nodes, "description").unwrap_or("").to_string(),+        kind: if named { PlotStyleKind::Named } else { PlotStyleKind::ColorDependent },+        styles: styles.into_iter().map(|(_, s)| s).collect(),+    };+    Ok(table.sanitized())+}++#[cfg(test)]+mod tests {+    /// A small table in the file format, made here (a colour-dependent table whose colour 1 is red+    /// at 0.35 mm and colour 2 black at the object's lineweight).+    fn file() -> Vec<u8> {+        made(true, 0)+    }++    /// The test table: colour-dependent (`aci`) or named, lineweights shown in millimetres (0) or+    /// inches (1). Style 0 plots at lineweight index 10, 0.35 mm.+    fn made(aci: bool, units: u8) -> Vec<u8> {+        let mut text = format!(+            "description=\"Test\naci_table_available={}\nscale_factor=1.0\napply_factor=FALSE\ncustom_lineweight_display_units={units}\nplot_style{{\n",+            if aci { "TRUE" } else { "FALSE" }+        );+        let count = if aci { 255 } else { 3 };+        for i in 0..count {+            let (color, lw) = match i {+                0 => ("-1006698496", 10),+                1 => ("-16777216", 0),+                _ => ("-1", 0),+            };+            let name = if aci { format!("Color_{}", i + 1) } else { ["Normal", "Walls", "Hidden"].get(i).copied().unwrap_or("Style").to_string() };+            text.push_str(&format!(+                " {i}{{\n  name=\"{name}\n  color={color}\n  color_policy=5\n  screen=100\n  linetype=31\n  lineweight={lw}\n  end_style=4\n  join_style=5\n }}\n"+            ));+        }+        text.push_str("}\ncustom_lineweight_table{\n");+        for (i, w) in [0.0, 0.05, 0.09, 0.1, 0.13, 0.15, 0.18, 0.2, 0.25, 0.3, 0.35, 0.4].iter().enumerate() {+            text.push_str(&format!(" {i}={w}\n"));+        }+        text.push_str("}\n");+        let z = miniz_oxide::deflate::compress_to_vec_zlib(text.as_bytes(), 6);+        let mut out = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec".to_vec();+        out.extend_from_slice(&0u32.to_le_bytes());+        out.extend_from_slice(&(text.len() as u32).to_le_bytes());+        out.extend_from_slice(&(z.len() as u32).to_le_bytes());+        out.extend_from_slice(&z);+        out+    }++    #[test]+    fn a_color_table_file_gives_colours_and_lineweights() {+        let t = super::read(&file(), "Mine.ctb").unwrap();+        assert!(t.is_color_dependent());+        let red = t.by_color(1).unwrap();+        assert_eq!(red.lineweight, Some(35));+        assert_eq!(red.color.map(|c| (c.0, c.1, c.2)), Some((255, 0, 0)));+        let black = t.by_color(2).unwrap();+        assert_eq!(black.color.map(|c| (c.0, c.1, c.2)), Some((0, 0, 0)));+        assert_eq!(black.lineweight, None);+        assert!(t.by_color(3).unwrap().color.is_none());+    }++    /// A named table has the same CTBVER1 header as a colour one; it says `aci_table_available=FALSE`.+    /// Its style names are kept, and a lineweight index reads the table at that index.+    #[test]+    fn a_named_table_is_read_as_named_with_its_style_names() {+        let t = super::read(&made(false, 0), "Mine.stb").unwrap();+        assert!(!t.is_color_dependent());+        let names: Vec<&str> = t.styles.iter().map(|s| s.name.as_str()).collect();+        assert!(names.contains(&"Walls") && names.contains(&"Hidden"), "{names:?}");+        let normal = t.styles.iter().find(|s| s.name == "Normal").unwrap();+        assert_eq!(normal.lineweight, Some(35), "index 10 is 0.35 mm, not the one before it");+    }++    /// Lineweights shown in inches are still stored in millimetres.+    #[test]+    fn inch_display_units_do_not_scale_stored_lineweights() {+        let t = super::read(&made(true, 1), "Inch.ctb").unwrap();+        assert_eq!(t.by_color(1).unwrap().lineweight, Some(35));+    }++    #[test]+    fn hostile_files_are_refused_not_panicked_on() {+        assert!(super::read(b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec\x01\x02", "x.ctb").is_err());+        assert!(super::read(b"hello", "x.ctb").is_err());+        let mut f = file();+        f.truncate(f.len() / 2);+        assert!(super::read(&f, "x.ctb").is_err());+    }+}diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs--- a/crates/io/src/plotter.rs+++ b/crates/io/src/plotter.rs@@ -50,6 +50,11 @@ pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {     } } +/// One PDF of sheets from several drawings ([`pdf::pdf_book`]), with the file-wide options of `opts`.+pub fn plot_book(items: &[(&Drawing, Space)], opts: &Value) -> Result<Vec<u8>> {+    pdf::pdf_book(items, &PdfOptions::from_json(opts))+}+ /// The sheet a plot draws on (a model-space plot's printable area inside the device margins). fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<(Sheet, cadcraft_doc::PageSetup)> {     let page = pdf::page_for(d, space, o)?;diff --git a/crates/io/tests/it/paperview_marker.rs b/crates/io/tests/it/paperview_marker.rs--- a/crates/io/tests/it/paperview_marker.rs+++ b/crates/io/tests/it/paperview_marker.rs@@ -40,3 +40,36 @@ fn viewport_with_a_layer_named_paperview_survives_dxf_roundtrip() {     assert_eq!(vps[0].frozen_layers, ["PAPERVIEW"]);     assert_eq!(vps[1].layer_colors, [("PAPERVIEW".to_string(), Color::Index(1))]); }++#[test]+fn a_viewport_looks_at_its_view_target_plus_its_view_centre() {+    // AutoCAD often writes the view centre (12/22) near zero and the view target (17/27) where+    // the drawing is: the model point at the viewport's middle is their sum.+    let mut d = Drawing::new_metric();+    let sheet = Space::Paper(d.layouts[0].name.clone());+    d.add(&sheet, Common::default(), viewport(2, vec![], vec![])).unwrap();+    let text = cadcraft_io::write_dxf(&d);+    // The viewport's view centre 10,10 becomes 2,3 with the target at 8,7: rewrite the group+    // values after "12" and "22" in the viewport whose centre (10) is 50.+    let lines: Vec<&str> = text.lines().collect();+    let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();+    let start = lines.windows(2).position(|w| w[0].trim() == "10" && w[1].trim().parse::<f64>().ok() == Some(50.0)).expect("the viewport");+    let mut done = false;+    let mut i = start;+    while i + 1 < lines.len() && !done {+        if lines[i].trim() == "12" {+            out[i + 1] = "2.0".into();+        }+        if lines[i].trim() == "22" {+            out[i + 1] = "3.0".into();+            out[i + 1].push_str("\n 17\n8.0\n 27\n7.0");+            done = true;+        }+        i += 2;+    }+    assert!(done, "the writer's view centre groups");+    let edited = out.join("\n");+    let back = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();+    let v = viewports(&back.layouts[0]).into_iter().find(|v| v.id == 2).unwrap();+    assert!((v.view_center.x - 10.0).abs() < 1e-9 && (v.view_center.y - 10.0).abs() < 1e-9, "{:?}", v.view_center);+}diff --git a/crates/io/tests/it/tables_objects_roundtrip.rs b/crates/io/tests/it/tables_objects_roundtrip.rs--- a/crates/io/tests/it/tables_objects_roundtrip.rs+++ b/crates/io/tests/it/tables_objects_roundtrip.rs@@ -106,6 +106,23 @@ fn layout_page_setup_and_paper_view_round_trip() {     assert_eq!(r.layouts.len(), d.layouts.len()); } +#[test]+fn a_layout_in_millimetres_in_an_inch_drawing_is_drawn_in_millimetres() {+    // As AutoCAD writes it: MEASUREMENT 0 (inches) but the layout's plot paper units are mm, so+    // paper space is in millimetres and an A3 sheet is 420 × 297 units, not 16.5 × 11.7.+    let mut d = Drawing::new_imperial();+    let l = d.layouts.get_mut(0).expect("Layout1");+    l.page.width_mm = 297.0;+    l.page.height_mm = 420.0;+    l.page.margins_mm = [0.0; 4];+    l.page.paper_mm = true;+    let r = round_trip(&d);+    let page = &r.layouts.first().expect("Layout1").page;+    assert!(page.paper_mm);+    let sheet = cadcraft_render::paper::Sheet::from_page(page, cadcraft_render::paper::page_unit_mm(&r, page));+    assert!((sheet.size.x - 420.0).abs() < 1e-9 && (sheet.size.y - 297.0).abs() < 1e-9, "{:?}", sheet.size);+}+ #[test] fn named_views_ucss_and_groups_round_trip() {     let mut d = Drawing::new_imperial();@@ -257,3 +274,26 @@ fn foreign_and_hostile_tables_objects() {     assert_eq!(again.groups, d.groups);     assert_eq!(again.views, d.views); }++#[test]+fn a_millimetre_layout_of_an_inch_drawing_plots_on_a_page_its_own_size() {+    // The PDF page of an A3 layout set up in millimetres is A3 (1190.55 × 841.89 pt), not 25.4+    // times larger, so a printed line keeps its lineweight instead of shrinking 25.4 times.+    let mut d = Drawing::new_imperial();+    let name = {+        let l = d.layouts.get_mut(0).expect("Layout1");+        l.page.width_mm = 297.0;+        l.page.height_mm = 420.0;+        l.page.landscape = true;+        l.page.margins_mm = [0.0; 4];+        l.page.paper_mm = true;+        l.name.clone()+    };+    d.add(&Space::Paper(name.clone()), Common::default(), EntityKind::Line(Line { a: Vec3::new(10.0, 10.0, 0.0), b: Vec3::new(400.0, 280.0, 0.0) }))+        .expect("line");+    let pdf = cadcraft_io::pdf::pdf(&d, &Space::Paper(name), &cadcraft_io::PdfOptions::default()).expect("pdf");+    let doc = crate::pdf_read::read(&pdf);+    let m = doc.pages.first().expect("a page").media;+    let (w, h) = (m[2] - m[0], m[3] - m[1]);+    assert!((w - 1190.55).abs() < 1.0 && (h - 841.89).abs() < 1.0, "{w} x {h}");+}diff --git a/crates/render/src/paper.rs b/crates/render/src/paper.rs--- a/crates/render/src/paper.rs+++ b/crates/render/src/paper.rs@@ -109,7 +109,7 @@ impl Sheet {     pub fn bounds(&self) -> Bounds2 {         Bounds2::new(Vec2::ZERO, self.size)     }-    /// The sheet for a page setup in a drawing whose paper units are `unit_mm` millimetres.+    /// The sheet for a page setup whose paper units are `unit_mm` millimetres ([`page_unit_mm`]).     pub fn from_page(page: &PageSetup, unit_mm: f64) -> Sheet {         let sane = |v: f64, d: f64| if v.is_finite() && v > 0.0 { v.min(100_000.0) } else { d };         let w = sane(page.width_mm, 215.9);diff --git a/crates/ui-egui/src/batchplot.rs b/crates/ui-egui/src/batchplot.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/batchplot.rs@@ -0,0 +1,609 @@+//! Batch Plot: a print manager. The sheets to plot in a list that can be reordered (drag, or the+//! Move buttons), with sheets repeated or removed; sheets of any open drawing can be added; the+//! result is one PDF with every sheet or one PDF per sheet (PUBLISH with `sheets`).++use egui::{Rect, RichText, Sense, Stroke, pos2, vec2};+use serde_json::{Value, json};++use crate::CadApp;+use crate::icons::{self, Icon};+use crate::theme::Tokens;++/// One sheet in the list: the drawing it belongs to and the layout (or `Model`). The drawing is+/// known by its identity ([`cadcraft_engine::DocState::uid`]), not its title: two open files can+/// both be `plan.dwg`.+#[derive(Clone, Debug, PartialEq)]+pub struct Sheet {+    pub doc: u64,+    /// The drawing's title, for the list.+    pub drawing: String,+    pub layout: String,+}++/// The window's state between frames.+#[derive(Clone, Default)]+struct State {+    sheets: Vec<Sheet>,+    /// The drawing the list was filled from when the window opened.+    filled_for: Option<u64>,+    /// The picked rows (Ctrl and Shift pick several).+    selected: Vec<usize>,+    /// Where Shift+click extends from.+    anchor: Option<usize>,+    /// The picked rows are being dragged.+    dragging: bool,+    /// Where a selection rectangle started (in the list's empty space).+    band_from: Option<egui::Pos2>,+    /// The drawing whose sheets the Add section shows.+    add_from: Option<u64>,+    separate: bool,+    note: Option<String>,+}++fn state_id() -> egui::Id {+    egui::Id::unique("cc_batch_plot")+}++/// The sheets of drawing `d` in tab order: Model when it has objects, then the layouts.+pub fn sheets_of(d: &cadcraft_doc::Drawing, doc: u64, title: &str) -> Vec<Sheet> {+    let mut layouts: Vec<_> = d.layouts.iter().collect();+    layouts.sort_by_key(|l| l.tab_order);+    let model = (!d.model.is_empty()).then(|| "Model".to_string());+    model.into_iter().chain(layouts.iter().map(|l| l.name.clone())).map(|layout| Sheet { doc, drawing: title.to_string(), layout }).collect()+}++/// Move the sheet at `from` to stand before `slot` (0..=len).+pub fn move_sheet(v: &mut Vec<Sheet>, from: usize, slot: usize) -> Option<usize> {+    if from >= v.len() {+        return None;+    }+    let s = v.remove(from);+    let to = if slot > from { slot - 1 } else { slot }.min(v.len());+    v.insert(to, s);+    Some(to)+}++/// The PUBLISH parameters for `sheets`, each resolved to the open drawing it came from (`docs`:+/// the open drawings' identities in order); an error names a sheet whose drawing was closed.+pub fn publish_params(docs: &[u64], sheets: &[Sheet], combine: bool, path: Option<&str>) -> Result<Value, String> {+    let list: Vec<Value> = sheets+        .iter()+        .map(|s| {+            let i = docs.iter().position(|d| *d == s.doc).ok_or_else(|| format!("{}: {}", crate::tl!("The drawing is closed"), s.drawing))?;+            Ok(json!({ "drawing": i, "layout": s.layout }))+        })+        .collect::<Result<_, String>>()?;+    let mut p = json!({ "sheets": list, "combine": combine });+    if let Some(path) = path {+        p["path"] = json!(path);+    }+    Ok(p)+}++/// Move the sheets at `picked` (any order) as one block to stand before `slot` (0..=len), keeping+/// their order; the indices they end up at.+pub fn move_sheets(v: &mut Vec<Sheet>, picked: &[usize], slot: usize) -> Vec<usize> {+    let mut idx: Vec<usize> = picked.iter().copied().filter(|i| *i < v.len()).collect();+    idx.sort_unstable();+    idx.dedup();+    if idx.is_empty() {+        return Vec::new();+    }+    let before = idx.iter().filter(|i| **i < slot).count();+    let moved: Vec<Sheet> = idx.iter().filter_map(|i| v.get(*i).cloned()).collect();+    for i in idx.iter().rev() {+        v.remove(*i);+    }+    let at = slot.saturating_sub(before).min(v.len());+    for (k, sh) in moved.into_iter().enumerate() {+        v.insert(at + k, sh);+    }+    (at..at + idx.len()).collect()+}++/// What a click asked for, applied once the list is drawn.+enum Act {+    /// A click on row `i`: with Ctrl it toggles, with Shift it extends from the anchor.+    Click(usize, egui::Modifiers),+    /// A rubber-band rectangle picked these rows (added to the selection with Ctrl).+    Band(Vec<usize>, bool),+    SelectAll,+    /// The selected rows to stand before this slot.+    MoveTo(usize),+    Up,+    Down,+    Duplicate,+    Remove,+    Add(Sheet),+    AddAll(u64),+    Clear,+    Plot,+}++/// File ▸ Batch Plot: the print manager window. While it is open the rest of the program waits.+pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {+    let mut st = ctx.data(|d| d.get_temp::<State>(state_id())).unwrap_or_default();+    let current = app.session.state().map(|s| s.title.clone()).unwrap_or_default();+    let current_doc = app.session.state().map_or(0, |s| s.uid);+    // Opened for a drawing: its sheets, in tab order (others are added by hand).+    if st.filled_for != Some(current_doc) {+        st.sheets = app.session.doc().map(|d| sheets_of(d, current_doc, &current)).unwrap_or_default();+        st.filled_for = Some(current_doc);+        st.selected.clear();+        st.add_from = Some(current_doc);+        st.note = None;+    }+    st.selected.retain(|i| *i < st.sheets.len());+    let uids: Vec<u64> = app.session.docs.iter().map(|d| d.uid).collect();+    // The open drawings as the Add list names them: the folder too where two share a title.+    let titles: Vec<(u64, String)> = app+        .session+        .docs+        .iter()+        .map(|d| {+            let twin = app.session.docs.iter().filter(|o| o.title == d.title).count() > 1;+            let folder = d.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|f| f.display().to_string());+            let name = match folder {+                Some(f) if twin => format!("{} — {f}", d.title),+                _ => d.title.clone(),+            };+            (d.uid, name)+        })+        .collect();+    let paper = |s: &Sheet| -> String {+        let Some(d) = app.session.docs.iter().find(|d| d.uid == s.doc) else { return crate::tl!("closed").to_string() };+        if s.layout == "Model" {+            return String::new();+        }+        d.doc.layout(&s.layout).map(|l| l.page.paper.clone()).unwrap_or_default()+    };+    let rows: Vec<(String, String, String)> = st.sheets.iter().map(|s| (s.layout.clone(), s.drawing.clone(), paper(s))).collect();+    let can_plot = app.services.pick_save.is_some();+    let mut act: Option<Act> = None;+    let mut close = false;+    // Modal: a dimmed backdrop over the whole program takes every click outside the window.+    let screen = ctx.content_rect();+    egui::Area::new(egui::Id::unique("cc_batch_plot_backdrop")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| {+        let (r, _) = ui.allocate_exact_size(screen.size(), Sense::click_and_drag());+        ui.painter().rect_filled(r, 0.0, egui::Color32::from_black_alpha(110));+    });+    egui::Window::new(crate::tl!("Batch Plot"))+        .id(egui::Id::unique("cc_batch_plot_window"))+        .order(egui::Order::Foreground)+        .open(open)+        .default_size(vec2(700.0, 520.0))+        .min_size(vec2(520.0, 380.0))+        .resizable(true)+        .show(ctx, |ui| {+            let t = Tokens::get();+            ui.label(RichText::new(crate::tl!("Sheets to plot, in order")).strong());+            ui.horizontal_top(|ui| {+                let list_w = (ui.available_width() - 190.0).max(260.0);+                let list_h = (ui.available_height() - 190.0).max(140.0);+                ui.allocate_ui_with_layout(vec2(list_w, list_h), egui::Layout::top_down(egui::Align::LEFT), |ui| {+                    egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).inner_margin(2.0).show(ui, |ui| {+                        let w = list_w - 10.0;+                        // Columns: number, sheet, drawing, paper, with a separator between each.+                        let cols = [34.0_f32, (w - 34.0) * 0.36, (w - 34.0) * 0.34, (w - 34.0) * 0.30];+                        let col_x = |k: usize| cols.iter().take(k).sum::<f32>();+                        let (hr, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());+                        ui.painter().rect_filled(hr, 0.0, t.chrome_dark);+                        for (k, title) in ["#", crate::tl!("Sheet"), crate::tl!("Drawing"), crate::tl!("Paper")].into_iter().enumerate() {+                            let cell = Rect::from_min_size(pos2(hr.left() + col_x(k), hr.top()), vec2(cols[k], hr.height()));+                            ui.painter().with_clip_rect(cell.shrink2(vec2(3.0, 0.0))).text(+                                pos2(cell.left() + 5.0, cell.center().y),+                                egui::Align2::LEFT_CENTER,+                                title,+                                crate::theme::small(),+                                t.text_dim,+                            );+                            if k > 0 {+                                ui.painter().vline(cell.left(), hr.y_range(), Stroke::new(1.0, t.border));+                            }+                        }+                        let body_h = list_h - 30.0;+                        egui::ScrollArea::vertical().id_salt("cc_batch_list").auto_shrink([false, false]).max_height(body_h).show(ui, |ui| {+                            ui.set_width(w);+                            let mut rects: Vec<Rect> = Vec::new();+                            for (i, (layout, drawing, paper)) in rows.iter().enumerate() {+                                let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click_and_drag());+                                rects.push(r);+                                let on = st.selected.contains(&i);+                                if on {+                                    ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(0.35));+                                } else if resp.hovered() {+                                    ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.6));+                                }+                                let y = r.center().y;+                                let cell = |k: usize| Rect::from_min_size(pos2(r.left() + col_x(k), r.top()), vec2(cols[k], r.height()));+                                // A grip that shows rows can be dragged, and the number.+                                let c0 = cell(0);+                                for dy in [-4.0, 0.0, 4.0] {+                                    ui.painter().hline((c0.left() + 3.0)..=(c0.left() + 9.0), y + dy, Stroke::new(1.0, t.text_faint));+                                }+                                ui.painter().with_clip_rect(c0).text(+                                    pos2(c0.left() + 12.0, y),+                                    egui::Align2::LEFT_CENTER,+                                    format!("{}", i + 1),+                                    crate::theme::small(),+                                    t.text_dim,+                                );+                                let c1 = cell(1);+                                ui.painter().with_clip_rect(c1.shrink2(vec2(3.0, 0.0))).text(+                                    pos2(c1.left() + 5.0, y),+                                    egui::Align2::LEFT_CENTER,+                                    crate::i18n::t(layout),+                                    crate::theme::body(),+                                    t.text,+                                );+                                // Hebrew names read right to left, as in the file manager.+                                let c2 = cell(2);+                                let job = crate::i18n::job(drawing, crate::theme::small(), t.text_dim);+                                let g = ui.ctx().fonts_mut(|f| f.layout_job(job));+                                ui.painter().with_clip_rect(c2.shrink2(vec2(3.0, 0.0))).galley(+                                    pos2(c2.left() + 5.0, y - g.size().y / 2.0),+                                    g,+                                    t.text_dim,+                                );+                                let c3 = cell(3);+                                ui.painter().with_clip_rect(c3.shrink2(vec2(3.0, 0.0))).text(+                                    pos2(c3.left() + 5.0, y),+                                    egui::Align2::LEFT_CENTER,+                                    paper,+                                    crate::theme::small(),+                                    t.text_dim,+                                );+                                for k in 1..4 {+                                    ui.painter().vline(cell(k).left(), r.y_range(), Stroke::new(1.0, t.border.gamma_multiply(0.6)));+                                }+                                if resp.clicked() {+                                    act = Some(Act::Click(i, ui.input(|inp| inp.modifiers)));+                                }+                                if resp.drag_started() {+                                    // Dragging an unselected row drags it alone.+                                    if !on {+                                        act = Some(Act::Click(i, egui::Modifiers::NONE));+                                    }+                                    st.dragging = true;+                                }+                                resp.context_menu(|ui| {+                                    if !on {+                                        act = Some(Act::Click(i, egui::Modifiers::NONE));+                                    }+                                    if ui.button(crate::tl!("Duplicate")).clicked() {+                                        act = Some(Act::Duplicate);+                                        ui.close();+                                    }+                                    if ui.button(crate::tl!("Delete")).clicked() {+                                        act = Some(Act::Remove);+                                        ui.close();+                                    }+                                });+                            }+                            if rows.is_empty() {+                                ui.label(RichText::new(crate::tl!("No sheets. Add sheets below.")).color(t.text_dim));+                            }+                            // The space below the rows: a drag there draws a selection rectangle.+                            let rest = ui.available_size().max(vec2(w, 30.0));+                            let (blank, bresp) = ui.allocate_exact_size(vec2(w, rest.y), Sense::click_and_drag());+                            if bresp.clicked() && !ui.input(|i| i.modifiers.command || i.modifiers.shift) {+                                act = Some(Act::Band(Vec::new(), false));+                            }+                            if bresp.drag_started() {+                                st.band_from = bresp.interact_pointer_pos();+                            }+                            if let (Some(from), Some(to)) = (st.band_from, ui.ctx().pointer_latest_pos()) {+                                let band = Rect::from_two_pos(from, to);+                                ui.painter().rect_filled(band, 0.0, t.accent.gamma_multiply(0.15));+                                ui.painter().rect_stroke(band, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);+                                let hit: Vec<usize> = rects.iter().enumerate().filter(|(_, r)| r.intersects(band)).map(|(i, _)| i).collect();+                                if ui.input(|i| i.pointer.any_released()) {+                                    act = Some(Act::Band(hit, ui.input(|i| i.modifiers.command)));+                                    st.band_from = None;+                                }+                            }+                            let _ = blank;+                            // Dragging rows: a line where they will land.+                            if st.dragging {+                                let pointer = ui.ctx().pointer_latest_pos();+                                let slot = pointer.map(|p| rects.iter().position(|r| p.y < r.center().y).unwrap_or(rects.len()));+                                if let Some(slot) = slot {+                                    let y = rects.get(slot).map_or_else(|| rects.last().map_or(0.0, |r| r.bottom()), |r| r.top());+                                    if let Some(first) = rects.first() {+                                        ui.painter().hline(first.left()..=first.right(), y, Stroke::new(2.0, t.accent));+                                    }+                                }+                                ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);+                                if ui.input(|i| i.pointer.any_released()) {+                                    if let Some(slot) = slot {+                                        act = Some(Act::MoveTo(slot));+                                    }+                                    st.dragging = false;+                                }+                            }+                        });+                    });+                });+                // The order and list buttons.+                ui.vertical(|ui| {+                    ui.set_width(180.0);+                    let any = !st.selected.is_empty();+                    let n = st.sheets.len();+                    let first = st.selected.iter().min().copied().unwrap_or(0);+                    let last = st.selected.iter().max().copied().unwrap_or(0);+                    let row = |ui: &mut egui::Ui, icon: Icon, label: &str, enabled: bool| -> bool {+                        ui.add_enabled_ui(enabled, |ui| {+                            ui.horizontal(|ui| {+                                let r = ui.add_sized(vec2(176.0, 26.0), egui::Button::new(""));+                                let ir = Rect::from_center_size(pos2(r.rect.left() + 15.0, r.rect.center().y), vec2(18.0, 18.0));+                                icons::paint(ui.painter(), ir, icon, !enabled);+                                let col = if enabled { Tokens::get().text } else { Tokens::get().text_faint };+                                ui.painter().text(+                                    pos2(r.rect.left() + 30.0, r.rect.center().y),+                                    egui::Align2::LEFT_CENTER,+                                    label,+                                    crate::theme::body(),+                                    col,+                                );+                                r.clicked()+                            })+                            .inner+                        })+                        .inner+                    };+                    if row(ui, Icon::MoveTop, crate::tl!("Move to Top"), any && first > 0) {+                        act = Some(Act::MoveTo(0));+                    }+                    if row(ui, Icon::MoveUp, crate::tl!("Move Higher"), any && first > 0) {+                        act = Some(Act::Up);+                    }+                    if row(ui, Icon::MoveDown, crate::tl!("Move Lower"), any && last + 1 < n) {+                        act = Some(Act::Down);+                    }+                    if row(ui, Icon::MoveBottom, crate::tl!("Move to Bottom"), any && last + 1 < n) {+                        act = Some(Act::MoveTo(n));+                    }+                    ui.add_space(6.0);+                    if row(ui, Icon::Copy, crate::tl!("Duplicate"), any) {+                        act = Some(Act::Duplicate);+                    }+                    if row(ui, Icon::Erase, crate::tl!("Delete"), any) {+                        act = Some(Act::Remove);+                    }+                    if row(ui, Icon::Close, crate::tl!("Clear List"), n > 0) {+                        act = Some(Act::Clear);+                    }+                    ui.add_space(4.0);+                    ui.label(+                        RichText::new(crate::tl!("Ctrl and Shift pick several sheets; drag in the empty space to pick with a rectangle."))+                            .small()+                            .color(t.text_dim),+                    );+                });+            });+            ui.add_space(6.0);+            ui.separator();+            // Adding sheets: from any open drawing.+            ui.horizontal(|ui| {+                ui.label(crate::tl!("Add sheets from"));+                let from = st.add_from.filter(|f| uids.contains(f)).unwrap_or(current_doc);+                let from_name = titles.iter().find(|(u, _)| *u == from).map(|(_, n)| n.clone()).unwrap_or_default();+                let shown = crate::i18n::widget(ui, &from_name, egui::TextStyle::Body);+                egui::ComboBox::from_id_salt("cc_batch_from").selected_text(shown).width(220.0).show_ui(ui, |ui| {+                    for (uid, title) in &titles {+                        let name = crate::i18n::widget(ui, title, egui::TextStyle::Body);+                        if ui.selectable_label(*uid == from, name).clicked() {+                            st.add_from = Some(*uid);+                        }+                    }+                });+                if ui.button(crate::tl!("Add All")).clicked() {+                    act = Some(Act::AddAll(from));+                }+            });+            let from = st.add_from.filter(|f| uids.contains(f)).unwrap_or(current_doc);+            let available = app.session.docs.iter().find(|d| d.uid == from).map(|d| sheets_of(&d.doc, d.uid, &d.title)).unwrap_or_default();+            egui::ScrollArea::horizontal().id_salt("cc_batch_add").max_height(34.0).show(ui, |ui| {+                ui.horizontal(|ui| {+                    for s in available {+                        if ui+                            .button(format!("+ {}", crate::i18n::t(&s.layout)))+                            .on_hover_text(crate::tl!("Add this sheet to the end of the list"))+                            .clicked()+                        {+                            act = Some(Act::Add(s));+                        }+                    }+                });+            });+            ui.separator();+            ui.horizontal(|ui| {+                ui.radio_value(&mut st.separate, false, crate::tl!("One PDF file with all sheets"));+                ui.radio_value(&mut st.separate, true, crate::tl!("A separate PDF file for each sheet"));+            });+            if let Some(n) = &st.note {+                ui.label(RichText::new(n).color(t.text_dim));+            }+            ui.add_space(4.0);+            ui.horizontal(|ui| {+                let enabled = can_plot && !st.sheets.is_empty();+                let r = ui.add_enabled(enabled, egui::Button::new(crate::tl!("Plot...")));+                let r = if can_plot { r } else { r.on_disabled_hover_text(crate::tl!("There is no file dialog here")) };+                if r.clicked() {+                    act = Some(Act::Plot);+                }+                if ui.button(crate::tl!("Close")).clicked() {+                    close = true;+                }+                ui.label(RichText::new(format!("{} {}", st.sheets.len(), crate::tl!("sheets"))).color(t.text_dim));+            });+            // Ctrl+A picks every sheet.+            if ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::A)) {+                act = Some(Act::SelectAll);+            }+        });+    let n = st.sheets.len();+    match act {+        Some(Act::Click(i, m)) => {+            if m.shift {+                let a = st.anchor.unwrap_or(i).min(n.saturating_sub(1));+                let (lo, hi) = (a.min(i), a.max(i));+                if !m.command {+                    st.selected.clear();+                }+                for k in lo..=hi {+                    if !st.selected.contains(&k) {+                        st.selected.push(k);+                    }+                }+            } else if m.command {+                if let Some(p) = st.selected.iter().position(|k| *k == i) {+                    st.selected.remove(p);+                } else {+                    st.selected.push(i);+                }+                st.anchor = Some(i);+            } else {+                st.selected = vec![i];+                st.anchor = Some(i);+            }+        }+        Some(Act::Band(hit, add)) => {+            if !add {+                st.selected.clear();+            }+            for k in hit {+                if !st.selected.contains(&k) {+                    st.selected.push(k);+                }+            }+            st.anchor = st.selected.first().copied();+        }+        Some(Act::SelectAll) => st.selected = (0..n).collect(),+        Some(Act::MoveTo(slot)) => {+            let picked = st.selected.clone();+            st.selected = move_sheets(&mut st.sheets, &picked, slot);+        }+        Some(Act::Up) => {+            let first = st.selected.iter().min().copied().unwrap_or(0);+            let picked = st.selected.clone();+            st.selected = move_sheets(&mut st.sheets, &picked, first.saturating_sub(1));+        }+        Some(Act::Down) => {+            let last = st.selected.iter().max().copied().unwrap_or(0);+            let picked = st.selected.clone();+            st.selected = move_sheets(&mut st.sheets, &picked, (last + 2).min(n));+        }+        Some(Act::Duplicate) => {+            // Each picked sheet gets its copy right after the block of picked sheets.+            let mut idx = st.selected.clone();+            idx.sort_unstable();+            let copies: Vec<Sheet> = idx.iter().filter_map(|i| st.sheets.get(*i).cloned()).collect();+            let at = idx.last().map_or(n, |l| l + 1);+            let k = copies.len();+            for (j, c) in copies.into_iter().enumerate() {+                st.sheets.insert(at + j, c);+            }+            st.selected = (at..at + k).collect();+        }+        Some(Act::Remove) => {+            let mut idx = st.selected.clone();+            idx.sort_unstable();+            idx.dedup();+            for i in idx.iter().rev() {+                if *i < st.sheets.len() {+                    st.sheets.remove(*i);+                }+            }+            st.selected = idx.first().filter(|_| !st.sheets.is_empty()).map(|f| vec![(*f).min(st.sheets.len() - 1)]).unwrap_or_default();+        }+        Some(Act::Add(s)) => {+            st.sheets.push(s);+            st.selected = vec![st.sheets.len() - 1];+        }+        Some(Act::AddAll(uid)) => {+            if let Some(d) = app.session.docs.iter().find(|d| d.uid == uid) {+                st.sheets.extend(sheets_of(&d.doc, d.uid, &d.title));+            }+        }+        Some(Act::Clear) => {+            st.sheets.clear();+            st.selected.clear();+        }+        Some(Act::Plot) => {+            let stem = std::path::Path::new(&current)+                .file_stem()+                .map(|s| s.to_string_lossy().to_string())+                .filter(|s| !s.is_empty())+                .unwrap_or_else(|| "Drawing".into());+            let picked = app.services.pick_save.as_ref().and_then(|pick| pick(&format!("{stem}.pdf")));+            if let Some(path) = picked {+                match publish_params(&app.session.docs.iter().map(|d| d.uid).collect::<Vec<_>>(), &st.sheets, !st.separate, Some(&path))+                    .and_then(|p| app.run("publish", p))+                {+                    Ok(r) => {+                        let msg = r["message"].as_str().unwrap_or("").to_string();+                        app.session.echo(msg);+                        close = true;+                    }+                    Err(e) => st.note = Some(e),+                }+            }+        }+        None => {}+    }+    if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !st.dragging {+        *open = false;+    }+    if *open {+        ctx.data_mut(|d| d.insert_temp(state_id(), st));+    } else {+        // Opened again later: filled anew from the drawing then current.+        ctx.data_mut(|d| d.remove::<State>(state_id()));+    }+}++#[cfg(test)]+mod tests {+    use super::{Sheet, move_sheet, move_sheets, publish_params};++    fn s(n: &str) -> Sheet {+        Sheet { doc: 1, drawing: "A.dwg".into(), layout: n.into() }+    }++    #[test]+    fn sheets_move_to_a_slot() {+        let mut v = vec![s("1"), s("2"), s("3"), s("4")];+        assert_eq!(move_sheet(&mut v, 3, 0), Some(0));+        assert_eq!(v.iter().map(|x| x.layout.as_str()).collect::<Vec<_>>(), ["4", "1", "2", "3"]);+        assert_eq!(move_sheet(&mut v, 0, 4), Some(3));+        assert_eq!(v.iter().map(|x| x.layout.as_str()).collect::<Vec<_>>(), ["1", "2", "3", "4"]);+        assert_eq!(move_sheet(&mut v, 1, 3), Some(2));+        assert_eq!(v.iter().map(|x| x.layout.as_str()).collect::<Vec<_>>(), ["1", "3", "2", "4"]);+        assert_eq!(move_sheet(&mut v, 9, 0), None);+    }++    #[test]+    fn several_sheets_move_as_a_block() {+        let names = |v: &[Sheet]| v.iter().map(|x| x.layout.clone()).collect::<Vec<_>>().join("");+        let mut v = vec![s("1"), s("2"), s("3"), s("4"), s("5")];+        assert_eq!(move_sheets(&mut v, &[3, 1], 0), vec![0, 1]);+        assert_eq!(names(&v), "24135");+        assert_eq!(move_sheets(&mut v, &[0, 1], 5), vec![3, 4]);+        assert_eq!(names(&v), "13524");+        assert_eq!(move_sheets(&mut v, &[2], 1), vec![1]);+        assert_eq!(names(&v), "15324");+    }++    /// Two open files with the same name (`/A/plan.dwg`, `/B/plan.dwg`): each sheet plots from+    /// the drawing it was added from, and a closed drawing is an error, not another drawing.+    #[test]+    fn sheets_resolve_by_drawing_identity_not_title() {+        let a = Sheet { doc: 7, drawing: "plan.dwg".into(), layout: "A1".into() };+        let b = Sheet { doc: 9, drawing: "plan.dwg".into(), layout: "A1".into() };+        let p = publish_params(&[7, 9], &[b.clone(), a.clone()], true, None).unwrap();+        assert_eq!(p["sheets"][0]["drawing"], 1);+        assert_eq!(p["sheets"][1]["drawing"], 0);+        assert!(publish_params(&[7], &[b], true, None).is_err());+    }+}diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs--- a/crates/ui-egui/src/chrome.rs+++ b/crates/ui-egui/src/chrome.rs@@ -77,7 +77,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {             };             // Centred on the traffic lights' centre line (the row's middle): with the system font             // the capitals' middle is the text box's middle, so no optical nudge.-            let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim);+            let galley = ui.painter().layout_job(crate::i18n::job(&title, egui::FontId::proportional(13.0), t.text_dim));             let x = title_left(r.left(), r.width(), galley.size().x);             ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);         }@@ -262,7 +262,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { /// `text` laid out on one line no wider than `max_width`, ending in "…" when it had to be cut; /// and whether it was. fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color32, max_width: f32) -> (std::sync::Arc<egui::Galley>, bool) {-    let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);+    let mut job = crate::i18n::job(text, font, color);     job.wrap = egui::text::TextWrapping { max_width: max_width.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };     let galley = p.layout_job(job);     let cut = galley.elided;diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs--- a/crates/ui-egui/src/closing.rs+++ b/crates/ui-egui/src/closing.rs@@ -208,7 +208,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) {         ui.set_width(360.0);         ui.label(RichText::new(crate::tl!("CADCraft")).strong());         ui.add_space(4.0);-        ui.label(crate::tf!("Save changes to {name}?", name = title));+        ui.label(crate::i18n::widget(ui, &crate::tf!("Save changes to {name}?", name = title), egui::TextStyle::Body));         ui.label(RichText::new(crate::tl!("Your changes will be lost if you don't save them.")).color(t.text_dim));         ui.add_space(10.0);         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs--- a/crates/ui-egui/src/cmdline.rs+++ b/crates/ui-egui/src/cmdline.rs@@ -284,7 +284,8 @@ fn enable_ime(app: &mut CadApp, ctx: &egui::Context) {  /// Handle keyboard input destined for the command line (when no text field has focus). pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {-    if ctx.egui_wants_keyboard_input() {+    // A modal window (Batch Plot) has the keyboard.+    if ctx.egui_wants_keyboard_input() || app.ui.dialog.as_deref() == Some("publish") {         app.cmd.preedit.clear();         app.cmd.ime_on = false;         return;@@ -453,6 +454,13 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize {     app.ui.history_lines.min(MAX_LINES).min(available) } +/// One line of command-line text laid out in reading order: text holding right-to-left letters+/// (a Hebrew text or layer name in a prompt, history line or typed value) would otherwise be+/// turned around as a whole by the shaper, English words and numbers included.+fn lay(p: &egui::Painter, s: String, font: egui::FontId, color: egui::Color32) -> std::sync::Arc<egui::Galley> {+    p.layout_job(crate::i18n::job(&s, font, color))+}+ /// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {     let t = Tokens::get();@@ -469,7 +477,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {         let hr = Rect::from_min_size(pos2(bar.left(), bar.top() - lh * lines.len() as f32 - 4.0), vec2(w, lh * lines.len() as f32 + 2.0));         p.rect_filled(hr, 3.0, t.cmd_history);         for (i, l) in lines.iter().enumerate() {-            p.text(pos2(hr.left() + 8.0, hr.top() + 1.0 + lh * i as f32), egui::Align2::LEFT_TOP, l, crate::theme::small(), t.text_dim);+            crate::i18n::paint(+                &p,+                pos2(hr.left() + 8.0, hr.top() + 1.0 + lh * i as f32),+                egui::Align2::LEFT_TOP,+                l,+                crate::theme::small(),+                t.text_dim,+            );         }     }     p.rect_filled(bar, 3.0, t.cmd_bg);@@ -483,24 +498,24 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {         Some(pr) => {             // The grip modes name themselves ("** STRETCH **…").             let name = app.session.running.as_ref().filter(|r| r.id != "grip").map(|r| r.id.to_ascii_uppercase() + " ").unwrap_or_default();-            let g = p.layout_no_wrap(name, font.clone(), t.text_faint);+            let g = lay(&p, name, font.clone(), t.text_faint);             let gw = g.size().x;             p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint);             x += gw;             // A LISP `get…` prompt shows as the routine wrote it (no keywords, default or colon added).             let message = if pr.raw { pr.display() } else { crate::i18n::t(&pr.message).to_string() };-            let g = p.layout_no_wrap(message, font.clone(), t.text);+            let g = lay(&p, message, font.clone(), t.text);             let gw = g.size().x;             p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);             x += gw;             if !pr.raw && !pr.keywords.is_empty() {-                let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text);+                let g = lay(&p, if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;                 let kws = pr.keywords.clone();                 for (i, k) in kws.iter().enumerate() {-                    let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword);+                    let g = lay(&p, k.clone(), font.clone(), t.cmd_keyword);                     let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0));                     let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click());                     if resp.hovered() {@@ -513,25 +528,25 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {                         let _ = app.session.input(Input::Keyword(k.clone()));                     }                     if i + 1 < kws.len() {-                        let g = p.layout_no_wrap("/".into(), font.clone(), t.text);+                        let g = lay(&p, "/".into(), font.clone(), t.text);                         let gw = g.size().x;                         p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                         x += gw;                     }                 }-                let g = p.layout_no_wrap("]".into(), font.clone(), t.text);+                let g = lay(&p, "]".into(), font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;             }             if let Some(d) = pr.default.as_ref().filter(|_| !pr.raw) {-                let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text);+                let g = lay(&p, format!(" <{d}>"), font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;             }             if !pr.raw {-                let g = p.layout_no_wrap(": ".into(), font.clone(), t.text);+                let g = lay(&p, ": ".into(), font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;@@ -551,7 +566,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {             }         }     }-    let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text);+    let g = lay(&p, app.cmd.buffer.clone(), font.clone(), t.text);     let gw = g.size().x;     p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);     let mut caret_x = x + gw;@@ -708,7 +723,7 @@ fn history_window(app: &CadApp, ui: &mut egui::Ui, r: Rect) {             for l in log.get(rows).into_iter().flatten() {                 let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), LINE_H), Sense::hover());                 let p = ui.painter().with_clip_rect(row.intersect(ui.clip_rect()));-                p.text(pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim);+                crate::i18n::paint(&p, pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim);             }         },     );diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -25,6 +25,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {         "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),         "about" => about(ctx, &mut open),         "preview" => crate::exchange::dialog(app, ctx, &mut open),+        "publish" => crate::batchplot::dialog(app, ctx, &mut open),         "commands" => commands(app, ctx, &mut open),         "blocks" => blocks(app, ctx, &mut open),         "bedit" => crate::bedit::dialog(app, ctx, &mut open),diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs--- a/crates/ui-egui/src/exchange.rs+++ b/crates/ui-egui/src/exchange.rs@@ -31,11 +31,9 @@ pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> {             }         }         "publish" => {-            let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "PUBLISH", "publish {}") };-            match pick(&format!("{stem}.pdf")) {-                Some(path) => app.run("publish", json!({ "path": path })),-                None => Ok(Value::Null),-            }+            // The Batch Plot window: which sheets, in what order, into one file or several.+            app.ui.dialog = Some("publish".into());+            Ok(Value::Null)         }         _ => {             let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "EXPORT", r#"export {"format": "svg"}"#) };@@ -53,6 +51,15 @@ fn no_picker(app: &mut CadApp, name: &str, example: &str) -> Result<Value, Strin     Err(msg) } +/// The preview's zoom (1 = the sheet fits the window) and a scroll position to apply.+#[derive(Clone)]+struct View {+    generation: u32,+    zoom: f32,+    offset: Option<egui::Vec2>,+    last_offset: egui::Vec2,+}+ #[derive(Clone)] struct Preview {     generation: u32,@@ -76,20 +83,33 @@ pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value,     app.ui.dialog = Some("preview".into()); } +/// `img` made small enough for the graphics card's largest texture (egui refuses a larger one).+fn fit_texture(ctx: &egui::Context, img: image::RgbaImage) -> image::RgbaImage {+    let max = u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(2048).max(256);+    let (w, h) = (img.width(), img.height());+    if w <= max && h <= max {+        return img;+    }+    let k = f64::from(max) / f64::from(w.max(h));+    let nw = ((f64::from(w) * k).floor() as u32).clamp(1, max);+    let nh = ((f64::from(h) * k).floor() as u32).clamp(1, max);+    image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)+}+ /// Render the plot preview of the current layout (or model), or of the Plot dialog's settings, /// as a texture. fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> {     if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))) {         let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?;         let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;-        let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();+        let img = fit_texture(ctx, image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8());         let size = [img.width() as usize, img.height() as usize];         let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);         return Ok((tex, f.info));     }-    let r = app.session.execute("preview", &json!({ "width": 1600 })).map_err(|e| e.to_string())?;+    let r = app.session.execute("preview", &json!({ "width": 2400 })).map_err(|e| e.to_string())?;     let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?;-    let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8();+    let img = fit_texture(ctx, image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8());     let size = [img.width() as usize, img.height() as usize];     let tex = ctx.load_texture("plot-preview", egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR);     let mut info = format!("{} · {}", r["layout"].as_str().unwrap_or(""), r["paper"].as_str().unwrap_or(""));@@ -113,24 +133,116 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {         }     };     let (mut close, mut print) = (false, false);-    egui::Window::new("Plot Preview").open(open).collapsible(false).resizable(false).show(ctx, |ui| {-        match &preview.shown {-            Ok((tex, info)) => {-                ui.label(info);-                let size = tex.size_vec2();-                let k = (760.0 / size.x).min(540.0 / size.y).min(1.0);-                ui.image((tex.id(), size * k));-            }-            Err(e) => {-                ui.label(e);+    let view_id = id.with("view");+    let mut view = ctx.data(|d| d.get_temp::<View>(view_id)).filter(|v| v.generation == generation).unwrap_or(View {+        generation,+        zoom: 1.0,+        offset: None,+        last_offset: egui::Vec2::ZERO,+    });+    egui::Window::new(crate::tl!("Plot Preview"))+        .id(egui::Id::unique("Plot Preview"))+        .open(open)+        .collapsible(false)+        .resizable(true)+        .default_size(egui::vec2(820.0, 640.0))+        .min_size(egui::vec2(360.0, 300.0))+        .show(ctx, |ui| {+            // Print, zoom in and out, fit, the zoom level and Close.+            let mut zoom_by: Option<f32> = None;+            ui.horizontal(|ui| {+                print = ui.button(crate::tl!("Print...")).clicked();+                ui.separator();+                if crate::icons::button(ui, crate::icons::Icon::ZoomIn, 22.0, crate::tl!("Zoom In"), false).clicked() {+                    zoom_by = Some(1.25);+                }+                if crate::icons::button(ui, crate::icons::Icon::ZoomOut, 22.0, crate::tl!("Zoom Out"), false).clicked() {+                    zoom_by = Some(0.8);+                }+                if crate::icons::button(ui, crate::icons::Icon::ZoomFit, 22.0, crate::tl!("Zoom to Fit"), false).clicked() {+                    view.zoom = 1.0;+                    view.offset = Some(egui::Vec2::ZERO);+                }+                ui.label(format!("{:.0}%", view.zoom * 100.0));+                ui.separator();+                close = ui.button(crate::tl!("Close")).clicked();+                if let Ok((_, info)) = &preview.shown {+                    ui.label(egui::RichText::new(info).weak());+                }+            });+            ui.separator();+            match &preview.shown {+                Ok((tex, _)) => {+                    let size = tex.size_vec2();+                    let avail = ui.available_size();+                    let fit = (avail.x / size.x).min(avail.y / size.y).max(1e-3);+                    let viewport = ui.available_rect_before_wrap();+                    let wheel = ui.ctx().input(|i| {+                        if viewport.contains(i.pointer.hover_pos().unwrap_or_default()) {+                            i.smooth_scroll_delta.y + i.zoom_delta().ln() * 200.0+                        } else {+                            0.0+                        }+                    });+                    let pointer = ui.ctx().pointer_hover_pos().filter(|p| viewport.contains(*p));+                    // The mouse wheel zooms about the cursor; the buttons about the middle.+                    let (factor, about) = match zoom_by {+                        Some(k) => (k, viewport.center()),+                        None if wheel.abs() > 0.0 && pointer.is_some() => ((wheel / 200.0).exp(), pointer.unwrap_or(viewport.center())),+                        None => (1.0, viewport.center()),+                    };+                    let old_zoom = view.zoom;+                    view.zoom = (view.zoom * factor).clamp(1.0 / 4.0, 16.0);+                    let mut area = egui::ScrollArea::both()+                        .id_salt("cc_plot_preview_scroll")+                        .auto_shrink([false, false])+                        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)+                        .scroll_source(egui::scroll_area::ScrollSource {+                            scroll_bar: true,+                            drag: egui::scroll_area::DragScroll::Never,+                            mouse_wheel: false,+                        });+                    if (view.zoom - old_zoom).abs() > f32::EPSILON {+                        // Keep the point under `about` where it is.+                        let ratio = view.zoom / old_zoom;+                        let rel = about - viewport.min;+                        let old = view.offset.unwrap_or(view.last_offset);+                        view.offset = Some(((old + rel) * ratio - rel).max(egui::Vec2::ZERO));+                    }+                    if let Some(o) = view.offset.take() {+                        area = area.scroll_offset(o);+                    }+                    let shown = size * fit * view.zoom;+                    // Scroll bars that stay visible (not the thin floating ones) once the sheet is larger.+                    ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();+                    let mut pan = egui::Vec2::ZERO;+                    let out = area.show(ui, |ui| {+                        // Centred while smaller than the window.+                        let pad = ((ui.available_size() - shown) / 2.0).max(egui::Vec2::ZERO);+                        let (r, resp) = ui.allocate_exact_size(shown + pad * 2.0, egui::Sense::drag());+                        let img = egui::Rect::from_min_size(r.min + pad, shown);+                        ui.painter().rect_filled(img.translate(egui::vec2(3.0, 3.0)), 0.0, egui::Color32::from_black_alpha(90));+                        ui.painter().image(tex.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);+                        if resp.hovered() || resp.dragged() {+                            ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });+                        }+                        // Dragging the sheet moves it with the pointer.+                        if resp.dragged() {+                            pan = resp.drag_delta();+                        }+                    });+                    view.last_offset = out.state.clamped_offset();+                    if pan != egui::Vec2::ZERO {+                        view.offset = Some((out.state.clamped_offset() - pan).max(egui::Vec2::ZERO));+                        ui.ctx().request_repaint();+                    }+                }+                Err(e) => {+                    ui.label(e);+                }             }-        }-        ui.add_space(4.0);-        ui.horizontal(|ui| {-            print = ui.button("Print...").clicked();-            close = ui.button("Close").clicked();         });-    });+    ctx.data_mut(|d| d.insert_temp(view_id, view));     if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {         close = true;     }@@ -187,8 +299,9 @@ mod tests {         app.start("export");         app.cmdline("PUBLISH");         app.start("imp");-        assert_eq!(*asked.borrow(), ["Drawing1.svg", "Drawing1.pdf", "open"]);-        assert!(app.ui.dialog.is_none());+        assert_eq!(*asked.borrow(), ["Drawing1.svg", "open"]);+        assert_eq!(app.ui.dialog.as_deref(), Some("publish"), "PUBLISH opens the Batch Plot window");+        app.ui.dialog = None;         app.cmdline("pre");         assert_eq!(app.ui.dialog.as_deref(), Some("preview"));         app.ui.dialog = None;@@ -201,7 +314,7 @@ mod tests {         let _ = app.run("publish", json!({ "layouts": [] }));         let r = app.run("preview", json!({ "width": 64 })).unwrap();         assert!(r["data"].is_string());-        assert_eq!(asked.borrow().len(), 3, "JSON calls never open a file dialog");+        assert_eq!(asked.borrow().len(), 2, "JSON calls never open a file dialog");         assert!(app.ui.dialog.is_none(), "nor the preview window");          let e = app.run("share", json!({})).unwrap_err();diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -401,6 +401,106 @@ macro_rules! tf { #[cfg(test)] mod tests; +/// Whether `c` is written right to left (Hebrew, Arabic).+fn is_rtl(c: char) -> bool {+    matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF)+}++/// Whether `s` holds right-to-left letters, so it needs [`job`] to be drawn in reading order.+pub fn has_rtl(s: &str) -> bool {+    s.chars().any(is_rtl)+}++/// Whether `c` attaches to the letter before it (combining marks, Hebrew points, joiners).+fn is_mark(c: char) -> bool {+    matches!(u32::from(c), 0x0300..=0x036F | 0x0591..=0x05BD | 0x05BF | 0x05C1..=0x05C2 | 0x05C4..=0x05C5 | 0x05C7 | 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x200C..=0x200D | 0xFE00..=0xFE0F | 0xFE20..=0xFE2F)+}++/// The pieces of `s` in display order for a left-to-right line, each to be shaped on its own:+/// left-to-right runs whole, right-to-left runs letter by letter (with their marks) reversed and+/// brackets mirrored. The text shaper turns any piece holding a right-to-left letter around, and+/// with a font that covers Latin and Hebrew alike (Segoe UI) it would turn the whole name+/// (extension included) around; one letter per piece leaves nothing to turn.+fn display_pieces(s: &str) -> Vec<String> {+    let chars: Vec<char> = s.chars().collect();+    let mut out = Vec::new();+    for (r, rtl) in cadcraft_fonts::bidi::visual_runs(&chars, Some(false)) {+        let run = chars.get(r).unwrap_or_default();+        if !rtl {+            out.push(run.iter().collect());+            continue;+        }+        let mut clusters: Vec<String> = Vec::new();+        for c in run {+            match clusters.last_mut() {+                Some(last) if is_mark(*c) => last.push(*c),+                _ => clusters.push(cadcraft_fonts::bidi::mirror(*c).to_string()),+            }+        }+        out.extend(clusters.into_iter().rev());+    }+    out+}++/// `s` (a file, drawing, block or layer name…) laid out on one line in reading order: right-to-left+/// runs read as they do in the file manager, `שלום.dwg` with the extension on the right.+pub fn job(s: &str, font: egui::FontId, color: egui::Color32) -> egui::text::LayoutJob {+    let mut job = egui::text::LayoutJob::simple_singleline(s.to_string(), font, color);+    // The text shaper orders right-to-left runs itself (UAX #9); the line reads left to right, as+    // the interface does, so a Hebrew name keeps its extension on the right.+    if has_rtl(s) {+        job.direction = egui::epaint::text::TextDirection::LeftToRight;+    }+    job+}++/// `s` as widget text (button, label, menu item) in reading order; the widget's own colour.+pub fn widget(ui: &egui::Ui, s: &str, style: egui::TextStyle) -> egui::WidgetText {+    if !has_rtl(s) {+        return s.to_string().into();+    }+    egui::WidgetText::from(job(s, style.resolve(ui.style()), egui::Color32::PLACEHOLDER))+}++/// Paints `s` in reading order, anchored like [`egui::Painter::text`]; returns its rectangle.+pub fn paint(p: &egui::Painter, pos: egui::Pos2, anchor: egui::Align2, s: &str, font: egui::FontId, color: egui::Color32) -> egui::Rect {+    let galley = p.layout_job(job(s, font, color));+    let rect = anchor.anchor_size(pos, galley.size());+    p.galley(rect.min, galley, color);+    rect+}++/// `s` in display order as one plain string, for tests and logs (what [`job`] shows).+pub fn ltr(s: &str) -> std::borrow::Cow<'_, str> {+    if !has_rtl(s) {+        return std::borrow::Cow::Borrowed(s);+    }+    std::borrow::Cow::Owned(display_pieces(s).concat())+}++#[cfg(test)]+mod ltr_tests {+    #[test]+    fn hebrew_file_names_keep_the_extension_on_the_right() {+        assert_eq!(super::ltr("שלום.dwg"), "םולש.dwg");+        assert_eq!(super::ltr("plan 2.dwg"), "plan 2.dwg");+        assert_eq!(super::ltr("תוכנית קומה 1.dwg"), "1 המוק תינכות.dwg");+    }++    #[test]+    fn every_right_to_left_letter_is_shaped_alone() {+        // Pieces: each Hebrew letter by itself, the extension whole, so the shaper (which turns+        // any piece holding Hebrew around) cannot reverse the name or its extension.+        assert_eq!(super::display_pieces("שלום.dwg"), ["ם", "ו", "ל", "ש", ".dwg"]);+        assert_eq!(super::display_pieces("plan.dwg"), ["plan.dwg"]);+        // Points stay on their letter.+        assert_eq!(super::display_pieces("שָׁלוֹם"), ["ם", "וֹ", "ל", "שָׁ"]);+        // The shaper orders the runs itself, on a left-to-right line.+        let job = super::job("שלום.dwg", egui::FontId::proportional(12.0), egui::Color32::WHITE);+        assert_eq!(job.direction, egui::epaint::text::TextDirection::LeftToRight);+    }+}+ /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are. fn name_sort_key(name: &str) -> String {diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs--- a/crates/ui-egui/src/icons.rs+++ b/crates/ui-egui/src/icons.rs@@ -149,6 +149,15 @@ pub enum Icon {     PickAdd,     SelectObjects,     QuickSelect,+    ZoomIn,+    ZoomOut,+    MoveTop,+    MoveUp,+    MoveDown,+    MoveBottom,+    ZoomFit,+    TextStyle,+    DimStyle, }  struct Pen<'a> {@@ -219,6 +228,13 @@ impl Pen<'_> {         let (nx, ny) = (-uy, ux);         self.tri([(x, y), (x - ux * 3.0 + nx * 1.4, y - uy * 3.0 + ny * 1.4), (x - ux * 3.0 - nx * 1.4, y - uy * 3.0 - ny * 1.4)], c);     }+    /// A larger arrowhead than [`Pen::arrow_tip`]: `len` long, `half` wide either side.+    fn big_arrow(&self, x: f32, y: f32, dx: f32, dy: f32, len: f32, half: f32, c: Color32) {+        let l = (dx * dx + dy * dy).sqrt().max(1e-3);+        let (ux, uy) = (dx / l, dy / l);+        let (nx, ny) = (-uy, ux);+        self.tri([(x, y), (x - ux * len + nx * half, y - uy * len + ny * half), (x - ux * len - nx * half, y - uy * len - ny * half)], c);+    }     fn text(&self, x: f32, y: f32, s: &str, size: f32, c: Color32) {         self.p.text(self.at(x, y), egui::Align2::CENTER_CENTER, s, egui::FontId::proportional(size * self.sc()), c);     }@@ -245,26 +261,50 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {             pen.rect(8.0, 4.0, 8.0, 5.0, b);             pen.rect(7.0, 13.0, 10.0, 7.0, b);             if icon == SaveAs {-                pen.la(15.0, 22.0, 22.0, 15.0);-                pen.fill_c(15.0, 22.0, 1.2, a);+                // A large pencil across the lower-right corner: body, ferrule line and a filled tip.+                pen.p.add(egui::Shape::convex_polygon(+                    [(12.6, 19.4), (20.6, 11.4), (23.6, 14.4), (15.6, 22.4)].iter().map(|(x, y)| pen.at(*x, *y)).collect(),+                    t.panel,+                    Stroke::NONE,+                ));+                pen.poly(&[(12.6, 19.4), (20.6, 11.4), (23.6, 14.4), (15.6, 22.4)], true, a);+                pen.la(19.0, 13.0, 22.0, 16.0);+                pen.tri([(12.6, 19.4), (15.6, 22.4), (10.8, 24.0)], a);             }         }-        Undo => {-            pen.arc(12.0, 13.0, 7.0, 150.0, -40.0, b);-            pen.arrow_tip(5.0, 9.0, -1.0, 2.5, b);-        }-        Redo => {-            pen.arc(12.0, 13.0, 7.0, 30.0, 220.0, b);-            pen.arrow_tip(19.0, 9.0, 1.0, 2.5, b);-        }-        Plot | Preview => {+        Undo | Redo => {+            // As in Office 2010: a thick blue hook over the top, its arrowhead at the open end+            // pointing down (Undo: on the left, Redo: mirrored on the right).+            let m = |x: f32| if icon == Undo { x } else { 24.0 - x };+            let (cx, cy, r) = (12.5_f32, 13.5_f32, 6.5_f32);+            let blue = if dim { a } else { Color32::from_rgb(0x3d, 0x8e, 0xe0) };+            let pts: Vec<egui::Pos2> = (0..=28)+                .map(|i| {+                    let ang = (-35.0 + 205.0 * i as f32 / 28.0).to_radians();+                    pen.at(m(cx + r * ang.cos()), cy - r * ang.sin())+                })+                .collect();+            p.add(egui::Shape::line(pts, Stroke::new(pen.w * 2.4, blue)));+            // The arrowhead at the arc's open end (170°), pointing down and a little out.+            let ang = 170.0_f32.to_radians();+            let (ex, ey) = (cx + r * ang.cos(), cy - r * ang.sin());+            let (dx, dy) = (-ang.sin(), -ang.cos());+            let (tx, ty) = (ex + dx * 4.6, ey + dy * 4.6);+            let ddx = if icon == Undo { dx } else { -dx };+            pen.big_arrow(m(tx), ty, ddx, dy, 7.0, 4.8, blue);+        }+        Plot => {             pen.rect(4.0, 9.0, 16.0, 8.0, b);             pen.rect(7.0, 4.0, 10.0, 5.0, b);             pen.rect(7.0, 14.0, 10.0, 7.0, b);-            if icon == Preview {-                pen.c(17.0, 17.0, 3.0, a);-                pen.la(19.0, 19.0, 22.0, 22.0);-            }+        }+        Preview => {+            // A smaller printer on the left and the magnifier clear of it on the right.+            pen.rect(1.5, 8.0, 12.0, 6.5, b);+            pen.rect(3.8, 4.0, 7.4, 4.0, b);+            pen.rect(3.8, 12.0, 7.4, 6.0, b);+            pen.c(17.5, 13.0, 4.0, a);+            p.line_segment([pen.at(20.4, 15.9), pen.at(23.0, 18.5)], Stroke::new(pen.w * 1.8, a));         }         Publish => {             pen.rect(3.0, 10.0, 13.0, 7.0, b);@@ -278,12 +318,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {         }         Import | Export => {             pen.poly(&[(5.0, 3.0), (14.0, 3.0), (18.0, 7.0), (18.0, 21.0), (5.0, 21.0)], true, b);+            // A large, thick arrow: into the page for Import, out of it for Export.+            let thick = Stroke::new(pen.w * 2.0, a);             if icon == Import {-                pen.la(1.0, 13.0, 11.0, 13.0);-                pen.arrow_tip(12.0, 13.0, 1.0, 0.0, a);+                pen.p.line_segment([pen.at(0.5, 13.0), pen.at(8.0, 13.0)], thick);+                pen.big_arrow(14.5, 13.0, 1.0, 0.0, 7.0, 5.0, a);             } else {-                pen.la(11.0, 13.0, 21.0, 13.0);-                pen.arrow_tip(23.0, 13.0, 1.0, 0.0, a);+                pen.p.line_segment([pen.at(9.0, 13.0), pen.at(17.0, 13.0)], thick);+                pen.big_arrow(24.0, 13.0, 1.0, 0.0, 7.0, 5.0, a);             }         }         Attach => {@@ -486,20 +528,24 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {             pen.la(14.0, 21.0, 19.0, 21.0);         }         Move => {-            pen.la(12.0, 3.0, 12.0, 21.0);-            pen.la(3.0, 12.0, 21.0, 12.0);-            pen.arrow_tip(12.0, 2.0, 0.0, -1.0, a);-            pen.arrow_tip(12.0, 22.0, 0.0, 1.0, a);-            pen.arrow_tip(2.0, 12.0, -1.0, 0.0, a);-            pen.arrow_tip(22.0, 12.0, 1.0, 0.0, a);+            pen.la(12.0, 6.0, 12.0, 18.0);+            pen.la(6.0, 12.0, 18.0, 12.0);+            pen.big_arrow(12.0, 1.5, 0.0, -1.0, 5.5, 3.3, a);+            pen.big_arrow(12.0, 22.5, 0.0, 1.0, 5.5, 3.3, a);+            pen.big_arrow(1.5, 12.0, -1.0, 0.0, 5.5, 3.3, a);+            pen.big_arrow(22.5, 12.0, 1.0, 0.0, 5.5, 3.3, a);         }         Copy => {             pen.rect(3.0, 3.0, 11.0, 13.0, b);             pen.rect(10.0, 8.0, 11.0, 13.0, a);         }         Rotate => {-            pen.arc(12.0, 12.0, 8.0, 100.0, 380.0, a);-            pen.arrow_tip(10.6, 4.1, -1.0, 0.1, a);+            // Most of a circle, counter-clockwise, ending in an arrowhead along it.+            pen.arc(12.0, 12.0, 8.0, 50.0, 300.0, a);+            let end = 300f32.to_radians();+            let (px, py) = (12.0 + 8.0 * end.cos(), 12.0 - 8.0 * end.sin());+            let (ux, uy) = (-end.sin(), -end.cos());+            pen.big_arrow(px + ux * 4.5, py + uy * 4.5, ux, uy, 5.5, 3.3, a);             pen.dot(12.0, 12.0);         }         Scale => {@@ -524,20 +570,22 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {             pen.la(4.0, 18.0, 4.0, 22.0);         }         Trim => {-            pen.l(8.0, 2.0, 8.0, 22.0);-            pen.l(16.0, 2.0, 16.0, 22.0);-            pen.l(2.0, 12.0, 8.0, 12.0);-            pen.l(16.0, 12.0, 22.0, 12.0);-            for x in [9.5, 12.0, 14.5] {-                pen.la(x, 12.0, x + 1.0, 12.0);-            }-            pen.poly(&[(10.0, 17.0), (12.0, 15.0), (14.0, 17.0)], false, a);+            // Scissors cutting a line in two: the line's halves apart under the blades.+            pen.l(1.5, 20.0, 9.0, 20.0);+            pen.l(15.0, 20.0, 22.5, 20.0);+            pen.c(7.5, 4.5, 3.0, a);+            pen.c(16.5, 4.5, 3.0, a);+            pen.poly(&[(9.3, 6.9), (14.6, 18.5)], false, a);+            pen.poly(&[(14.7, 6.9), (9.4, 18.5)], false, a);+            pen.tri([(14.6, 18.5), (13.0, 13.5), (11.8, 14.6)], a);+            pen.tri([(9.4, 18.5), (11.0, 13.5), (12.2, 14.6)], a);+            pen.fill_c(12.0, 12.6, 1.3, pen.pt);         }         Extend => {-            pen.l(20.0, 2.0, 20.0, 22.0);-            pen.l(3.0, 12.0, 11.0, 12.0);-            pen.la(11.0, 12.0, 19.0, 12.0);-            pen.arrow_tip(19.5, 12.0, 1.0, 0.0, a);+            pen.l(21.0, 2.0, 21.0, 22.0);+            pen.l(2.0, 12.0, 9.0, 12.0);+            pen.la(9.0, 12.0, 14.0, 12.0);+            pen.big_arrow(20.5, 12.0, 1.0, 0.0, 7.0, 4.0, a);         }         Fillet => {             pen.l(4.0, 20.0, 4.0, 12.0);@@ -604,7 +652,21 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {             pen.poly(&[(20.0, 6.0), (20.0, 11.0), (11.0, 11.0), (11.0, 14.0)], false, b);             pen.fill_rect(9.5, 14.0, 3.0, 8.0, a);         }-        DimLinear | DimAligned | DimBaseline | DimContinue | QDim => {+        DimAligned => {+            // The linear dimension's figure turned 13° counter-clockwise, as an aligned+            // dimension follows a slanted object.+            let (sin, cos) = 13f32.to_radians().sin_cos();+            let r = |x: f32, y: f32| (12.0 + (x - 12.0) * cos + (y - 12.0) * sin, 12.0 - (x - 12.0) * sin + (y - 12.0) * cos);+            let line = |x1: f32, y1: f32, x2: f32, y2: f32, c: Color32| pen.poly(&[r(x1, y1), r(x2, y2)], false, c);+            let (y0, y1) = (9.0, 19.0);+            line(4.0, y1, 4.0, 6.0, b);+            line(20.0, y1, 20.0, 6.0, b);+            line(4.0, y0, 20.0, y0, a);+            let ((ax, ay), (bx, by)) = (r(4.0, y0), r(20.0, y0));+            pen.arrow_tip(ax, ay, -cos, sin, a);+            pen.arrow_tip(bx, by, cos, -sin, a);+        }+        DimLinear | DimBaseline | DimContinue | QDim => {             let (y0, y1) = (8.0, 18.0);             pen.l(4.0, y1, 4.0, 5.0);             pen.l(20.0, y1, 20.0, 5.0);@@ -617,7 +679,6 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {                     pen.l(12.0, y1, 12.0, 5.0);                 }                 QDim => pen.text(12.0, 15.0, "Q", 7.0, b),-                DimAligned => {}                 _ => pen.text(12.0, 14.5, "1.5", 6.0, b),             }         }@@ -781,15 +842,20 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {             pen.l(17.0, 13.0, 17.0, 21.0);         }         Gear => {-            pen.c(12.0, 12.0, 4.0, b);+            // A gear wheel: eight square teeth on a rim, and the hole in the middle.+            let mut outline = Vec::new();             for i in 0..8 {-                let ang = (i as f32 * 45.0).to_radians();-                p.line_segment(-                    [pen.at(12.0 + 6.0 * ang.cos(), 12.0 + 6.0 * ang.sin()), pen.at(12.0 + 9.0 * ang.cos(), 12.0 + 9.0 * ang.sin())],-                    Stroke::new(2.2 * pen.sc(), b),-                );+                let c = i as f32 * 45.0;+                for (da, rr) in [(-17.0, 7.0), (-11.0, 9.6), (11.0, 9.6), (17.0, 7.0)] {+                    let a = (c + da).to_radians();+                    outline.push(pen.at(12.0 + rr * a.cos(), 12.0 + rr * a.sin()));+                }             }-            pen.c(12.0, 12.0, 6.5, b);+            if let Some(f) = outline.first().copied() {+                outline.push(f);+            }+            p.add(egui::Shape::line(outline, Stroke::new(pen.w * 1.2, b)));+            pen.c(12.0, 12.0, 3.2, b);         }         Annotation => {             pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], true, b);@@ -814,6 +880,59 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {                 pen.fill_rect(x, y, 8.0, 8.0, if x == 3.0 && y == 3.0 { a } else { b });             }         }+        ZoomIn | ZoomOut => {+            pen.c(10.0, 10.0, 7.0, b);+            p.line_segment([pen.at(15.0, 15.0), pen.at(21.5, 21.5)], Stroke::new(pen.w * 2.2, b));+            pen.la(6.5, 10.0, 13.5, 10.0);+            if icon == ZoomIn {+                pen.la(10.0, 6.5, 10.0, 13.5);+            }+        }+        MoveTop | MoveBottom => {+            // A bar at the end the arrow points to.+            let (bar, tip, tail, dir) = if icon == MoveTop { (4.0, 7.0, 21.0, -1.0) } else { (20.0, 17.0, 3.0, 1.0) };+            p.line_segment([pen.at(5.0, bar), pen.at(19.0, bar)], Stroke::new(pen.w * 1.6, a));+            pen.l(12.0, tail, 12.0, tip - 5.0 * dir);+            pen.big_arrow(12.0, tip, 0.0, dir, 6.0, 4.5, b);+        }+        MoveUp | MoveDown => {+            let dir = if icon == MoveUp { -1.0 } else { 1.0 };+            pen.l(12.0, 12.0 - 8.0 * dir, 12.0, 12.0 + 2.0 * dir);+            pen.big_arrow(12.0, 12.0 + 8.0 * dir, 0.0, dir, 7.0, 5.0, b);+        }+        ZoomFit => {+            // Four right-angle corners framing a rectangle.+            let s = Stroke::new(pen.w * 1.6, b);+            for (x, y, dx, dy) in [(3.0, 5.0, 1.0, 1.0), (21.0, 5.0, -1.0, 1.0), (3.0, 19.0, 1.0, -1.0), (21.0, 19.0, -1.0, -1.0)] {+                p.line(vec![pen.at(x + dx * 5.0, y), pen.at(x, y), pen.at(x, y + dy * 4.5)], s);+            }+            pen.rect(8.0, 9.0, 8.0, 6.0, a);+        }+        TextStyle | DimStyle => {+            if icon == TextStyle {+                pen.text(9.5, 10.5, "A", 15.0, b);+            } else {+                pen.l(2.0, 4.0, 2.0, 12.0);+                pen.l(15.0, 4.0, 15.0, 12.0);+                pen.l(2.0, 8.0, 15.0, 8.0);+                pen.arrow_tip(2.0, 8.0, -1.0, 0.0, b);+                pen.arrow_tip(15.0, 8.0, 1.0, 0.0, b);+            }+            // A small gear: the style's settings.+            let mut outline = Vec::new();+            for i in 0..6 {+                let c = i as f32 * 60.0;+                for (da, rr) in [(-20.0, 3.6), (-12.0, 5.0), (12.0, 5.0), (20.0, 3.6)] {+                    let ang = (c + da).to_radians();+                    outline.push(pen.at(17.5 + rr * ang.cos(), 17.5 + rr * ang.sin()));+                }+            }+            if let Some(f) = outline.first().copied() {+                outline.push(f);+            }+            p.add(egui::Shape::line(outline, Stroke::new(pen.w, a)));+            pen.c(17.5, 17.5, 1.5, a);+        }         Close => {             pen.l(6.0, 6.0, 18.0, 18.0);             pen.l(18.0, 6.0, 6.0, 18.0);@@ -945,6 +1064,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {  /// A shared button interaction with CADCraft's original multicolor icon painting. pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response {+    button_bare(ui, icon, size, tooltip, selected).on_hover_text(crate::i18n::t(tooltip))+}++/// [`button`] without its hover text, for callers that show a richer tooltip+/// ([`crate::toolbar::tool_button`]).+pub fn button_bare(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response {     let t = Tokens::get();     let visuals = |fill| ButtonVisuals { fill, stroke: Stroke::NONE, icon: t.icon };     let style = IconButtonStyle {@@ -961,7 +1086,7 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected:         paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled());     });     crate::control::record_widget(ui, &resp, tooltip, "button", Some(selected));-    resp.on_hover_text(crate::i18n::t(tooltip))+    resp }  /// Labels and focus outlines for controls whose existing geometry is painted by the app.@@ -980,9 +1105,8 @@ pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, s                 node.set_selected(selected);             }         });-        if response.clicked() {-            response.request_focus();-        }+        // A click leaves the keyboard with the drawing (shortcuts such as Ctrl+V and typed+        // commands keep working after switching drawings); Tab still reaches the tab.     }     if response.has_focus() && response.rect.width() > 4.0 && response.rect.height() > 4.0 {         ui.painter().rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.0, Tokens::get().accent), egui::StrokeKind::Inside);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -11,6 +11,7 @@ pub mod aliasdlg; pub mod animation; pub mod autosave;+pub mod batchplot; pub mod bedit; pub mod canvas; pub mod capture;diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -581,7 +581,7 @@ pub(crate) fn shortcut_label(s: &str, mac: bool) -> String { pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String>) {     match e {         Entry::Item { label, id, shortcut, enabled } => {-            let mut b = egui::Button::new(crate::i18n::t(label));+            let mut b = egui::Button::new(crate::i18n::widget(ui, crate::i18n::t(label), egui::TextStyle::Button));             if let Some(s) = shortcut {                 b = b.shortcut_text(shortcut_label(s, ui.ctx().os().is_mac()));             }diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs--- a/crates/ui-egui/src/palettes.rs+++ b/crates/ui-egui/src/palettes.rs@@ -556,7 +556,18 @@ fn edit_field(ui: &mut egui::Ui, id: egui::Id, value: String) -> Option<String>     let mut buf = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| value.clone());     let w = ui.available_width() - 10.0;     let te = id.with("te");-    let resp = ui.add_sized([w, 20.0], egui::TextEdit::singleline(&mut buf).id(te).font(crate::theme::body()));+    let focused = ui.memory(|m| m.has_focus(te));+    // Unfocused, a value holding right-to-left letters (a Hebrew text) shows in reading order;+    // the shaper would otherwise turn it around as a whole, English words and numbers included.+    let mut reading_order = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _wrap: f32| {+        let job = crate::i18n::job(text.as_str(), crate::theme::body(), ui.visuals().text_color());+        ui.fonts_mut(|f| f.layout_job(job))+    };+    let mut edit = egui::TextEdit::singleline(&mut buf).id(te).font(crate::theme::body());+    if !focused && crate::i18n::has_rtl(&value) {+        edit = edit.layouter(&mut reading_order);+    }+    let resp = ui.add_sized([w, 20.0], edit);     let mut out = None;     // Keyboard focus from memory: `Response::has_focus` is false while the window itself is     // unfocused, which dropped the typed text every frame.diff --git a/crates/ui-egui/tests/it/clipboard_between_drawings.rs b/crates/ui-egui/tests/it/clipboard_between_drawings.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/clipboard_between_drawings.rs@@ -0,0 +1,83 @@+//! Ctrl+C in one open drawing, Ctrl+V in another: the objects are pasted there.++use cadcraft_engine::doc::EntityKind;+use cadcraft_engine::geom::Vec2;+use cadcraft_engine::{Input, Session};+use cadcraft_ui_egui::{CadApp, Services};+use egui::{Event, vec2};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;++fn harness(app: CadApp) -> Harness<'static, CadApp> {+    let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_ui_state(+        |ui, app: &mut CadApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(5);+    h+}++/// The text the app put on the system clipboard in the last frames, if any.+fn copied(h: &Harness<'_, CadApp>) -> Option<String> {+    h.output().platform_output.commands.iter().find_map(|c| match c {+        egui::OutputCommand::CopyText(t) => Some(t.clone()),+        _ => None,+    })+}++#[test]+fn copy_in_one_drawing_and_paste_in_another() {+    let mut app = CadApp::new(Session::new(), Services::default());+    app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap();+    app.run("circle", json!({ "center": [5, 5], "radius": 2 })).unwrap();+    app.run("block", json!({ "name": "DOT", "base": [5, 5], "handles": [app.session.doc().unwrap().model.iter().nth(1).unwrap().handle.hex()], "keep": "convert" }))+        .unwrap();+    let first = app.session.active;+    let all: Vec<_> = app.session.doc().unwrap().model.iter().map(|e| e.handle).collect();+    app.session.set_selection(all);+    let mut h = harness(app);+    h.event(Event::Copy);+    let mut text = None;+    for _ in 0..4 {+        h.run_steps(1);+        text = text.or_else(|| copied(&h));+    }+    assert_eq!(+        h.state().session.clipboard.len(),+        2,+        "{:?}",+        h.state().session.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>()+    );+    // Another drawing, made active by clicking its tab (which must not keep the keyboard).+    h.state_mut().run("new", json!({})).unwrap();+    h.run_steps(3);+    let second = h.state().session.active;+    let title = h.state().session.docs[second].title.clone();+    h.state_mut().run("document.switch", json!({ "index": first })).unwrap();+    h.run_steps(3);+    h.get_by_label(&title).click();+    h.run_steps(3);+    assert_eq!(h.state().session.active, second);+    assert_ne!(second, first);+    assert!(h.state().session.doc().unwrap().model.is_empty());+    assert!(!h.ctx.egui_wants_keyboard_input(), "the clicked tab doesn't hold the keyboard");+    // Ctrl+V: egui reports it as a Paste event with the system clipboard's text.+    h.event(Event::Paste(text.unwrap_or_else(|| "something else".into())));+    h.run_steps(3);+    assert_eq!(+        h.state().session.running.as_ref().map(|r| r.id.as_str()),+        Some("pasteclip"),+        "{:?}",+        h.state().session.log.iter().rev().take(5).collect::<Vec<_>>()+    );+    h.state_mut().session.input(Input::Point(Vec2::new(100.0, 100.0))).unwrap();+    h.run_steps(2);+    let d = h.state().session.doc().unwrap();+    assert_eq!(d.model.len(), 2);+    assert!(d.model.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "DOT")));+    assert!(d.block("DOT").is_some());+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -1,5 +1,6 @@ mod animation_effects; mod chrome_geometry;+mod clipboard_between_drawings; mod context_menu; mod context_menus; mod control_cmdline;@@ -24,6 +25,7 @@ mod parameters_rename; mod pickdrag; mod properties_units; mod rapid_clicks;+mod rtl_names; mod selection_cycling; mod shared_controls; mod shortcut_menus;diff --git a/crates/ui-egui/tests/it/rtl_names.rs b/crates/ui-egui/tests/it/rtl_names.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/rtl_names.rs@@ -0,0 +1,17 @@+//! A Hebrew file name reads as it does in the file manager: the letters right to left, the+//! extension on the right (`שלום.dwg`), whatever the text shaper does with right-to-left runs.++#[test]+fn hebrew_file_name_is_laid_out_in_reading_order() {+    let ctx = egui::Context::default();+    let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});+    out.textures_delta.clear();+    let job = cadcraft_ui_egui::i18n::job("שלום.dwg", egui::FontId::proportional(14.0), egui::Color32::WHITE);+    let galley = ctx.fonts_mut(|f| f.layout_job(job));+    let mut glyphs: Vec<(f32, char)> = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.pos.x, g.chr))).collect();+    glyphs.sort_by(|a, b| a.0.total_cmp(&b.0));+    let shown: String = glyphs.iter().map(|(_, c)| c).collect();+    // Left to right on screen: the last letter first, the extension last and not reversed.+    assert_eq!(shown, "םולש.dwg");+    ctx.tex_manager().write().take_delta().clear();+}