Added PDF previews. Preview quality configurable. Preview browser caching and cache busting.

This commit is contained in:
Leo Vasanko 2023-11-18 15:16:24 -08:00
parent a366a0bcc6
commit 115f3e20d0
3 changed files with 57 additions and 19 deletions

View File

@ -1,11 +1,14 @@
import asyncio
import io
import urllib.parse
from pathlib import PurePosixPath
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import fitz # PyMuPDF
from PIL import Image
from sanic import Blueprint, raw
from sanic.exceptions import Forbidden, NotFound
from sanic import Blueprint, empty, raw
from sanic.exceptions import NotFound
from sanic.log import logger
from cista import config
@ -17,36 +20,68 @@ bp = Blueprint("preview", url_prefix="/preview")
@bp.get("/<path:path>")
async def preview(req, path):
"""Preview a file"""
width = int(req.query_string) if req.query_string else 1024
maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 40))
rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel
stat = path.lstat()
etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex()
savename = PurePosixPath(path.name).with_suffix(".webp")
headers = {
"etag": etag,
"last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable",
"content-type": "image/webp",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
if not path.is_file():
raise NotFound("File not found")
size = path.lstat().st_size
if size > 20 * 10**6:
raise Forbidden("File too large")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, process_image, path, width
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
)
return raw(img, content_type="image/webp")
return raw(img, headers=headers)
def process_image(path, maxsize):
def dispatch(path, quality, maxsize, maxzoom):
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
return process_image(path, quality=quality, maxsize=maxsize)
def process_image(path, *, maxsize, quality):
img = Image.open(path)
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Fix rotation based on EXIF data
try:
rotate_values = {3: 180, 6: 270, 8: 90}
exif = img._getexif()
if exif:
orientation = exif.get(274)
if orientation in rotate_values:
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
img = img.rotate(rotate_values[orientation], expand=True)
orientation = img._getexif().get(274)
if orientation in rotate_values:
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
img = img.rotate(rotate_values[orientation], expand=True)
except AttributeError:
...
except Exception as e:
logger.error(f"Error rotating preview image: {e}")
# Save as webp
imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=70, method=6)
img.save(imgdata, format="webp", quality=quality, method=4)
return imgdata.getvalue()
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
pdf = fitz.open(path)
page = pdf.load_page(page_number)
w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
return pix.pil_tobytes(format="webp", quality=quality, method=4)

View File

@ -10,7 +10,7 @@
>
<figure>
<slot></slot>
<MediaPreview ref=m :doc="doc" :tabindex=-1 />
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="?sz=512" />
<caption>
<label>
<SelectBox :doc=doc />

View File

@ -1,5 +1,6 @@
<template>
<img v-if=doc.img :src="preview() ? doc.previewurl : doc.url" alt="">
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
<img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url controls preload=none @click.prevent>📄</video>
<audio ref=aud v-else-if=audio() :src=doc.url controls preload=metadata @click.stop>📄</audio>
@ -16,6 +17,7 @@ const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value)
const props = defineProps<{
doc: Doc
quality: string
}>()
defineExpose({
@ -34,8 +36,9 @@ const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
const preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) ||
props.doc.size > 500000 &&
['png', 'bmp', 'ico', 'webp', 'avif', 'jpg', 'jpeg'].includes(props.doc.ext)
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
)
</script>