diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -724,6 +724,7 @@ dependencies = [
  "cadcraft-engine",
  "cadcraft-fonts",
  "cadcraft-geom",
+ "cadcraft-io",
  "cadcraft-render",
  "chrono",
  "craft-ui",
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
@@ -341,6 +341,10 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
     // Curves are flattened to about half a pixel.
     let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
     let tol = 0.5 / (px_per_paper_unit * est);
+    // The plot style table the sheet plots with (the `plotStyleTable` option, else its page setup's),
+    // so the preview shows the printed colours, screening and lineweights.
+    let style_name = str_param(p, "plotStyleTable").map_or_else(|| page.plot_style_table.clone(), str::to_string);
+    let plot_style_table = cadcraft_doc::plot_style_table(d, &style_name).map(std::sync::Arc::new);
     let ropts = cadcraft_render::Options {
         tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
         min_dash: 0.0,
@@ -348,9 +352,10 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
         fill: true,
         lineweights,
         transparency,
+        plot_style_table,
         ..Default::default()
     };
-    let list = cadcraft_render::build_plot(d, &space, &ropts);
+    let mut list = cadcraft_render::build_plot(d, &space, &ropts);
     let b = list.bounds;
     // Paper point q shows drawing point (q - to) / k + from.
     let (from, k, to) = if fit || space == Space::Model {
@@ -358,6 +363,8 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
     } else {
         (Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
     };
+    // The table's line ends, joins and fill patterns at the plot scale, as the plotters draw them.
+    cadcraft_render::pen::apply(&mut list, k * sheet.unit_mm);
     let world = Bounds2::new((Vec2::ZERO - to) / k + from, (sheet.size - to) / k + from);
     if ![world.min.x, world.min.y, world.max.x, world.max.y].iter().all(|v| v.is_finite()) {
         return Err(bad("preview", "the drawing is too large to preview at this scale"));
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
@@ -62,20 +62,42 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 {
     pdf::plot_scale(b, sheet, o.fit != Some(false), o.scale)
 }
 
+/// The part of the sheet a PNG plot draws, from the `crop` option: `[x0, y0, x1, y1]` as fractions
+/// of the sheet, measured from its top-left corner (so `[0, 0, 1, 1]` is the whole sheet). A preview
+/// zoomed in on a detail asks for just that part at the resolution it needs.
+fn crop(opts: &Value) -> Result<Option<[f64; 4]>> {
+    let Some(raw) = opts.get("crop").filter(|v| !v.is_null()) else { return Ok(None) };
+    let v: Vec<f64> = raw.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
+    match v[..] {
+        [x0, y0, x1, y1]
+            if raw.as_array().is_some_and(|a| a.len() == 4)
+                && [x0, y0, x1, y1].iter().all(|f| f.is_finite() && (0.0..=1.0).contains(f))
+                && x1 - x0 >= 1e-6
+                && y1 - y0 >= 1e-6 =>
+        {
+            Ok(Some([x0, y0, x1, y1]))
+        }
+        _ => Err(IoError::Format("`crop` must be [x0, y0, x1, y1]: fractions of the sheet from 0 to 1, with x0 < x1 and y0 < y1".into())),
+    }
+}
+
 fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
     let o = PdfOptions::from_json(opts);
     let (sheet, page) = sheet(d, space, &o)?;
+    let crop = crop(opts)?;
+    // The size of what is drawn, as fractions of the sheet.
+    let (frac_x, frac_y) = crop.map_or((1.0, 1.0), |c| (c[2] - c[0], c[3] - c[1]));
     let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0);
     // Pixels per paper unit, lowered so the image stays within the caps.
     let mut k = sheet.unit_mm / 25.4 * dpi;
-    let (w, h) = (sheet.size.x * k, sheet.size.y * k);
+    let (w, h) = (sheet.size.x * frac_x * k, sheet.size.y * frac_y * k);
     let shrink = (MAX_PNG_PIXELS / (w * h).max(1.0)).sqrt().min(16384.0 / w.max(h).max(1.0)).min(1.0);
     if !(shrink.is_finite() && shrink > 0.0) {
         return Err(IoError::Format("the sheet is too large to plot as an image".into()));
     }
     k *= shrink;
     let px = |v: f64| (v * k).round().clamp(1.0, 16384.0) as u32;
-    let (width, height) = (px(sheet.size.x), px(sheet.size.y));
+    let (width, height) = (px(sheet.size.x * frac_x), px(sheet.size.y * frac_y));
     let lineweights = o.lineweights.unwrap_or(if space.is_paper() { page.lineweights } else { true });
     let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new);
     // Paper units per drawing unit: 1 for a layout at 1:1, the fitted scale for model space.
@@ -87,7 +109,12 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
     let transparency = o.plot_transparency.unwrap_or(page.plot_transparency);
     let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, transparency, ..cadcraft_render::Options::default() };
     let mut list = cadcraft_render::build_plot(d, space, &ropts);
