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);
|
let mut app = Self::bare(&cc.egui_ctx, scheme);
|
||||||
app.filter = filter;
|
app.filter = filter;
|
||||||
|
app.receipts.thumb_dir = crate::receipts::thumb_dir();
|
||||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -170,6 +171,7 @@ impl App {
|
|||||||
/// An app showing fixed books (tests and screenshots).
|
/// An app showing fixed books (tests and screenshots).
|
||||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||||
let mut app = Self::bare(ctx, schemes::own(dark));
|
let mut app = Self::bare(ctx, schemes::own(dark));
|
||||||
|
app.receipts.prefetch(&books);
|
||||||
app.books = Some(Arc::new(books));
|
app.books = Some(Arc::new(books));
|
||||||
app.nav = Nav::new(start);
|
app.nav = Nav::new(start);
|
||||||
app
|
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).
|
/// Block until every receipt on screen has rendered (screenshots).
|
||||||
pub fn settle(&mut self, ctx: &egui::Context) {
|
pub fn settle(&mut self, ctx: &egui::Context) {
|
||||||
self.receipts.settle(ctx);
|
self.receipts.settle(ctx);
|
||||||
@@ -1622,6 +1640,7 @@ impl eframe::App for App {
|
|||||||
if self.books.is_some() {
|
if self.books.is_some() {
|
||||||
self.updated_at = ui.input(|i| i.time);
|
self.updated_at = ui.input(|i| i.time);
|
||||||
}
|
}
|
||||||
|
self.receipts.prefetch(&b);
|
||||||
self.books = Some(b);
|
self.books = Some(b);
|
||||||
self.error = None;
|
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));
|
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
|
||||||
ui.painter()
|
ui.painter()
|
||||||
.rect_filled(thumb, CornerRadius::same(6), p.hover);
|
.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) => {
|
Render::Ready(pages) => {
|
||||||
if let Some(tex) = pages.first() {
|
if let Some(tex) = pages.first() {
|
||||||
// Top of the first page, filling the width.
|
// 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
|
//! 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
|
//! are read through the verifying blob store: a corrupt blob shows as an
|
||||||
//! error, never as a picture.
|
//! 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 egui::{ColorImage, Context, TextureHandle, TextureOptions};
|
||||||
use ledger_store::BlobStore;
|
use ledger_store::BlobStore;
|
||||||
@@ -35,19 +44,63 @@ enum Rendered {
|
|||||||
/// rather than lay out megabytes of text every frame.
|
/// rather than lay out megabytes of text every frame.
|
||||||
pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
|
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 {
|
struct Job {
|
||||||
sha: String,
|
sha: String,
|
||||||
mime: String,
|
mime: String,
|
||||||
root: PathBuf,
|
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 {
|
struct Done {
|
||||||
sha: String,
|
sha: String,
|
||||||
|
thumb: bool,
|
||||||
|
generation: u64,
|
||||||
result: Result<Rendered, String>,
|
result: Result<Rendered, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Receipts {
|
pub struct Receipts {
|
||||||
cache: HashMap<String, State>,
|
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.
|
/// egui_commonmark's layout cache, shared by every Markdown file.
|
||||||
pub markdown: egui_commonmark::CommonMarkCache,
|
pub markdown: egui_commonmark::CommonMarkCache,
|
||||||
jobs: Sender<Job>,
|
jobs: Sender<Job>,
|
||||||
@@ -56,37 +109,16 @@ pub struct Receipts {
|
|||||||
|
|
||||||
impl Receipts {
|
impl Receipts {
|
||||||
pub fn new(ctx: &Context) -> Self {
|
pub fn new(ctx: &Context) -> Self {
|
||||||
let (jobs, job_rx) = channel::<Job>();
|
|
||||||
let (done_tx, done) = channel();
|
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));
|
let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4));
|
||||||
for i in 0..workers {
|
let jobs = workers_for(ctx, &done_tx, "receipt-render", workers);
|
||||||
let ctx = ctx.clone();
|
let thumb_jobs = workers_for(ctx, &done_tx, "thumb-render", 2);
|
||||||
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");
|
|
||||||
}
|
|
||||||
Self {
|
Self {
|
||||||
cache: HashMap::new(),
|
cache: HashMap::new(),
|
||||||
|
thumbs: HashMap::new(),
|
||||||
|
thumb_dir: None,
|
||||||
|
generation: 0,
|
||||||
|
thumb_jobs,
|
||||||
markdown: egui_commonmark::CommonMarkCache::default(),
|
markdown: egui_commonmark::CommonMarkCache::default(),
|
||||||
jobs,
|
jobs,
|
||||||
done,
|
done,
|
||||||
@@ -101,11 +133,53 @@ impl Receipts {
|
|||||||
sha: sha.to_string(),
|
sha: sha.to_string(),
|
||||||
mime: mime.to_string(),
|
mime: mime.to_string(),
|
||||||
root,
|
root,
|
||||||
|
thumb: None,
|
||||||
|
generation: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
&self.cache[sha]
|
&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.
|
/// Turn finished renders into textures. Call once per frame.
|
||||||
pub fn pump(&mut self, ctx: &Context) {
|
pub fn pump(&mut self, ctx: &Context) {
|
||||||
while let Ok(done) = self.done.try_recv() {
|
while let Ok(done) = self.done.try_recv() {
|
||||||
@@ -114,6 +188,9 @@ impl Receipts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn accept(&mut self, ctx: &Context, done: Done) {
|
fn accept(&mut self, ctx: &Context, done: Done) {
|
||||||
|
if done.thumb && done.generation != self.generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let state = match done.result {
|
let state = match done.result {
|
||||||
Ok(Rendered::Text { text, markdown }) => State::Text { text, markdown },
|
Ok(Rendered::Text { text, markdown }) => State::Text { text, markdown },
|
||||||
Ok(Rendered::Pages(images)) => State::Ready(
|
Ok(Rendered::Pages(images)) => State::Ready(
|
||||||
@@ -121,8 +198,13 @@ impl Receipts {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, img)| {
|
.map(|(i, img)| {
|
||||||
|
let img = fit_side(img, ctx.input(|i| i.max_texture_side));
|
||||||
ctx.load_texture(
|
ctx.load_texture(
|
||||||
format!("blob-{}-{i}", done.sha),
|
format!(
|
||||||
|
"{}-{}-{i}",
|
||||||
|
if done.thumb { "thumb" } else { "blob" },
|
||||||
|
done.sha
|
||||||
|
),
|
||||||
img,
|
img,
|
||||||
TextureOptions::LINEAR,
|
TextureOptions::LINEAR,
|
||||||
)
|
)
|
||||||
@@ -131,13 +213,21 @@ impl Receipts {
|
|||||||
),
|
),
|
||||||
Err(e) => State::Failed(e),
|
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).
|
/// Wait until nothing is rendering (tests and screenshots).
|
||||||
pub fn settle(&mut self, ctx: &Context) {
|
pub fn settle(&mut self, ctx: &Context) {
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
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
|
&& std::time::Instant::now() < deadline
|
||||||
{
|
{
|
||||||
if let Ok(done) = self
|
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> {
|
fn render(job: &Job) -> Result<Rendered, String> {
|
||||||
let bytes = BlobStore::open_read_only(&job.root)
|
let bytes = BlobStore::open_read_only(&job.root)
|
||||||
.get(&job.sha)
|
.get(&job.sha)
|
||||||
@@ -159,6 +424,16 @@ fn render(job: &Job) -> Result<Rendered, String> {
|
|||||||
render_pdf(bytes).map(Rendered::Pages)
|
render_pdf(bytes).map(Rendered::Pages)
|
||||||
} else if job.mime.starts_with("image/") {
|
} else if job.mime.starts_with("image/") {
|
||||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
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())]))
|
Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]))
|
||||||
} else if let Some(markdown) = text_kind(&job.mime) {
|
} else if let Some(markdown) = text_kind(&job.mime) {
|
||||||
let text = text_of(bytes)?;
|
let text = text_of(bytes)?;
|
||||||
@@ -212,13 +487,16 @@ fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
|
|||||||
let cache = RenderCache::new();
|
let cache = RenderCache::new();
|
||||||
let interp = InterpreterSettings::default();
|
let interp = InterpreterSettings::default();
|
||||||
let settings = RenderSettings::default();
|
let settings = RenderSettings::default();
|
||||||
let px = PixmapSettings {
|
|
||||||
x_scale: 2.0,
|
|
||||||
y_scale: 2.0,
|
|
||||||
bg_color: WHITE,
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for page in pdf.pages().iter().take(30) {
|
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 pixmap = render(page, &cache, &interp, &settings, &px);
|
||||||
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
|
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
|
||||||
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
|
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)
|
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 {
|
fn to_color_image(img: image::RgbaImage) -> ColorImage {
|
||||||
let size = [img.width() as usize, img.height() as usize];
|
let size = [img.width() as usize, img.height() as usize];
|
||||||
ColorImage::from_rgba_unmultiplied(size, img.as_raw())
|
ColorImage::from_rgba_unmultiplied(size, img.as_raw())
|
||||||
|
|||||||
@@ -121,3 +121,100 @@ fn the_documents_page_draws_text_tiles() {
|
|||||||
shot(&mut h, "text-tiles");
|
shot(&mut h, "text-tiles");
|
||||||
assert_eq!(h.state_mut().route(), &Route::Documents);
|
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