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),