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 asyncio
import io import io
import urllib.parse
from pathlib import PurePosixPath from pathlib import PurePosixPath
from urllib.parse import unquote from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import fitz # PyMuPDF
from PIL import Image from PIL import Image
from sanic import Blueprint, raw from sanic import Blueprint, empty, raw
from sanic.exceptions import Forbidden, NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
from cista import config from cista import config
@ -17,36 +20,68 @@ bp = Blueprint("preview", url_prefix="/preview")
@bp.get("/<path:path>") @bp.get("/<path:path>")
async def preview(req, path): async def preview(req, path):
"""Preview a file""" """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))) rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel 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(): if not path.is_file():
raise NotFound("File not found") 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( 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) img = Image.open(path)
w, h = img.size w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize))) img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Fix rotation based on EXIF data # Fix rotation based on EXIF data
try: try:
rotate_values = {3: 180, 6: 270, 8: 90} rotate_values = {3: 180, 6: 270, 8: 90}
exif = img._getexif() orientation = img._getexif().get(274)
if exif: if orientation in rotate_values:
orientation = exif.get(274) logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
if orientation in rotate_values: img = img.rotate(rotate_values[orientation], expand=True)
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}") except AttributeError:
img = img.rotate(rotate_values[orientation], expand=True) ...
except Exception as e: except Exception as e:
logger.error(f"Error rotating preview image: {e}") logger.error(f"Error rotating preview image: {e}")
# Save as webp # Save as webp
imgdata = io.BytesIO() imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=70, method=6) img.save(imgdata, format="webp", quality=quality, method=4)
return imgdata.getvalue() 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> <figure>
<slot></slot> <slot></slot>
<MediaPreview ref=m :doc="doc" :tabindex=-1 /> <MediaPreview ref=m :doc="doc" tabindex=-1 quality="?sz=512" />
<caption> <caption>
<label> <label>
<SelectBox :doc=doc /> <SelectBox :doc=doc />

View File

@ -1,5 +1,6 @@
<template> <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> <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> <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> <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 media = computed(() => aud.value || vid.value)
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
quality: string
}>() }>()
defineExpose({ 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 audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext) const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
const preview = () => ( const preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) ||
props.doc.size > 500000 && 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> </script>