diff --git a/apps/staged/docs/inline-note-media.md b/apps/staged/docs/inline-note-media.md index e3d30ae6e..be20efcd3 100644 --- a/apps/staged/docs/inline-note-media.md +++ b/apps/staged/docs/inline-note-media.md @@ -41,10 +41,24 @@ the current webview. Older QuickTime files without an `ftyp` header are rejected Chat attachments (file picker, paste, drop) share the validation code but are stored under the format their bytes actually are: browsers label `file.type` from the filename, so a WebP saved as `shot.png` is stored as `shot.webp` with -an `image/webp` MIME rather than rejected. Only unrecognized signatures, videos, -and oversized files are refused, and the composer shows the reason for pasted, -picked, and natively dropped files alike, clearing it when the message is sent -or the attachment removed. +an `image/webp` MIME rather than rejected. A dropped, pasted, or picked PDF (up +to 50 MiB) is stored byte-for-byte as `application/pdf` so the viewer can open +it in the webview's native PDF viewer (vector zoom, every page) from its served +media URL (`staged-media://.pdf`, `/api/media/.pdf` in web mode, +where the response omits the `sandbox` CSP directive that would blank WebKit's +viewer); thumbnails show its first page, rendered once with CoreGraphics on +macOS and written atomically as `.thumb.png` beside the file, and the +agent prompt gets its pages as captioned image blocks, since the ACP agents +accept only image blocks: up to 20 pages per PDF, within a 20 MB budget of +encoded images that the whole message's attachments share (an image or page +that would cross it is named in the prompt text), each page at most 5 MB (PNG, +re-rendered at half scale and then re-encoded as JPEG if it has to be), with +the pages left out named on the last one sent. A `.pdf` +reference in a note is still refused. Only +unrecognized signatures, videos, oversized files, and PDFs CoreGraphics cannot +open are refused, and the composer shows the reason for pasted, picked, and +natively dropped files alike, clearing it when the message is sent or the +attachment removed. On session completion, files are copied into the existing project `images/` store and destinations become `staged-media://.`. Captions and titles diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 687682319..d619aef7c 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -20,9 +20,12 @@ dependencies = [ "flate2", "git-diff", "hex", + "image", "include_dir", "libc", "log", + "objc2-core-foundation", + "objc2-core-graphics", "pikchr", "rand 0.10.3", "regex", @@ -2537,6 +2540,8 @@ dependencies = [ "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index b33d73b8c..370f4d954 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -85,6 +85,29 @@ tiny-skia = "0.12" tar = "0.4" flate2 = "1" +# PDF attachments: rasterize pages with CoreGraphics (note_media::pdf). All +# three crates are already in the lock via arboard/tray-icon (and `image`'s +# jpeg feature only adds zune-jpeg, which resvg pulls in); the feature lists +# are the minimum for CGPDFDocument + a bitmap CGContext + CFData input, and +# a JPEG encoder for prompt pages whose PNG is over the per-image cap. +[target.'cfg(target_os = "macos")'.dependencies] +image = { version = "0.25", default-features = false, features = ["jpeg"] } +objc2-core-graphics = { version = "0.3.2", default-features = false, features = [ + "CGAffineTransform", + "CGBitmapContext", + "CGColorSpace", + "CGContext", + "CGDataProvider", + "CGGeometry", + "CGImage", + "CGPDFDocument", + "CGPDFPage", +] } +objc2-core-foundation = { version = "0.3.2", default-features = false, features = [ + "CFCGTypes", + "CFData", +] } + [dev-dependencies] tower = { version = "0.5", features = ["util"] } # test-util: `#[tokio::test(start_paused = true)]` for the store-events diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 87487025b..6b25be8d9 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -2679,6 +2679,21 @@ fn move_branch_image_files( let (Ok(from), Ok(to)) = (from, to) else { continue; }; + // A PDF's cached thumbnail travels with it; losing one only costs a + // re-render, so its failure is not worth more than a log line. + if let (Ok(thumb_from), Ok(thumb_to)) = ( + store::images::thumbnail_file_path(source_project_id, &image.id), + store::images::thumbnail_file_path(target_project_id, &image.id), + ) { + if thumb_from.exists() { + if let Some(parent) = thumb_to.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::rename(&thumb_from, &thumb_to) { + log::warn!("Failed to move thumbnail {}: {e}", thumb_from.display()); + } + } + } if !from.exists() { continue; } diff --git a/apps/staged/src-tauri/src/image_commands.rs b/apps/staged/src-tauri/src/image_commands.rs index 921d98d73..97ef66495 100644 --- a/apps/staged/src-tauri/src/image_commands.rs +++ b/apps/staged/src-tauri/src/image_commands.rs @@ -1,6 +1,8 @@ //! Image commands — image upload, retrieval, and management. +mod thumbnail; mod tool_results; +pub use thumbnail::*; pub use tool_results::*; use crate::store::Store; @@ -14,7 +16,7 @@ use std::sync::{Arc, Mutex}; /// real session ID). Pass `false` for images that should appear in the /// timeline immediately (e.g. direct branch-card drops). #[tauri::command(rename_all = "camelCase")] -pub fn create_image( +pub async fn create_image( store: tauri::State<'_, Mutex>>>, branch_id: Option, project_id: String, @@ -22,14 +24,27 @@ pub fn create_image( pending: Option, ) -> Result { let store = crate::get_store(&store)?; + // Reading and validating a 50 MB PDF is not main-thread work. + tauri::async_runtime::spawn_blocking(move || { + create_image_impl(store, branch_id, project_id, file_path, pending) + }) + .await + .map_err(|e| format!("Failed to store image: {e}"))? +} +fn create_image_impl( + store: Arc, + branch_id: Option, + project_id: String, + file_path: String, + pending: Option, +) -> Result { let src = Path::new(&file_path); let filename = src .file_name() .and_then(|n| n.to_str()) .ok_or("Invalid filename")?; - let bytes = - crate::note_media::files::read_local(src, crate::note_media::files::MAX_IMAGE_SIZE)?; + let bytes = crate::note_media::files::read_local_attachment(src)?; crate::note_media::files::store_sniffed_image( &store, branch_id.as_deref(), @@ -73,18 +88,25 @@ pub fn delete_image( // Delete the DB record first (triggers session cleanup). store.delete_image(&image_id).map_err(|e| e.to_string())?; - // Best-effort file removal. - if let Ok(path) = - crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) - { - if let Err(e) = std::fs::remove_file(&path) { - log::warn!("Failed to remove image file {}: {e}", path.display()); - } - } + // Best-effort removal of the file and any cached PDF thumbnail. + crate::store::images::remove_image_files(&image.project_id, &image.id, &image.filename); Ok(()) } +/// A small preview of an attachment: the image itself, or for a PDF a PNG of +/// its first page, rendered on first use and cached beside the file. +#[tauri::command(rename_all = "camelCase")] +pub async fn get_attachment_thumbnail( + store: tauri::State<'_, Mutex>>>, + image_id: String, +) -> Result { + let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || attachment_thumbnail_impl(&store, &image_id)) + .await + .map_err(|e| format!("Failed to load thumbnail: {e}"))? +} + /// List all images for a branch. #[tauri::command(rename_all = "camelCase")] pub fn list_branch_images( @@ -121,7 +143,7 @@ pub fn get_image_data( /// the stored format follows the file signature. See [`create_image`] for the /// meaning of the `pending` flag. #[tauri::command(rename_all = "camelCase")] -pub fn create_image_from_data( +pub async fn create_image_from_data( store: tauri::State<'_, Mutex>>>, branch_id: Option, project_id: String, @@ -131,9 +153,14 @@ pub fn create_image_from_data( pending: Option, ) -> Result { let store = crate::get_store(&store)?; - create_image_from_data_impl( - store, branch_id, project_id, filename, mime_type, data, pending, - ) + // See `create_image`: decoding a large payload must not block the main thread. + tauri::async_runtime::spawn_blocking(move || { + create_image_from_data_impl( + store, branch_id, project_id, filename, mime_type, data, pending, + ) + }) + .await + .map_err(|e| format!("Failed to store image: {e}"))? } pub(crate) fn create_image_from_data_impl( @@ -146,10 +173,19 @@ pub(crate) fn create_image_from_data_impl( pending: Option, ) -> Result { use base64::Engine; - if data.len() as u64 > crate::note_media::files::MAX_IMAGE_SIZE.div_ceil(3) * 4 { - return Err("Image too large (max 10 MB)".into()); + let engine = base64::engine::general_purpose::STANDARD; + // The cap depends on the payload (a PDF may be larger than an image), so + // peek at the first six bytes — two base64 quanta — before decoding a + // payload that may turn out to be oversized. + let header = data + .get(..8) + .and_then(|quanta| engine.decode(quanta).ok()) + .unwrap_or_default(); + let (cap, what) = crate::note_media::files::attachment_limit(&header); + if data.len() as u64 > cap.div_ceil(3) * 4 { + return Err(crate::note_media::files::size_error(what, cap)); } - let bytes = base64::engine::general_purpose::STANDARD + let bytes = engine .decode(&data) .map_err(|e| format!("Invalid base64 data: {e}"))?; @@ -219,4 +255,130 @@ mod tests { let err = attach(&f, "clip.png", "", b"\0\0\0\x18ftypisom\0\0\0\0").unwrap_err(); assert!(err.contains("only images"), "{err}"); } + + #[test] + fn pdf_attachments_are_stored_as_pdfs() { + use crate::note_media::pdf::tests::{tiny_pdf, PAGE_ONE}; + let f = Fixture::new(); + let pdf = tiny_pdf(&[PAGE_ONE]); + let image = attach(&f, "doc.pdf", "application/pdf", &pdf).unwrap(); + assert_eq!(image.mime_type, "application/pdf"); + assert_eq!(image.filename, "doc.pdf"); + assert_eq!(image.size_bytes, pdf.len() as i64); + let path = + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + .unwrap(); + assert_eq!(path.extension().unwrap(), "pdf"); + assert_eq!(std::fs::read(path).unwrap(), pdf); + + // Pasted PDFs arrive without a usable name, like pasted images, and a + // mislabelled one takes the extension its bytes call for. + assert_eq!( + attach(&f, "pasted", "application/pdf", &pdf) + .unwrap() + .filename, + "pasted.pdf" + ); + assert_eq!( + attach(&f, "scan.png", "image/png", &pdf).unwrap().filename, + "scan.pdf" + ); + } + + /// PDFs get the larger input cap on both routes; images keep theirs. The + /// padded PDFs here are well-formed, so acceptance means the PDF passed + /// the open check, not that the bytes merely got past the size check. + #[test] + fn pdf_input_cap_is_separate_from_the_image_cap() { + use crate::note_media::files::{MAX_IMAGE_SIZE, MAX_PDF_INPUT_SIZE}; + use crate::note_media::pdf::tests::{padded_pdf, PAGE_ONE}; + use crate::note_media::tests::PNG; + let f = Fixture::new(); + let large_pdf = padded_pdf(&[PAGE_ONE], MAX_IMAGE_SIZE as usize + 1); + + let image = attach(&f, "scan.pdf", "application/pdf", &large_pdf).unwrap(); + assert_eq!(image.mime_type, "application/pdf"); + assert_eq!(image.size_bytes, large_pdf.len() as i64); + + let path = f.dir.path().join("dropped.pdf"); + std::fs::write(&path, &large_pdf).unwrap(); + let image = create_image_impl( + f.store.clone(), + Some(f.branch.id.clone()), + f.project.id.clone(), + path.to_string_lossy().into_owned(), + None, + ) + .unwrap(); + assert_eq!( + (image.mime_type.as_str(), image.filename.as_str()), + ("application/pdf", "dropped.pdf") + ); + + // Over the PDF cap: refused before decoding (base64) or reading (path). + let mut oversized = "JVBERi0x".to_owned(); // "%PDF-1" — a PDF signature + oversized.push_str(&"A".repeat((MAX_PDF_INPUT_SIZE.div_ceil(3) * 4) as usize)); + let err = create_image_from_data_impl( + f.store.clone(), + None, + f.project.id.clone(), + "huge.pdf".into(), + "application/pdf".into(), + oversized, + None, + ) + .unwrap_err(); + assert_eq!(err, "PDF exceeds 50 MB limit"); + let sparse = f.dir.path().join("huge.pdf"); + let mut file = std::fs::File::create(&sparse).unwrap(); + std::io::Write::write_all(&mut file, b"%PDF-1.4\n").unwrap(); + file.set_len(MAX_PDF_INPUT_SIZE + 1).unwrap(); + let err = create_image_impl( + f.store.clone(), + None, + f.project.id.clone(), + sparse.to_string_lossy().into_owned(), + None, + ) + .unwrap_err(); + assert_eq!(err, "PDF exceeds 50 MB limit"); + + // An image over its own cap is still refused with the image message, + // on both routes, and from metadata alone on the path route. + let mut png = PNG.to_vec(); + png.resize(MAX_IMAGE_SIZE as usize + 1, 0); + let err = attach(&f, "big.png", "image/png", &png).unwrap_err(); + assert_eq!(err, "media exceeds 10 MB limit"); + let sparse = f.dir.path().join("big.png"); + let mut file = std::fs::File::create(&sparse).unwrap(); + std::io::Write::write_all(&mut file, PNG).unwrap(); + file.set_len(MAX_IMAGE_SIZE + 1).unwrap(); + let err = create_image_impl( + f.store.clone(), + None, + f.project.id.clone(), + sparse.to_string_lossy().into_owned(), + None, + ) + .unwrap_err(); + assert_eq!(err, "media exceeds 10 MB limit"); + } + + #[cfg(target_os = "macos")] + #[test] + fn malformed_pdf_is_rejected_without_hanging() { + let f = Fixture::new(); + let err = attach(&f, "doc.pdf", "application/pdf", b"%PDF-1.4\ngarbage").unwrap_err(); + assert!(err.contains("PDF"), "{err}"); + } + + /// Without CoreGraphics only the signature is checked, so the file is + /// still stored and viewable as a PDF. + #[cfg(not(target_os = "macos"))] + #[test] + fn pdf_attachments_are_stored_without_a_renderer() { + let f = Fixture::new(); + let image = attach(&f, "doc.pdf", "application/pdf", b"%PDF-1.4\ngarbage").unwrap(); + assert_eq!(image.mime_type, "application/pdf"); + } } diff --git a/apps/staged/src-tauri/src/image_commands/thumbnail.rs b/apps/staged/src-tauri/src/image_commands/thumbnail.rs new file mode 100644 index 000000000..c7a85228d --- /dev/null +++ b/apps/staged/src-tauri/src/image_commands/thumbnail.rs @@ -0,0 +1,228 @@ +//! Small previews for chat attachments. Images are their own preview; a PDF +//! has its first page rendered once and cached beside the file. + +use crate::note_media::pdf; +use crate::store::Store; +use base64::Engine; +use serde::Serialize; + +/// Long edge of a cached PDF thumbnail. The composer and chat history show +/// attachments at 48–200 CSS px, so this covers a 2x display with room to +/// spare while staying a few tens of KB. +pub(crate) const THUMBNAIL_EDGE_PX: u32 = 512; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentThumbnail { + /// PNG (or the image's own) data URL, or `None` when a PDF could not be + /// rendered — the frontend shows a document icon instead. + pub data_url: Option, + /// The attachment's stored MIME, so a PDF can be badged as one. + pub mime_type: String, + pub filename: String, +} + +pub(crate) fn attachment_thumbnail_impl( + store: &Store, + image_id: &str, +) -> Result { + let image = store + .get_image(image_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Image not found: {image_id}"))?; + let path = + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename)?; + let engine = base64::engine::general_purpose::STANDARD; + let data_url = if image.mime_type == "application/pdf" { + pdf_thumbnail_png(&image.project_id, &image.id, &path) + .map(|png| format!("data:image/png;base64,{}", engine.encode(png))) + } else { + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {e}"))?; + Some(format!( + "data:{};base64,{}", + image.mime_type, + engine.encode(bytes) + )) + }; + Ok(AttachmentThumbnail { + data_url, + mime_type: image.mime_type, + filename: image.filename, + }) +} + +/// The cached first-page PNG, rendering and caching it on first use. `None` +/// when the page cannot be rendered (no renderer on this platform, or a PDF +/// CoreGraphics opens but cannot draw); a thumbnail is not worth failing the +/// attachment over, and the cache is never written for a failed render so a +/// later attempt can succeed. +fn pdf_thumbnail_png( + project_id: &str, + image_id: &str, + pdf_path: &std::path::Path, +) -> Option> { + let cache = crate::store::images::thumbnail_file_path(project_id, image_id).ok()?; + if let Ok(png) = std::fs::read(&cache) { + if png.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some(png); + } + } + let bytes = std::fs::read(pdf_path) + .map_err(|e| log::warn!("Cannot read PDF attachment {image_id}: {e}")) + .ok()?; + let rendered = pdf::render_first_page(&bytes, THUMBNAIL_EDGE_PX) + .map_err(|e| log::warn!("Cannot render thumbnail for PDF attachment {image_id}: {e}")) + .ok()?; + match crate::store::images::thumbnail_temp_file_path(project_id, image_id) { + Ok(temp) => { + if let Err(e) = cache_thumbnail(&cache, &temp, &rendered.png) { + log::warn!("Cannot cache thumbnail {}: {e}", cache.display()); + } + } + Err(e) => log::warn!("Cannot cache thumbnail {}: {e}", cache.display()), + } + Some(rendered.png) +} + +/// Write the PNG beside the cache and rename it into place, so a concurrent +/// reader (the composer strip and the history effect ask for a new +/// attachment at the same moment) sees either no cache or a complete file, +/// never a partial one that passes the signature check. The temp file does +/// not outlive a failure. +fn cache_thumbnail( + cache: &std::path::Path, + temp: &std::path::Path, + png: &[u8], +) -> std::io::Result<()> { + let result = std::fs::write(temp, png).and_then(|()| std::fs::rename(temp, cache)); + if result.is_err() { + let _ = std::fs::remove_file(temp); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::note_media::files::store_sniffed_image; + use crate::note_media::pdf::tests::{tiny_pdf, PAGE_ONE}; + use crate::note_media::tests::{Fixture, PNG}; + + fn stored_pdf(f: &Fixture) -> crate::store::Image { + store_sniffed_image( + &f.store, + Some(&f.branch.id), + &f.project.id, + None, + "doc.pdf", + &tiny_pdf(&[PAGE_ONE]), + ) + .unwrap() + } + + #[test] + fn images_are_their_own_thumbnail() { + let f = Fixture::new(); + let image = + store_sniffed_image(&f.store, None, &f.project.id, None, "shot.png", PNG).unwrap(); + let thumbnail = attachment_thumbnail_impl(&f.store, &image.id).unwrap(); + assert_eq!( + thumbnail, + AttachmentThumbnail { + data_url: Some(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(PNG) + )), + mime_type: "image/png".into(), + filename: "shot.png".into(), + } + ); + assert!( + crate::store::images::thumbnail_file_path(&f.project.id, &image.id) + .map(|p| !p.exists()) + .unwrap_or(true) + ); + + let err = attachment_thumbnail_impl(&f.store, "missing").unwrap_err(); + assert!(err.contains("not found"), "{err}"); + } + + #[cfg(target_os = "macos")] + #[test] + fn pdf_thumbnails_are_rendered_once_and_cached_beside_the_pdf() { + let f = Fixture::new(); + let image = stored_pdf(&f); + let thumbnail = attachment_thumbnail_impl(&f.store, &image.id).unwrap(); + assert_eq!(thumbnail.mime_type, "application/pdf"); + assert_eq!(thumbnail.filename, "doc.pdf"); + let data_url = thumbnail.data_url.unwrap(); + let png = base64::engine::general_purpose::STANDARD + .decode(data_url.strip_prefix("data:image/png;base64,").unwrap()) + .unwrap(); + let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap(); + // Letter page at a 512 px long edge. + assert_eq!((pixmap.width(), pixmap.height()), (396, 512)); + assert!(pixmap.pixels().iter().any(|p| p.red() < 128)); + + let cache = crate::store::images::thumbnail_file_path(&f.project.id, &image.id).unwrap(); + assert_eq!(std::fs::read(&cache).unwrap(), png); + + // The cache is what is served from now on, even once the PDF is gone. + std::fs::remove_file( + crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) + .unwrap(), + ) + .unwrap(); + assert_eq!( + attachment_thumbnail_impl(&f.store, &image.id) + .unwrap() + .data_url, + Some(data_url) + ); + + // Rendering left no temp file behind. + assert_eq!(thumbnail_temp_files(&cache), Vec::::new()); + + // Deleting the attachment removes the thumbnail with it, and any + // temp file a crash mid-write left behind. + let stray = cache.with_file_name(format!("{}.thumb.1-0.tmp", image.id)); + std::fs::write(&stray, b"partial").unwrap(); + crate::note_media::files::delete_media_file(&f.store, &image).unwrap(); + assert!(!cache.exists()); + assert!(!stray.exists()); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_failed_cache_write_serves_the_render_and_leaves_no_temp_file() { + let f = Fixture::new(); + let image = stored_pdf(&f); + let cache = crate::store::images::thumbnail_file_path(&f.project.id, &image.id).unwrap(); + // A directory where the cache file goes: the rename cannot land. + std::fs::create_dir(&cache).unwrap(); + let thumbnail = attachment_thumbnail_impl(&f.store, &image.id).unwrap(); + assert!(thumbnail.data_url.is_some()); + assert!(cache.is_dir()); + assert_eq!(thumbnail_temp_files(&cache), Vec::::new()); + } + + /// Names of thumbnail temp files beside `cache`. + fn thumbnail_temp_files(cache: &std::path::Path) -> Vec { + std::fs::read_dir(cache.parent().unwrap()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".thumb.") && n.ends_with(".tmp")) + .collect() + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn pdf_thumbnails_are_absent_without_a_renderer() { + let f = Fixture::new(); + let image = stored_pdf(&f); + let thumbnail = attachment_thumbnail_impl(&f.store, &image.id).unwrap(); + assert_eq!(thumbnail.data_url, None); + assert_eq!(thumbnail.mime_type, "application/pdf"); + } +} diff --git a/apps/staged/src-tauri/src/image_commands/tool_results.rs b/apps/staged/src-tauri/src/image_commands/tool_results.rs index debc7e63d..2fbf439ba 100644 --- a/apps/staged/src-tauri/src/image_commands/tool_results.rs +++ b/apps/staged/src-tauri/src/image_commands/tool_results.rs @@ -1,4 +1,4 @@ -use crate::note_media::files::{mime_for_extension, MAX_IMAGE_SIZE}; +use crate::note_media::files::{mime_for_extension, size_error, MAX_IMAGE_SIZE}; const ALLOWED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; use base64::Engine; use std::io::Read; @@ -27,14 +27,14 @@ pub fn read_image_file(file_path: String) -> Result { return Err("Image path is not a file".to_string()); } if metadata.len() > MAX_IMAGE_SIZE { - return Err("Image too large (max 10 MB)".to_string()); + return Err(size_error("Image", MAX_IMAGE_SIZE)); } let mut bytes = Vec::new(); file.take(MAX_IMAGE_SIZE + 1) .read_to_end(&mut bytes) .map_err(|e| format!("Failed to read image: {e}"))?; if bytes.len() as u64 > MAX_IMAGE_SIZE { - return Err("Image too large (max 10 MB)".to_string()); + return Err(size_error("Image", MAX_IMAGE_SIZE)); } Ok(format!( "data:{};base64,{}", @@ -87,6 +87,6 @@ mod tests { .unwrap(); assert!(read_image_file(path.to_string_lossy().into_owned()) .unwrap_err() - .contains("too large")); + .contains("exceeds 10 MB limit")); } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 64aa53c14..8ab5ff0ac 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2398,6 +2398,7 @@ pub fn run() { image_commands::delete_image, image_commands::list_branch_images, image_commands::create_image_from_data, + image_commands::get_attachment_thumbnail, // Timeline delete commands timeline::get_worktree_changes_preview, timeline::discard_worktree_changes, diff --git a/apps/staged/src-tauri/src/note_media/files.rs b/apps/staged/src-tauri/src/note_media/files.rs index dfffbc57b..37e3cc8cf 100644 --- a/apps/staged/src-tauri/src/note_media/files.rs +++ b/apps/staged/src-tauri/src/note_media/files.rs @@ -6,7 +6,25 @@ use std::io::Read; use std::path::Path; pub(crate) const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; +/// PDF attachments (scans and print-ready files routinely run 10–50 MB) get +/// their own cap; they are stored as-is, and only the PNGs rendered from them +/// for thumbnails and the agent prompt have to satisfy [`MAX_IMAGE_SIZE`]. +pub(crate) const MAX_PDF_INPUT_SIZE: u64 = 50 * 1024 * 1024; pub(crate) const MAX_VIDEO_SIZE: u64 = 100 * 1024 * 1024; + +pub(crate) fn size_error(what: &str, cap: u64) -> String { + format!("{what} exceeds {} MB limit", cap / 1024 / 1024) +} + +/// Input cap for a chat attachment, decided from its leading bytes, with the +/// noun its size error names so an oversized PDF is reported as such. +pub(crate) fn attachment_limit(header: &[u8]) -> (u64, &'static str) { + if super::pdf::is_pdf(header) { + (MAX_PDF_INPUT_SIZE, "PDF") + } else { + (MAX_IMAGE_SIZE, "media") + } +} pub(crate) const MAX_REMOTE_VIDEO_SIZE: i64 = 20 * 1024 * 1024; pub(crate) fn extension(filename: &str) -> String { @@ -26,13 +44,20 @@ pub(crate) fn mime_for_extension(ext: &str) -> Option<&'static str> { "mp4" => "video/mp4", "webm" => "video/webm", "mov" => "video/quicktime", + "pdf" => "application/pdf", _ => return None, }) } +/// Cap for a note media reference. PDFs are chat attachments only: the note +/// renderer has no PDF figure, so a `.pdf` reference is refused here rather +/// than stored and shown broken. pub(crate) fn size_limit(filename: &str, allow_video: bool) -> Result { let ext = extension(filename); let mime = mime_for_extension(&ext).ok_or("unsupported media format")?; + if mime == "application/pdf" { + return Err("PDFs cannot be embedded in notes".into()); + } if mime.starts_with("video/") { if !allow_video { return Err("only images can be uploaded here".into()); @@ -47,6 +72,8 @@ pub(crate) fn size_limit(filename: &str, allow_video: bool) -> Result Option<&'static str> { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Some("png") + } else if super::pdf::is_pdf(bytes) { + Some("pdf") } else if bytes.starts_with(b"\xff\xd8\xff") { Some("jpg") } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { @@ -79,7 +106,7 @@ pub(crate) fn validate_media( ) -> Result<&'static str, String> { let cap = size_limit(filename, allow_video)?; if bytes.len() as u64 > cap { - return Err(format!("media exceeds {} MB limit", cap / 1024 / 1024)); + return Err(size_error("media", cap)); } let ext = extension(filename); let valid = match (ext.as_str(), sniff_format(bytes)) { @@ -107,17 +134,24 @@ pub(crate) fn store_sniffed_image( filename: &str, bytes: &[u8], ) -> Result { - if bytes.len() as u64 > MAX_IMAGE_SIZE { - return Err(format!( - "media exceeds {} MB limit", - MAX_IMAGE_SIZE / 1024 / 1024 - )); + let (cap, what) = attachment_limit(bytes); + if bytes.len() as u64 > cap { + return Err(size_error(what, cap)); + } + // A PDF is stored byte-for-byte so the viewer can show the vector + // original; thumbnails and the agent prompt render pages from it later. + // Only this chat/timeline path accepts one: note ingest (`validate_media`) + // still refuses `.pdf` references. + if super::pdf::is_pdf(bytes) { + super::pdf::check(bytes)?; } let sniffed = sniff_format(bytes).ok_or("unsupported image format")?; - if mime_for_extension(sniffed).unwrap().starts_with("video/") { + // Every sniffed extension has a MIME entry. + let mime = mime_for_extension(sniffed).unwrap(); + if mime.starts_with("video/") { return Err("only images can be uploaded here".into()); } - let filename = if mime_for_extension(&extension(filename)) == mime_for_extension(sniffed) { + let filename = if mime_for_extension(&extension(filename)) == Some(mime) { filename.to_owned() } else { Path::new(filename) @@ -125,8 +159,10 @@ pub(crate) fn store_sniffed_image( .to_string_lossy() .into_owned() }; - store_media_file( - store, branch_id, project_id, session_id, &filename, bytes, false, + // Already validated above (cap, signature, no video); `validate_media` + // would refuse the PDF a note cannot embed. + write_media( + store, branch_id, project_id, session_id, &filename, mime, bytes, ) } @@ -161,6 +197,35 @@ impl From<&str> for ReadError { } pub(crate) fn read_local(path: &Path, cap: u64) -> Result, ReadError> { + let (file, len) = open_regular(path)?; + if len > cap { + return Err(size_error("media", cap).into()); + } + read_capped(file, Vec::new(), cap) +} + +/// [`read_local`] for a chat attachment, whose cap depends on what the file +/// is (see [`attachment_limit`]). The signature is read before the body so an +/// oversized image is still refused from its metadata, without buffering +/// anything like [`MAX_PDF_INPUT_SIZE`] first. +pub(crate) fn read_local_attachment(path: &Path) -> Result, ReadError> { + let (mut file, len) = open_regular(path)?; + let mut bytes = Vec::new(); + (&mut file) + .take(PDF_SIGNATURE_LEN) + .read_to_end(&mut bytes) + .map_err(|_| "cannot read media file")?; + let (cap, what) = attachment_limit(&bytes); + if len > cap { + return Err(size_error(what, cap).into()); + } + read_capped(file, bytes, cap) +} + +const PDF_SIGNATURE_LEN: u64 = b"%PDF-".len() as u64; + +/// Open a regular file for reading and return it with its current length. +fn open_regular(path: &Path) -> Result<(std::fs::File, u64), ReadError> { let mut options = std::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] @@ -177,11 +242,13 @@ pub(crate) fn read_local(path: &Path, cap: u64) -> Result, ReadError> { if !metadata.is_file() { return Err("media must be a regular file".into()); } - if metadata.len() > cap { - return Err(format!("media exceeds {} MB limit", cap / 1024 / 1024).into()); - } - let mut bytes = Vec::new(); - file.take(cap + 1) + Ok((file, metadata.len())) +} + +/// Read the rest of `file` after `bytes` (already read from it), refusing a +/// file that grew past `cap` since its metadata was checked. +fn read_capped(file: std::fs::File, mut bytes: Vec, cap: u64) -> Result, ReadError> { + file.take(cap + 1 - bytes.len() as u64) .read_to_end(&mut bytes) .map_err(|_| "cannot read media file")?; if bytes.len() as u64 > cap { @@ -202,6 +269,22 @@ pub(crate) fn store_media_file( allow_video: bool, ) -> Result { let mime = validate_media(filename, bytes, allow_video)?; + write_media( + store, branch_id, project_id, session_id, filename, mime, bytes, + ) +} + +/// Write validated bytes under their MIME: a unique filename on the branch, +/// the file, then the row (the file is removed if the insert fails). +fn write_media( + store: &Store, + branch_id: Option<&str>, + project_id: &str, + session_id: Option<&str>, + filename: &str, + mime: &str, + bytes: &[u8], +) -> Result { let filename = Path::new(filename) .file_name() .and_then(|s| s.to_str()) @@ -238,15 +321,7 @@ pub(crate) fn store_media_file( pub(crate) fn delete_media_file(store: &Store, image: &Image) -> Result<(), String> { store.delete_image(&image.id).map_err(|e| e.to_string())?; - if let Ok(path) = - crate::store::images::image_file_path(&image.project_id, &image.id, &image.filename) - { - if let Err(e) = std::fs::remove_file(&path) { - if e.kind() != std::io::ErrorKind::NotFound { - log::warn!("Cannot remove media {}: {e}", image.id); - } - } - } + crate::store::images::remove_image_files(&image.project_id, &image.id, &image.filename); Ok(()) } @@ -292,7 +367,7 @@ pub(crate) fn read_candidate( } let bytes = output.stdout; if bytes.len() as u64 > cap { - return Err(format!("media exceeds {} MB limit", cap / 1024 / 1024).into()); + return Err(size_error("media", cap).into()); } Ok(bytes) } else { diff --git a/apps/staged/src-tauri/src/note_media/mod.rs b/apps/staged/src-tauri/src/note_media/mod.rs index 83d9b2915..b1530ed13 100644 --- a/apps/staged/src-tauri/src/note_media/mod.rs +++ b/apps/staged/src-tauri/src/note_media/mod.rs @@ -5,6 +5,7 @@ mod blocks; pub(crate) mod files; mod materialize; pub(crate) use materialize::materialize_note_media; +pub(crate) mod pdf; #[cfg(test)] pub(crate) use materialize::{materialize_note_media_with, RemoteMedia}; pub(crate) mod refs; diff --git a/apps/staged/src-tauri/src/note_media/pdf.rs b/apps/staged/src-tauri/src/note_media/pdf.rs new file mode 100644 index 000000000..39e545a29 --- /dev/null +++ b/apps/staged/src-tauri/src/note_media/pdf.rs @@ -0,0 +1,701 @@ +//! PDF attachments are stored as the PDF itself; this module rasterizes pages +//! to opaque bitmaps where one is needed: the thumbnail shown next to image +//! attachments, and the agent prompt, whose transports accept only image +//! blocks. Rendering uses CoreGraphics in-process (no subprocess, no temp +//! files, malformed input fails fast instead of hanging). Pages are PNG, or +//! JPEG when a caller allows it and the PNG cannot meet its size cap. +//! +//! macOS only — the shipped target. Other platforms compile stubs so the +//! Linux CI build stays green; they accept any `%PDF-` payload at store time +//! and report that rendering needs macOS. The stubs leave the shared types +//! only half used there, hence the module-wide allowance. +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +#[cfg(target_os = "macos")] +use super::files::size_error; +use super::files::MAX_IMAGE_SIZE; + +/// Long edge of a page rendered for the agent prompt, in pixels. The Claude +/// API rejects any image over 2000 px on a side once a request carries more +/// than 20 image blocks (it counts the images resent from earlier turns and +/// those in tool results, so a 20-page PDF plus one screenshot gets there), +/// and the high-resolution tier tops out at 2576 px, so 2000 loses nothing a +/// 2048 would have kept. A letter page lands at ~182 DPI, which keeps body +/// text legible to a vision model without approaching the attachment size cap. +pub(crate) const MAX_EDGE_PX: u32 = 2000; + +/// Pages are never blown up beyond 4 px per PDF point, so a tiny page does +/// not become a huge blurry bitmap just to reach the long-edge target. +#[cfg(target_os = "macos")] +const MAX_SCALE: f64 = 4.0; + +/// Quality of the JPEG a page falls back to when its PNG is over the cap even +/// at half scale — photographic scans, where a PNG is several times the size +/// of a JPEG that looks the same. +#[cfg(target_os = "macos")] +const JPEG_QUALITY: u8 = 85; + +pub(crate) fn is_pdf(bytes: &[u8]) -> bool { + bytes.starts_with(b"%PDF-") +} + +pub(crate) struct RenderedPage { + pub png: Vec, + /// Pages in the document, so callers can say "page 1 of N". + pub page_count: usize, +} + +impl std::fmt::Debug for RenderedPage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RenderedPage") + .field("png_bytes", &self.png.len()) + .field("page_count", &self.page_count) + .finish() + } +} + +/// One page, encoded: PNG unless the [`Fit`] allowed a JPEG and it was needed. +pub(crate) struct EncodedPage { + pub bytes: Vec, + pub mime_type: &'static str, +} + +impl std::fmt::Debug for EncodedPage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncodedPage") + .field("bytes", &self.bytes.len()) + .field("mime_type", &self.mime_type) + .finish() + } +} + +/// How large an encoded page may be, and how far to go to get it there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Fit { + /// Ceiling on the encoded bytes. + pub max_bytes: u64, + /// When the PNG is over the ceiling even at half scale, try a full-scale + /// JPEG before giving up on the page. + pub jpeg_fallback: bool, +} + +impl Fit { + /// A PNG under the media cap: thumbnails, which are cached as PNG files. + pub(crate) const PNG_MEDIA: Fit = Fit { + max_bytes: MAX_IMAGE_SIZE, + jpeg_fallback: false, + }; +} + +/// Render page 1 to a white-backed PNG whose long edge is `max_edge_px` +/// (less when that would exceed [`MAX_SCALE`]). +pub(crate) fn render_first_page(bytes: &[u8], max_edge_px: u32) -> Result { + let document = Document::open(bytes)?; + let page = document.render_page(1, max_edge_px, Fit::PNG_MEDIA)?; + Ok(RenderedPage { + png: page.bytes, + page_count: document.page_count(), + }) +} + +/// Reject what CoreGraphics cannot open before it is stored, so a `%PDF-` +/// prefix on garbage never becomes an attachment that fails to render later. +#[cfg(target_os = "macos")] +pub(crate) fn check(bytes: &[u8]) -> Result<(), String> { + Document::open(bytes).map(drop) +} + +/// An open PDF. Pages are rendered one at a time so a caller with a page cap +/// or a byte budget can stop without rasterizing the rest. +#[cfg(target_os = "macos")] +pub(crate) struct Document { + document: objc2_core_foundation::CFRetained, + page_count: usize, +} + +#[cfg(target_os = "macos")] +impl Document { + pub(crate) fn open(bytes: &[u8]) -> Result { + use objc2_core_foundation::CFData; + use objc2_core_graphics::{CGDataProvider, CGPDFDocument}; + + let data = CFData::from_bytes(bytes); + let provider = + CGDataProvider::with_cf_data(Some(&data)).ok_or("could not read PDF data")?; + // CoreGraphics returns no document for anything it cannot parse, so a + // `%PDF-` prefix on garbage is rejected here rather than drawn as blank. + let document = CGPDFDocument::with_provider(Some(&provider)).ok_or("could not open PDF")?; + let page_count = CGPDFDocument::number_of_pages(Some(&document)); + if page_count == 0 { + return Err("PDF has no pages".into()); + } + Ok(Document { + document, + page_count, + }) + } + + pub(crate) fn page_count(&self) -> usize { + self.page_count + } + + /// Draw page `number` (1-based) at `max_edge_px` along its long edge + /// (less when that would exceed [`MAX_SCALE`]) and encode it within + /// `fit`: see [`fit_page`]. + pub(crate) fn render_page( + &self, + number: usize, + max_edge_px: u32, + fit: Fit, + ) -> Result { + use objc2_core_graphics::{CGPDFBox, CGPDFDocument, CGPDFPage}; + + let page = CGPDFDocument::page(Some(&self.document), number) + .ok_or_else(|| format!("could not read PDF page {number}"))?; + + // CropBox is what Preview and Quick Look display (CoreGraphics clips + // it to the MediaBox, and returns the MediaBox when none is set), so + // a print-ready PDF renders without its bleed and crop marks. Fall + // back to the MediaBox only when the CropBox is unusable. + let mut page_box = CGPDFBox::CropBox; + let mut rect = CGPDFPage::box_rect(Some(&page), page_box); + if !is_drawable(rect.size.width, rect.size.height) { + page_box = CGPDFBox::MediaBox; + rect = CGPDFPage::box_rect(Some(&page), page_box); + if !is_drawable(rect.size.width, rect.size.height) { + return Err("PDF page has no drawable area".into()); + } + } + // `drawing_transform` honours the page's own /Rotate, so the bitmap + // must take the rotated orientation for the page to fill it. + let rotated = CGPDFPage::rotation_angle(Some(&page)).rem_euclid(180) == 90; + let (w_pt, h_pt) = if rotated { + (rect.size.height, rect.size.width) + } else { + (rect.size.width, rect.size.height) + }; + let scale = (f64::from(max_edge_px) / w_pt.max(h_pt)).min(MAX_SCALE); + fit_page( + |scale| render(&page, page_box, (w_pt, h_pt), scale), + scale, + fit, + ) + } +} + +/// Encode the page `render` draws at `scale` px/pt within `fit.max_bytes`: +/// as a PNG; if that is over, as a PNG of a render at half scale; if that is +/// still over and `fit` allows it, as a JPEG of the full-scale render; else +/// fail with the size message. +#[cfg(target_os = "macos")] +fn fit_page( + render: impl Fn(f64) -> Result, + scale: f64, + fit: Fit, +) -> Result { + let fits = |bytes: &[u8]| bytes.len() as u64 <= fit.max_bytes; + let full = render(scale)?; + let png = encode_png(&full)?; + if fits(&png) { + return Ok(EncodedPage::png(png)); + } + let png = encode_png(&render(scale / 2.0)?)?; + if fits(&png) { + return Ok(EncodedPage::png(png)); + } + if fit.jpeg_fallback { + let jpeg = encode_jpeg(&full)?; + if fits(&jpeg) { + return Ok(EncodedPage { + bytes: jpeg, + mime_type: "image/jpeg", + }); + } + } + Err(size_error("PDF page", fit.max_bytes)) +} + +#[cfg(target_os = "macos")] +impl EncodedPage { + fn png(bytes: Vec) -> Self { + EncodedPage { + bytes, + mime_type: "image/png", + } + } +} + +#[cfg(target_os = "macos")] +fn encode_png(pixmap: &tiny_skia::Pixmap) -> Result, String> { + pixmap + .encode_png() + .map_err(|e| format!("could not encode PDF page: {e}")) +} + +/// Pages are drawn over opaque white, so demultiplying changes nothing today; +/// it keeps the conversion right should the background ever go. +#[cfg(target_os = "macos")] +fn encode_jpeg(pixmap: &tiny_skia::Pixmap) -> Result, String> { + use image::ImageEncoder; + + let rgb: Vec = pixmap + .pixels() + .iter() + .flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue()] + }) + .collect(); + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY) + .write_image( + &rgb, + pixmap.width(), + pixmap.height(), + image::ExtendedColorType::Rgb8, + ) + .map_err(|e| format!("could not encode PDF page as JPEG: {e}"))?; + Ok(out) +} + +#[cfg(target_os = "macos")] +fn is_drawable(width: f64, height: f64) -> bool { + width.is_finite() && height.is_finite() && width >= 1.0 && height >= 1.0 +} + +#[cfg(target_os = "macos")] +fn pixel_size(w_pt: f64, h_pt: f64, scale: f64) -> (u32, u32) { + let px = |pt: f64| (pt * scale).round().max(1.0) as u32; + (px(w_pt), px(h_pt)) +} + +/// Draw the page, displayed at `(w_pt, h_pt)` points, into a fresh RGBA +/// bitmap at `scale` px/pt as a tiny-skia pixmap (premultiplied RGBA8, the +/// same layout CoreGraphics writes for `PremultipliedLast`). +#[cfg(target_os = "macos")] +fn render( + page: &objc2_core_graphics::CGPDFPage, + page_box: objc2_core_graphics::CGPDFBox, + (w_pt, h_pt): (f64, f64), + scale: f64, +) -> Result { + use objc2_core_foundation::{CGPoint, CGRect, CGSize}; + use objc2_core_graphics::{ + CGBitmapContextCreate, CGColorSpace, CGContext, CGImageAlphaInfo, CGPDFPage, + }; + + let (width, height) = pixel_size(w_pt, h_pt, scale); + let (w, h) = (width as usize, height as usize); + let mut buf = vec![0u8; w * h * 4]; + let space = CGColorSpace::new_device_rgb().ok_or("could not create color space")?; + let bitmap = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: f64::from(width), + height: f64::from(height), + }, + }; + // In points, after the CTM is scaled. `drawing_transform` only ever + // scales a page *down* to fit its rect (otherwise it centres the page at + // 1:1), so the magnification has to come from the CTM, and the rect is + // the page's own displayed size so the box lands flush at the origin. + let page_rect = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: w_pt, + height: h_pt, + }, + }; + { + // SAFETY: the context draws into `buf`, which outlives it — the + // context is released at the end of this block, before `buf` moves + // into the pixmap — and the width, height, and row stride describe + // exactly the RGBA8 buffer allocated above. + let context = unsafe { + CGBitmapContextCreate( + buf.as_mut_ptr().cast(), + w, + h, + 8, + w * 4, + Some(&space), + CGImageAlphaInfo::PremultipliedLast.0, + ) + } + .ok_or("could not create bitmap context")?; + let ctx = Some(&*context); + // White behind the page: PDFs have no background, and a transparent + // PNG would lose black text on the dark theme and for the agent. + CGContext::set_rgb_fill_color(ctx, 1.0, 1.0, 1.0, 1.0); + CGContext::fill_rect(ctx, bitmap); + CGContext::scale_ctm(ctx, scale, scale); + CGContext::concat_ctm( + ctx, + CGPDFPage::drawing_transform(Some(page), page_box, page_rect, 0, true), + ); + CGContext::draw_pdf_page(ctx, Some(page)); + } + let size = tiny_skia::IntSize::from_wh(width, height).ok_or("invalid PDF page size")?; + tiny_skia::Pixmap::from_vec(buf, size).ok_or_else(|| "invalid PDF page bitmap".into()) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) const UNSUPPORTED: &str = "PDF rendering requires macOS"; + +/// Without a renderer only the signature is checked; the file is still +/// stored and viewed as a PDF. +#[cfg(not(target_os = "macos"))] +pub(crate) fn check(_bytes: &[u8]) -> Result<(), String> { + Ok(()) +} + +/// No renderer: [`Document::open`] always fails, so no page is ever rendered. +#[cfg(not(target_os = "macos"))] +pub(crate) enum Document {} + +#[cfg(not(target_os = "macos"))] +impl Document { + pub(crate) fn open(_bytes: &[u8]) -> Result { + Err(UNSUPPORTED.into()) + } + + pub(crate) fn page_count(&self) -> usize { + match *self {} + } + + pub(crate) fn render_page( + &self, + _number: usize, + _max_edge_px: u32, + _fit: Fit, + ) -> Result { + match *self {} + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A minimal PDF with one letter-size page per content stream, using the + /// built-in Helvetica. Offsets in the xref table are computed, so the + /// output is well-formed rather than relying on reader repair. + pub(crate) fn tiny_pdf(pages: &[&str]) -> Vec { + tiny_pdf_with(pages, "[0 0 612 792]", None, None) + } + + pub(crate) fn tiny_pdf_with( + pages: &[&str], + media_box: &str, + crop_box: Option<&str>, + rotate: Option, + ) -> Vec { + build_pdf(pages, media_box, crop_box, rotate, 0) + } + + /// [`tiny_pdf`] grown past `min_len` bytes with comment lines after the + /// header, so a large file is still a well-formed, instantly rendered PDF + /// (the xref offsets skip the comments) — for exercising the size caps. + pub(crate) fn padded_pdf(pages: &[&str], min_len: usize) -> Vec { + let pdf = build_pdf(pages, "[0 0 612 792]", None, None, min_len); + assert!(pdf.len() >= min_len); + pdf + } + + fn build_pdf( + pages: &[&str], + media_box: &str, + crop_box: Option<&str>, + rotate: Option, + padding: usize, + ) -> Vec { + let font_id = 3 + pages.len() * 2; + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + format!( + "<< /Type /Pages /Kids [{}] /Count {} >>", + (0..pages.len()) + .map(|i| format!("{} 0 R", 3 + i * 2)) + .collect::>() + .join(" "), + pages.len() + ), + ]; + for (i, content) in pages.iter().enumerate() { + let crop_box = crop_box + .map(|b| format!(" /CropBox {b}")) + .unwrap_or_default(); + let rotate = rotate.map(|r| format!(" /Rotate {r}")).unwrap_or_default(); + objects.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox {media_box}{crop_box}{rotate} \ + /Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {} 0 R >>", + 4 + i * 2 + )); + objects.push(format!( + "<< /Length {} >>\nstream\n{content}\nendstream", + content.len() + )); + } + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned()); + + let mut out = b"%PDF-1.4\n".to_vec(); + let comment = format!("%{}\n", "x".repeat(4095)); + while out.len() < padding { + out.extend_from_slice(comment.as_bytes()); + } + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(), + ); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objects.len() + 1 + ) + .as_bytes(), + ); + out + } + + pub(crate) const PAGE_ONE: &str = + "BT /F1 24 Tf 72 700 Td (PAGE ONE Staged PDF probe) Tj ET\n4 w 72 650 m 540 650 l S"; + + /// Page one's text plus a second rule, at y=400 pt, that page one lacks. + pub(crate) const PAGE_TWO: &str = + "BT /F1 24 Tf 72 700 Td (PAGE TWO should not appear) Tj ET\n4 w 72 400 m 540 400 l S"; + + #[test] + fn detects_the_pdf_signature_only() { + assert!(is_pdf(&tiny_pdf(&[PAGE_ONE]))); + assert!(is_pdf(b"%PDF-1.7 junk")); + assert!(!is_pdf(b"\x89PNG\r\n\x1a\n")); + assert!(!is_pdf(b" %PDF-1.4")); + assert!(!is_pdf(b"")); + } + + #[cfg(target_os = "macos")] + mod macos { + use super::*; + + fn decode(png: &[u8]) -> tiny_skia::Pixmap { + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + tiny_skia::Pixmap::decode_png(png).unwrap() + } + + fn is_opaque_white(pixmap: &tiny_skia::Pixmap, x: u32, y: u32) -> bool { + let p = pixmap.pixel(x, y).unwrap(); + (p.red(), p.green(), p.blue(), p.alpha()) == (255, 255, 255, 255) + } + + /// Bitmap row through the middle of a 4 pt rule drawn at `y_pt` on a + /// letter page rendered [`MAX_EDGE_PX`] tall. + fn letter_rule_row(y_pt: f64) -> u32 { + ((792.0 - y_pt) * f64::from(MAX_EDGE_PX) / 792.0) as u32 + } + + #[test] + fn renders_first_page_to_opaque_png_at_the_long_edge() { + let rendered = + render_first_page(&tiny_pdf(&[PAGE_ONE, PAGE_TWO]), MAX_EDGE_PX).unwrap(); + assert_eq!(rendered.page_count, 2); + let pixmap = decode(&rendered.png); + // Letter: 2000 / 792 pt ≈ 2.53x, under the 4x ceiling. + assert_eq!((pixmap.width(), pixmap.height()), (1545, 2000)); + for (x, y) in [(0, 0), (1544, 0), (0, 1999), (1544, 1999)] { + assert!(is_opaque_white(&pixmap, x, y), "corner {x},{y}"); + } + // The rule at y=650pt from the bottom is drawn; everything is opaque. + let p = pixmap.pixel(800, letter_rule_row(650.0)).unwrap(); + assert!(p.red() < 128 && p.alpha() == 255, "rule pixel {p:?}"); + assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255)); + // Page two's own rule, at y=400pt, is not drawn... + assert!(is_opaque_white(&pixmap, 800, letter_rule_row(400.0))); + // ...and would have been, had page two been the one rendered. + let swapped = decode( + &render_first_page(&tiny_pdf(&[PAGE_TWO]), MAX_EDGE_PX) + .unwrap() + .png, + ); + let p = swapped.pixel(800, letter_rule_row(400.0)).unwrap(); + assert!( + p.red() < 128 && p.alpha() == 255, + "page-two rule pixel {p:?}" + ); + } + + #[test] + fn wide_pages_are_downscaled_and_tiny_pages_capped_at_4x() { + // Same box as the onboarding flow chart fixture: 3150×1800 pt. + let wide = tiny_pdf_with(&[PAGE_ONE], "[0 0 3150 1800]", None, None); + let pixmap = decode(&render_first_page(&wide, MAX_EDGE_PX).unwrap().png); + assert_eq!((pixmap.width(), pixmap.height()), (2000, 1143)); + + let tiny = tiny_pdf_with(&[PAGE_ONE], "[0 0 100 50]", None, None); + let pixmap = decode(&render_first_page(&tiny, MAX_EDGE_PX).unwrap().png); + assert_eq!((pixmap.width(), pixmap.height()), (400, 200)); + } + + #[test] + fn rotated_pages_take_their_displayed_orientation() { + let landscape = tiny_pdf_with(&[PAGE_ONE], "[0 0 612 792]", None, Some(90)); + let pixmap = decode(&render_first_page(&landscape, MAX_EDGE_PX).unwrap().png); + assert_eq!((pixmap.width(), pixmap.height()), (2000, 1545)); + assert!(pixmap.pixels().iter().any(|p| p.red() < 128)); + } + + #[test] + fn crop_box_wins_over_media_box() { + // Letter MediaBox with bleed trimmed to a 300×400 pt CropBox; the + // rule is drawn inside the crop, the page-one text outside it. + const INSIDE_CROP: &str = "4 w 100 300 m 400 300 l S"; + let cropped = tiny_pdf_with( + &[&format!("{PAGE_ONE}\n{INSIDE_CROP}")], + "[0 0 612 792]", + Some("[100 100 400 500]"), + None, + ); + let pixmap = decode(&render_first_page(&cropped, MAX_EDGE_PX).unwrap().png); + // 2000 / 400 pt would be 5x, so the 4x ceiling applies. + assert_eq!((pixmap.width(), pixmap.height()), (1200, 1600)); + let rule_y = ((500.0 - 300.0) * 4.0) as u32; + let p = pixmap.pixel(600, rule_y).unwrap(); + assert!(p.red() < 128 && p.alpha() == 255, "rule pixel {p:?}"); + for (x, y) in [(0, 0), (1199, 0), (0, 1599), (1199, 1599)] { + assert!(is_opaque_white(&pixmap, x, y), "corner {x},{y}"); + } + + // An empty CropBox is not drawable, so the MediaBox is used. + let degenerate = tiny_pdf_with(&[PAGE_ONE], "[0 0 612 792]", Some("[0 0 0 0]"), None); + let pixmap = decode(&render_first_page(°enerate, MAX_EDGE_PX).unwrap().png); + assert_eq!((pixmap.width(), pixmap.height()), (1545, 2000)); + } + + #[test] + fn malformed_pdfs_are_rejected_without_hanging() { + for bytes in [ + &b"%PDF-1.4\nnot really a pdf"[..], + b"%PDF-", + &tiny_pdf(&[])[..], + ] { + let err = render_first_page(bytes, MAX_EDGE_PX).unwrap_err(); + assert!(err.contains("PDF"), "{err}"); + assert_eq!(check(bytes).unwrap_err(), err); + } + check(&tiny_pdf(&[PAGE_ONE])).unwrap(); + } + + #[test] + fn documents_render_pages_on_demand() { + let document = Document::open(&tiny_pdf(&[PAGE_ONE, PAGE_TWO, PAGE_ONE])).unwrap(); + assert_eq!(document.page_count(), 3); + let row = ((792.0 - 400.0) * 512.0 / 792.0) as u32; + let first = document.render_page(1, 512, Fit::PNG_MEDIA).unwrap(); + let second = document.render_page(2, 512, Fit::PNG_MEDIA).unwrap(); + assert_eq!( + (first.mime_type, second.mime_type), + ("image/png", "image/png") + ); + let first = decode(&first.bytes); + let second = decode(&second.bytes); + assert_eq!((first.width(), first.height()), (396, 512)); + assert!(is_opaque_white(&first, 200, row)); + assert!(second.pixel(200, row).unwrap().red() < 128); + + let err = document.render_page(4, 512, Fit::PNG_MEDIA).unwrap_err(); + assert!(err.contains("page 4"), "{err}"); + } + + /// `fit_page` only sees what the closure returns, so the chain can be + /// pinned with stand-in bitmaps whose encoded sizes are known to + /// order as a photographic page's do: PNG at half scale under PNG at + /// full scale, and the full-scale JPEG under both. + #[test] + fn pages_fall_back_to_half_scale_then_jpeg_to_meet_the_cap() { + let photo = |scale: f64| Ok(photographic_pixmap((256.0 * scale) as u32)); + let png_full = encode_png(&photo(1.0).unwrap()).unwrap(); + let png_half = encode_png(&photo(0.5).unwrap()).unwrap(); + let jpeg_full = encode_jpeg(&photo(1.0).unwrap()).unwrap(); + assert!( + jpeg_full.len() < png_half.len() && png_half.len() < png_full.len(), + "stand-in sizes: png {} / half {} / jpeg {}", + png_full.len(), + png_half.len(), + jpeg_full.len() + ); + let fit = |max_bytes: usize, jpeg_fallback: bool| Fit { + max_bytes: max_bytes as u64, + jpeg_fallback, + }; + + let page = fit_page(photo, 1.0, fit(png_full.len(), false)).unwrap(); + assert_eq!( + (page.mime_type, page.bytes.len()), + ("image/png", png_full.len()) + ); + assert_eq!(decode(&page.bytes).width(), 256); + + let page = fit_page(photo, 1.0, fit(png_full.len() - 1, false)).unwrap(); + assert_eq!( + (page.mime_type, page.bytes.len()), + ("image/png", png_half.len()) + ); + assert_eq!(decode(&page.bytes).width(), 128); + + let page = fit_page(photo, 1.0, fit(png_half.len() - 1, true)).unwrap(); + assert_eq!( + (page.mime_type, page.bytes.len()), + ("image/jpeg", jpeg_full.len()) + ); + assert!(page.bytes.starts_with(&[0xFF, 0xD8, 0xFF])); + + let err = fit_page(photo, 1.0, fit(png_half.len() - 1, false)).unwrap_err(); + assert_eq!(err, "PDF page exceeds 0 MB limit"); + let err = fit_page(photo, 1.0, fit(jpeg_full.len() - 1, true)).unwrap_err(); + assert!(err.starts_with("PDF page exceeds"), "{err}"); + } + + /// Smooth colour gradients under low-amplitude per-pixel noise: PNG + /// filters cannot predict the noise, JPEG quantizes it away. + fn photographic_pixmap(edge: u32) -> tiny_skia::Pixmap { + let mut pixmap = tiny_skia::Pixmap::new(edge, edge).unwrap(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut noise = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 7) as f64 - 3.0 + }; + let channel = |v: f64| v.clamp(0.0, 255.0) as u8; + for (i, pixel) in pixmap.pixels_mut().iter_mut().enumerate() { + let x = f64::from(i as u32 % edge) / f64::from(edge); + let y = f64::from(i as u32 / edge) / f64::from(edge); + let r = channel(x * 200.0 + 30.0 + noise()); + let g = channel(y * 200.0 + 30.0 + noise()); + let b = channel(((x + y) * 3.0).sin() * 80.0 + 128.0 + noise()); + *pixel = tiny_skia::ColorU8::from_rgba(r, g, b, 255).premultiply(); + } + pixmap + } + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn reports_platform_support() { + let pdf = tiny_pdf(&[PAGE_ONE]); + let err = render_first_page(&pdf, MAX_EDGE_PX).unwrap_err(); + assert!(err.contains("macOS"), "{err}"); + let err = Document::open(&pdf).err().unwrap(); + assert!(err.contains("macOS"), "{err}"); + // Storage only needs the signature here. + check(&pdf).unwrap(); + check(b"%PDF-1.4\ngarbage").unwrap(); + } +} diff --git a/apps/staged/src-tauri/src/note_media/tests.rs b/apps/staged/src-tauri/src/note_media/tests.rs index 311d6327f..7c671758c 100644 --- a/apps/staged/src-tauri/src/note_media/tests.rs +++ b/apps/staged/src-tauri/src/note_media/tests.rs @@ -550,6 +550,19 @@ fn validates_magic_and_caps_by_kind_without_reading_oversized_files() { } assert!(validate_media("fake.mp4", b"\0\0\0\x18ftypavif\0\0\0\0", true).is_err()); assert!(validate_media("fake.webm", b"\x1a\x45\xdf\xa3matroska", true).is_err()); + // PDFs are chat attachments only; note ingest refuses them even though + // the sniffer and MIME map know the format (for the attachment store). + assert_eq!(sniff_format(b"%PDF-1.7 ..."), Some("pdf")); + assert_eq!(mime_for_extension("pdf"), Some("application/pdf")); + assert_eq!( + validate_media( + "a.pdf", + &pdf::tests::tiny_pdf(&[pdf::tests::PAGE_ONE]), + true + ), + Err("PDFs cannot be embedded in notes".into()) + ); + assert!(size_limit("a.pdf", true).is_err()); assert!(size_limit("a.mp4", false).is_err()); assert_eq!(size_limit("a.png", true).unwrap(), MAX_IMAGE_SIZE); assert_eq!(size_limit("a.mov", true).unwrap(), MAX_VIDEO_SIZE); @@ -565,6 +578,55 @@ fn validates_magic_and_caps_by_kind_without_reading_oversized_files() { } } +#[test] +fn attachment_reads_pick_their_cap_from_the_file_signature() { + let f = Fixture::new(); + let sparse = |name: &str, header: &[u8], len: u64| { + let path = f.dir.path().join(name); + let mut file = std::fs::File::create(&path).unwrap(); + std::io::Write::write_all(&mut file, header).unwrap(); + file.set_len(len).unwrap(); + path + }; + // A PDF between the image and PDF caps is read in full. + let path = sparse("a.pdf", b"%PDF-1.4\n", files::MAX_IMAGE_SIZE + 1); + let bytes = files::read_local_attachment(&path).unwrap(); + assert_eq!(bytes.len() as u64, files::MAX_IMAGE_SIZE + 1); + assert!(bytes.starts_with(b"%PDF-1.4\n")); + assert_eq!( + files::read_local_attachment(&sparse("b.pdf", b"%PDF-", files::MAX_PDF_INPUT_SIZE + 1)), + Err("PDF exceeds 50 MB limit".into()) + ); + // Anything else keeps the image cap, judged from metadata. + assert_eq!( + files::read_local_attachment(&sparse("c.png", PNG, files::MAX_IMAGE_SIZE + 1)), + Err("media exceeds 10 MB limit".into()) + ); + // Files shorter than the signature are read, not mistaken for PDFs. + assert_eq!( + files::read_local_attachment(&sparse("d.png", b"%PD", 3)).unwrap(), + b"%PD" + ); + assert_eq!( + files::read_local_attachment(f.dir.path()), + Err("media must be a regular file".into()) + ); + assert_eq!( + files::read_local_attachment(&f.dir.path().join("missing.pdf")), + Err(files::ReadError::NotFound("media file is unavailable")) + ); + + // The same decision for bytes already in memory. + let mut pdf = b"%PDF-1.4\n".to_vec(); + pdf.resize(files::MAX_PDF_INPUT_SIZE as usize + 1, b' '); + let err = files::store_sniffed_image(&f.store, None, &f.project.id, None, "a.pdf", &pdf); + assert_eq!(err.unwrap_err(), "PDF exceeds 50 MB limit"); + let mut png = PNG.to_vec(); + png.resize(files::MAX_IMAGE_SIZE as usize + 1, 0); + let err = files::store_sniffed_image(&f.store, None, &f.project.id, None, "a.png", &png); + assert_eq!(err.unwrap_err(), "media exceeds 10 MB limit"); +} + #[test] fn failed_note_save_rolls_back_new_files_but_keeps_previous_media() { let f = Fixture::new(); @@ -631,6 +693,42 @@ fn materialization_missing_files_and_remote_video_caps_are_readable() { assert!(materialized.contains(&video.size_bytes.to_string())); } +/// A PDF attachment is viewed through the same protocol as note media, in an +/// ` + {:else if loading}
Loading...
{:else if dataUrl} {filename} @@ -111,6 +146,12 @@ min-height: 200px; } + .modal-body.pdf { + flex: 1; + min-height: 0; + padding: 0; + } + .modal-body img { max-width: 100%; max-height: calc(90vh - 80px); @@ -118,6 +159,14 @@ border-radius: 4px; } + .pdf-frame { + flex: 1; + width: 100%; + height: 100%; + border: 0; + background: var(--bg-primary); + } + .placeholder { font-size: var(--size-sm); color: var(--text-muted); diff --git a/apps/staged/src/lib/shared/markdown/mediaUrl.test.ts b/apps/staged/src/lib/shared/markdown/mediaUrl.test.ts index c2910365a..3a7a72aa3 100644 --- a/apps/staged/src/lib/shared/markdown/mediaUrl.test.ts +++ b/apps/staged/src/lib/shared/markdown/mediaUrl.test.ts @@ -4,8 +4,20 @@ vi.mock('@tauri-apps/api/core', () => ({ convertFileSrc: vi.fn(() => 'staged-media://localhost/file'), })); import { convertFileSrc } from '@tauri-apps/api/core'; -import { resolveStagedMediaUrl } from './mediaUrl'; +import { + parseStagedMediaReference, + resolvePdfAttachmentUrl, + resolveStagedMediaUrl, +} from './mediaUrl'; it('delegates platform-specific protocol URLs to Tauri', () => { expect(resolveStagedMediaUrl('id', 'png')).toBe('staged-media://localhost/file'); expect(convertFileSrc).toHaveBeenCalledWith('id.png', 'staged-media'); }); + +it('resolves PDF attachments by id without admitting them as note embeds', () => { + expect(resolvePdfAttachmentUrl('id')).toBe('staged-media://localhost/file'); + expect(convertFileSrc).toHaveBeenCalledWith('id.pdf', 'staged-media'); + expect( + parseStagedMediaReference('staged-media://0f3b9d2a-1c4e-4a5b-8d6e-7f8091a2b3c4.pdf') + ).toBeNull(); +}); diff --git a/apps/staged/src/lib/shared/markdown/mediaUrl.ts b/apps/staged/src/lib/shared/markdown/mediaUrl.ts index ef94c33c5..68b95a50e 100644 --- a/apps/staged/src/lib/shared/markdown/mediaUrl.ts +++ b/apps/staged/src/lib/shared/markdown/mediaUrl.ts @@ -17,3 +17,13 @@ export function parseStagedMediaReference(href: string) { export function resolveStagedMediaUrl(id: string, ext: string): string { return isTauri ? convertFileSrc(`${id}.${ext}`, 'staged-media') : `/api/media/${id}.${ext}`; } + +/** + * Served URL of a PDF chat attachment, for the viewer's `