-    let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k);
+    // The paper point at the image centre (the sheet's centre, or the middle of the crop; the
+    // sheet's origin is its lower-left corner and the crop's is its top-left).
+    let half = match crop {
+        Some(c) => Vec2::new((c[0] + c[2]) / 2.0 * sheet.size.x, (1.0 - (c[1] + c[3]) / 2.0) * sheet.size.y),
+        None => Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k),
+    };
     let b = window.unwrap_or(list.bounds);
     let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 };
     // Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's
@@ -150,6 +177,63 @@ mod tests {
         d
     }
 
+    /// A PNG plot decoded.
+    fn decode(bytes: &[u8]) -> tiny_skia::Pixmap {
+        tiny_skia::Pixmap::decode_png(bytes).unwrap()
+    }
+
+    /// A PNG crop is that part of the sheet at the same scale.
+    #[test]
+    fn a_png_crop_is_that_part_of_the_sheet_at_the_same_scale() {
+        let d = drawing();
+        // 127 dpi is 5 pixels per mm: a 200 x 100 mm sheet is 1000 x 500 pixels, and a crop of its
+        // middle half starts on whole pixels.
+        let base = json!({"output": "png", "resolution": 127, "width": 100, "height": 200, "landscape": true, "margins": [0, 0, 0, 0]});
+        let full = decode(&plot(&d, &Space::Model, &base).unwrap());
+        assert_eq!((full.width(), full.height()), (1000, 500));
+        let mut cropped = base.clone();
+        cropped["crop"] = json!([0.25, 0.25, 0.75, 0.75]);
+        let part = decode(&plot(&d, &Space::Model, &cropped).unwrap());
+        assert_eq!((part.width(), part.height()), (500, 250), "half the sheet each way");
+        let mut drawn = 0;
+        for y in 0..part.height() {
+            for x in 0..part.width() {
+                let (a, b) = (part.pixel(x, y).unwrap(), full.pixel(x + 250, y + 125).unwrap());
+                let close = |p: u8, q: u8| p.abs_diff(q) <= 2;
+                assert!(close(a.red(), b.red()) && close(a.green(), b.green()) && close(a.blue(), b.blue()), "pixel {x},{y}");
+                drawn += usize::from(a.red() < 128);
+            }
+        }
+        assert!(drawn > 100, "the crop shows part of the drawing ({drawn} dark pixels)");
+        // A layout is cropped the same way.
+        let layout = Space::Paper(d.layouts[0].name.clone());
+        let lay = json!({"output": "png", "resolution": 127, "width": 100, "height": 200, "landscape": true, "margins": [0, 0, 0, 0]});
+        let l_full = decode(&plot(&d, &layout, &lay).unwrap());
+        let mut l_crop = lay.clone();
+        l_crop["crop"] = json!([0.0, 0.5, 0.5, 1.0]);
+        let l_part = decode(&plot(&d, &layout, &l_crop).unwrap());
+        assert_eq!((l_part.width(), l_part.height()), (l_full.width() / 2, l_full.height() / 2));
+    }
+
+    /// Bad crops are errors; no crop, or the whole sheet, is fine.
+    #[test]
+    fn a_bad_png_crop_is_an_error() {
+        let d = drawing();
+        for crop in [
+            json!([0.5, 0.0, 0.5, 1.0]),
+            json!([0.0, 0.8, 1.0, 0.2]),
+            json!([0, 0, 1]),
+            json!([0, 0, 1, 2]),
+            json!([-0.1, 0, 1, 1]),
+            json!("all"),
+            json!([0, 0, 1, f64::NAN]),
+        ] {
+            assert!(plot(&d, &Space::Model, &json!({"output": "png", "resolution": 50, "crop": crop})).is_err(), "{crop}");
+        }
+        assert!(plot(&d, &Space::Model, &json!({"output": "png", "resolution": 50, "crop": null})).is_ok(), "no crop is the whole sheet");
+        assert!(plot(&d, &Space::Model, &json!({"output": "png", "resolution": 50, "crop": [0, 0, 1, 1]})).is_ok());
+    }
+
     #[test]
     fn png_and_svg_devices() {
         let d = drawing();
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -28,6 +28,8 @@ image = { workspace = true }
 
 [dev-dependencies]
 egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+# The real plotter, to check what the preview draws (tests only).
+cadcraft-io = { workspace = true }
 # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
 naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
 
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
@@ -4,7 +4,7 @@
 
 use std::sync::atomic::{AtomicU32, Ordering};
 
-use egui::TextureHandle;
+use egui::{Pos2, Rect, Sense, TextureHandle, Vec2, pos2, vec2};
 use serde_json::{Value, json};
 
 use crate::CadApp;
@@ -53,10 +53,127 @@ fn no_picker(app: &mut CadApp, name: &str, example: &str) -> Result<Value, Strin
     Err(msg)
 }
 
