feat(viewer): thumbnails drawn at load, kept on disk by hash; no picture crashes it
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Tiles draw a thumbnail, not the file: the top of the first page, 560 pixels wide (never scaled up) and at most 420 tall, rendered by two workers of their own so opening a file never waits behind them. Every file's thumbnail starts when the books load, before the documents page is opened, and the live viewer keeps each as <sha>.png in $XDG_CACHE_HOME (else ~/.cache)/wanderbooks/thumbs/v1-560 (private to the user, written whole and renamed into place), so the next launch reads small PNGs, not PDFs. A file's name is its content's hash, so a thumbnail always matches the file the books hold; the blob is still read through the verifying store each time, so a corrupt or missing file is an error, never a kept picture. From review: a picture or page past the device's largest texture is scaled to fit (egui panicked on it, in the full view too); a renderer panic is that file's error, not the end of its worker; a thumbnail asked for before the cache moved is dropped, never shown over a newer one; text thumbnails keep 2 KiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
4 files changed
+454
-36
No files matched your search
@@ -163,6 +163,7 @@ impl App {
|
||||
);
|
||||
let mut app = Self::bare(&cc.egui_ctx, scheme);
|
||||
app.filter = filter;
|
||||
app.receipts.thumb_dir = crate::receipts::thumb_dir();
|
||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||
app
|
||||
}
|
||||
@@ -170,6 +171,7 @@ impl App {
|
||||
/// An app showing fixed books (tests and screenshots).
|
||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||
let mut app = Self::bare(ctx, schemes::own(dark));
|
||||
app.receipts.prefetch(&books);
|
||||
app.books = Some(Arc::new(books));
|
||||
app.nav = Nav::new(start);
|
||||
app
|
||||
@@ -369,6 +371,22 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep thumbnails on disk in `dir` (the live viewer keeps them in
|
||||
/// the user's cache directory, `wanderbooks/thumbs`); tests point it
|
||||
/// at their own.
|
||||
pub fn keep_thumbs_in(&mut self, dir: std::path::PathBuf) {
|
||||
self.receipts.thumb_dir = Some(dir);
|
||||
self.receipts.forget_thumbs();
|
||||
if let Some(b) = self.books.clone() {
|
||||
self.receipts.prefetch(&b);
|
||||
}
|
||||
}
|
||||
|
||||
/// The size in pixels of a file's thumbnail, once drawn (tests).
|
||||
pub fn thumb_size(&self, sha: &str) -> Option<[usize; 2]> {
|
||||
self.receipts.thumb_size(sha)
|
||||
}
|
||||
|
||||
/// Block until every receipt on screen has rendered (screenshots).
|
||||
pub fn settle(&mut self, ctx: &egui::Context) {
|
||||
self.receipts.settle(ctx);
|
||||
@@ -1622,6 +1640,7 @@ impl eframe::App for App {
|
||||
if self.books.is_some() {
|
||||
self.updated_at = ui.input(|i| i.time);
|
||||
}
|
||||
self.receipts.prefetch(&b);
|
||||
self.books = Some(b);
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,10 @@ fn tile(
|
||||
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
|
||||
ui.painter()
|
||||
.rect_filled(thumb, CornerRadius::same(6), p.hover);
|
||||
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
|
||||
match shell
|
||||
.receipts
|
||||
.thumb(&a.blob_sha, &a.mime_type, b.blob_root())
|
||||
{
|
||||
Render::Ready(pages) => {
|
||||
if let Some(tex) = pages.first() {
|
||||
// Top of the first page, filling the width.
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
//! twice, and a hash is the content, so the cache never goes stale. Bytes
|
||||
//! are read through the verifying blob store: a corrupt blob shows as an
|
||||
//! error, never as a picture.
|
||||
//!
|
||||
//! Tiles draw a thumbnail, not the file: its first page, [`THUMB_PX`]
|
||||
//! wide, rendered by workers of their own (opening a file never waits
|
||||
//! behind them). The live viewer keeps each thumbnail on disk as
|
||||
//! `<sha>.png` ([`thumb_dir`]) and starts every one the books hold when
|
||||
//! they load ([`Receipts::prefetch`]), so a tile's picture is there
|
||||
//! before the page is opened and the next launch reads small PNGs, not
|
||||
//! PDFs. A file's name is its content's hash, so a thumbnail always
|
||||
//! matches the file the books hold: a changed file is another hash.
|
||||
|
||||
use egui::{ColorImage, Context, TextureHandle, TextureOptions};
|
||||
use ledger_store::BlobStore;
|
||||
@@ -35,19 +44,63 @@ enum Rendered {
|
||||
/// rather than lay out megabytes of text every frame.
|
||||
pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// A thumbnail's width in pixels: a tile's picture is 260 points wide at
|
||||
/// most, twice that on a high-density screen.
|
||||
pub const THUMB_PX: f32 = 560.0;
|
||||
|
||||
/// A thumbnail's greatest height in pixels: a tile shows only the top of
|
||||
/// the first page (170 points of a 180 to 260 point width), so the rest
|
||||
/// is cut, and a long receipt costs no more than a short one.
|
||||
pub const THUMB_MAX_H: u32 = 420;
|
||||
|
||||
/// The longest side of any texture drawn: GPUs refuse larger (8192 is
|
||||
/// the least a wgpu device must take), and a refused texture panics.
|
||||
pub const MAX_SIDE: f32 = 8192.0;
|
||||
|
||||
/// A text thumbnail keeps only this much of its text: a tile shows a few
|
||||
/// lines.
|
||||
const THUMB_TEXT_BYTES: usize = 2048;
|
||||
|
||||
/// Where the live viewer keeps thumbnails: the user's cache directory
|
||||
/// (`$XDG_CACHE_HOME`, else `~/.cache`), `wanderbooks/thumbs/v1-560`;
|
||||
/// the version and width name how they were drawn, so a change in either
|
||||
/// is a new directory, never an old picture. None without a home.
|
||||
pub fn thumb_dir() -> Option<PathBuf> {
|
||||
// A relative XDG_CACHE_HOME is ignored, as the XDG spec says.
|
||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?;
|
||||
Some(base.join("wanderbooks").join("thumbs").join("v1-560"))
|
||||
}
|
||||
|
||||
struct Job {
|
||||
sha: String,
|
||||
mime: String,
|
||||
root: PathBuf,
|
||||
/// Some: a thumbnail, kept in this directory when it is Some(Some).
|
||||
thumb: Option<Option<PathBuf>>,
|
||||
/// The thumbnails' generation when this was asked for.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
struct Done {
|
||||
sha: String,
|
||||
thumb: bool,
|
||||
generation: u64,
|
||||
result: Result<Rendered, String>,
|
||||
}
|
||||
|
||||
pub struct Receipts {
|
||||
cache: HashMap<String, State>,
|
||||
thumbs: HashMap<String, State>,
|
||||
/// Where thumbnails are kept on disk; None keeps them in memory only
|
||||
/// (tests and screenshots, unless they ask).
|
||||
pub thumb_dir: Option<PathBuf>,
|
||||
/// Bumped when the thumbnails are forgotten: a thumbnail asked for
|
||||
/// before is dropped when it comes, never shown over a newer one.
|
||||
generation: u64,
|
||||
thumb_jobs: Sender<Job>,
|
||||
/// egui_commonmark's layout cache, shared by every Markdown file.
|
||||
pub markdown: egui_commonmark::CommonMarkCache,
|
||||
jobs: Sender<Job>,
|
||||
@@ -56,37 +109,16 @@ pub struct Receipts {
|
||||
|
||||
impl Receipts {
|
||||
pub fn new(ctx: &Context) -> Self {
|
||||
let (jobs, job_rx) = channel::<Job>();
|
||||
let (done_tx, done) = channel();
|
||||
let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));
|
||||
let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4));
|
||||
for i in 0..workers {
|
||||
let ctx = ctx.clone();
|
||||
let job_rx = job_rx.clone();
|
||||
let done_tx = done_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("receipt-render-{i}"))
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let job = match job_rx.lock() {
|
||||
Ok(rx) => rx.recv(),
|
||||
Err(_) => return,
|
||||
};
|
||||
let Ok(job) = job else {
|
||||
return;
|
||||
};
|
||||
let result = render(&job);
|
||||
let _ = done_tx.send(Done {
|
||||
sha: job.sha,
|
||||
result,
|
||||
});
|
||||
ctx.request_repaint();
|
||||
}
|
||||
})
|
||||
.expect("spawn renderer");
|
||||
}
|
||||
let jobs = workers_for(ctx, &done_tx, "receipt-render", workers);
|
||||
let thumb_jobs = workers_for(ctx, &done_tx, "thumb-render", 2);
|
||||
Self {
|
||||
cache: HashMap::new(),
|
||||
thumbs: HashMap::new(),
|
||||
thumb_dir: None,
|
||||
generation: 0,
|
||||
thumb_jobs,
|
||||
markdown: egui_commonmark::CommonMarkCache::default(),
|
||||
jobs,
|
||||
done,
|
||||
@@ -101,11 +133,53 @@ impl Receipts {
|
||||
sha: sha.to_string(),
|
||||
mime: mime.to_string(),
|
||||
root,
|
||||
thumb: None,
|
||||
generation: 0,
|
||||
});
|
||||
}
|
||||
&self.cache[sha]
|
||||
}
|
||||
|
||||
/// A blob's thumbnail (its first page, [`THUMB_PX`] wide; a text
|
||||
/// file's text), starting it if needed.
|
||||
pub fn thumb(&mut self, sha: &str, mime: &str, root: PathBuf) -> &State {
|
||||
if !self.thumbs.contains_key(sha) {
|
||||
self.thumbs.insert(sha.to_string(), State::Loading);
|
||||
let _ = self.thumb_jobs.send(Job {
|
||||
sha: sha.to_string(),
|
||||
mime: mime.to_string(),
|
||||
root,
|
||||
thumb: Some(self.thumb_dir.clone()),
|
||||
generation: self.generation,
|
||||
});
|
||||
}
|
||||
&self.thumbs[sha]
|
||||
}
|
||||
|
||||
/// Start the thumbnail of every file the books hold, so each tile's
|
||||
/// picture is ready before its page is opened. Files already started
|
||||
/// cost nothing; call it whenever books load.
|
||||
pub fn prefetch(&mut self, books: &crate::books::Books) {
|
||||
let root = books.blob_root();
|
||||
for a in &books.attachments {
|
||||
self.thumb(&a.blob_sha, &a.mime_type, root.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every thumbnail held in memory (their directory changed).
|
||||
pub fn forget_thumbs(&mut self) {
|
||||
self.thumbs.clear();
|
||||
self.generation += 1;
|
||||
}
|
||||
|
||||
/// A drawn thumbnail's size in pixels.
|
||||
pub fn thumb_size(&self, sha: &str) -> Option<[usize; 2]> {
|
||||
match self.thumbs.get(sha)? {
|
||||
State::Ready(pages) => pages.first().map(|t| t.size()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn finished renders into textures. Call once per frame.
|
||||
pub fn pump(&mut self, ctx: &Context) {
|
||||
while let Ok(done) = self.done.try_recv() {
|
||||
@@ -114,6 +188,9 @@ impl Receipts {
|
||||
}
|
||||
|
||||
fn accept(&mut self, ctx: &Context, done: Done) {
|
||||
if done.thumb && done.generation != self.generation {
|
||||
return;
|
||||
}
|
||||
let state = match done.result {
|
||||
Ok(Rendered::Text { text, markdown }) => State::Text { text, markdown },
|
||||
Ok(Rendered::Pages(images)) => State::Ready(
|
||||
@@ -121,8 +198,13 @@ impl Receipts {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, img)| {
|
||||
let img = fit_side(img, ctx.input(|i| i.max_texture_side));
|
||||
ctx.load_texture(
|
||||
format!("blob-{}-{i}", done.sha),
|
||||
format!(
|
||||
"{}-{}-{i}",
|
||||
if done.thumb { "thumb" } else { "blob" },
|
||||
done.sha
|
||||
),
|
||||
img,
|
||||
TextureOptions::LINEAR,
|
||||
)
|
||||
@@ -131,13 +213,21 @@ impl Receipts {
|
||||
),
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
self.cache.insert(done.sha, state);
|
||||
if done.thumb {
|
||||
self.thumbs.insert(done.sha, state);
|
||||
} else {
|
||||
self.cache.insert(done.sha, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until nothing is rendering (tests and screenshots).
|
||||
pub fn settle(&mut self, ctx: &Context) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while self.cache.values().any(|s| matches!(s, State::Loading))
|
||||
while self
|
||||
.cache
|
||||
.values()
|
||||
.chain(self.thumbs.values())
|
||||
.any(|s| matches!(s, State::Loading))
|
||||
&& std::time::Instant::now() < deadline
|
||||
{
|
||||
if let Ok(done) = self
|
||||
@@ -150,6 +240,181 @@ impl Receipts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Workers taking jobs from one queue, sending what they render to
|
||||
/// `done`; the queue's sender.
|
||||
fn workers_for(ctx: &Context, done_tx: &Sender<Done>, name: &str, n: usize) -> Sender<Job> {
|
||||
let (jobs, job_rx) = channel::<Job>();
|
||||
let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));
|
||||
for i in 0..n {
|
||||
let ctx = ctx.clone();
|
||||
let job_rx = job_rx.clone();
|
||||
let done_tx = done_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("{name}-{i}"))
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let job = match job_rx.lock() {
|
||||
Ok(rx) => rx.recv(),
|
||||
Err(_) => return,
|
||||
};
|
||||
let Ok(job) = job else {
|
||||
return;
|
||||
};
|
||||
// A file that crashes its renderer is that file's
|
||||
// error, not the end of the worker (and every file
|
||||
// queued after it).
|
||||
let result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
match &job.thumb {
|
||||
Some(dir) => render_thumb(&job, dir.as_deref()),
|
||||
None => render(&job),
|
||||
}
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("the renderer crashed on this file".into()));
|
||||
let _ = done_tx.send(Done {
|
||||
sha: job.sha,
|
||||
thumb: job.thumb.is_some(),
|
||||
generation: job.generation,
|
||||
result,
|
||||
});
|
||||
ctx.request_repaint();
|
||||
}
|
||||
})
|
||||
.expect("spawn renderer");
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
/// A thumbnail: the top of the first page, [`THUMB_PX`] wide (never
|
||||
/// scaled up) and at most [`THUMB_MAX_H`] tall. The blob is read through
|
||||
/// the verifying store every time, so a corrupt or missing file is an
|
||||
/// error, never a picture; its thumbnail is then read from `dir` when it
|
||||
/// is there, else drawn (a PDF's first page, an image) and kept in `dir`.
|
||||
/// A text file's thumbnail is the start of its text, kept nowhere: it is
|
||||
/// cheaper to read than a picture. A thumbnail that can't be kept is
|
||||
/// still shown.
|
||||
fn render_thumb(job: &Job, dir: Option<&std::path::Path>) -> Result<Rendered, String> {
|
||||
let bytes = BlobStore::open_read_only(&job.root)
|
||||
.get(&job.sha)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(markdown) = text_kind(&job.mime)
|
||||
&& !bytes.starts_with(b"%PDF")
|
||||
{
|
||||
let mut text = text_of(bytes)?;
|
||||
if text.len() > THUMB_TEXT_BYTES {
|
||||
let mut cut = THUMB_TEXT_BYTES;
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
text.truncate(cut);
|
||||
}
|
||||
return Ok(Rendered::Text { text, markdown });
|
||||
}
|
||||
// A blob's hash names the file; anything else never names a path.
|
||||
let named = job.sha.len() == 64 && job.sha.bytes().all(|b| b.is_ascii_hexdigit());
|
||||
let kept = dir
|
||||
.filter(|_| named)
|
||||
.map(|d| d.join(format!("{}.png", job.sha)));
|
||||
if let Some(path) = &kept
|
||||
&& let Ok(png) = std::fs::read(path)
|
||||
&& let Ok(img) = image::load_from_memory(&png)
|
||||
{
|
||||
return Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]));
|
||||
}
|
||||
let img = if job.mime == "application/pdf" || bytes.starts_with(b"%PDF") {
|
||||
let png = pdf_first_page_png(bytes)?;
|
||||
image::load_from_memory(&png).map_err(|e| e.to_string())?
|
||||
} else if job.mime.starts_with("image/") {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
if img.width() as f32 > THUMB_PX {
|
||||
let h = (img.height() as f32 * THUMB_PX / img.width() as f32).max(1.0);
|
||||
img.resize_exact(
|
||||
THUMB_PX as u32,
|
||||
h as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
}
|
||||
} else {
|
||||
return Err(format!("{} can't be previewed", job.mime));
|
||||
};
|
||||
let img = img.crop_imm(0, 0, img.width(), img.height().min(THUMB_MAX_H));
|
||||
if let Some(path) = &kept {
|
||||
let mut png = Vec::new();
|
||||
if img
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.is_ok()
|
||||
{
|
||||
keep(path, &png);
|
||||
}
|
||||
}
|
||||
Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]))
|
||||
}
|
||||
|
||||
/// Write a thumbnail into the cache: its directory made private to the
|
||||
/// user (it pictures receipts), the bytes written to a temporary name
|
||||
/// and renamed into place. A failure only means it is drawn again.
|
||||
fn keep(path: &std::path::Path, png: &[u8]) {
|
||||
let Some(dir) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
if builder.create(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
// Made before, it may be looser: the thumbnails are private all the same.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
// One name per write, across viewers (the pid) and this one's workers.
|
||||
static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let tmp = dir.join(format!(
|
||||
".{}.{}.{}.tmp",
|
||||
path.file_name().and_then(|n| n.to_str()).unwrap_or("thumb"),
|
||||
std::process::id(),
|
||||
WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
if std::fs::write(&tmp, png).is_ok() && std::fs::rename(&tmp, path).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF's first page as a PNG, [`THUMB_PX`] wide.
|
||||
fn pdf_first_page_png(bytes: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
|
||||
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
|
||||
let pages = pdf.pages();
|
||||
let page = pages.first().ok_or("the PDF has no pages")?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
// THUMB_PX wide, but never past a texture's side on a long page (the
|
||||
// picture is cut to THUMB_MAX_H after), and never nothing.
|
||||
let scale = (THUMB_PX / w.max(1.0))
|
||||
.min(MAX_SIDE / h.max(1.0))
|
||||
.max(1.0 / w.max(h).max(1.0));
|
||||
let px = PixmapSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
};
|
||||
let pixmap = render(
|
||||
page,
|
||||
&RenderCache::new(),
|
||||
&InterpreterSettings::default(),
|
||||
&RenderSettings::default(),
|
||||
&px,
|
||||
);
|
||||
pixmap.into_png().map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
|
||||
fn render(job: &Job) -> Result<Rendered, String> {
|
||||
let bytes = BlobStore::open_read_only(&job.root)
|
||||
.get(&job.sha)
|
||||
@@ -159,6 +424,16 @@ fn render(job: &Job) -> Result<Rendered, String> {
|
||||
render_pdf(bytes).map(Rendered::Pages)
|
||||
} else if job.mime.starts_with("image/") {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
// Past a texture's side, scaled down to fit (aspect kept).
|
||||
let img = if img.width().max(img.height()) as f32 > MAX_SIDE {
|
||||
img.resize(
|
||||
MAX_SIDE as u32,
|
||||
MAX_SIDE as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]))
|
||||
} else if let Some(markdown) = text_kind(&job.mime) {
|
||||
let text = text_of(bytes)?;
|
||||
@@ -212,13 +487,16 @@ fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
|
||||
let cache = RenderCache::new();
|
||||
let interp = InterpreterSettings::default();
|
||||
let settings = RenderSettings::default();
|
||||
let px = PixmapSettings {
|
||||
x_scale: 2.0,
|
||||
y_scale: 2.0,
|
||||
bg_color: WHITE,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for page in pdf.pages().iter().take(30) {
|
||||
// Twice the page's size, but never past a texture's side.
|
||||
let (w, h) = page.render_dimensions();
|
||||
let scale = 2.0f32.min(MAX_SIDE / w.max(h).max(1.0));
|
||||
let px = PixmapSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
};
|
||||
let pixmap = render(page, &cache, &interp, &settings, &px);
|
||||
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
|
||||
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
|
||||
@@ -230,6 +508,27 @@ fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A picture no side of which is past `max` pixels (the device's
|
||||
/// largest texture: egui panics on a larger one), scaled down with its
|
||||
/// aspect kept; most pictures are already within it and pass untouched.
|
||||
fn fit_side(img: ColorImage, max: usize) -> ColorImage {
|
||||
let [w, h] = img.size;
|
||||
if w.max(h) <= max || max == 0 {
|
||||
return img;
|
||||
}
|
||||
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
let Some(buf) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else {
|
||||
return img;
|
||||
};
|
||||
let scaled = image::DynamicImage::ImageRgba8(buf).resize(
|
||||
max as u32,
|
||||
max as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
let size = [scaled.width() as usize, scaled.height() as usize];
|
||||
ColorImage::from_rgba_premultiplied(size, scaled.to_rgba8().as_raw())
|
||||
}
|
||||
|
||||
fn to_color_image(img: image::RgbaImage) -> ColorImage {
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
ColorImage::from_rgba_unmultiplied(size, img.as_raw())
|
||||
|
||||
@@ -121,3 +121,100 @@ fn the_documents_page_draws_text_tiles() {
|
||||
shot(&mut h, "text-tiles");
|
||||
assert_eq!(h.state_mut().route(), &Route::Documents);
|
||||
}
|
||||
|
||||
/// A PNG `w` by `h` pixels.
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 90, 160, 255]));
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img)
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// receipts.rs: every file's thumbnail is started when the books load,
|
||||
/// before its page is opened; it is kept on disk as `<sha>.png`, at most
|
||||
/// 560 pixels wide (a smaller picture is never scaled up), and the next
|
||||
/// launch draws what was kept instead of the file.
|
||||
#[test]
|
||||
fn thumbnails_are_drawn_at_load_and_kept_by_hash() {
|
||||
let dir = TempDir::new("thumbs");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
// The second of what filing gives is the file's SHA-256.
|
||||
let (_, big_sha, _) = s
|
||||
.file_document("photo", "wide.png", "image/png", &png(1200, 600), None)
|
||||
.unwrap();
|
||||
let (_, small_sha, _) = s
|
||||
.file_document("photo", "small.png", "image/png", &png(300, 200), None)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let thumbs = dir.0.join("thumbs");
|
||||
// The overview, not the documents page: the thumbnails come anyway.
|
||||
let mut h = open(&dir, Route::Overview);
|
||||
h.state_mut().keep_thumbs_in(thumbs.clone());
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().settle(&ctx);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().thumb_size(&big_sha), Some([560, 280]));
|
||||
assert_eq!(h.state().thumb_size(&small_sha), Some([300, 200]));
|
||||
let kept = thumbs.join(format!("{big_sha}.png"));
|
||||
let img = image::open(&kept).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (560, 280));
|
||||
assert!(thumbs.join(format!("{small_sha}.png")).exists());
|
||||
// What was kept is what the next launch draws.
|
||||
std::fs::write(&kept, png(7, 5)).unwrap();
|
||||
let mut h = open(&dir, Route::Documents);
|
||||
h.state_mut().keep_thumbs_in(thumbs.clone());
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().settle(&ctx);
|
||||
assert_eq!(h.state().thumb_size(&big_sha), Some([7, 5]));
|
||||
}
|
||||
|
||||
/// receipts.rs: a long picture's thumbnail is its top, at most 420
|
||||
/// pixels tall; a picture past a texture's side opens scaled to fit
|
||||
/// rather than crash the viewer; a file whose blob is gone shows as an
|
||||
/// error even with its thumbnail kept.
|
||||
#[test]
|
||||
fn long_and_huge_pictures_are_cut_or_scaled_and_a_lost_file_is_an_error() {
|
||||
let dir = TempDir::new("long");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let (_, long_sha, _) = s
|
||||
.file_document("photo", "long.png", "image/png", &png(400, 9000), None)
|
||||
.unwrap();
|
||||
let (huge, huge_sha, _) = s
|
||||
.file_document("photo", "huge.png", "image/png", &png(9000, 300), None)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let thumbs = dir.0.join("thumbs");
|
||||
let mut h = open(&dir, Route::Document(huge));
|
||||
h.state_mut().keep_thumbs_in(thumbs.clone());
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().settle(&ctx);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().thumb_size(&long_sha), Some([400, 420]));
|
||||
assert_eq!(h.state().thumb_size(&huge_sha), Some([560, 18]));
|
||||
assert!(h.query_by_label_contains("Can't show").is_none());
|
||||
// The blob gone: the kept thumbnail is not shown in its place.
|
||||
let blob = walk(&dir.0.join("books-data"), &long_sha).expect("the blob's file");
|
||||
std::fs::remove_file(blob).unwrap();
|
||||
let mut h = open(&dir, Route::Documents);
|
||||
h.state_mut().keep_thumbs_in(thumbs);
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().settle(&ctx);
|
||||
assert_eq!(h.state().thumb_size(&long_sha), None);
|
||||
}
|
||||
|
||||
/// The file under `dir` named `name`.
|
||||
fn walk(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
|
||||
for e in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
if let Some(f) = walk(&p, name) {
|
||||
return Some(f);
|
||||
}
|
||||
} else if p.file_name().and_then(|n| n.to_str()) == Some(name) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in new issue
Block a user