diff --git a/crates/render/src/blobs.rs b/crates/render/src/blobs.rs
--- a/crates/render/src/blobs.rs
+++ b/crates/render/src/blobs.rs
@@ -11,8 +11,15 @@ pub(crate) fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>>
 pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option<Arc<ink::InkLut>>, Option<(u16, u16)>) {
     let id = images::blob_id(blob);
     let lut = id.cmyk.then(ink::display_lut);
-    let key =
-        images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 };
+    let key = images::ImageKey {
+        content: id.content,
+        adjust: None,
+        grey: false,
+        ink: Ink::Display,
+        luts: (lut.as_ref().map_or(0, |l| l.1), 0),
+        plates: 0,
+        mip: 0,
+    };
     (key, lut.map(|l| l.0), id.size)
 }
 
diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs
--- a/crates/render/src/images.rs
+++ b/crates/render/src/images.rs
@@ -90,6 +90,9 @@ pub(crate) struct ImageKey {
     /// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's
     /// display table, and the separation table of ink planes (see `ink`).
     pub luts: (u64, u64),
+    /// Generation of the Separations Preview tables the pixels show the visible plates with
+    /// (0 = none, see `proof::plate_image`).
+    pub plates: u64,
     /// Halved this many times ([`half`]).
     pub mip: u8,
 }
@@ -337,7 +340,7 @@ mod tests {
     use super::*;
 
     fn key(content: &str) -> ImageKey {
-        ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 }
+        ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), plates: 0, mip: 0 }
     }
 
     #[test]
diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs
--- a/crates/render/src/proof.rs
+++ b/crates/render/src/proof.rs
@@ -8,7 +8,8 @@
 //!   renders as greyscale ink coverage (black = 100%); with several, the visible inks are
 //!   composited through the proof profile, spots multiplied on top. Colours linked to the
 //!   Registration swatch print their tint on every plate, spot plates included.
-//!   Placed raster images are not separated (limitation).
+//!   Placed raster images show their pixels the same way ([`plate_image`]): RGB pixels are
+//!   separated into process inks, CMYK images show their own inks; they print on no spot plate.
 //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
 //!   overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
 //!   Darken, which approximates their inks printing over the inks below: a zero ink lets the
@@ -19,16 +20,20 @@
 
 use std::borrow::Cow;
 use std::collections::HashMap;
-use std::sync::{Arc, RwLock, Weak};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak};
 
-use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
+use vectorcraft_color::cms::{self, Cms, CmykLut, PROCESS_PLATES, ProofLut, lab};
 pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
 use vectorcraft_color::{Color, Swatch, SwatchGroup};
 pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
 use vectorcraft_doc::overprint::darken_overprints;
-use vectorcraft_doc::{Document, Node, NodeId, Shared};
+use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, Shared};
+use vello_cpu::Pixmap;
+use vello_cpu::color::PremulRgba8;
 
 use crate::RenderOptions;
+use crate::images::{self, ImageKey};
 
 /// Whether overprinting shows: Overprint Preview, or Separations Preview (which implies it).
 pub(crate) fn overprints(opts: &RenderOptions) -> bool {
@@ -40,13 +45,7 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String],
     let rgb = if visible.len() == 1 {
         [1.0 - ink.on_plate(&visible[0]); 3]
     } else {
-        let mut cmyk = ink.cmyk;
-        for (i, p) in PROCESS_PLATES.iter().enumerate() {
-            if !visible.iter().any(|v| v == p) {
-                cmyk[i] = 0.0;
-            }
-        }
-        let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof);
+        let mut rgb = visible_process_srgb(c, proof, visible, ink.cmyk);
         for name in visible.iter().filter(|v| !PROCESS_PLATES.contains(&v.as_str())) {
             let t = ink.spot_tint(name);
             if t > 0.0
@@ -64,6 +63,153 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String],
     Color::Rgb { r, g, b }
 }
 
