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.rs
new 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.rs
new 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.rs
new 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.rs
new 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();
+}