+/// How far the preview can be zoomed in, as a multiple of the whole sheet fitting its area.
+const MAX_ZOOM: f32 = 24.0;
+/// How long (seconds) the view must rest before the part in view is drawn again in more detail.
+const SETTLE: f64 = 0.15;
+
+/// How the sheet's image sits in the preview area: `zoom` 1 fits the whole sheet, `pan` moves the
+/// image's centre from the area's centre (points).
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct PanZoom {
+    zoom: f32,
+    pan: Vec2,
+}
+
+impl Default for PanZoom {
+    /// The whole sheet in view.
+    fn default() -> Self {
+        PanZoom { zoom: 1.0, pan: Vec2::ZERO }
+    }
+}
+
+impl PanZoom {
+    /// Points per image pixel when the whole image fits `area`.
+    fn fit(area: Rect, image: Vec2) -> f32 {
+        (area.width() / image.x.max(1.0)).min(area.height() / image.y.max(1.0)).max(1e-6)
+    }
+
+    /// Where the whole image is drawn.
+    fn image_rect(&self, area: Rect, image: Vec2) -> Rect {
+        Rect::from_center_size(area.center() + self.pan, image * (Self::fit(area, image) * self.zoom))
+    }
+
+    /// Keep the zoom in range and the image over the area: it can't be dragged away, and is
+    /// centred along an axis where it is smaller than the area.
+    fn clamp(&mut self, area: Rect, image: Vec2) {
+        self.zoom = if self.zoom.is_finite() { self.zoom.clamp(1.0, MAX_ZOOM) } else { 1.0 };
+        let size = image * (Self::fit(area, image) * self.zoom);
+        let room = |image: f32, area: f32| ((image - area) / 2.0).max(0.0);
+        let (rx, ry) = (room(size.x, area.width()), room(size.y, area.height()));
+        self.pan =
+            if self.pan.x.is_finite() && self.pan.y.is_finite() { vec2(self.pan.x.clamp(-rx, rx), self.pan.y.clamp(-ry, ry)) } else { Vec2::ZERO };
+    }
+
+    /// Zoom by `factor` about the screen point `at`, which stays over the same point of the image.
+    fn zoom_at(&mut self, area: Rect, image: Vec2, at: Pos2, factor: f32) {
+        if !(factor.is_finite() && factor > 0.0) {
+            return;
+        }
+        let centre = self.image_rect(area, image).center();
+        let zoom = (self.zoom * factor).clamp(1.0, MAX_ZOOM);
+        let moved = at + (centre - at) * (zoom / self.zoom);
+        self.zoom = zoom;
+        self.pan = moved - area.center();
+        self.clamp(area, image);
+    }
+}
+
+/// The part of the image drawn at `shown` that is inside `area`, as fractions of the image from its
+/// top-left corner `[x0, y0, x1, y1]`; `None` when none of it is.
+fn visible_crop(area: Rect, shown: Rect) -> Option<[f32; 4]> {
+    let v = area.intersect(shown);
+    if shown.width() < 1.0 || shown.height() < 1.0 || v.width() < 1.0 || v.height() < 1.0 {
+        return None;
+    }
+    let (w, h) = (shown.width(), shown.height());
+    Some([(v.min.x - shown.min.x) / w, (v.min.y - shown.min.y) / h, (v.max.x - shown.min.x) / w, (v.max.y - shown.min.y) / h])
+}
+
+/// `crop` grown by a quarter of its size on each side (within the sheet): what is drawn in detail,
+/// so that a small pan stays sharp.
+fn with_margin(c: [f32; 4]) -> [f32; 4] {
+    let (mx, my) = ((c[2] - c[0]) / 4.0, (c[3] - c[1]) / 4.0);
+    [(c[0] - mx).max(0.0), (c[1] - my).max(0.0), (c[2] + mx).min(1.0), (c[3] + my).min(1.0)]
+}
+
+/// Whether crop `outer` covers crop `inner`.
+fn covers(outer: [f32; 4], inner: [f32; 4]) -> bool {
+    outer[0] <= inner[0] + 1e-4 && outer[1] <= inner[1] + 1e-4 && outer[2] >= inner[2] - 1e-4 && outer[3] >= inner[3] - 1e-4
+}
+
+/// The sheet as plotted: the whole of it drawn once, and what the preview needs to draw parts of it
+/// again in more detail.
+#[derive(Clone)]
+struct Shown {
+    base: TextureHandle,
+    info: String,
+    /// The sheet's width and height in mm, as plotted (the orientation applied).
+    sheet_mm: Vec2,
+    /// The `plot` parameters of the whole sheet.
+    params: Value,
+}
+
+/// A part of the sheet drawn at the resolution of the screen it is shown on.
+#[derive(Clone)]
+struct Detail {
+    tex: TextureHandle,
+    /// The part, as fractions of the sheet from its top-left corner.
+    crop: [f32; 4],
+    /// The width in points the whole sheet had on screen when it was drawn.
+    sheet_width: f32,
+}
+
+impl Detail {
+    /// Whether it is drawn at the scale the sheet has on screen now.
+    fn matches(&self, sheet_width: f32) -> bool {
+        sheet_width > 0.0 && (self.sheet_width / sheet_width - 1.0).abs() < 0.01
+    }
+}
+
 #[derive(Clone)]
 struct Preview {
     generation: u32,
-    shown: Result<(TextureHandle, String), String>,
+    shown: Result<Shown, String>,
+    view: PanZoom,
+    detail: Option<Detail>,
+    /// When the view, or the size the sheet is drawn at, last changed (seconds), to draw in detail
+    /// once it rests.
+    changed: f64,
+    /// Where the sheet was drawn last: a resized window changes it with the view unchanged.
+    drawn: Rect,
+    /// The part and scale last drawn in detail, so a failure is not retried every frame.
+    tried: Option<([i32; 4], i32)>,
 }
 
 /// A preview the Plot dialog asked for: its PLOT parameters (PNG output) and a caption.