+/// The display sRGB of process inks `cmyk` with only the `visible` plates printing, through the
+/// proof's CMYK space.
+fn visible_process_srgb(c: &Cms, proof: &ProofSetup, visible: &[String], mut cmyk: [f32; 4]) -> [f32; 3] {
+    for (i, p) in PROCESS_PLATES.iter().enumerate() {
+        if !visible.iter().any(|v| v == p) {
+            cmyk[i] = 0.0;
+        }
+    }
+    c.proof_cmyk_to_srgb(cmyk, proof)
+}
+
+/// The tables Separations Preview shows placed images' pixels with ([`plate_image`]), for one
+/// proof setup and its visible plates.
+pub(crate) struct ImagePlates {
+    cms: Arc<Cms>,
+    /// [`cms::profiles_generation`].
+    profiles: u64,
+    proof: ProofSetup,
+    /// Images made with these tables are kept under it ([`ImageKey::plates`]); never reused.
+    generation: u64,
+    /// One plate visible: the process plate's index, `None` for a spot plate (no image prints on
+    /// it).
+    single: Option<Option<usize>>,
+    /// Display sRGB → the visible plates as they show (display sRGB).
+    rgb: ProofLut,
+    /// CMYK inks → the visible plates as they show (XYZ), made when a CMYK image is first shown
+    /// with several plates.
+    cmyk: OnceLock<CmykLut>,
+}
+
+impl ImagePlates {
+    fn build(cms: Arc<Cms>, profiles: u64, proof: ProofSetup, generation: u64) -> Self {
+        let visible = proof.separations.as_deref().unwrap_or_default();
+        let single = match visible {
+            [one] => Some(PROCESS_PLATES.iter().position(|p| p == one)),
+            _ => None,
+        };
+        let rgb = ProofLut::build(|rgb| {
+            let inks = cms.srgb_to_cmyk(rgb, proof.intent);
+            match single {
+                Some(i) => [1.0 - i.map_or(0.0, |i| inks[i]); 3],
+                None => visible_process_srgb(&cms, &proof, visible, inks),
+            }
+        });
+        Self { cms, profiles, proof, generation, single, rgb, cmyk: OnceLock::new() }
+    }
+
+    /// CMYK ink amounts `inks` (0 = no ink) as the visible plates show them.
+    fn show_inks(&self, inks: [u8; 4]) -> [u8; 3] {
+        match self.single {
+            Some(Some(i)) => [255 - inks.get(i).copied().unwrap_or(0); 3],
+            Some(None) => [255; 3],
+            None => {
+                let lut = self.cmyk.get_or_init(|| {
+                    let visible = self.proof.separations.as_deref().unwrap_or_default();
+                    CmykLut::build(|inks| {
+                        let [x, y, z] = lab::srgb_to_xyz(visible_process_srgb(&self.cms, &self.proof, visible, inks));
+                        [x, y, z, 0.0]
+                    })
+                });
+                let [x, y, z, _] = lut.apply(inks.map(|v| v as f32 / 255.0));
+                lab::xyz_to_srgb([x, y, z]).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
+            }
+        }
+    }
+}
+
+static IMAGE_PLATES: Mutex<Option<Arc<ImagePlates>>> = Mutex::new(None);
+static IMAGE_PLATES_GENERATION: AtomicU64 = AtomicU64::new(0);
+
+/// The tables placed images show with in Separations Preview, `None` when it's off.
+pub(crate) fn image_plates(opts: &RenderOptions) -> Option<Arc<ImagePlates>> {
+    let proof = opts.proof.as_ref().filter(|p| p.separations.is_some())?;
+    let c = cms::active();
+    let profiles = cms::profiles_generation();
+    let mut slot = IMAGE_PLATES.lock().unwrap_or_else(|e| e.into_inner());
+    if let Some(p) = &*slot
+        && Arc::ptr_eq(&p.cms, &c)
+        && p.profiles == profiles
+        && p.proof == *proof
+    {
+        return Some(p.clone());
+    }
+    let generation = IMAGE_PLATES_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
+    let p = Arc::new(ImagePlates::build(c, profiles, proof.clone(), generation));
+    *slot = Some(p.clone());
+    Some(p)
+}
+
+/// The key [`plate_image`] gives image pixels `key` shown with `plates`.
+pub(crate) fn plate_key(key: &ImageKey, plates: &ImagePlates) -> ImageKey {
+    ImageKey { plates: plates.generation, ..key.clone() }
+}
+
+/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as the
+/// visible plates of `plates` print them, cached with the images, and their key. A CMYK image
+/// shows its own inks; other pixels are separated.
+pub(crate) fn plate_image(key: &ImageKey, pm: &Arc<Pixmap>, blob: Option<&ImageBlob>, plates: &ImagePlates, touch: bool) -> (ImageKey, Arc<Pixmap>) {
+    let want = plate_key(key, plates);
+    let hit = {
+        let mut s = images::store();
+        if touch { s.get(&want) } else { s.peek(&want) }
+    };
+    if let Some(p) = hit {
+        return (want, p);
+    }
+    let size = (pm.width() as u32, pm.height() as u32);
+    let cmyk = key.luts.0 != 0 && key.adjust.is_none() && !key.grey;
+    let inks = blob.filter(|_| cmyk).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size);
+    let mut out = Pixmap::new(pm.width(), pm.height());
+    // Runs of one colour convert once.
+    if let Some(inks) = &inks {
+        let mut last: Option<([u8; 4], [u8; 3])> = None;
+        for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) {
+            let [r, g, b] = match last {
+                Some((k, v)) if k == *p => v,
+                _ => {
+                    let v = plates.show_inks(*p);
+                    last = Some((*p, v));
+                    v
+                }
+            };
+            *o = PremulRgba8 { r, g, b, a: 255 };
+        }
+    } else {
+        let mut last: Option<(PremulRgba8, PremulRgba8)> = None;
+        for (o, s) in out.data_mut().iter_mut().zip(pm.data()) {
+            let a = s.a;
+            if a == 0 {
+                continue;
+            }
+            *o = match last {
+                Some((k, v)) if k == *s => v,
+                _ => {
+                    let un = |v: u8| (v as f32 / a as f32).min(1.0);
+                    let [r, g, b] = plates.rgb.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8);
+                    let v = PremulRgba8 { r, g, b, a };
+                    last = Some((*s, v));
+                    v
+                }
+            };
+        }
+    }
+    let pm = images::store().insert(want.clone(), Arc::new(out));
+    (want, pm)
+}
+
 /// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame
 /// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for
 /// it, and what that art was made with. The kept nodes keep their addresses: no other node gets
diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs
--- a/crates/render/src/renderer/images.rs
+++ b/crates/render/src/renderer/images.rs
@@ -22,14 +22,20 @@ impl Renderer {
         // With mips the full pixels are only looked at (not marked used) until they're drawn, and
         // a mip the store holds is drawn without them: they may have been dropped (#2820).
         let mips = f.opts.image_mips;
+        // Separations Preview: the pixels show the visible plates.
+        let plates = if outline { None } else { proof::image_plates(f.opts) };
         let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64);
         let shown = if outline && !f.doc.setup.outline_images {
             None
-        } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() {
+        } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, plates.as_deref(), unit)).flatten() {
             Some(pm)
         } else {
             self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| {
                 let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) };
+                let (key, pm) = match &plates {
+                    Some(p) => proof::plate_image(&key, &pm, f.doc.image_blob(im), p, !mips),
+                    None => (key, pm),
+                };
                 if mips { image_mip(&key, pm, unit) } else { pm }
             })
         };
@@ -191,11 +197,24 @@ impl Renderer {
     }
 
     /// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with
-    /// `grey` (the outline view's), when the store holds it, marked used there; `None` when it
-    /// draws the full pixels or the mip is yet to be made.
-    fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {
+    /// `grey` (the outline view's) or showing the visible `plates` of Separations Preview, when the
+    /// store holds it, marked used there; `None` when it draws the full pixels or the mip is yet
+    /// to be made.
+    fn kept_mip(
+        &self,
+        f: &Frame,
+        key: &str,
+        own: Option<&vectorcraft_doc::ImageBlob>,
+        grey: bool,
+        plates: Option<&proof::ImagePlates>,
+        m: Affine,
+    ) -> Option<Arc<Pixmap>> {
         let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
         let key = if grey { key } else { ink::ink_key(&key, f.ink) };
+        let key = match plates {
+            Some(p) => proof::plate_key(&key, p),
+            None => key,
+        };
         let level = mip_level(m, w, h);
         if level == 0 {
             return None;
diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs
--- a/crates/render/src/renderer/text.rs
+++ b/crates/render/src/renderer/text.rs
@@ -242,6 +242,10 @@ impl Renderer {
     fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) {
         let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return };
         let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true);
+        let (key, pm) = match proof::image_plates(f.opts) {
+            Some(p) => proof::plate_image(&key, &pm, Some(&image.blob), &p, true),
+            None => (key, pm),
+        };
         let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64);
         let pm = image_mip(&key, pm, unit);
         let sx = image.width as f64 / pm.width().max(1) as f64;
diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs
--- a/crates/render/src/tests.rs
+++ b/crates/render/src/tests.rs
@@ -24,6 +24,7 @@ mod memory;
 mod objectfx;
 mod placed_document;
 mod rasterize;
+mod separations;
 mod setup;
 mod strokegradient;
 mod tileedge;
diff --git a/crates/render/src/tests/separations.rs b/crates/render/src/tests/separations.rs
new file mode 100644
--- /dev/null
+++ b/crates/render/src/tests/separations.rs
@@ -0,0 +1,68 @@
+//! Separations Preview of placed raster images (#4153): an image shows the inks of the visible
+//! plates, as vector art does, not its composite colours.
+
+use vectorcraft_doc::cmyk::Inks;
+use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
+
+use crate::*;
+
+/// A 20×20 document filled by image `blob`.
+fn doc_with(blob: ImageBlob) -> Document {
+    let mut d = Document::new(20.0, 20.0);
+    d.images.insert("img".into(), blob);
+    let (id, layer) = (d.alloc_id(), d.layers[0].id);
+    let im = ImageObject { key: "img".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
+    d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
+    d
+}
+
+/// The centre pixel of `d` with the plates `visible` shown (`None`: Separations Preview off).
+fn centre(d: &Document, visible: Option<&[&str]>) -> [u8; 4] {
+    let proof = visible.map(|v| proof::ProofSetup { separations: Some(v.iter().map(|p| p.to_string()).collect()), ..Default::default() });
+    let opts = RenderOptions { background: Some([255; 4]), overprint_preview: proof.is_some(), proof, ..Default::default() };
+    Renderer::new().render_region_with(d, d.artboards[0].rect, 1.0, &opts).pixel(10, 10)
+}
+
+fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
+    a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
+}
+
+const ALL: &[&str] = &["Cyan", "Magenta", "Yellow", "Black"];
+
+#[test]
+fn rgb_images_show_the_visible_plates() {
+    let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 255, 255, 255]));
+    let mut png = vec![];
+    img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+    let d = doc_with(ImageBlob::new("image/png", png));
+    let cms = vectorcraft_color::cms::active();
+    let inks = cms.srgb_to_cmyk([0.0, 1.0, 1.0], proof::ProofSetup::default().intent);
+    // One plate: its ink as grey coverage (black = 100%).
+    for (i, plate) in ["Cyan", "Magenta", "Yellow", "Black"].into_iter().enumerate() {
+        let v = ((1.0 - inks[i]) * 255.0).round() as u8;
+        let got = centre(&d, Some(&[plate]));
+        assert!(near(got, [v, v, v, 255], 4), "{plate}: {got:?} ≠ {v}");
+    }
+    let cyan = centre(&d, Some(&["Cyan"]));
+    assert!(cyan[0] < 110, "a cyan image prints mostly cyan: {cyan:?}");
+    // Hiding the cyan plate takes the cyan ink away.
+    let all = centre(&d, Some(ALL));
+    let no_cyan = centre(&d, Some(&["Magenta", "Yellow", "Black"]));
+    assert!(no_cyan[0] > all[0] + 60, "hiding Cyan: {no_cyan:?} vs {all:?}");
+    // A spot plate the image doesn't print on shows paper.
+    assert_eq!(centre(&d, Some(&["Spot"])), [255; 4]);
+}
+
+#[test]
+fn cmyk_images_show_their_own_inks_on_the_plates() {
+    let ink = [255u8, 0, 0, 0];
+    let blob = ImageBlob::cmyk_tiff(&Inks::new(20, 20, ink.repeat(400)).unwrap()).unwrap();
+    let d = doc_with(blob);
+    assert_eq!(centre(&d, Some(&["Cyan"])), [0, 0, 0, 255], "100% cyan is solid on the cyan plate");
+    for plate in ["Magenta", "Yellow", "Black"] {
+        assert_eq!(centre(&d, Some(&[plate])), [255; 4], "no {plate} ink");
+    }
+    assert!(near(centre(&d, Some(&["Magenta", "Yellow", "Black"])), [255; 4], 2), "paper where only cyan prints");
+    let all = centre(&d, Some(ALL));
+    assert!(near(all, centre(&d, None), 4), "every plate shows the image: {all:?}");
+}
