mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
A native file whose images together pass IMAGE_BUDGET (4 GiB, or 20,000 images) opens without the images past it. They are intact in the file, but saving it again wrote the archive from the images in memory, so every save, autosave or `convert` onto itself deleted them for good. A 7.6 GiB `.fig` converted to a 7.3 GiB `.figcraft` reopened with 1,312 of its 2,337 images: one save would have dropped the other 1,025. Loading now tells images left out for the budget apart from damaged ones (`Loaded::images_past_budget`). A tab opened or reverted from such a file remembers the file, and saving over it is refused with a message that says how many images would be lost and to save under another name; saving a copy elsewhere works, and the original keeps its images. Auto-save skips the tab instead of retrying and failing every minute. `figcraft-cli convert` and `run --in X --out X` refuse to write over their input the same way. The same file is matched by its resolved path, so `./a.figcraft` and `a.figcraft` count as one (`file_access::same_file`). The note for each image left out for the budget now says it stays in the file, which can't be saved over without losing it. Damaged and missing images keep the old wording: they can't be kept, and saving drops them as before. The doc comment on report_lost_images said saving always drops the images it reports, which is no longer true for budget ones, so it went. This stops the data loss; it doesn't make such files editable in place. Loading images on demand would, and is discussed in the issue this links. Tests: the count and the note for both budgets; saving over the file refused with the file unchanged, Save As then working, and saving back over the original refused again; a snapshot encoded in the background refused too; a tab without images left out saving as before. Checked by hand on the 7.3 GiB file with file.save, file.saveAs, convert and run --out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
4.6 KiB
Rust
107 lines
4.6 KiB
Rust
//! The images of a native file within a budget (#189): however many members each within the
|
|
//! per-member limit a file holds, the images read from it together stay within a byte and a count
|
|
//! budget. The document's own images are read first, and the ones past the budget are reported.
|
|
|
|
use std::io::Write;
|
|
|
|
use figcraft_doc::Document;
|
|
|
|
use super::*;
|
|
|
|
fn png(k: u8) -> Vec<u8> {
|
|
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255]));
|
|
let mut out = Vec::new();
|
|
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
|
out
|
|
}
|
|
|
|
/// A saved document with `n` images, and their hashes and total size.
|
|
fn file_with_images(n: u8) -> (Vec<u8>, Vec<String>, u64) {
|
|
let mut d = Document::new("File");
|
|
let mut hashes = Vec::new();
|
|
let mut total = 0;
|
|
for k in 0..n {
|
|
let bytes = png(k);
|
|
total += bytes.len() as u64;
|
|
hashes.push(add_image(&mut d, bytes).unwrap().0);
|
|
}
|
|
(save_bytes(&d, None).unwrap(), hashes, total)
|
|
}
|
|
|
|
/// `bytes` with one more member, `name` holding `data`.
|
|
fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec<u8> {
|
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
|
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
|
let mut buf = std::io::Cursor::new(Vec::new());
|
|
let mut w = zip::ZipWriter::new(&mut buf);
|
|
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
for n in names {
|
|
let member = read_member(&mut z, &n, u64::MAX).unwrap();
|
|
w.start_file(n, stored).unwrap();
|
|
w.write_all(&member).unwrap();
|
|
}
|
|
w.start_file(name, stored).unwrap();
|
|
w.write_all(data).unwrap();
|
|
w.finish().unwrap();
|
|
buf.into_inner()
|
|
}
|
|
|
|
#[test]
|
|
fn images_exactly_within_the_budget_all_load() {
|
|
let (bytes, hashes, total) = file_with_images(3);
|
|
let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap();
|
|
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
|
|
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)));
|
|
}
|
|
|
|
#[test]
|
|
fn images_past_the_byte_budget_are_left_out_and_reported() {
|
|
let (bytes, hashes, total) = file_with_images(3);
|
|
let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap();
|
|
let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum();
|
|
assert!(held < total, "{held} bytes held");
|
|
assert_eq!(loaded.doc.images.len(), 2);
|
|
let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect();
|
|
assert_eq!(lost.len(), 1);
|
|
assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues);
|
|
}
|
|
|
|
#[test]
|
|
fn images_past_the_budget_are_counted_and_stay_in_the_file() {
|
|
let (bytes, _, total) = file_with_images(3);
|
|
let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap();
|
|
assert_eq!(loaded.images_past_budget, 1);
|
|
assert!(loaded.issues.iter().any(|i| i.contains("stays in the file")), "{:?}", loaded.issues);
|
|
assert!(!loaded.issues.iter().any(|i| i.contains("drops it")), "{:?}", loaded.issues);
|
|
let counted = load_within(&bytes, ImageBudget { images: 2, bytes: u64::MAX }).unwrap();
|
|
assert_eq!(counted.images_past_budget, 1);
|
|
let within = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap();
|
|
assert_eq!(within.images_past_budget, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn images_past_the_count_budget_are_left_out_and_reported() {
|
|
let (bytes, _, _) = file_with_images(4);
|
|
let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap();
|
|
assert_eq!(loaded.doc.images.len(), 3);
|
|
assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues);
|
|
}
|
|
|
|
#[test]
|
|
fn the_documents_own_images_are_read_before_others() {
|
|
let (bytes, hashes, total) = file_with_images(2);
|
|
// An image the document doesn't list, named to come first in the archive's order.
|
|
let extra = with_member(&bytes, "images/0000", &png(99));
|
|
let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap();
|
|
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues);
|
|
assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues);
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_within_the_shipped_budget_loads_as_before() {
|
|
let (bytes, hashes, _) = file_with_images(3);
|
|
let loaded = load_bytes_with_versions(&bytes).unwrap();
|
|
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
|
|
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)));
|
|
}
|