diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index ac33326994..a64c74e035 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -1,8 +1,8 @@ //! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As //! → EPS Options): the options parsed from the params, transparency flattened with a flattener //! preset, one file of the art's bounds or one per artboard (with a central file of the whole -//! document when there are several and art shows), the preview and thumbnail rendered, -//! and the native document embedded for reopening. +//! document when there are several and art shows), the preview rendered and the thumbnail made +//! from it, and the native document embedded for reopening. use std::borrow::Cow; @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::Document; use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; use vectorcraft_geom::{Point, Rect}; +use vectorcraft_render::Rendered; use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded}; use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param}; @@ -231,14 +232,38 @@ fn pages(doc: &Document, set: &Settings) -> Result, Rect, Poi } } -/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA, -/// transparent or on white. -fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { +/// Pixels a point for `rect`: one, or fewer when it is larger than `max` a side. +fn fit(rect: Rect, max: f64) -> f64 { let long = rect.width().max(rect.height()); - let scale = if long > max { max / long } else { 1.0 }; + if long > max { max / long } else { 1.0 } +} + +/// `rect` of `doc` drawn at [`fit`]'s scale for `max`, transparent or on white. +fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { + let scale = fit(rect, max); vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?; - let img = r.render_region(doc, rect, scale, !transparent); - Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() }) + Ok(r.render_region(doc, rect, scale, !transparent)) +} + +/// `img` as the writer takes it: straight RGBA. +fn straight(img: &Rendered) -> Raster { + Raster { width: img.width, height: img.height, rgba: img.to_straight() } +} + +/// The thumbnail of `rect` made from `preview`, the same rect rendered larger: at the size a +/// thumbnail render of `rect` has, each pixel the average of the preview's pixels under it, on white. +fn thumbnail_of(preview: &Rendered, rect: Rect) -> Result { + let (width, height) = vectorcraft_render::raster_size(rect, fit(rect, THUMBNAIL)).map_err(|e| bad(C, e))?; + let mut rgba = preview.area_resized(width, height).map_err(|e| bad(C, e))?.pixels; + // Premultiplied over white: each channel gains the white the pixel's alpha leaves showing. + for px in rgba.as_chunks_mut::<4>().0 { + let clear = 255 - px[3]; + for c in &mut px[..3] { + *c = c.saturating_add(clear); + } + px[3] = 255; + } + Ok(Raster { width, height, rgba }) } /// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of @@ -318,11 +343,18 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let mut renderer = vectorcraft_render::Renderer::new(); for (board, region, origin) in pages { let rect = vectorcraft_eps::preview_rect(region, origin); - let preview = match set.eps.preview { + let shown = match set.eps.preview { Preview::None => None, kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?), }; - let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; + // The thumbnail is made from the preview; only a file without one renders it. + let thumbnail = match (set.thumbnails, &shown) { + (false, _) => None, + (true, Some(img)) => Some(thumbnail_of(img, rect)?), + (true, None) => Some(straight(&render(&mut renderer, doc, rect, THUMBNAIL, false)?)), + }; + let preview = shown.as_ref().map(straight); + drop(shown); let o = EpsOptions { region, origin, diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 8e2c1d429a..db667e6ef2 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -218,6 +218,106 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() { assert!(l3.contains("/FlateDecode filter >> image")); } +/// The PNG thumbnail of an EPS file, decoded. +fn thumbnail_png(bytes: &[u8]) -> image::RgbaImage { + let png = vectorcraft_eps::thumbnail(bytes).expect("a thumbnail"); + image::load_from_memory_with_format(&png, image::ImageFormat::Png).unwrap().to_rgba8() +} + +/// The TIFF preview of an EPS file, decoded. +fn preview_tiff(bytes: &[u8]) -> image::RgbaImage { + let tiff = vectorcraft_eps::sections(bytes).unwrap().1.expect("a preview"); + image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap().to_rgba8() +} + +/// The size of a thumbnail render of the rect an EPS file's preview shows (its bounding box). +fn thumbnail_size(bytes: &[u8]) -> (u32, u32) { + let b: Vec = dsc(&postscript(bytes), "BoundingBox").unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let rect = vectorcraft_geom::Rect::new(b[0], b[1], b[2], b[3]); + let long = rect.width().max(rect.height()); + vectorcraft_render::raster_size(rect, if long > 128.0 { 128.0 / long } else { 1.0 }).unwrap() +} + +/// The thumbnail is the color preview made smaller, the area average of its pixels at the size a +/// 128 px render has, not a second render of the document; it shows the same picture as one. +#[test] +fn the_thumbnail_is_made_from_the_preview() { + let mut s = session(1000.0, 700.0, 1); + s.execute("paint.setFill", &json!({"color": "#2060c0"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 37.3, "y": 41.9, "width": 811.7, "height": 563.1})).unwrap(); + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "tiffColor", "transparentPreview": false})).unwrap(); + let bytes = b64(&r); + let preview = preview_tiff(&bytes); + assert_eq!(preview.dimensions(), (1000, 700)); + let thumb = thumbnail_png(&bytes); + assert_eq!(thumb.dimensions(), (128, 90)); + // An opaque preview's straight pixels are its premultiplied ones. + let shown = vectorcraft_render::Rendered { width: 1000, height: 700, pixels: preview.into_raw() }; + assert!(thumb.as_raw() == &shown.area_resized(128, 90).unwrap().pixels, "the thumbnail isn't the preview made smaller"); + let doc = &s.doc().unwrap().doc; + let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 700.0), 0.128, true); + let mut diffs: Vec = thumb.as_raw().iter().zip(&fresh.pixels).map(|(a, b)| a.abs_diff(*b)).collect(); + diffs.sort_unstable(); + let mean = diffs.iter().map(|d| f64::from(*d)).sum::() / diffs.len() as f64; + let p95 = diffs[diffs.len() * 95 / 100]; + assert!(mean <= 8.0 && p95 <= 32, "mean {mean}, 95th percentile {p95}"); +} + +/// A thumbnail is opaque color on white whatever the preview: a transparent color preview's, and a +/// black-and-white preview's, which keeps the art's colors. +#[test] +fn thumbnails_of_transparent_and_black_and_white_previews_are_opaque_color() { + let mut s = session(400.0, 200.0, 1); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap(); + let doc = &s.doc().unwrap().doc; + let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), 0.32, true); + let red: [u8; 4] = fresh.pixels[(32 * 128 + 10) * 4..][..4].try_into().unwrap(); + for preview in ["tiffColor", "tiffBw"] { + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap(); + let thumb = thumbnail_png(&b64(&r)); + assert_eq!(thumb.dimensions(), (128, 64), "{preview}"); + assert_eq!(thumb.get_pixel(10, 32).0, red, "{preview}"); + assert_eq!(thumb.get_pixel(120, 32).0, [255, 255, 255, 255], "{preview}"); + assert!(thumb.pixels().all(|p| p[3] == 255), "{preview}"); + } +} + +/// A page that fits in a thumbnail gets its preview's own pixels, unresampled. +#[test] +fn a_small_page_s_thumbnail_is_its_preview() { + let mut s = session(100.0, 60.0, 1); + s.execute("shape.ellipse", &json!({"x": 10.5, "y": 7.25, "width": 70, "height": 41})).unwrap(); + let bytes = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "transparentPreview": false})).unwrap()); + let thumb = thumbnail_png(&bytes); + assert_eq!(thumb.dimensions(), (100, 60)); + assert!(thumb == preview_tiff(&bytes), "the thumbnail isn't the preview"); +} + +/// Every file has a thumbnail at the size a thumbnail render has, with any page shape, on Save +/// too and without a preview; with thumbnails off there is none. +#[test] +fn every_file_s_thumbnail_has_the_thumbnail_size() { + let mut s = session(333.3, 77.7, 2); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("shape.rectangle", &json!({"x": 3.7, "y": 5.1, "width": 300, "height": 61.3})).unwrap(); + s.execute("shape.ellipse", &json!({"x": second + 1.0, "y": 2.0, "width": 20, "height": 70})).unwrap(); + for preview in ["tiffColor", "tiffBw", "none"] { + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 3); + for f in files { + let bytes = b64(f); + assert_eq!(thumbnail_png(&bytes).dimensions(), thumbnail_size(&bytes), "{preview} {}", f["name"]); + } + } + assert_eq!(thumbnail_size(&b64(&s.execute("document.exportEps", &json!({"range": "1"})).unwrap())), (128, 30)); + let saved = b64(&s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap()); + assert_eq!(thumbnail_png(&saved).dimensions(), thumbnail_size(&saved)); + let r = s.execute("document.exportEps", &json!({"useArtboards": true, "thumbnails": false})).unwrap(); + assert!(r["files"].as_array().unwrap().iter().all(|f| vectorcraft_eps::thumbnail(&b64(f)).is_none())); +} + /// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings` /// holds the note raster exports give, once, without naming either image's key. #[test] diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 8a152e11f2..8ca670eb3f 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -264,6 +264,25 @@ pub(crate) fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec { out } +/// For each of `to` pixels spread over `from` (both above 0), the `from` pixels under it and the +/// share of it each covers (summing to 1). +fn area_spans(from: usize, to: usize) -> Vec> { + let step = from as f64 / to as f64; + (0..to) + .map(|i| { + let (start, end) = ((i * from) as f64 / to as f64, ((i + 1) * from) as f64 / to as f64); + let first = start.floor() as usize; + let last = (end.ceil() as usize).max(first + 1).min(from); + (first..last) + .filter_map(|j| { + let cover = end.min((j + 1) as f64) - start.max(j as f64); + (cover > 0.0).then_some((j, cover / step)) + }) + .collect() + }) + .collect() +} + /// A rendered image (premultiplied RGBA8, row-major). pub struct Rendered { pub width: u32, @@ -285,6 +304,49 @@ impl Rendered { } out } + /// This image at `width`×`height` pixels: each new pixel is the average of the area of this one + /// under it, the pixels at its edges weighted by how much of them it covers. Colors are averaged + /// premultiplied, so transparent pixels add no color. At the same size it is a copy. An error + /// when a side is 0 or the pixel buffer doesn't match the image size. + pub fn area_resized(&self, width: u32, height: u32) -> Result { + let (sw, sh) = (self.width as usize, self.height as usize); + if sw.checked_mul(sh).and_then(|n| n.checked_mul(4)) != Some(self.pixels.len()) { + return Err("the pixel buffer doesn't match the image size".into()); + } + if sw == 0 || sh == 0 || width == 0 || height == 0 { + return Err(format!("a {sw} × {sh} image can't be resized to {width} × {height} pixels")); + } + if (width, height) == (self.width, self.height) { + return Ok(Rendered { width, height, pixels: self.pixels.clone() }); + } + let (dw, dh) = (width as usize, height as usize); + let len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| format!("a {width} × {height} image is too large"))?; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(len).map_err(|e| format!("a {width} × {height} image: {e}"))?; + let (cols, rows) = (area_spans(sw, dw), area_spans(sh, dh)); + let src: Vec<&[[u8; 4]]> = self.pixels.as_chunks::<4>().0.chunks_exact(sw).collect(); + let mut acc = vec![[0f64; 4]; dw]; + for row in &rows { + acc.fill([0.0; 4]); + for &(y, wy) in row { + let Some(line) = src.get(y) else { continue }; + for (a, col) in acc.iter_mut().zip(&cols) { + for &(x, wx) in col { + if let Some(px) = line.get(x) { + let w = wy * wx; + for (s, v) in a.iter_mut().zip(px) { + *s += w * f64::from(*v); + } + } + } + } + } + for a in &acc { + pixels.extend(a.map(|v| v.round().clamp(0.0, 255.0) as u8)); + } + } + Ok(Rendered { width, height, pixels }) + } /// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use /// [`encode`]). pub fn to_png(&self) -> Result, String> { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d5c129e5c1..83f6131071 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::shapes; mod adjust; mod alternate; +mod arearesize; mod bitmapglyphs; mod blend; mod changed; diff --git a/crates/render/src/tests/arearesize.rs b/crates/render/src/tests/arearesize.rs new file mode 100644 index 0000000000..a53c05dc58 --- /dev/null +++ b/crates/render/src/tests/arearesize.rs @@ -0,0 +1,66 @@ +//! `Rendered::area_resized`: area averages of premultiplied pixels at any size, edge pixels by +//! their share, a copy at the same size, and errors instead of malformed images. + +use crate::*; + +fn image(width: u32, height: u32, pixels: &[[u8; 4]]) -> Rendered { + Rendered { width, height, pixels: pixels.concat() } +} + +#[test] +fn whole_blocks_average_into_one_pixel() { + let a = [200, 100, 0, 255]; + let b = [0, 100, 200, 255]; + let img = image(4, 2, &[a, b, a, a, a, b, a, b]); + let out = img.area_resized(2, 1).unwrap(); + assert_eq!((out.width, out.height), (2, 1)); + assert_eq!(out.pixels, [100, 100, 100, 255, 150, 100, 50, 255]); +} + +/// Three pixels into two: each new pixel takes one whole old pixel and half of the middle one. +#[test] +fn edge_pixels_count_by_the_share_they_cover() { + let img = image(3, 1, &[[0, 0, 0, 255], [255, 255, 255, 255], [90, 90, 90, 255]]); + let out = img.area_resized(2, 1).unwrap(); + // (0 + 255 / 2) / 1.5 = 85, (255 / 2 + 90) / 1.5 = 145. + assert_eq!(out.pixels, [85, 85, 85, 255, 145, 145, 145, 255]); +} + +/// A transparent pixel carries no color into its opaque neighbor: the average is half red, half +/// clear, still pure red once un-premultiplied. +#[test] +fn transparent_pixels_add_no_color() { + let img = image(2, 1, &[[0, 0, 0, 0], [255, 0, 0, 255]]); + let out = img.area_resized(1, 1).unwrap(); + assert_eq!(out.pixels, [128, 0, 0, 128]); + assert_eq!(out.to_straight(), [255, 0, 0, 128]); +} + +#[test] +fn the_same_size_is_a_copy() { + let px: Vec<[u8; 4]> = (0..12u8).map(|i| [i * 20, 255 - i * 20, i, 255]).collect(); + let img = image(4, 3, &px); + let out = img.area_resized(4, 3).unwrap(); + assert_eq!(out.pixels, img.pixels); +} + +/// A solid image stays exactly its color at any smaller size, with no rounding drift at +/// fractional spans. +#[test] +fn a_solid_color_stays_exact() { + let px = vec![[12, 34, 56, 255]; 1000 * 7]; + let out = image(1000, 7, &px).area_resized(128, 3).unwrap(); + assert_eq!((out.width, out.height), (128, 3)); + assert!(out.pixels.as_chunks::<4>().0.iter().all(|p| *p == [12, 34, 56, 255])); +} + +#[test] +fn bad_sizes_are_errors() { + let img = image(2, 2, &[[0, 0, 0, 255]; 4]); + assert!(img.area_resized(0, 1).is_err()); + assert!(img.area_resized(1, 0).is_err()); + let short = Rendered { width: 2, height: 2, pixels: vec![0; 12] }; + assert!(short.area_resized(1, 1).err().is_some_and(|e| e.contains("doesn't match"))); + let empty = Rendered { width: 0, height: 5, pixels: Vec::new() }; + assert!(empty.area_resized(1, 1).is_err()); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..83d56faec7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1460,6 +1460,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). + EPS export and EPS Save make each file's PNG thumbnail from its TIFF preview, averaging the preview's pixels down to the thumbnail's size on white, instead of drawing the document a second time; a file without a preview still draws its thumbnail (`crates/engine/src/cmd/fileio/tests_eps.rs`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`).