From 3d734b845e0c090f065940239dfc6b85b12c2e65 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Fri, 7 Aug 2026 18:43:29 -0400 Subject: [PATCH 01/20] Working on PDF support --- server/Cargo.lock | 457 +++++++++++++++++++++++++++++++++++ server/Cargo.toml | 2 + server/src/content/cache.rs | 1 + server/src/content/decode.rs | 5 +- server/src/content/mod.rs | 1 + server/src/content/pdf.rs | 57 +++++ server/src/model/enums.rs | 6 + 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 server/src/content/pdf.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 4597966d1..4c0653e8a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -35,6 +35,21 @@ dependencies = [ "equator", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -338,6 +353,27 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" @@ -364,6 +400,20 @@ name = "bytemuck" version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] [[package]] name = "byteorder" @@ -460,6 +510,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -947,6 +1006,15 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "exr" version = "1.74.2" @@ -979,6 +1047,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "ffmpeg-sidecar" version = "2.5.2" @@ -1011,6 +1085,30 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -1177,6 +1275,49 @@ dependencies = [ "weezl", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.2.0", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.15" @@ -1218,6 +1359,99 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "vello_cpu 0.0.8", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf", + "rustc-hash", + "siphasher", + "skrifa 0.42.1", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] [[package]] name = "heck" @@ -1638,6 +1872,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1706,6 +1952,12 @@ dependencies = [ "cc", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2094,6 +2346,7 @@ dependencies = [ "ffmpeg-sidecar", "flate2", "futures", + "hayro", "hex", "hmac", "image", @@ -2132,6 +2385,7 @@ dependencies = [ "utoipa-axum", "utoipa-swagger-ui", "uuid", + "vello_cpu 0.2.0", "walkdir", ] @@ -2187,12 +2441,77 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c80d88d5c31215ceec2862abb2504860c715b8e2545a5ab15b62a3d097f30d" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2218,6 +2537,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2492,6 +2820,27 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -2678,6 +3027,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -3071,6 +3426,32 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -3712,6 +4093,82 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo 0.1.1", + "hashbrown 0.17.1", + "png", + "vello_common 0.0.8", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo 0.3.0", + "hashbrown 0.17.1", + "png", + "vello_common 0.2.0", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/server/Cargo.toml b/server/Cargo.toml index 4a1bc1710..3f308b53c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -19,6 +19,7 @@ dotenvy = { version = "0.15.7", optional = true } ffmpeg-sidecar = { version = "2.5.0", default-features = false } flate2 = "1.0.35" futures = "0.3.32" +hayro = { version = "0.7.1", features = ["embed-fonts", "embed-cmaps"] } hex = "0.4.3" hmac = "0.13.0" image = { version = "0.25.5", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp", "rayon"] } @@ -55,6 +56,7 @@ utoipa = { version = "5.4.0", features = ["axum_extras", "time", "url", "uuid"] utoipa-axum = "0.2.0" utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } uuid = { version = "1.13.1", features = ["v4", "serde"] } +vello_cpu = "0.2.0" walkdir = "2.5.0" [target.'cfg(target_env = "musl")'.dependencies] diff --git a/server/src/content/cache.rs b/server/src/content/cache.rs index 7148a0333..281dd2a82 100644 --- a/server/src/content/cache.rs +++ b/server/src/content/cache.rs @@ -87,6 +87,7 @@ fn compute_properties_no_cache(ctx: &Ctx, token: UploadToken) -> ApiResult false, PostType::Video => decode::video_has_audio(&ctx.config, &temp_path)?, PostType::Flash => decode::swf_has_audio(&temp_path)?, + PostType::Pdf => false, }; let flags = if has_sound { PostFlags::one(PostFlag::Sound) diff --git a/server/src/content/decode.rs b/server/src/content/decode.rs index 57f8de767..e30e8cb3a 100644 --- a/server/src/content/decode.rs +++ b/server/src/content/decode.rs @@ -1,5 +1,6 @@ use crate::api::error::{ApiError, ApiResult}; use crate::config::Config; +use crate::content::pdf::pdf_representative_image; use crate::content::{self, flash}; use crate::model::enums::{MimeType, PostType}; use ffmpeg_sidecar::child::FfmpegChild; @@ -10,7 +11,7 @@ use image::codecs::{gif::GifDecoder, webp::WebPDecoder}; use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits, RgbImage, RgbaImage}; use std::borrow::Cow; use std::fs::File; -use std::io::BufReader; +use std::io::{BufReader, Read}; use std::path::Path; use std::str::FromStr; use std::sync::mpsc::{RecvTimeoutError, SyncSender}; @@ -36,6 +37,7 @@ pub fn representative_image(config: &Config, file_path: &Path, mime_type: MimeTy MimeType::Mov | MimeType::Mp4 | MimeType::Webm => { ffmpeg_frame(config, file_path, PostType::Video).and_then(|frame| frame.ok_or(ApiError::EmptyVideo)) } + MimeType::Pdf => pdf_representative_image(file_path), } } @@ -94,6 +96,7 @@ pub fn detect_post_type(config: &Config, file_path: &Path, mime_type: MimeType) MimeType::Bmp | MimeType::Jpeg | MimeType::Png => Ok(PostType::Image), MimeType::Mp4 | MimeType::Mov | MimeType::Webm => Ok(PostType::Video), MimeType::Swf => Ok(PostType::Flash), + MimeType::Pdf => Ok(PostType::Pdf), } } diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index 2c6ed271e..06d244cc7 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -13,6 +13,7 @@ pub mod decode; pub mod download; mod flash; pub mod hash; +pub mod pdf; pub mod signature; pub mod thumbnail; pub mod upload; diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs new file mode 100644 index 000000000..2157116c8 --- /dev/null +++ b/server/src/content/pdf.rs @@ -0,0 +1,57 @@ +use crate::api::error::{ApiError, ApiResult}; +use crate::content::{self, flash}; +use crate::model::enums::{MimeType, PostType}; +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_interpret::font::{FontData, FontQuery, StandardFont}; +use hayro::hayro_interpret::hayro_cmap::CidFamily; +use hayro::hayro_syntax::Pdf; +use hayro::{RenderCache, RenderSettings, render}; +use image::{DynamicImage, RgbaImage}; +use std::borrow::Cow; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::mpsc::{RecvTimeoutError, SyncSender}; +use vello_cpu::color::palette::css::WHITE; + +pub fn pdf_representative_image(file_path: &Path) -> ApiResult { + let mut file = Vec::new(); + File::open(file_path)? + .read_to_end(&mut file) + .map_err(|_| ApiError::FromStr("Failed to render PDF".into()))?; + + let pdf = Pdf::new(file).map_err(|_| ApiError::FromStr("Failed to render PDF".into()))?; + + let interpreter_settings = InterpreterSettings { ..Default::default() }; + + let render_settings = RenderSettings { + x_scale: 1.0, + y_scale: 1.0, + bg_color: WHITE, + ..Default::default() + }; + let cache = RenderCache::new(); + + let pixmap = render( + pdf.pages() + .get(0) + .ok_or(ApiError::FromStr("Failed to get page 1 from PDF".into()))?, + &cache, + &interpreter_settings, + &render_settings, + ); + + let png = pixmap.data_as_u8_slice(); + + Ok(DynamicImage::ImageRgba8( + RgbaImage::from_raw(pixmap.width() as u32, pixmap.height() as u32, png.to_vec()) + .ok_or(ApiError::FromStr("Failed to render PDF".into()))?, + )) + + // let mut reader = ImageReader::new(BufReader::new(png.as_slice())); + // reader.set_format(ImageFormat::Png); + // reader.limits(Limits::default()); + // reader.decode().map_err(ApiError::from) +} diff --git a/server/src/model/enums.rs b/server/src/model/enums.rs index 3a225890d..49e9fca43 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -73,6 +73,7 @@ pub enum PostType { Animation, Video, Flash, + Pdf, } impl ToSql for PostType { @@ -113,6 +114,8 @@ pub enum MimeType { Swf, #[serde(rename = "image/avif")] Avif, + #[serde(rename = "application/pdf")] + Pdf, } impl MimeType { @@ -130,6 +133,7 @@ impl MimeType { "webm" => Ok(Self::Webm), "webp" => Ok(Self::Webp), "swf" => Ok(Self::Swf), + "pdf" => Ok(Self::Pdf), _ => Err(ParseExtensionError(extension.into())), } } @@ -154,6 +158,7 @@ impl MimeType { Self::Mov => "mov", Self::Webm => "webm", Self::Swf => "swf", + Self::Pdf => "pdf", } } @@ -168,6 +173,7 @@ impl FromStr for MimeType { let content_type = s.split(';').next().unwrap_or(s).trim().to_ascii_lowercase(); match content_type.as_str() { "application/x-shockwave-flash" | "application/vnd.adobe.flash.movie" => Ok(MimeType::Swf), + "application/pdf" => Ok(MimeType::Pdf), "image/avif" => Ok(MimeType::Avif), "image/bmp" => Ok(MimeType::Bmp), "image/gif" => Ok(MimeType::Gif), From 37f7111ccff7598ac36a6d884a198e7ff37a6e44 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Fri, 7 Aug 2026 20:48:49 -0400 Subject: [PATCH 02/20] Got PDFs working --- client/html/post_content.tpl | 7 ++++ client/js/views/post_upload_view.js | 2 +- server/src/content/pdf.rs | 50 +++++++++++++++-------------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/client/html/post_content.tpl b/client/html/post_content.tpl index 9a9c4f304..a55c29353 100644 --- a/client/html/post_content.tpl +++ b/client/html/post_content.tpl @@ -28,6 +28,13 @@ 'Your browser doesn\'t support HTML5 videos.') %> + <% } if (ctx.post.type === 'pdf') { %> + + + + <% } else { console.log(new Error('Unknown post type')); } %>
diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js index 63c78a916..e0119b2ad 100644 --- a/client/js/views/post_upload_view.js +++ b/client/js/views/post_upload_view.js @@ -165,7 +165,7 @@ class PostUploadView extends events.EventTarget { this._contentInputNode, { extraText: - "Allowed extensions: .jpg, .png, .gif, .webm, .mp4, .swf, .avif, .heif, .heic", + "Allowed extensions: .jpg, .png, .gif, .webm, .mp4, .swf, .avif, .heif, .heic, .pdf", allowUrls: true, allowMultiple: true, lock: false, diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index 2157116c8..e69351681 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -1,19 +1,11 @@ use crate::api::error::{ApiError, ApiResult}; -use crate::content::{self, flash}; -use crate::model::enums::{MimeType, PostType}; use hayro::hayro_interpret::InterpreterSettings; -use hayro::hayro_interpret::font::{FontData, FontQuery, StandardFont}; -use hayro::hayro_interpret::hayro_cmap::CidFamily; use hayro::hayro_syntax::Pdf; use hayro::{RenderCache, RenderSettings, render}; use image::{DynamicImage, RgbaImage}; -use std::borrow::Cow; use std::fs::File; -use std::io::{BufReader, Read}; +use std::io::Read; use std::path::Path; -use std::str::FromStr; -use std::sync::Arc; -use std::sync::mpsc::{RecvTimeoutError, SyncSender}; use vello_cpu::color::palette::css::WHITE; pub fn pdf_representative_image(file_path: &Path) -> ApiResult { @@ -26,22 +18,37 @@ pub fn pdf_representative_image(file_path: &Path) -> ApiResult { let interpreter_settings = InterpreterSettings { ..Default::default() }; + let page = pdf + .pages() + .get(0) + .ok_or(ApiError::FromStr("Failed to get page 1 from PDF".into()))?; + + let (dimensions, ratio) = { + let dimensions = page.render_dimensions(); + + let max_size = 1000.0; + + if dimensions.0 <= max_size && dimensions.1 <= max_size { + (dimensions, 1.0) + } else { + let longest_side = dimensions.0.max(dimensions.1); + let ratio = max_size / longest_side; + + ((dimensions.0 * ratio, dimensions.1 * ratio), ratio) + } + }; + let render_settings = RenderSettings { - x_scale: 1.0, - y_scale: 1.0, + x_scale: ratio, + y_scale: ratio, + width: Some(dimensions.0 as u16), + height: Some(dimensions.1 as u16), bg_color: WHITE, ..Default::default() }; let cache = RenderCache::new(); - let pixmap = render( - pdf.pages() - .get(0) - .ok_or(ApiError::FromStr("Failed to get page 1 from PDF".into()))?, - &cache, - &interpreter_settings, - &render_settings, - ); + let pixmap = render(page, &cache, &interpreter_settings, &render_settings); let png = pixmap.data_as_u8_slice(); @@ -49,9 +56,4 @@ pub fn pdf_representative_image(file_path: &Path) -> ApiResult { RgbaImage::from_raw(pixmap.width() as u32, pixmap.height() as u32, png.to_vec()) .ok_or(ApiError::FromStr("Failed to render PDF".into()))?, )) - - // let mut reader = ImageReader::new(BufReader::new(png.as_slice())); - // reader.set_format(ImageFormat::Png); - // reader.limits(Limits::default()); - // reader.decode().map_err(ApiError::from) } From 7f024d08f2b253fc711d29249c75252621fba0a8 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Fri, 7 Aug 2026 20:50:28 -0400 Subject: [PATCH 03/20] Removed unused import from decode.rs --- server/src/content/decode.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/content/decode.rs b/server/src/content/decode.rs index f71bb906a..894fe3252 100644 --- a/server/src/content/decode.rs +++ b/server/src/content/decode.rs @@ -11,7 +11,7 @@ use image::codecs::{gif::GifDecoder, webp::WebPDecoder}; use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits, RgbImage, RgbaImage}; use std::borrow::Cow; use std::fs::File; -use std::io::{BufReader, Read}; +use std::io::BufReader; use std::path::Path; use std::str::FromStr; use std::sync::mpsc::{RecvTimeoutError, SyncSender}; From 99c018ffece7462ade51b98e29fc392b26b8cc01 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Fri, 7 Aug 2026 21:17:17 -0400 Subject: [PATCH 04/20] Added PDF size limits --- server/config.toml.dist | 2 ++ server/src/config.rs | 2 ++ server/src/content/decode.rs | 2 +- server/src/content/pdf.rs | 20 +++++++++++--------- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/server/config.toml.dist b/server/config.toml.dist index e9cbcf421..1be664a92 100644 --- a/server/config.toml.dist +++ b/server/config.toml.dist @@ -58,6 +58,8 @@ post_height = 300 [limits] max_image_width = 16384 # Widest image that can be uploaded max_image_height = 16384 # Tallest image that can be uploaded +max_pdf_width = 1200 # Max width of the preview image generated for a PDF +max_pdf_height = 1200 # Max height of the preview image generated for a PDF max_image_allocation = "256MB" # Largest uncompressed image size max_upload_size = "4GB" # Largest file size that can be uploaded/downloaded ffmpeg_timeout_seconds = 60 # Fallback timeout for FFmpeg processes that stall diff --git a/server/src/config.rs b/server/src/config.rs index 054e73111..22e2c0f2d 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -85,6 +85,8 @@ pub struct SmtpConfig { pub struct LimitsConfig { pub max_image_width: u32, pub max_image_height: u32, + pub max_pdf_width: u32, + pub max_pdf_height: u32, pub max_image_allocation: ByteCount, pub max_upload_size: ByteCount, pub ffmpeg_timeout_seconds: u64, diff --git a/server/src/content/decode.rs b/server/src/content/decode.rs index 894fe3252..8766f461a 100644 --- a/server/src/content/decode.rs +++ b/server/src/content/decode.rs @@ -37,7 +37,7 @@ pub fn representative_image(config: &Config, file_path: &Path, mime_type: MimeTy MimeType::Mov | MimeType::Mp4 | MimeType::Webm => { ffmpeg_frame(config, file_path, PostType::Video).and_then(|frame| frame.ok_or(ApiError::EmptyVideo)) } - MimeType::Pdf => pdf_representative_image(file_path), + MimeType::Pdf => pdf_representative_image(config, file_path), } } diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index e69351681..33560a470 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -1,4 +1,5 @@ use crate::api::error::{ApiError, ApiResult}; +use crate::config::Config; use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; use hayro::{RenderCache, RenderSettings, render}; @@ -8,7 +9,7 @@ use std::io::Read; use std::path::Path; use vello_cpu::color::palette::css::WHITE; -pub fn pdf_representative_image(file_path: &Path) -> ApiResult { +pub fn pdf_representative_image(config: &Config, file_path: &Path) -> ApiResult { let mut file = Vec::new(); File::open(file_path)? .read_to_end(&mut file) @@ -26,16 +27,17 @@ pub fn pdf_representative_image(file_path: &Path) -> ApiResult { let (dimensions, ratio) = { let dimensions = page.render_dimensions(); - let max_size = 1000.0; + let max_size = (config.limits.max_pdf_width as f32, config.limits.max_pdf_height as f32); - if dimensions.0 <= max_size && dimensions.1 <= max_size { - (dimensions, 1.0) - } else { - let longest_side = dimensions.0.max(dimensions.1); - let ratio = max_size / longest_side; + let ratios = (max_size.0 / dimensions.0, max_size.1 / dimensions.1); - ((dimensions.0 * ratio, dimensions.1 * ratio), ratio) - } + // find the min ratio to scale down the image while maintaining aspect ratio + let ratio = f32::min(ratios.0, ratios.1); + + // ensure ratio is at most 1.0. We only want to downscale, not upscale. + let ratio = f32::min(1.0, ratio); + + ((dimensions.0 * ratio, dimensions.1 * ratio), ratio) }; let render_settings = RenderSettings { From eea868863ef93901a76a216ee3bee68623464640 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Fri, 7 Aug 2026 23:34:35 -0400 Subject: [PATCH 05/20] Followed clippy's sage advice --- server/src/config.rs | 4 ++-- server/src/content/cache.rs | 3 +-- server/src/content/pdf.rs | 16 ++++++++++------ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/server/src/config.rs b/server/src/config.rs index 22e2c0f2d..12c2af3c3 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -85,8 +85,8 @@ pub struct SmtpConfig { pub struct LimitsConfig { pub max_image_width: u32, pub max_image_height: u32, - pub max_pdf_width: u32, - pub max_pdf_height: u32, + pub max_pdf_width: u16, + pub max_pdf_height: u16, pub max_image_allocation: ByteCount, pub max_upload_size: ByteCount, pub ffmpeg_timeout_seconds: u64, diff --git a/server/src/content/cache.rs b/server/src/content/cache.rs index 281dd2a82..5dd1e35a1 100644 --- a/server/src/content/cache.rs +++ b/server/src/content/cache.rs @@ -84,10 +84,9 @@ fn compute_properties_no_cache(ctx: &Ctx, token: UploadToken) -> ApiResult false, + PostType::Image | PostType::Animation | PostType::Pdf => false, PostType::Video => decode::video_has_audio(&ctx.config, &temp_path)?, PostType::Flash => decode::swf_has_audio(&temp_path)?, - PostType::Pdf => false, }; let flags = if has_sound { PostFlags::one(PostFlag::Sound) diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index 33560a470..fd0bc17c4 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -21,13 +21,13 @@ pub fn pdf_representative_image(config: &Config, file_path: &Path) -> ApiResult< let page = pdf .pages() - .get(0) + .first() .ok_or(ApiError::FromStr("Failed to get page 1 from PDF".into()))?; let (dimensions, ratio) = { let dimensions = page.render_dimensions(); - let max_size = (config.limits.max_pdf_width as f32, config.limits.max_pdf_height as f32); + let max_size = (f32::from(config.limits.max_pdf_width), f32::from(config.limits.max_pdf_height)); let ratios = (max_size.0 / dimensions.0, max_size.1 / dimensions.1); @@ -40,13 +40,17 @@ pub fn pdf_representative_image(config: &Config, file_path: &Path) -> ApiResult< ((dimensions.0 * ratio, dimensions.1 * ratio), ratio) }; + let dimensions: (u16, u16) = ( + num_traits::cast(dimensions.0).ok_or(ApiError::FromStr("Failed to render PDF".into()))?, + num_traits::cast(dimensions.1).ok_or(ApiError::FromStr("Failed to render PDF".into()))?, + ); + let render_settings = RenderSettings { x_scale: ratio, y_scale: ratio, - width: Some(dimensions.0 as u16), - height: Some(dimensions.1 as u16), + width: Some(dimensions.0), + height: Some(dimensions.1), bg_color: WHITE, - ..Default::default() }; let cache = RenderCache::new(); @@ -55,7 +59,7 @@ pub fn pdf_representative_image(config: &Config, file_path: &Path) -> ApiResult< let png = pixmap.data_as_u8_slice(); Ok(DynamicImage::ImageRgba8( - RgbaImage::from_raw(pixmap.width() as u32, pixmap.height() as u32, png.to_vec()) + RgbaImage::from_raw(u32::from(pixmap.width()), u32::from(pixmap.height()), png.to_vec()) .ok_or(ApiError::FromStr("Failed to render PDF".into()))?, )) } From 7b57f9c1b32971294046f77d6c72fb92ed86b13c Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Fri, 7 Aug 2026 23:35:52 -0400 Subject: [PATCH 06/20] Renamed "pdf_representative_image" function --- server/src/content/decode.rs | 4 ++-- server/src/content/pdf.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/content/decode.rs b/server/src/content/decode.rs index 8766f461a..852da58e7 100644 --- a/server/src/content/decode.rs +++ b/server/src/content/decode.rs @@ -1,6 +1,6 @@ use crate::api::error::{ApiError, ApiResult}; use crate::config::Config; -use crate::content::pdf::pdf_representative_image; +use crate::content::pdf::pdf_preview_image; use crate::content::{self, flash}; use crate::model::enums::{MimeType, PostType}; use ffmpeg_sidecar::child::FfmpegChild; @@ -37,7 +37,7 @@ pub fn representative_image(config: &Config, file_path: &Path, mime_type: MimeTy MimeType::Mov | MimeType::Mp4 | MimeType::Webm => { ffmpeg_frame(config, file_path, PostType::Video).and_then(|frame| frame.ok_or(ApiError::EmptyVideo)) } - MimeType::Pdf => pdf_representative_image(config, file_path), + MimeType::Pdf => pdf_preview_image(config, file_path), } } diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index fd0bc17c4..c97e88eff 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -9,7 +9,7 @@ use std::io::Read; use std::path::Path; use vello_cpu::color::palette::css::WHITE; -pub fn pdf_representative_image(config: &Config, file_path: &Path) -> ApiResult { +pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { let mut file = Vec::new(); File::open(file_path)? .read_to_end(&mut file) From c8eb8c25857fb5d2f85dca3c6ea13c0fcfe113b6 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 16:11:28 -0400 Subject: [PATCH 07/20] Added PdfLoadError type --- server/src/api/error.rs | 3 +++ server/src/content/pdf.rs | 13 +++++-------- server/src/error.rs | 2 ++ server/src/model/enums.rs | 12 ++++++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/server/src/api/error.rs b/server/src/api/error.rs index e9adb8ac9..934796b41 100644 --- a/server/src/api/error.rs +++ b/server/src/api/error.rs @@ -85,6 +85,7 @@ pub enum ApiError { NotLoggedIn, Password(#[from] argon2::password_hash::Error), PathRejection(#[from] axum::extract::rejection::PathRejection), + PdfLoadError(#[from] crate::model::enums::PdfLoadError), QueryRejection(#[from] axum::extract::rejection::QueryRejection), Request(#[from] reqwest::Error), #[error("Someone else modified this in the meantime. Please try again.")] @@ -152,6 +153,7 @@ impl ApiError { | Self::InvalidHeader(_) | Self::MissingSmtpInfo | Self::StdIo(_) + | Self::PdfLoadError(_) | Self::TaskJoin(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::FailedConnection(_) => StatusCode::SERVICE_UNAVAILABLE, Self::FailedAuthentication(err) => match err { @@ -211,6 +213,7 @@ impl ApiError { Self::NotLoggedIn => "Not Logged In", Self::Password(_) => "Password Error", Self::PathRejection(_) => "Path Rejection", + Self::PdfLoadError(_) => "PDF Load Error", Self::QueryRejection(_) => "Query Rejection", Self::Request(_) => "Request Error", Self::ResourceModified => "Resource Modified", diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index c97e88eff..a0e5788a6 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -1,21 +1,18 @@ use crate::api::error::{ApiError, ApiResult}; use crate::config::Config; +use crate::content; use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; use hayro::{RenderCache, RenderSettings, render}; use image::{DynamicImage, RgbaImage}; -use std::fs::File; -use std::io::Read; use std::path::Path; use vello_cpu::color::palette::css::WHITE; pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { - let mut file = Vec::new(); - File::open(file_path)? - .read_to_end(&mut file) - .map_err(|_| ApiError::FromStr("Failed to render PDF".into()))?; - - let pdf = Pdf::new(file).map_err(|_| ApiError::FromStr("Failed to render PDF".into()))?; + let pdf = { + let file_contents = content::map_read_result(std::fs::read(file_path))?; + Pdf::new(file_contents).map_err(crate::model::enums::PdfLoadError)? + }; let interpreter_settings = InterpreterSettings { ..Default::default() }; diff --git a/server/src/error.rs b/server/src/error.rs index 1d383b123..aadb37a95 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -134,6 +134,7 @@ pub enum ErrorName { PathParseErrorAtIndex, PathParseErrorAtKey, PermissionDenied, + PdfLoadError, PhcStringTrailingData, PoolCategoryNameAlreadyExists, PoolCategoryNotFound, @@ -654,6 +655,7 @@ impl ErrorKind for crate::api::error::ApiError { Self::NotLoggedIn => ErrorName::NotLoggedIn, Self::Password(err) => err.kind(), Self::PathRejection(err) => err.kind(), + Self::PdfLoadError(_) => ErrorName::PdfLoadError, Self::QueryRejection(err) => err.kind(), Self::Request(_) => ErrorName::RequestError, Self::ResourceModified => ErrorName::ResourceModified, diff --git a/server/src/model/enums.rs b/server/src/model/enums.rs index 0f71d3c0b..f3694164a 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -27,6 +27,18 @@ use utoipa::{PartialSchema, ToSchema}; #[error("{0} is not a supported file extension")] pub struct ParseExtensionError(String); +#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)] +pub struct PdfLoadError(pub hayro::hayro_syntax::LoadPdfError); + +impl std::fmt::Display for PdfLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + hayro::hayro_syntax::LoadPdfError::Decryption(e) => write!(f, "PDF decryption error: {:?}", e), + hayro::hayro_syntax::LoadPdfError::Invalid => write!(f, "Invalid PDF file"), + } + } +} + #[derive( Debug, Default, Clone, Copy, PartialEq, Eq, FromRepr, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema, )] From 88ae09076526a766db77b1a93aa69a14e401740a Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 16:12:59 -0400 Subject: [PATCH 08/20] Added explanation comment for PdfLoadError --- server/src/model/enums.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/model/enums.rs b/server/src/model/enums.rs index f3694164a..51d6ad1ad 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -27,6 +27,7 @@ use utoipa::{PartialSchema, ToSchema}; #[error("{0} is not a supported file extension")] pub struct ParseExtensionError(String); +// hayro::LoadPdfError doesn't impl Error, Display so we wrap it here #[derive(Debug, Copy, Clone, PartialEq, Eq, Error)] pub struct PdfLoadError(pub hayro::hayro_syntax::LoadPdfError); From 78439da80e76ef4517bc3cf1755aa27cf77f9829 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 17:10:32 -0400 Subject: [PATCH 09/20] Added empty PDF error Moved interpreter settings Changed status code for PdfLoadError --- server/src/api/error.rs | 6 +++++- server/src/content/pdf.rs | 9 ++------- server/src/error.rs | 2 ++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/server/src/api/error.rs b/server/src/api/error.rs index 934796b41..29b6af0e1 100644 --- a/server/src/api/error.rs +++ b/server/src/api/error.rs @@ -28,6 +28,8 @@ pub enum ApiError { DeleteDefault(ResourceType), #[error("Downloaded content exceeds maximum allowed size")] DownloadTooLarge, + #[error("PDF has no pages")] + EmptyPdf, #[error("SWF has no decodable images")] EmptySwf, #[error("Video file has no frames")] @@ -129,6 +131,7 @@ impl ApiError { Self::UnsupportedContentType(_) | Self::UnsupportedExtension(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::CyclicDependency(_) | Self::DeleteDefault(_) + | Self::EmptyPdf | Self::EmptySwf | Self::EmptyVideo | Self::ExpressionFailsRegex(..) @@ -145,6 +148,7 @@ impl ApiError { | Self::NoEmail | Self::NoNamesGiven(_) | Self::NotAnInteger(_) + | Self::PdfLoadError(_) | Self::SelfMerge(_) | Self::SwfDecoding(_) | Self::UrlValidation(_) => StatusCode::UNPROCESSABLE_ENTITY, @@ -153,7 +157,6 @@ impl ApiError { | Self::InvalidHeader(_) | Self::MissingSmtpInfo | Self::StdIo(_) - | Self::PdfLoadError(_) | Self::TaskJoin(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::FailedConnection(_) => StatusCode::SERVICE_UNAVAILABLE, Self::FailedAuthentication(err) => match err { @@ -174,6 +177,7 @@ impl ApiError { Self::CyclicDependency(_) => "Cyclic Dependency", Self::DeleteDefault(_) => "Delete Default", Self::DownloadTooLarge => "Download Too Large", + Self::EmptyPdf => "Empty PDF", Self::EmptySwf => "Empty SWF", Self::EmptyVideo => "Empty Video", Self::ExpressionFailsRegex(..) => "Expression Fails Regex", diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index a0e5788a6..96b3340ec 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -14,12 +14,7 @@ pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult ApiResult ErrorName::CyclicDependency, Self::DeleteDefault(_) => ErrorName::DeleteDefault, Self::DownloadTooLarge => ErrorName::DownloadTooLarge, + Self::EmptyPdf => ErrorName::EmptyPdf, Self::EmptySwf => ErrorName::EmptySwf, Self::EmptyVideo => ErrorName::EmptyVideo, Self::ExpressionFailsRegex(..) => ErrorName::ExpressionFailsRegex, From cb20cc836c3d7695ff103981fe1801633020c2ff Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 18:15:09 -0400 Subject: [PATCH 10/20] Changed error types in pdf_preview_image --- server/src/content/pdf.rs | 40 +++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index 96b3340ec..d7f77a819 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -3,10 +3,11 @@ use crate::config::Config; use crate::content; use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; +use hayro::vello_cpu::color::palette::css::WHITE; use hayro::{RenderCache, RenderSettings, render}; +use image::error::LimitErrorKind; use image::{DynamicImage, RgbaImage}; use std::path::Path; -use vello_cpu::color::palette::css::WHITE; pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { let pdf = { @@ -16,7 +17,7 @@ pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult ApiResult is the same bitwise as Vec (except for num elements) + let rgba_vec = pixmap + .take() + .into_iter() + .map(|c| c.to_u8_array()) + .flatten() + .collect::>(); + + let size = rgba_vec.len(); Ok(DynamicImage::ImageRgba8( - RgbaImage::from_raw(u32::from(pixmap.width()), u32::from(pixmap.height()), png.to_vec()) - .ok_or(ApiError::FromStr("Failed to render PDF".into()))?, + RgbaImage::from_raw(u32::from(width), u32::from(height), rgba_vec).ok_or(ApiError::FrameBufferMismatch( + width as u32, + height as u32, + size as usize, + ))?, )) } From 7d3d38f57c944ad5ebe1edf5f886f9600e8a3b68 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 18:58:42 -0400 Subject: [PATCH 11/20] Changed PDF post type to Document post type --- client/html/post_content.tpl | 2 +- server/src/content/cache.rs | 2 +- server/src/content/decode.rs | 2 +- server/src/model/enums.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/html/post_content.tpl b/client/html/post_content.tpl index a55c29353..2802e6192 100644 --- a/client/html/post_content.tpl +++ b/client/html/post_content.tpl @@ -28,7 +28,7 @@ 'Your browser doesn\'t support HTML5 videos.') %> - <% } if (ctx.post.type === 'pdf') { %> + <% } if (ctx.post.type === 'document' && ctx.post.mimeType === 'application/pdf') { %> ApiResult false, + PostType::Image | PostType::Animation | PostType::Document => false, PostType::Video => decode::video_has_audio(&ctx.config, &temp_path)?, PostType::Flash => decode::swf_has_audio(&temp_path)?, }; diff --git a/server/src/content/decode.rs b/server/src/content/decode.rs index 852da58e7..f94418354 100644 --- a/server/src/content/decode.rs +++ b/server/src/content/decode.rs @@ -96,7 +96,7 @@ pub fn detect_post_type(config: &Config, file_path: &Path, mime_type: MimeType) MimeType::Bmp | MimeType::Jpeg | MimeType::Png => Ok(PostType::Image), MimeType::Mp4 | MimeType::Mov | MimeType::Webm => Ok(PostType::Video), MimeType::Swf => Ok(PostType::Flash), - MimeType::Pdf => Ok(PostType::Pdf), + MimeType::Pdf => Ok(PostType::Document), } } diff --git a/server/src/model/enums.rs b/server/src/model/enums.rs index 51d6ad1ad..eeeb25578 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -78,7 +78,7 @@ pub enum PostType { Animation, Video, Flash, - Pdf, + Document, } impl ToSql for PostType { From d232f50c1571a1567f9d37a247a3943454f2c19f Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 19:00:06 -0400 Subject: [PATCH 12/20] Removed explicit vello_cpu dependency --- server/Cargo.lock | 89 ++++------------------------------------------- server/Cargo.toml | 1 - 2 files changed, 6 insertions(+), 84 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 4c0653e8a..f3bc1ba55 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1100,15 +1100,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "font-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" -dependencies = [ - "bytemuck", -] - [[package]] name = "foreign-types" version = "0.3.2" @@ -1287,28 +1278,11 @@ dependencies = [ "log", "peniko", "png", - "skrifa 0.42.1", + "skrifa", "smallvec", "vello_common 0.0.9", ] -[[package]] -name = "glifo" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" -dependencies = [ - "bytemuck", - "foldhash", - "hashbrown 0.17.1", - "log", - "peniko", - "png", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.2.0", -] - [[package]] name = "guillotiere" version = "0.7.0" @@ -1374,7 +1348,7 @@ dependencies = [ "image", "kurbo", "pic-scale", - "vello_cpu 0.0.8", + "vello_cpu", ] [[package]] @@ -1407,7 +1381,7 @@ dependencies = [ "phf", "rustc-hash", "siphasher", - "skrifa 0.42.1", + "skrifa", "smallvec", "yoke", ] @@ -2385,7 +2359,6 @@ dependencies = [ "utoipa-axum", "utoipa-swagger-ui", "uuid", - "vello_cpu 0.2.0", "walkdir", ] @@ -2827,18 +2800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "font-types 0.11.3", -] - -[[package]] -name = "read-fonts" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" -dependencies = [ - "bytemuck", - "font-types 0.12.2", - "once_cell", + "font-types", ] [[package]] @@ -3439,17 +3401,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts 0.39.2", -] - -[[package]] -name = "skrifa" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" -dependencies = [ - "bytemuck", - "read-fonts 0.41.0", + "read-fonts", ] [[package]] @@ -4127,22 +4079,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "vello_common" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "png", - "smallvec", - "thiserror", -] - [[package]] name = "vello_cpu" version = "0.0.8" @@ -4150,25 +4086,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", - "glifo 0.1.1", + "glifo", "hashbrown 0.17.1", "png", "vello_common 0.0.8", ] -[[package]] -name = "vello_cpu" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" -dependencies = [ - "bytemuck", - "glifo 0.3.0", - "hashbrown 0.17.1", - "png", - "vello_common 0.2.0", -] - [[package]] name = "version_check" version = "0.9.5" diff --git a/server/Cargo.toml b/server/Cargo.toml index 3f308b53c..4910af47e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -56,7 +56,6 @@ utoipa = { version = "5.4.0", features = ["axum_extras", "time", "url", "uuid"] utoipa-axum = "0.2.0" utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } uuid = { version = "1.13.1", features = ["v4", "serde"] } -vello_cpu = "0.2.0" walkdir = "2.5.0" [target.'cfg(target_env = "musl")'.dependencies] From 83249cf36c2a50a324203603fe62e79f720ca44d Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 19:03:49 -0400 Subject: [PATCH 13/20] Letting clippy run my life --- server/src/content/pdf.rs | 9 ++++----- server/src/model/enums.rs | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index d7f77a819..b6171324b 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -53,17 +53,16 @@ pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult>(); let size = rgba_vec.len(); Ok(DynamicImage::ImageRgba8( RgbaImage::from_raw(u32::from(width), u32::from(height), rgba_vec).ok_or(ApiError::FrameBufferMismatch( - width as u32, - height as u32, - size as usize, + u32::from(width), + u32::from(height), + size, ))?, )) } diff --git a/server/src/model/enums.rs b/server/src/model/enums.rs index eeeb25578..0d74b7ec0 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -34,7 +34,7 @@ pub struct PdfLoadError(pub hayro::hayro_syntax::LoadPdfError); impl std::fmt::Display for PdfLoadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.0 { - hayro::hayro_syntax::LoadPdfError::Decryption(e) => write!(f, "PDF decryption error: {:?}", e), + hayro::hayro_syntax::LoadPdfError::Decryption(e) => write!(f, "PDF decryption error: {e:?}"), hayro::hayro_syntax::LoadPdfError::Invalid => write!(f, "Invalid PDF file"), } } From 2f242b315f76402e7a85e660df670caa5dfaba98 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 19:23:32 -0400 Subject: [PATCH 14/20] Moved PDF render dimensions into separate struct, functions --- server/src/content/pdf.rs | 72 ++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index b6171324b..14f215a13 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -3,48 +3,72 @@ use crate::config::Config; use crate::content; use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::page::Page; use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::vello_cpu::color::{AlphaColor, Srgb}; use hayro::{RenderCache, RenderSettings, render}; use image::error::LimitErrorKind; use image::{DynamicImage, RgbaImage}; use std::path::Path; -pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { - let pdf = { - let file_contents = content::map_read_result(std::fs::read(file_path))?; - Pdf::new(file_contents).map_err(crate::model::enums::PdfLoadError)? - }; - - let page = pdf.pages().first().ok_or(ApiError::EmptyPdf)?; +struct PdfRenderDimensions { + pub width: f32, + pub height: f32, + pub ratio: f32, +} - let (page_width, page_height, ratio) = { - let dimensions = page.render_dimensions(); +impl PdfRenderDimensions { + // Calculates the dimensions to render a PDF page at, given the config limits and the page's original dimensions. + fn from_page(config: &Config, page: &Page<'_>) -> Self { + let (width, height) = page.render_dimensions(); - let max_size = (f32::from(config.limits.max_pdf_width), f32::from(config.limits.max_pdf_height)); + // find the min ratio to scale down the image while maintaining aspect ratio + let ratio = { + let max_width = f32::from(config.limits.max_pdf_width); + let max_height = f32::from(config.limits.max_pdf_height); - let ratios = (max_size.0 / dimensions.0, max_size.1 / dimensions.1); + let width_ratio = max_width / width; + let height_ratio = max_height / height; - // find the min ratio to scale down the image while maintaining aspect ratio - let ratio = f32::min(ratios.0, ratios.1); + f32::min(width_ratio, height_ratio) + }; // ensure ratio is at most 1.0. We only want to downscale, not upscale. let ratio = f32::min(1.0, ratio); - (dimensions.0 * ratio, dimensions.1 * ratio, ratio) - }; + Self { + width: width * ratio, + height: height * ratio, + ratio: ratio, + } + } - let page_width = num_traits::cast(page_width).ok_or(LimitErrorKind::DimensionError)?; - let page_height = num_traits::cast(page_height).ok_or(LimitErrorKind::DimensionError)?; + // Returns the render settings for rendering a PDF page at the dimensions specified by this struct. + fn render_settings(&self, background_color: AlphaColor) -> ApiResult { + Ok(RenderSettings { + x_scale: self.ratio, + y_scale: self.ratio, + width: Some(num_traits::cast(self.width).ok_or(LimitErrorKind::DimensionError)?), + height: Some(num_traits::cast(self.height).ok_or(LimitErrorKind::DimensionError)?), + bg_color: background_color, + }) + } +} - let render_settings = RenderSettings { - x_scale: ratio, - y_scale: ratio, - width: Some(page_width), - height: Some(page_height), - bg_color: WHITE, +pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { + let pdf = { + let file_contents = content::map_read_result(std::fs::read(file_path))?; + Pdf::new(file_contents).map_err(crate::model::enums::PdfLoadError)? }; - let pixmap = render(page, &RenderCache::new(), &InterpreterSettings::default(), &render_settings); + // The preview image is just gonna show the first page + let page = pdf.pages().first().ok_or(ApiError::EmptyPdf)?; + + let dimensions = PdfRenderDimensions::from_page(config, page); + + let pixmap = + render(page, &RenderCache::new(), &InterpreterSettings::default(), &dimensions.render_settings(WHITE)?); + let width = pixmap.width(); let height = pixmap.height(); From 3893b708ecf1d5ec5f0c6673894bc2828d92e413 Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Sun, 9 Aug 2026 20:26:20 -0400 Subject: [PATCH 15/20] Added comments to PdfRenderDimensions struct Renamed ratio to scale for PdfRenderDimensions --- server/src/content/pdf.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index 14f215a13..2edf1a3f3 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -12,9 +12,14 @@ use image::{DynamicImage, RgbaImage}; use std::path::Path; struct PdfRenderDimensions { + // Width (in pixels) of the rendered PDF page. pub width: f32, + + // Height (in pixels) of the rendered PDF page. pub height: f32, - pub ratio: f32, + + // Scale applied to the original PDF page dimensions to fit within width/height. + pub scale: f32, } impl PdfRenderDimensions { @@ -23,31 +28,31 @@ impl PdfRenderDimensions { let (width, height) = page.render_dimensions(); // find the min ratio to scale down the image while maintaining aspect ratio - let ratio = { + let scale = { let max_width = f32::from(config.limits.max_pdf_width); let max_height = f32::from(config.limits.max_pdf_height); - let width_ratio = max_width / width; - let height_ratio = max_height / height; + let width_scale = max_width / width; + let height_scale = max_height / height; - f32::min(width_ratio, height_ratio) + f32::min(width_scale, height_scale) }; - // ensure ratio is at most 1.0. We only want to downscale, not upscale. - let ratio = f32::min(1.0, ratio); + // ensure scale is at most 1.0. We only want to downscale, not upscale. + let scale = f32::min(1.0, scale); Self { - width: width * ratio, - height: height * ratio, - ratio: ratio, + width: width * scale, + height: height * scale, + scale, } } // Returns the render settings for rendering a PDF page at the dimensions specified by this struct. fn render_settings(&self, background_color: AlphaColor) -> ApiResult { Ok(RenderSettings { - x_scale: self.ratio, - y_scale: self.ratio, + x_scale: self.scale, + y_scale: self.scale, width: Some(num_traits::cast(self.width).ok_or(LimitErrorKind::DimensionError)?), height: Some(num_traits::cast(self.height).ok_or(LimitErrorKind::DimensionError)?), bg_color: background_color, From 4c77ab4c582bc0510c2b267c31fba54e8c0847e2 Mon Sep 17 00:00:00 2001 From: Liam Welch Date: Sun, 9 Aug 2026 21:18:35 -0400 Subject: [PATCH 16/20] Made several minor stylistic changes. Moved `PdfLoadError` to `api::error.rs` and made `LoadPdfError` convertible to `ApiError`. Moved private implementation details in `pdf.rs` to bottom. Made `from_page` fallible instead of `render_settings`. Trimmed some spacing and intermediate variables. --- server/src/api/error.rs | 23 +++++++- server/src/content/pdf.rs | 111 ++++++++++++++++---------------------- server/src/error.rs | 27 +++++++++- server/src/model/enums.rs | 13 ----- 4 files changed, 92 insertions(+), 82 deletions(-) diff --git a/server/src/api/error.rs b/server/src/api/error.rs index 29b6af0e1..1ace42f18 100644 --- a/server/src/api/error.rs +++ b/server/src/api/error.rs @@ -7,9 +7,11 @@ use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use diesel::QueryResult; +use hayro::hayro_syntax::LoadPdfError; use image::error::{ImageError, LimitError, LimitErrorKind}; use serde::Serialize; use std::borrow::Cow; +use std::fmt::{Display, Formatter}; use std::sync::Arc; use thiserror::Error; use utoipa::ToSchema; @@ -87,7 +89,7 @@ pub enum ApiError { NotLoggedIn, Password(#[from] argon2::password_hash::Error), PathRejection(#[from] axum::extract::rejection::PathRejection), - PdfLoadError(#[from] crate::model::enums::PdfLoadError), + PdfLoadError(#[from] PdfLoadError), QueryRejection(#[from] axum::extract::rejection::QueryRejection), Request(#[from] reqwest::Error), #[error("Someone else modified this in the meantime. Please try again.")] @@ -247,6 +249,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(value: LoadPdfError) -> Self { + Self::PdfLoadError(PdfLoadError(value)) + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { let mut response = (self.status_code(), Json(self.response())).into_response(); @@ -255,6 +263,19 @@ impl IntoResponse for ApiError { } } +/// [`LoadPdfError`] doesn't impl Error, Display so we wrap it here +#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)] +pub struct PdfLoadError(pub LoadPdfError); + +impl Display for PdfLoadError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self.0 { + LoadPdfError::Decryption(e) => write!(f, "PDF decryption error: {e:?}"), + LoadPdfError::Invalid => f.write_str("Invalid PDF file"), + } + } +} + pub fn map_unique_violation(result: QueryResult, property: ResourceProperty) -> ApiResult { use diesel::result::DatabaseErrorKind; use diesel::result::Error as DeiselError; diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index 2edf1a3f3..699cafe43 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -6,92 +6,71 @@ use hayro::hayro_syntax::Pdf; use hayro::hayro_syntax::page::Page; use hayro::vello_cpu::color::palette::css::WHITE; use hayro::vello_cpu::color::{AlphaColor, Srgb}; -use hayro::{RenderCache, RenderSettings, render}; +use hayro::{RenderCache, RenderSettings}; use image::error::LimitErrorKind; use image::{DynamicImage, RgbaImage}; use std::path::Path; -struct PdfRenderDimensions { - // Width (in pixels) of the rendered PDF page. - pub width: f32, +pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { + let pdf = { + let file_contents = content::map_read_result(std::fs::read(file_path))?; + Pdf::new(file_contents) + }?; + let page = pdf.pages().first().ok_or(ApiError::EmptyPdf)?; // The preview image will be the first page + + let dimensions = PdfRenderDimensions::from_page(config, page)?; + let pixmap = + hayro::render(page, &RenderCache::new(), &InterpreterSettings::default(), &dimensions.render_settings(WHITE)); + + let width = u32::from(pixmap.width()); + let height = u32::from(pixmap.height()); + + // There should be some way to do this without reallocating, + // Vec is the same bitwise as Vec (except for num elements) + let rgba_vec = pixmap + .take() + .into_iter() + .flat_map(hayro::vello_cpu::color::PremulRgba8::to_u8_array) + .collect::>(); + let size = rgba_vec.len(); - // Height (in pixels) of the rendered PDF page. - pub height: f32, + RgbaImage::from_raw(width, height, rgba_vec) + .ok_or(ApiError::FrameBufferMismatch(width, height, size)) + .map(DynamicImage::ImageRgba8) +} - // Scale applied to the original PDF page dimensions to fit within width/height. - pub scale: f32, +struct PdfRenderDimensions { + pub width: u16, // Width (in pixels) of the rendered PDF page. + pub height: u16, // Height (in pixels) of the rendered PDF page. + pub scale: f32, // Scale applied to the original PDF page dimensions to fit within width/height. } impl PdfRenderDimensions { - // Calculates the dimensions to render a PDF page at, given the config limits and the page's original dimensions. - fn from_page(config: &Config, page: &Page<'_>) -> Self { + /// Calculates the dimensions to render a PDF page at, given the config limits and the page's original dimensions. + fn from_page(config: &Config, page: &Page<'_>) -> ApiResult { let (width, height) = page.render_dimensions(); - // find the min ratio to scale down the image while maintaining aspect ratio + // Find the min ratio to scale down the image while maintaining aspect ratio let scale = { let max_width = f32::from(config.limits.max_pdf_width); let max_height = f32::from(config.limits.max_pdf_height); - - let width_scale = max_width / width; - let height_scale = max_height / height; - - f32::min(width_scale, height_scale) + f32::min(max_width / width, max_height / height) }; + let scale = f32::min(1.0, scale); // Ensure scale is at most 1.0. We only want to downscale, not upscale. - // ensure scale is at most 1.0. We only want to downscale, not upscale. - let scale = f32::min(1.0, scale); - - Self { - width: width * scale, - height: height * scale, - scale, - } + let width = num_traits::cast(width * scale).ok_or(LimitErrorKind::DimensionError)?; + let height = num_traits::cast(height * scale).ok_or(LimitErrorKind::DimensionError)?; + Ok(Self { width, height, scale }) } - // Returns the render settings for rendering a PDF page at the dimensions specified by this struct. - fn render_settings(&self, background_color: AlphaColor) -> ApiResult { - Ok(RenderSettings { + /// Returns the render settings for rendering a PDF page at the dimensions specified by this struct. + fn render_settings(&self, background_color: AlphaColor) -> RenderSettings { + RenderSettings { x_scale: self.scale, y_scale: self.scale, - width: Some(num_traits::cast(self.width).ok_or(LimitErrorKind::DimensionError)?), - height: Some(num_traits::cast(self.height).ok_or(LimitErrorKind::DimensionError)?), + width: Some(self.width), + height: Some(self.height), bg_color: background_color, - }) + } } } - -pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { - let pdf = { - let file_contents = content::map_read_result(std::fs::read(file_path))?; - Pdf::new(file_contents).map_err(crate::model::enums::PdfLoadError)? - }; - - // The preview image is just gonna show the first page - let page = pdf.pages().first().ok_or(ApiError::EmptyPdf)?; - - let dimensions = PdfRenderDimensions::from_page(config, page); - - let pixmap = - render(page, &RenderCache::new(), &InterpreterSettings::default(), &dimensions.render_settings(WHITE)?); - - let width = pixmap.width(); - let height = pixmap.height(); - - // There should be some way to do this without reallocating, - // Vec is the same bitwise as Vec (except for num elements) - let rgba_vec = pixmap - .take() - .into_iter() - .flat_map(hayro::vello_cpu::color::PremulRgba8::to_u8_array) - .collect::>(); - - let size = rgba_vec.len(); - - Ok(DynamicImage::ImageRgba8( - RgbaImage::from_raw(u32::from(width), u32::from(height), rgba_vec).ok_or(ApiError::FrameBufferMismatch( - u32::from(width), - u32::from(height), - size, - ))?, - )) -} diff --git a/server/src/error.rs b/server/src/error.rs index e1c0915c8..6b427f71d 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -78,6 +78,7 @@ pub enum ErrorName { InvalidData, InvalidDigit, InvalidEncoding, + InvalidEncryption, InvalidFilename, InvalidFormat, InvalidHeader, @@ -87,6 +88,7 @@ pub enum ErrorName { InvalidMime, InvalidPadding, InvalidPassword, + InvalidPDF, InvalidPhcStringField, InvalidSort, InvalidUploadToken, @@ -106,6 +108,7 @@ pub enum ErrorName { MissingContent, MissingContentType, MissingFormData, + MissingIDEntry, MissingJsonContentType, MissingMetadata, MissingPathParams, @@ -130,12 +133,12 @@ pub enum ErrorName { ParamNameDuplicated, ParamNameInvalid, ParamsMaxExceeded, + PasswordProtected, PathDeserializeError, PathParseError, PathParseErrorAtIndex, PathParseErrorAtKey, PermissionDenied, - PdfLoadError, PhcStringTrailingData, PoolCategoryNameAlreadyExists, PoolCategoryNotFound, @@ -441,6 +444,26 @@ impl ErrorKind for diesel::ConnectionError { } } +impl ErrorKind for hayro::hayro_syntax::DecryptionError { + fn kind(&self) -> ErrorName { + match self { + Self::MissingIDEntry => ErrorName::MissingIDEntry, + Self::PasswordProtected => ErrorName::PasswordProtected, + Self::InvalidEncryption => ErrorName::InvalidEncryption, + Self::UnsupportedAlgorithm => ErrorName::UnsupportedAlgorithm, + } + } +} + +impl ErrorKind for hayro::hayro_syntax::LoadPdfError { + fn kind(&self) -> ErrorName { + match self { + Self::Decryption(err) => err.kind(), + Self::Invalid => ErrorName::InvalidPDF, + } + } +} + impl ErrorKind for image::error::LimitErrorKind { fn kind(&self) -> ErrorName { match self { @@ -657,7 +680,7 @@ impl ErrorKind for crate::api::error::ApiError { Self::NotLoggedIn => ErrorName::NotLoggedIn, Self::Password(err) => err.kind(), Self::PathRejection(err) => err.kind(), - Self::PdfLoadError(_) => ErrorName::PdfLoadError, + Self::PdfLoadError(err) => err.0.kind(), Self::QueryRejection(err) => err.kind(), Self::Request(_) => ErrorName::RequestError, Self::ResourceModified => ErrorName::ResourceModified, diff --git a/server/src/model/enums.rs b/server/src/model/enums.rs index 0d74b7ec0..b92f0d6db 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -27,19 +27,6 @@ use utoipa::{PartialSchema, ToSchema}; #[error("{0} is not a supported file extension")] pub struct ParseExtensionError(String); -// hayro::LoadPdfError doesn't impl Error, Display so we wrap it here -#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)] -pub struct PdfLoadError(pub hayro::hayro_syntax::LoadPdfError); - -impl std::fmt::Display for PdfLoadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.0 { - hayro::hayro_syntax::LoadPdfError::Decryption(e) => write!(f, "PDF decryption error: {e:?}"), - hayro::hayro_syntax::LoadPdfError::Invalid => write!(f, "Invalid PDF file"), - } - } -} - #[derive( Debug, Default, Clone, Copy, PartialEq, Eq, FromRepr, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema, )] From ae9767d82d8a6b266d53216cfa214c964aaa7a2a Mon Sep 17 00:00:00 2001 From: Liam Welch Date: Sun, 9 Aug 2026 21:54:00 -0400 Subject: [PATCH 17/20] Removed the need to copy pixel buffer by transmuting `Vec` to `Vec`. --- server/src/content/pdf.rs | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index 699cafe43..d177affde 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -5,7 +5,7 @@ use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; use hayro::hayro_syntax::page::Page; use hayro::vello_cpu::color::palette::css::WHITE; -use hayro::vello_cpu::color::{AlphaColor, Srgb}; +use hayro::vello_cpu::color::{AlphaColor, PremulRgba8, Srgb}; use hayro::{RenderCache, RenderSettings}; use image::error::LimitErrorKind; use image::{DynamicImage, RgbaImage}; @@ -25,17 +25,12 @@ pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult is the same bitwise as Vec (except for num elements) - let rgba_vec = pixmap - .take() - .into_iter() - .flat_map(hayro::vello_cpu::color::PremulRgba8::to_u8_array) - .collect::>(); - let size = rgba_vec.len(); - - RgbaImage::from_raw(width, height, rgba_vec) - .ok_or(ApiError::FrameBufferMismatch(width, height, size)) + // NOTE: Using the premultipied RGBA8 pixels here for the image buffer is only correct because the + // the background color is opaque. If this changes, `take_unpremultiplied` should be used instead. + let rgba_buffer = into_bytes(pixmap.take()); + let buffer_size = rgba_buffer.len(); + RgbaImage::from_raw(width, height, rgba_buffer) + .ok_or(ApiError::FrameBufferMismatch(width, height, buffer_size)) .map(DynamicImage::ImageRgba8) } @@ -74,3 +69,15 @@ impl PdfRenderDimensions { } } } + +/// Converts a `Vec` to `Vec` without copying. +fn into_bytes(rgba_buffer: Vec) -> Vec { + const _: () = assert!(size_of::() == 4 * size_of::(), "PremulRgba8 must be 4 bytes"); + const _: () = assert!(align_of::() == align_of::(), "PremulRgba8 must have same alignment as u8"); + const _: () = assert!(!std::mem::needs_drop::(), "PremulRgba8 must be trivially destructible"); + + let (ptr, len, capacity) = rgba_buffer.into_raw_parts(); + // SAFETY: PremulRgba8 is repr(C), size 4, align 1, and has no padding, + // so the transmuted buffer is safe to read and deallocate. + unsafe { Vec::from_raw_parts(ptr.cast::(), 4 * len, 4 * capacity) } +} From aee5d1bfe58c92f5e7ef556ec806fcf72b26df8d Mon Sep 17 00:00:00 2001 From: Liam Welch Date: Sun, 9 Aug 2026 23:01:58 -0400 Subject: [PATCH 18/20] Fixed document rescaling on client. PDF posts now respect rescale options. Since the "size" of the PDF is somewhat arbitrary, small PDFs are always upscaled regardless of the value of `upscaleSmallPosts`. --- client/html/post_content.tpl | 7 ++++--- client/js/controls/post_content_control.js | 12 ++++++++---- server/src/content/decode.rs | 5 ++--- server/src/content/pdf.rs | 8 ++++---- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/client/html/post_content.tpl b/client/html/post_content.tpl index 2802e6192..8b6bd5f03 100644 --- a/client/html/post_content.tpl +++ b/client/html/post_content.tpl @@ -28,10 +28,11 @@ 'Your browser doesn\'t support HTML5 videos.') %> - <% } if (ctx.post.type === 'document' && ctx.post.mimeType === 'application/pdf') { %> + <% } else if (ctx.post.type === 'document' && ctx.post.mimeType === 'application/pdf') { %> - diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js index 8cbd5d89e..024088bff 100644 --- a/client/js/controls/post_content_control.js +++ b/client/js/controls/post_content_control.js @@ -39,7 +39,7 @@ class PostContentControl { this._currentFitFunction = this.fitWidth; const mul = this._post.canvasHeight / this._post.canvasWidth; let width = this._viewportWidth; - if (!settings.get().upscaleSmallPosts) { + if (!this._shouldUpscale) { width = Math.min(this._post.canvasWidth, width); } this._resize(width, width * mul); @@ -49,7 +49,7 @@ class PostContentControl { this._currentFitFunction = this.fitHeight; const mul = this._post.canvasWidth / this._post.canvasHeight; let height = this._viewportHeight; - if (!settings.get().upscaleSmallPosts) { + if (!this._shouldUpscale) { height = Math.min(this._post.canvasHeight, height); } this._resize(height * mul, height); @@ -60,13 +60,13 @@ class PostContentControl { let mul = this._post.canvasHeight / this._post.canvasWidth; if (this._viewportWidth * mul < this._viewportHeight) { let width = this._viewportWidth; - if (!settings.get().upscaleSmallPosts) { + if (!this._shouldUpscale) { width = Math.min(this._post.canvasWidth, width); } this._resize(width, width * mul); } else { let height = this._viewportHeight; - if (!settings.get().upscaleSmallPosts) { + if (!this._shouldUpscale) { height = Math.min(this._post.canvasHeight, height); } this._resize(height / mul, height); @@ -86,6 +86,10 @@ class PostContentControl { return this._viewportSizeCalculator()[1]; } + get _shouldUpscale() { + return settings.get().upscaleSmallPosts || this._post.type === "document"; + } + _evtPostContentChange(e) { this._post = e.detail.post; this._post.mutateContentUrl(); diff --git a/server/src/content/decode.rs b/server/src/content/decode.rs index f94418354..5a53518f6 100644 --- a/server/src/content/decode.rs +++ b/server/src/content/decode.rs @@ -1,7 +1,6 @@ use crate::api::error::{ApiError, ApiResult}; use crate::config::Config; -use crate::content::pdf::pdf_preview_image; -use crate::content::{self, flash}; +use crate::content::{self, flash, pdf}; use crate::model::enums::{MimeType, PostType}; use ffmpeg_sidecar::child::FfmpegChild; use ffmpeg_sidecar::command::FfmpegCommand; @@ -31,13 +30,13 @@ pub fn representative_image(config: &Config, file_path: &Path, mime_type: MimeTy MimeType::Jpeg => image(config, file_path, ImageFormat::Jpeg), MimeType::Png => image(config, file_path, ImageFormat::Png), MimeType::Webp => image(config, file_path, ImageFormat::WebP), + MimeType::Pdf => pdf::preview_image(config, file_path), MimeType::Swf => flash_image(config, file_path).and_then(|frame| frame.ok_or(ApiError::EmptySwf)), MimeType::Avif => ffmpeg_frame(config, file_path, PostType::Image) .and_then(|frame| frame.ok_or(ApiError::FfmpegError("Unable to decode AVIF image with FFmpeg".into()))), MimeType::Mov | MimeType::Mp4 | MimeType::Webm => { ffmpeg_frame(config, file_path, PostType::Video).and_then(|frame| frame.ok_or(ApiError::EmptyVideo)) } - MimeType::Pdf => pdf_preview_image(config, file_path), } } diff --git a/server/src/content/pdf.rs b/server/src/content/pdf.rs index d177affde..a5a887eef 100644 --- a/server/src/content/pdf.rs +++ b/server/src/content/pdf.rs @@ -11,7 +11,7 @@ use image::error::LimitErrorKind; use image::{DynamicImage, RgbaImage}; use std::path::Path; -pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult { +pub fn preview_image(config: &Config, file_path: &Path) -> ApiResult { let pdf = { let file_contents = content::map_read_result(std::fs::read(file_path))?; Pdf::new(file_contents) @@ -35,9 +35,9 @@ pub fn pdf_preview_image(config: &Config, file_path: &Path) -> ApiResult Date: Tue, 11 Aug 2026 14:28:02 -0400 Subject: [PATCH 19/20] Added mime type id to sidebar --- client/html/post_readonly_sidebar.tpl | 1 + 1 file changed, 1 insertion(+) diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl index 25a8d7f78..d14b4cdfd 100644 --- a/client/html/post_readonly_sidebar.tpl +++ b/client/html/post_readonly_sidebar.tpl @@ -17,6 +17,7 @@ 'video/mp4': 'MPEG-4', 'video/quicktime': 'MOV', 'application/x-shockwave-flash': 'SWF', + 'application/pdf': 'PDF', }[ctx.post.mimeType] %> (<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>) From 587f034c878b0ca9c8201b1e911d0b185eddc64e Mon Sep 17 00:00:00 2001 From: EPICGameGuy Date: Tue, 11 Aug 2026 14:34:55 -0400 Subject: [PATCH 20/20] Hid reverse search on documents --- client/html/post_readonly_sidebar.tpl | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl index d14b4cdfd..30f578f84 100644 --- a/client/html/post_readonly_sidebar.tpl +++ b/client/html/post_readonly_sidebar.tpl @@ -55,12 +55,14 @@ <% } %> - + <% if (ctx.post.type !== 'document') { %> + + <% } %>