Merge pull request #6750 from dotCooCoo/perf/eps-thumbnail-from-preview

EPS export: the thumbnail is made from the preview instead of drawing the document again
This commit is contained in:
BFlat
2026-11-12 09:27:26 +07:00
committed by GitHub
6 changed files with 272 additions and 10 deletions
+42 -10
View File
@@ -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<Vec<(Option<usize>, 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<Raster> {
/// 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<Rendered> {
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<Raster> {
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<Encoded> {
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,
+100
View File
@@ -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<f64> = 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<u8> = 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::<f64>() / 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]
+62
View File
@@ -264,6 +264,25 @@ pub(crate) fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec<u8> {
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<Vec<(usize, f64)>> {
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<Rendered, String> {
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<Vec<u8>, String> {
+1
View File
@@ -5,6 +5,7 @@ use vectorcraft_geom::shapes;
mod adjust;
mod alternate;
mod arearesize;
mod bitmapglyphs;
mod blend;
mod changed;
+66
View File
@@ -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());
}
+1
View File
@@ -1474,6 +1474,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`).
- File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`).