@@ -76,50 +193,137 @@ pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value,
     app.ui.dialog = Some("preview".into());
 }
 
-/// 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::new(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 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())?;
+/// Plot `params` (a PNG) and load it as a texture called `name`.
+fn plot_texture(app: &mut CadApp, ctx: &egui::Context, params: &Value, name: &str) -> Result<TextureHandle, String> {
+    let r = app.session.execute("plot", 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 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(""));
-    if r["landscape"].as_bool() == Some(true) {
-        info.push_str(" · landscape");
+    Ok(ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR))
+}
+
+/// Render the plot preview of the tab being shown, or of the Plot dialog's settings, as it plots
+/// (page setup, plot style table, lineweights): the whole sheet at about 1600 pixels.
+fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<Shown, String> {
+    let (params, info) = match ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::new(FROM_PLOT))) {
+        Some(f) => (f.params, f.info),
+        None => crate::managers::current_preview(app)?,
+    };
+    let base = plot_texture(app, ctx, &params, "plot-preview")?;
+    let mm = |k: &str| params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(210.0) as f32;
+    let (a, b) = (mm("width"), mm("height"));
+    let (long, short) = (a.max(b), a.min(b));
+    let sheet_mm = if params.get("landscape").and_then(Value::as_bool) == Some(true) { vec2(long, short) } else { vec2(short, long) };
+    Ok(Shown { base, info, sheet_mm, params })
+}
+
+/// Draw the part `crop` of the sheet again at the resolution of the screen, where the whole sheet is
+/// `sheet_width` points wide.
+fn render_detail(app: &mut CadApp, ctx: &egui::Context, shown: &Shown, crop: [f32; 4], sheet_width: f32) -> Result<Detail, String> {
+    // One image pixel per screen pixel: the sheet's pixel width over its width in inches.
+    let pixels = f64::from(sheet_width) * f64::from(ctx.pixels_per_point());
+    let dpi = (pixels / (f64::from(shown.sheet_mm.x) / 25.4)).clamp(50.0, 2400.0);
+    let mut params = shown.params.clone();
+    if let Some(o) = params.as_object_mut() {
+        o.insert("resolution".into(), json!(dpi));
+        o.insert("crop".into(), json!(crop));
     }
-    Ok((tex, info))
+    let tex = plot_texture(app, ctx, &params, "plot-preview-detail")?;
+    Ok(Detail { tex, crop, sheet_width })
 }
 
-/// The Plot Preview window: the sheet as it plots, with Print and Close.
+/// The Plot Preview window: the sheet as it plots, with Print and Close. The mouse wheel zooms
+/// about the pointer, dragging pans and a double click shows the whole sheet again; the part in
+/// view is drawn again at the screen's resolution once the view rests.
 pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
     let id = egui::Id::new("cc_plot_preview");
     let generation = PREVIEW_GEN.load(Ordering::Relaxed);
+    let now = ctx.input(|i| i.time);
     let cached = ctx.data(|d| d.get_temp::<Preview>(id)).filter(|p| p.generation == generation);
-    let preview = match cached {
+    let mut pv = match cached {
         Some(p) => p,
         None => {
-            let p = Preview { generation, shown: render(app, ctx) };
-            ctx.data_mut(|d| d.insert_temp(id, p.clone()));
-            p
+            Preview { generation, shown: render(app, ctx), view: PanZoom::default(), detail: None, changed: now, drawn: Rect::NOTHING, tried: None }
         }
     };
     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));
