Add bounded portable Home thumbnails to design files (#518)
A save with cache: true also stores a small bounded PNG thumbnail (240 × 150, at most 256 KiB, keyed to the exact document) made from cached meshes only. Home uses it when its local picture is missing, without rebuilding the design; ordinary saves and downloads still omit it. Closes #303. Co-authored-by: lillith <[email protected]>
This commit is contained in:
1 parent
24974e6f37
commit
bcbfa50eac
10 files changed
+431
-5
No files matched your search
@@ -5,7 +5,9 @@ use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod native_cache;
|
||||
mod thumbnail;
|
||||
pub use native_cache::{DesignGeometry, read_design_geometry};
|
||||
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -110,6 +112,18 @@ pub fn write_design_container_with_cache(
|
||||
compression: DesignCompression,
|
||||
preview: Option<&DesignPreview>,
|
||||
geometry: Option<&[DesignGeometry]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
write_design_container_with_thumbnail(doc, compression, preview, geometry, None)
|
||||
}
|
||||
|
||||
/// Include a bounded portable PNG picture when one is already available.
|
||||
/// Unsupported headers or oversized pictures are omitted without changing the design.
|
||||
pub fn write_design_container_with_thumbnail(
|
||||
doc: &Document,
|
||||
compression: DesignCompression,
|
||||
preview: Option<&DesignPreview>,
|
||||
geometry: Option<&[DesignGeometry]>,
|
||||
thumbnail: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
|
||||
let mut features = Vec::with_capacity(doc.features.len());
|
||||
@@ -184,6 +198,9 @@ pub fn write_design_container_with_cache(
|
||||
writer.part("cache/preview.json", &manifest, false)?;
|
||||
writer.part("cache/preview.bin", &data, false)?;
|
||||
}
|
||||
if let Some(png) = thumbnail {
|
||||
thumbnail::write(&mut writer, png, &metadata)?;
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid("compressed file too large"));
|
||||
@@ -465,6 +482,11 @@ impl<R: Read + Seek> DesignContainerReader<R> {
|
||||
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
|
||||
}
|
||||
|
||||
/// Portable PNG picture only; no source blobs or geometry are evaluated.
|
||||
pub fn thumbnail(&mut self) -> Option<Vec<u8>> {
|
||||
thumbnail::read(&mut self.reader).ok()
|
||||
}
|
||||
|
||||
/// Native geometry is provisional, never a substitute for rebuilding the document.
|
||||
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
|
||||
let document = self.reader.document(16 << 20).ok()?;
|
||||
@@ -837,6 +859,53 @@ mod tests {
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_is_keyed_bounded_and_does_not_read_sources() {
|
||||
// IO validates an opaque PNG header/hash; UI tests exercise real PNG decoding.
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
png.extend(240u32.to_be_bytes());
|
||||
png.extend(150u32.to_be_bytes());
|
||||
let mut doc = Document::from_json(&document().to_json()).unwrap();
|
||||
if let FeatureKind::Import { step, .. } = &mut doc.features[0].kind {
|
||||
*step = "source data".repeat(250_000);
|
||||
}
|
||||
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&png)).unwrap();
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes.clone()), count: count.clone() }).unwrap();
|
||||
assert_eq!(reader.thumbnail(), Some(png.clone()));
|
||||
assert!(count.get() < 256 * 1024);
|
||||
assert_eq!(reader.read_document().unwrap(), doc);
|
||||
let original = parts(&bytes);
|
||||
for field in ["format", "app", "document", "payload"] {
|
||||
let mut entries = original.clone();
|
||||
let mut key: Value = serde_json::from_slice(&entries["cache/thumbnail.json"]).unwrap();
|
||||
key[field] = json!("mismatch");
|
||||
entries.insert("cache/thumbnail.json".into(), serde_json::to_vec(&key).unwrap());
|
||||
let changed = package(&entries);
|
||||
assert!(read_design_thumbnail(&changed).is_none());
|
||||
assert_eq!(crate::read_design(&changed).unwrap(), doc);
|
||||
}
|
||||
let mut entries = original.clone();
|
||||
let source = entries.keys().find(|k| k.ends_with(".step")).unwrap().clone();
|
||||
entries.insert(source, b"corrupt source".to_vec());
|
||||
let changed = package(&entries);
|
||||
assert_eq!(read_design_thumbnail(&changed), Some(png));
|
||||
assert!(crate::read_design(&changed).is_err());
|
||||
for n in [0, 1, 8, bytes.len() / 2, bytes.len() - 1] {
|
||||
assert!(read_design_thumbnail(&bytes[..n]).is_none());
|
||||
}
|
||||
for i in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
|
||||
let mut changed = bytes.clone();
|
||||
changed[i] ^= 0xff;
|
||||
assert!(std::panic::catch_unwind(|| read_design_thumbnail(&changed)).is_ok());
|
||||
}
|
||||
for invalid in [vec![], vec![0; 300_000]] {
|
||||
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&invalid)).unwrap();
|
||||
assert!(read_design_thumbnail(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
|
||||
let doc = Document::from_json(&document().to_json()).unwrap();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Portable, disposable PNG pictures. Reading never resolves source blobs.
|
||||
use super::*;
|
||||
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
|
||||
const MAX_BYTES: usize = 256 << 10;
|
||||
const FORMAT: &str = "solidcraft-thumbnail/1";
|
||||
|
||||
fn valid_header(bytes: &[u8]) -> bool {
|
||||
bytes.len() <= MAX_BYTES
|
||||
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
|
||||
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
|
||||
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
|
||||
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
|
||||
}
|
||||
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
|
||||
if !valid_header(png) {
|
||||
return Ok(());
|
||||
}
|
||||
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
|
||||
.map_err(invalid)?;
|
||||
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|
||||
|| writer
|
||||
.expanded
|
||||
.saturating_add(key.len())
|
||||
.saturating_add(png.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
> MAX_DESIGN_BYTES
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
writer.part("cache/thumbnail.json", &key, false)?;
|
||||
writer.part("cache/thumbnail.png", png, false)
|
||||
}
|
||||
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
|
||||
return Err(invalid("thumbnail container version"));
|
||||
}
|
||||
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
|
||||
let document = reader.document(16 << 20)?;
|
||||
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|
||||
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|
||||
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
|
||||
{
|
||||
return Err(invalid("thumbnail key"));
|
||||
}
|
||||
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
|
||||
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
|
||||
return Err(invalid("thumbnail payload"));
|
||||
}
|
||||
// Consumers must also use a bounded PNG decoder; the header is not a decode.
|
||||
Ok(png)
|
||||
}
|
||||
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
|
||||
/// decode with dimension/allocation limits; invalid pictures are cache misses.
|
||||
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
|
||||
}
|
||||
/// Read a portable picture without importing or reading the source blobs.
|
||||
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let source = {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
let source = std::fs::File::open(path).ok()?;
|
||||
let metadata = source.metadata().ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
source
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
|
||||
DesignContainerReader::new(source).ok()?.thumbnail()
|
||||
}
|
||||
@@ -10,9 +10,10 @@ mod design;
|
||||
mod design_metadata;
|
||||
mod dxf;
|
||||
pub use design::{
|
||||
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, is_design_container, read_design_file, read_design_geometry,
|
||||
read_design_preview, write_design_container, write_design_container_with_cache, write_design_container_with_compression,
|
||||
write_design_container_with_preview,
|
||||
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
|
||||
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
|
||||
write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
|
||||
write_design_container_with_thumbnail,
|
||||
};
|
||||
mod legacy_dir;
|
||||
pub mod safe_write;
|
||||
|
||||
Reference in new issue
Block a user