tts-voice-creator-clone-and.../tests/test_magazine_image_import.py
mARTin-B78 0005ae0ebd feat(magazine): import a magazine from PNG/JPEG page images
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:19:14 +02:00

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