+    // What to draw in detail after the window: the part and the sheet's width on screen.
+    let mut want: Option<([f32; 4], f32)> = None;
+    egui::Window::new("Plot Preview").open(open).collapsible(false).resizable(true).default_size(vec2(900.0, 680.0)).show(ctx, |ui| {
+        match &pv.shown {
+            Ok(shown) => {
+                let image = shown.base.size_vec2();
+                let mut view = pv.view;
+                let (mut zoom_out, mut fit, mut zoom_in) = (false, false, false);
+                ui.horizontal(|ui| {
+                    ui.label(&shown.info);
+                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                        ui.label(format!("{:.0}%", view.zoom * 100.0));
+                        fit = ui.small_button("Fit").on_hover_text("The whole sheet (or double-click)").clicked();
+                        zoom_in = ui.small_button("+").on_hover_text("Zoom in (or the mouse wheel)").clicked();
+                        zoom_out = ui.small_button("−").on_hover_text("Zoom out").clicked();
+                    });
+                });
+                let size = vec2(ui.available_width().max(160.0), (ui.available_height() - 36.0).max(160.0));
+                let (area, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
+                let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| area.contains(*p));
+                if resp.hovered() {
+                    let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
+                    if let Some(at) = hover {
+                        view.zoom_at(area, image, at, (scroll * 0.002).exp() * pinch);
+                    }
+                }
+                if resp.dragged() {
+                    view.pan += resp.drag_delta();
+                }
+                if resp.double_clicked() || fit {
+                    view = PanZoom::default();
+                }
+                if zoom_in {
+                    view.zoom_at(area, image, area.center(), 1.5);
+                }
+                if zoom_out {
+                    view.zoom_at(area, image, area.center(), 1.0 / 1.5);
+                }
+                view.clamp(area, image);
+                if resp.dragged() || resp.hovered() {
+                    ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
+                }
+                let sheet = view.image_rect(area, image);
+                let painter = ui.painter_at(area);
+                painter.rect_filled(area, 0.0, egui::Color32::from_gray(70));
+                let full = Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0));
+                painter.image(shown.base.id(), sheet, full, egui::Color32::WHITE);
+                if let Some(d) = pv.detail.as_ref().filter(|d| d.matches(sheet.width())) {
+                    let [x0, y0, x1, y1] = d.crop;
+                    let (w, h) = (sheet.width(), sheet.height());
+                    let part = Rect::from_min_max(sheet.min + vec2(x0 * w, y0 * h), sheet.min + vec2(x1 * w, y1 * h));
+                    painter.image(d.tex.id(), part, full, egui::Color32::WHITE);
+                }
+                painter.rect_stroke(sheet, 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(120)), egui::StrokeKind::Outside);
+                // A change of the view or of the size the sheet is drawn at (the window was resized)
+                // restarts the wait, so the detail is not drawn again at every step of a resize.
+                if view != pv.view || sheet != pv.drawn {
+                    pv.changed = now;
+                    pv.view = view;
+                    pv.drawn = sheet;
+                }
+                // Drawn in detail when the sheet is shown larger than its image, once the view rests.
+                let larger = sheet.width() * ui.ctx().pixels_per_point() > image.x * 1.1;
+                if let Some(crop) = visible_crop(area, sheet).filter(|_| larger) {
+                    let covered = pv.detail.as_ref().is_some_and(|d| d.matches(sheet.width()) && covers(d.crop, crop));
+                    let key = (crop.map(|v| (v * 1000.0) as i32), sheet.width() as i32);
+                    if !covered && pv.tried != Some(key) {
+                        if now - pv.changed >= SETTLE {
+                            want = Some((with_margin(crop), sheet.width()));
+                            pv.tried = Some(key);
+                        } else {
+                            ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(SETTLE));
+                        }
+                    }
+                } else {
+                    pv.detail = None;
+                }
             }
             Err(e) => {
                 ui.label(e);
@@ -131,6 +335,12 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
             close = ui.button("Close").clicked();
         });
     });
+    if let (Some((crop, width)), Ok(shown)) = (want, pv.shown.as_ref()) {
+        // A failure keeps the sheet as it is and is not tried again for this view.
+        pv.detail = render_detail(app, ctx, shown, crop, width).ok();
+        ctx.request_repaint();
+    }
+    ctx.data_mut(|d| d.insert_temp(id, pv));
     if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
         close = true;
     }
@@ -207,4 +417,127 @@ mod tests {
         let e = app.run("share", json!({})).unwrap_err();
         assert!(e.contains("not available yet"), "{e}");
     }
