140 lines
5.9 KiB
Python
140 lines
5.9 KiB
Python
"""Importing a magazine from page images: conversion to a scan-style PDF and its refusals."""
|
|
import io
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-"))
|
|
|
|
from PIL import Image
|
|
|
|
from core import magazine as mag
|
|
|
|
|
|
def png(w=300, h=400, dpi=None, color=(250, 250, 250)):
|
|
buf = io.BytesIO()
|
|
kw = {"dpi": (dpi, dpi)} if dpi else {}
|
|
Image.new("RGB", (w, h), color).save(buf, format="PNG", **kw)
|
|
return buf.getvalue()
|
|
|
|
|
|
def jpeg(w=300, h=400, orientation=None):
|
|
buf = io.BytesIO()
|
|
im = Image.new("RGB", (w, h), (200, 200, 200))
|
|
exif = Image.Exif()
|
|
if orientation:
|
|
exif[0x0112] = orientation
|
|
im.save(buf, format="JPEG", exif=exif)
|
|
return buf.getvalue()
|
|
|
|
|
|
class ImagesToPdfTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-img-"))
|
|
self.dest = self.dir / "source.pdf"
|
|
|
|
def pages(self):
|
|
import pymupdf
|
|
with pymupdf.open(str(self.dest)) as d:
|
|
return [(round(p.rect.width), round(p.rect.height), len(p.get_text().strip())) for p in d]
|
|
|
|
def test_pages_in_order_sized_from_resolution_with_no_text_layer(self):
|
|
n = mag.images_to_pdf([("a.png", png(600, 900, dpi=300)), ("b.jpg", jpeg(150, 300))], self.dest)
|
|
self.assertEqual(n, 2)
|
|
# 600x900 px at 300 dpi = 144x216 pt; the jpeg has no dpi -> assumed 150 dpi = 72x144 pt
|
|
self.assertEqual(self.pages(), [(144, 216, 0), (72, 144, 0)])
|
|
|
|
def test_exif_rotation_is_applied(self):
|
|
mag.images_to_pdf([("r.jpg", jpeg(300, 200, orientation=6))], self.dest) # rotated 90°: becomes portrait
|
|
w, h, _ = self.pages()[0]
|
|
self.assertLess(w, h)
|
|
|
|
def test_unrotated_jpeg_is_not_reencoded(self):
|
|
data = jpeg(300, 200)
|
|
mag.images_to_pdf([("r.jpg", data)], self.dest)
|
|
import pymupdf
|
|
with pymupdf.open(str(self.dest)) as d:
|
|
extracted = d.extract_image(d[0].get_images()[0][0])["image"]
|
|
self.assertEqual(extracted, data)
|
|
|
|
def test_rejects_a_renamed_non_image_by_content(self):
|
|
with self.assertRaisesRegex(mag.ImageImportError, "not a PNG or JPEG"):
|
|
mag.images_to_pdf([("page.png", b"<html>not an image</html>")], self.dest)
|
|
self.assertFalse(self.dest.exists())
|
|
|
|
def test_rejects_a_truncated_image(self):
|
|
with self.assertRaisesRegex(mag.ImageImportError, "could not be read"):
|
|
mag.images_to_pdf([("t.png", png()[:40])], self.dest)
|
|
|
|
def test_rejects_too_many_pixels_before_decoding(self):
|
|
old = mag.IMAGE_IMPORT_MAX_PIXELS
|
|
mag.IMAGE_IMPORT_MAX_PIXELS = 1000
|
|
try:
|
|
with self.assertRaisesRegex(mag.ImageImportError, "too many pixels"):
|
|
mag.images_to_pdf([("big.png", png(300, 400))], self.dest)
|
|
finally:
|
|
mag.IMAGE_IMPORT_MAX_PIXELS = old
|
|
|
|
def test_rejects_oversize_file_and_empty_and_too_many(self):
|
|
with self.assertRaisesRegex(mag.ImageImportError, "larger than"):
|
|
mag.images_to_pdf([("x.png", b"\x89PNG\r\n\x1a\n" + b"0" * (mag.IMAGE_IMPORT_MAX_BYTES + 1))], self.dest)
|
|
with self.assertRaises(mag.ImageImportError):
|
|
mag.images_to_pdf([], self.dest)
|
|
with self.assertRaisesRegex(mag.ImageImportError, "At most"):
|
|
mag.images_to_pdf([("a.png", png(10, 10))] * (mag.IMAGE_IMPORT_MAX_FILES + 1), self.dest)
|
|
|
|
def test_one_bad_image_leaves_no_partial_pdf(self):
|
|
with self.assertRaises(mag.ImageImportError):
|
|
mag.images_to_pdf([("a.png", png()), ("b.png", b"nope")], self.dest)
|
|
self.assertEqual(list(self.dir.iterdir()), [])
|
|
|
|
def test_name_is_only_used_as_a_short_label(self):
|
|
with self.assertRaises(mag.ImageImportError) as ctx:
|
|
mag.images_to_pdf([("../../etc/passwd" + "x" * 200, b"nope")], self.dest)
|
|
self.assertNotIn("..", str(ctx.exception))
|
|
|
|
|
|
class EndpointTests(unittest.TestCase):
|
|
def setUp(self):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from unittest import mock
|
|
import routes.magazine as routes
|
|
self.routes = routes
|
|
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
|
|
self.patch = mock.patch.object(routes, "_LIB", self.lib)
|
|
self.patch.start()
|
|
self.addCleanup(self.patch.stop)
|
|
app = FastAPI()
|
|
app.include_router(routes.router)
|
|
self.client = TestClient(app)
|
|
self.doc = self.client.post("/api/magazine/docs", json={"title": "T"}).json()["id"]
|
|
|
|
def test_upload_builds_the_source_pdf(self):
|
|
r = self.client.post(f"/api/magazine/{self.doc}/source-images",
|
|
files=[("files", ("1.png", png(), "image/png")), ("files", ("2.jpg", jpeg(), "image/jpeg"))])
|
|
self.assertEqual(r.status_code, 200, r.text)
|
|
self.assertEqual(r.json()["pages"], 2)
|
|
self.assertTrue((self.lib / self.doc / "source.pdf").is_file())
|
|
|
|
def test_a_second_upload_cannot_replace_existing_source(self):
|
|
files = [("files", ("1.png", png(), "image/png"))]
|
|
self.assertEqual(self.client.post(f"/api/magazine/{self.doc}/source-images", files=files).status_code, 200)
|
|
self.assertEqual(self.client.post(f"/api/magazine/{self.doc}/source-images", files=files).status_code, 409)
|
|
|
|
def test_bad_content_is_a_400_with_a_message(self):
|
|
r = self.client.post(f"/api/magazine/{self.doc}/source-images",
|
|
files=[("files", ("1.png", b"GIF89a....", "image/png"))])
|
|
self.assertEqual(r.status_code, 400)
|
|
self.assertIn("not a PNG or JPEG", r.json()["detail"])
|
|
self.assertFalse((self.lib / self.doc / "source.pdf").exists())
|
|
|
|
def test_unknown_document_is_rejected(self):
|
|
r = self.client.post("/api/magazine/nope/source-images", files=[("files", ("1.png", png(), "image/png"))])
|
|
self.assertIn(r.status_code, (400, 404))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|