diff --git a/client/html/post_content.tpl b/client/html/post_content.tpl index 9a9c4f304..8b6bd5f03 100644 --- a/client/html/post_content.tpl +++ b/client/html/post_content.tpl @@ -28,6 +28,14 @@ 'Your browser doesn\'t support HTML5 videos.') %> + <% } else if (ctx.post.type === 'document' && ctx.post.mimeType === 'application/pdf') { %> + + + + <% } else { console.log(new Error('Unknown post type')); } %>
diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl index 25a8d7f78..30f578f84 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 %>) @@ -54,12 +55,14 @@ <% } %> - + <% if (ctx.post.type !== 'document') { %> + + <% } %>
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/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/Cargo.lock b/server/Cargo.lock index 4597966d1..f3bc1ba55 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,21 @@ 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 = "foreign-types" version = "0.3.2" @@ -1177,6 +1266,32 @@ 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", + "smallvec", + "vello_common 0.0.9", +] + +[[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 +1333,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", +] + +[[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", + "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 +1846,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 +1926,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 +2320,7 @@ dependencies = [ "ffmpeg-sidecar", "flate2", "futures", + "hayro", "hex", "hmac", "image", @@ -2187,12 +2414,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 +2510,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 +2793,16 @@ 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", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -2678,6 +2989,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 +3388,22 @@ 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", +] + [[package]] name = "slab" version = "0.4.12" @@ -3712,6 +4045,53 @@ 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_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png", + "vello_common 0.0.8", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/server/Cargo.toml b/server/Cargo.toml index 4a1bc1710..4910af47e 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"] } 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/api/error.rs b/server/src/api/error.rs index e9adb8ac9..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; @@ -28,6 +30,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")] @@ -85,6 +89,7 @@ pub enum ApiError { NotLoggedIn, Password(#[from] argon2::password_hash::Error), PathRejection(#[from] axum::extract::rejection::PathRejection), + PdfLoadError(#[from] PdfLoadError), QueryRejection(#[from] axum::extract::rejection::QueryRejection), Request(#[from] reqwest::Error), #[error("Someone else modified this in the meantime. Please try again.")] @@ -128,6 +133,7 @@ impl ApiError { Self::UnsupportedContentType(_) | Self::UnsupportedExtension(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::CyclicDependency(_) | Self::DeleteDefault(_) + | Self::EmptyPdf | Self::EmptySwf | Self::EmptyVideo | Self::ExpressionFailsRegex(..) @@ -144,6 +150,7 @@ impl ApiError { | Self::NoEmail | Self::NoNamesGiven(_) | Self::NotAnInteger(_) + | Self::PdfLoadError(_) | Self::SelfMerge(_) | Self::SwfDecoding(_) | Self::UrlValidation(_) => StatusCode::UNPROCESSABLE_ENTITY, @@ -172,6 +179,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", @@ -211,6 +219,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", @@ -240,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(); @@ -248,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/config.rs b/server/src/config.rs index 054e73111..12c2af3c3 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: 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 7148a0333..81999d8ef 100644 --- a/server/src/content/cache.rs +++ b/server/src/content/cache.rs @@ -84,7 +84,7 @@ fn compute_properties_no_cache(ctx: &Ctx, token: UploadToken) -> 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 4a099fbbb..5a53518f6 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::{self, flash}; +use crate::content::{self, flash, pdf}; use crate::model::enums::{MimeType, PostType}; use ffmpeg_sidecar::child::FfmpegChild; use ffmpeg_sidecar::command::FfmpegCommand; @@ -30,6 +30,7 @@ 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()))), @@ -94,6 +95,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::Document), } } 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..a5a887eef --- /dev/null +++ b/server/src/content/pdf.rs @@ -0,0 +1,83 @@ +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::hayro_syntax::page::Page; +use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::vello_cpu::color::{AlphaColor, PremulRgba8, Srgb}; +use hayro::{RenderCache, RenderSettings}; +use image::error::LimitErrorKind; +use image::{DynamicImage, RgbaImage}; +use std::path::Path; + +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) + }?; + 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()); + + // 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) +} + +struct PdfRenderDimensions { + width: u16, // Width (in pixels) of the rendered PDF page. + height: u16, // Height (in pixels) of the rendered PDF page. + 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<'_>) -> ApiResult { + let (width, height) = page.render_dimensions(); + + // 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); + 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. + + 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) -> RenderSettings { + RenderSettings { + x_scale: self.scale, + y_scale: self.scale, + width: Some(self.width), + height: Some(self.height), + bg_color: background_color, + } + } +} + +/// 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) } +} diff --git a/server/src/error.rs b/server/src/error.rs index 1d383b123..6b427f71d 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -42,6 +42,7 @@ pub enum ErrorName { EmailMissingTo, EmailNonAsciiChars, EmailTooManyFrom, + EmptyPdf, EmptySwf, EmptyValue, EmptyVideo, @@ -77,6 +78,7 @@ pub enum ErrorName { InvalidData, InvalidDigit, InvalidEncoding, + InvalidEncryption, InvalidFilename, InvalidFormat, InvalidHeader, @@ -86,6 +88,7 @@ pub enum ErrorName { InvalidMime, InvalidPadding, InvalidPassword, + InvalidPDF, InvalidPhcStringField, InvalidSort, InvalidUploadToken, @@ -105,6 +108,7 @@ pub enum ErrorName { MissingContent, MissingContentType, MissingFormData, + MissingIDEntry, MissingJsonContentType, MissingMetadata, MissingPathParams, @@ -129,6 +133,7 @@ pub enum ErrorName { ParamNameDuplicated, ParamNameInvalid, ParamsMaxExceeded, + PasswordProtected, PathDeserializeError, PathParseError, PathParseErrorAtIndex, @@ -439,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 { @@ -615,6 +640,7 @@ impl ErrorKind for crate::api::error::ApiError { Self::CyclicDependency(_) => 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, @@ -654,6 +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(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 5d0b7fa3d..b92f0d6db 100644 --- a/server/src/model/enums.rs +++ b/server/src/model/enums.rs @@ -65,6 +65,7 @@ pub enum PostType { Animation, Video, Flash, + Document, } impl ToSql for PostType { @@ -105,6 +106,8 @@ pub enum MimeType { Swf, #[serde(rename = "image/avif")] Avif, + #[serde(rename = "application/pdf")] + Pdf, } impl MimeType { @@ -122,6 +125,7 @@ impl MimeType { "webm" => Ok(Self::Webm), "webp" => Ok(Self::Webp), "swf" => Ok(Self::Swf), + "pdf" => Ok(Self::Pdf), _ => Err(ParseExtensionError(extension.into())), } } @@ -146,6 +150,7 @@ impl MimeType { Self::Mov => "mov", Self::Webm => "webm", Self::Swf => "swf", + Self::Pdf => "pdf", } } @@ -160,6 +165,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),