+
+    use egui::{Rect, Vec2, pos2, vec2};
+
+    use super::{MAX_ZOOM, PanZoom, covers, visible_crop, with_margin};
+
+    /// The preview area the zoom tests use.
+    fn area() -> Rect {
+        Rect::from_min_size(pos2(10.0, 20.0), vec2(800.0, 600.0))
+    }
+
+    const IMAGE: Vec2 = vec2(1600.0, 1000.0);
+
+    /// The sheet fits the area and is centred until zoomed.
+    #[test]
+    fn the_sheet_fits_the_area_and_is_centred_until_zoomed() {
+        let r = PanZoom::default().image_rect(area(), IMAGE);
+        assert!((r.width() - 800.0).abs() < 1e-3 && (r.height() - 500.0).abs() < 1e-3, "fits by width: {r:?}");
+        assert!((r.center() - area().center()).length() < 1e-3);
+    }
+
+    /// Zooming keeps the image point under the pointer in place.
+    #[test]
+    fn zoom_keeps_the_point_under_the_pointer() {
+        let mut v = PanZoom::default();
+        let at = pos2(430.0, 330.0);
+        let r = v.image_rect(area(), IMAGE);
+        let u = ((at.x - r.min.x) / r.width(), (at.y - r.min.y) / r.height());
+        v.zoom_at(area(), IMAGE, at, 3.0);
+        assert!((v.zoom - 3.0).abs() < 1e-5);
+        let r = v.image_rect(area(), IMAGE);
+        let now = pos2(r.min.x + u.0 * r.width(), r.min.y + u.1 * r.height());
+        assert!((now - at).length() < 0.01, "the point under the pointer stayed: {now:?} vs {at:?}");
+        // And back out: the whole sheet again, centred.
+        v.zoom_at(area(), IMAGE, at, 1.0 / 3.0);
+        assert_eq!(v, PanZoom::default());
+    }
+
+    /// The zoom is limited and the sheet stays over the area.
+    #[test]
+    fn zoom_is_limited_and_the_sheet_stays_over_the_area() {
+        let mut v = PanZoom::default();
+        v.zoom_at(area(), IMAGE, area().center(), 1e6);
+        assert_eq!(v.zoom, MAX_ZOOM);
+        v.zoom_at(area(), IMAGE, area().center(), 1e-6);
+        assert_eq!(v.zoom, 1.0, "no further out than the whole sheet");
+        // Dragged far away, the sheet still covers the area along the axes where it is larger.
+        v.zoom_at(area(), IMAGE, area().center(), 4.0);
+        v.pan = vec2(1e6, -1e6);
+        v.clamp(area(), IMAGE);
+        let r = v.image_rect(area(), IMAGE);
+        assert!(
+            r.min.x <= area().min.x + 1e-3 && r.max.x >= area().max.x - 1e-3 && r.min.y <= area().min.y + 1e-3 && r.max.y >= area().max.y - 1e-3,
+            "{r:?}"
+        );
+        // Nonsense values change nothing or reset.
+        let before = v;
+        v.zoom_at(area(), IMAGE, area().center(), f32::NAN);
+        v.zoom_at(area(), IMAGE, area().center(), -2.0);
+        assert_eq!(v, before);
+        v.pan = vec2(f32::NAN, 0.0);
+        v.zoom = f32::INFINITY;
+        v.clamp(area(), IMAGE);
+        assert!(v.zoom == 1.0 && v.pan == Vec2::ZERO);
+    }
+
+    /// The part in view is what is drawn again in detail.
+    #[test]
+    fn the_visible_part_is_what_is_drawn_again_in_detail() {
+        // Whole sheet in view: all of it.
+        let whole = PanZoom::default().image_rect(area(), IMAGE);
+        assert_eq!(visible_crop(area(), whole), Some([0.0, 0.0, 1.0, 1.0]));
+        // Zoomed 4x on the centre: the middle eighth... of each side.
+        let mut v = PanZoom::default();
+        v.zoom_at(area(), IMAGE, area().center(), 4.0);
+        let c = visible_crop(area(), v.image_rect(area(), IMAGE)).unwrap();
+        assert!((c[0] - 0.375).abs() < 1e-4 && (c[2] - 0.625).abs() < 1e-4, "{c:?}");
+        assert!(c[1] > 0.0 && c[3] < 1.0 && c[1] < c[3]);
+        // The detail drawn covers it with a margin all round, within the sheet.
+        let d = with_margin(c);
+        assert!(covers(d, c) && !covers(c, d));
+        assert!(d.iter().all(|v| (0.0..=1.0).contains(v)));
+        assert_eq!(with_margin([0.0, 0.0, 1.0, 1.0]), [0.0, 0.0, 1.0, 1.0], "the margin stays inside the sheet");
+        // Nothing of the sheet in the area: no part.
+        let away = Rect::from_min_size(pos2(5000.0, 5000.0), vec2(100.0, 100.0));
+        assert_eq!(visible_crop(area(), away), None);
+    }
+
+    /// The red-looking pixels of a plotted PNG, and its size.
+    fn red_pixels(png: &[u8]) -> (usize, u32, u32) {
+        let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
+        let red = img.pixels().filter(|p| p.0[0] > 180 && p.0[1] < 90 && p.0[2] < 90).count();
+        (red, img.width(), img.height())
+    }
+
+    /// The preview draws the page setup's plot style table, and `None` overrides it.
+    #[test]
+    fn the_preview_draws_the_plot_style_table_of_the_page_setup() {
+        let (mut app, _) = app();
+        app.run("color", json!({ "color": 1 })).unwrap();
+        app.run("line", json!({ "points": [[0, 0], [100, 60]] })).unwrap();
+        app.run("mview", json!({ "layout": "Layout1" })).unwrap();
+        app.run("layout.set", json!({ "name": "Layout1" })).unwrap();
+        // The real plotter is called directly: the plot hook is global to the test process, which
+        // other tests replace.
+        let plot = |app: &CadApp, params: &serde_json::Value| {
+            cadcraft_io::plotter::plot(app.session.doc().unwrap(), &cadcraft_doc::Space::Paper("Layout1".into()), params).unwrap()
+        };
+        let (params, info) = crate::managers::current_preview(&app).unwrap();
+        assert!(info.starts_with("Layout1"), "{info}");
+        let (plain, w, h) = red_pixels(&plot(&app, &params));
+        assert!(plain > 50, "without a table the red line is drawn red ({plain} red pixels)");
+        // The page setup's table is what the preview shows, as it is what plots.
+        app.run("pagesetup", json!({ "layout": "Layout1", "plotStyleTable": "monochrome.ctb" })).unwrap();
+        let (mut params, _) = crate::managers::current_preview(&app).unwrap();
+        assert_eq!(red_pixels(&plot(&app, &params)).0, 0, "monochrome.ctb prints it black");
+        // The Plot dialog's "None" is sent as such, so it overrides the layout's own table.
+        params["plotStyleTable"] = json!("None");
+        assert!(red_pixels(&plot(&app, &params)).0 > 50, "None overrides the page setup's table");
+        // A crop is that part of the sheet, at the same scale.
+        params["crop"] = json!([0.25, 0.25, 0.75, 0.75]);
+        let (_, cw, ch) = red_pixels(&plot(&app, &params));
+        assert!((i64::from(cw) - i64::from(w) / 2).abs() <= 1 && (i64::from(ch) - i64::from(h) / 2).abs() <= 1, "{cw}x{ch} of {w}x{h}");
+    }
 }
diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs
--- a/crates/ui-egui/src/managers.rs
+++ b/crates/ui-egui/src/managers.rs
@@ -873,9 +873,9 @@ fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {
                 o.insert("height".into(), json!(page.height_mm));
             }
         }
-        if style_label(page) != "None" {
-            o.insert("plotStyleTable".into(), json!(page.plot_style_table));
-        }
+        // Always sent, "None" included: left out, the layout's own table would apply to a plot
+        // the dialog shows without one.
+        o.insert("plotStyleTable".into(), json!(if style_label(page) == "None" { "None" } else { page.plot_style_table.as_str() }));
         if st.target == "Model" {
             o.insert("plotArea".into(), json!(page.plot_area));
             if output != "svg" {
@@ -1018,13 +1018,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
         return action;
     }
     if preview {
-        // A sheet about 1600 pixels on its long side.
-        let long_in = st.page.width_mm.max(st.page.height_mm).max(1.0) / 25.4;
-        let mut info = format!("{} · {}", st.target, st.page.paper);
-        if st.page.landscape {
-            info.push_str(" · landscape");
-        }
-        crate::exchange::preview_plot(app, ctx, plot_params(&st, Some((1600.0 / long_in).clamp(50.0, 300.0))), info);
+        crate::exchange::preview_plot(app, ctx, plot_params(&st, Some(preview_dpi(&st.page))), plot_caption(&st));
         return action;
     }
     if ok {
@@ -1055,6 +1049,32 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
     action
 }
 
+/// The resolution (dpi) that draws a sheet about 1600 pixels on its long side: the preview's
+/// starting image of the whole sheet.
+fn preview_dpi(page: &cadcraft_doc::PageSetup) -> f64 {
+    let long_in = page.width_mm.max(page.height_mm).max(1.0) / 25.4;
+    (1600.0 / long_in).clamp(50.0, 300.0)
+}
+
+/// The caption of a preview of these settings: the sheet and its paper.
+fn plot_caption(st: &PlotState) -> String {
+    let mut info = format!("{} · {}", st.target, st.page.paper);
+    if st.page.landscape {
+        info.push_str(" · landscape");
+    }
+    info
+}
+
+/// The Plot Preview of the tab being shown: the `plot` parameters of a PNG of its sheet as its page
+/// setup plots it (the Plot dialog's starting settings, so the two previews agree), and a caption.
+pub(crate) fn current_preview(app: &CadApp) -> Result<(Value, String), String> {
+    let d = app.session.doc().map_err(|e| e.to_string())?;
+    let target = app.session.layout_space().tab_name().to_string();
+    let page = plot_page(d, &target);
+    let st = PlotState { generation: 0, page, target, window: [0.0; 4], center: true, offset_mm: [0.0, 0.0] };
+    Ok((plot_params(&st, Some(preview_dpi(&st.page))), plot_caption(&st)))
+}
+
 /// The sheet in the chosen orientation with its printable area, and its size.
 fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) {
     let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
@@ -1179,8 +1199,10 @@ mod plot_dialog_tests {
         let p = plot_params(&st, None);
         let a3 = cadcraft_render::paper_size("A3").unwrap().name;
         assert_eq!((p["device"].as_str(), p["paper"].as_str()), (Some("None"), Some(a3)));
+        // "None" is sent as such: left out, a layout's own table would plot and preview in a dialog
+        // that shows none.
         apply_page_change(&mut st.page, "plotStyleTable", &json!("None"));
-        assert!(plot_params(&st, None).get("plotStyleTable").is_none());
+        assert_eq!(plot_params(&st, None)["plotStyleTable"], json!("None"));
     }
 
     #[test]
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
@@ -19,6 +19,7 @@ mod mspace_viewport_view;
 mod options_dialog;
 mod parameters_rename;
 mod pickdrag;
+mod preview_zoom;
 mod properties_units;
 mod rapid_clicks;
 mod selection_cycling;
diff --git a/crates/ui-egui/tests/it/preview_zoom.rs b/crates/ui-egui/tests/it/preview_zoom.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/preview_zoom.rs
@@ -0,0 +1,76 @@
+//! The Plot Preview window draws the sheet once, and zooming in on it draws the part in view again
+//! at the screen's resolution (a `crop` of the sheet) once the view rests.
+
+use std::sync::Mutex;
+
+use cadcraft_doc::{Drawing, Space};
+use cadcraft_engine::Session;
+use cadcraft_engine::cmd::file::{IoHooks, set_io};
+use cadcraft_ui_egui::{CadApp, Services};
+use egui::{Event, Modifiers, MouseWheelUnit, vec2};
+use egui_kittest::Harness;
+use serde_json::{Value, json};
+
+/// The options of every `plot` the preview made.
+static PLOTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());
+
+/// A plot hook that records the options it is given.
+fn recording_plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>, String> {
+    if let Ok(mut plots) = PLOTS.lock() {
+        plots.push(opts.clone());
+    }
+    cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())
+}
+
+/// The PNG plots so far: other tests in this process also plot (to PDF) through the global hook.
+fn plots() -> Vec<Value> {
+    PLOTS.lock().map(|p| p.iter().filter(|o| o["output"] == "png").cloned().collect()).unwrap_or_default()
+}
+
+/// Zooming in draws the part in view again at the screen's resolution.
+#[test]
+fn zooming_in_draws_the_part_in_view_at_the_screens_resolution() {
+    set_io(IoHooks { read: |_, _| Err("no".into()), write: |_, _| Err("no".into()), plot: Some(recording_plot) });
+    let mut app = CadApp::new(Session::default(), Services::default());
+    app.ui.start_tab = false;
+    app.ui.in_window_menu = false;
+    app.run("line", json!({ "points": [[0, 0], [100, 60]] })).unwrap();
+    app.run("mview", json!({ "layout": "Layout1" })).unwrap();
+    app.run("layout.set", json!({ "name": "Layout1" })).unwrap();
+    let mut h = Harness::builder().with_size(vec2(1280.0, 900.0)).build_ui_state(
+        |ui, app: &mut CadApp| {
+            app.logic(&ui.ctx().clone());
+            app.ui(ui);
+        },
+        app,
+    );
+    h.run_steps(5);
+
+    PLOTS.lock().unwrap().clear();
+    h.state_mut().start("preview");
+    h.run_steps(5);
+    let first = plots();
+    assert_eq!(first.len(), 1, "the whole sheet is drawn once: {first:?}");
+    assert!(first[0].get("crop").is_none());
+    assert_eq!(first[0]["output"], "png");
+
+    // Scroll up over the preview: zoom in. Rendered again only once the view rests.
+    let over = egui::pos2(450.0, 380.0);
+    h.hover_at(over);
+    h.run_steps(2);
+    h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 1000.0), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move });
+    h.run_steps(12);
+    let after = plots();
+    assert!(after.len() >= 2, "the part in view is drawn again: {} plots", after.len());
+    let detail = after.last().unwrap();
+    let crop: Vec<f64> = detail["crop"].as_array().expect("a crop").iter().filter_map(Value::as_f64).collect();
+    assert_eq!(crop.len(), 4);
+    assert!(crop[2] - crop[0] < 1.0 && crop[3] - crop[1] < 1.0, "a part of the sheet, not all of it: {crop:?}");
+    assert!(detail["resolution"].as_f64().unwrap() > first[0]["resolution"].as_f64().unwrap(), "at a higher resolution than the whole sheet");
+
+    // Zooming back out to the whole sheet needs no further drawing.
+    let n = plots().len();
+    h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -2000.0), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move });
+    h.run_steps(12);
+    assert_eq!(plots().len(), n, "the whole sheet is shown from its first image");
+}
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
 # Where CADCraft falls short of AutoCAD
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Plot Preview shows plot styles and zooms and pans) · **Target:** Autodesk AutoCAD 2027
 
 Every known shortfall, one row each, ranked within each section by user impact. This is the work
 list: agents pick from here (top of a section first), and remove or update a row in the same PR
@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Plot Preview draws the sheet as it plots: the page setup's plot style table (colours, screening, lineweights, line ends and fills; before, only the Plot dialog's preview did, and its "None" now overrides the layout's table), and the window zooms (mouse wheel, + / −, Fit or double-click) and pans (drag); the part in view is drawn again at the screen's resolution once the view rests (PNG plots take `crop`, a part of the sheet as fractions) |
 | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed |
 | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed |
 | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |
