"""The print file must reproduce the reviewed layout: size, place, turn and mirror. Runs where Pillow is installed (the API image, or a local virtualenv). The raster checks additionally need PyMuPDF to draw the page, and skip without it; the structural checks do not. """ import io import os import re import tempfile import unittest import zlib from PIL import Image from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render try: import fitz # PyMuPDF, a development-only rasteriser except ImportError: fitz = None RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0) def quadrants(width=80, height=40, alpha=False): """Top-left red, top-right green, bottom-left blue, bottom-right yellow.""" image = Image.new('RGBA' if alpha else 'RGB', (width, height)) for x in range(width): for y in range(height): left, top = x < width // 2, y < height // 2 color = RED if left and top else GREEN if top else BLUE if left else YELLOW image.putpixel((x, y), color + ((255,) if alpha else ())) return image def item(placements, sources, height_cm, billed='1.0', film=57): return {'mode': 'avulsa', 'billed_metres': billed, 'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm, 'sources': sources, 'placements': placements}} def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False): return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork', 'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies, 'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'} def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0): return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y, 'width_cm': width, 'length_cm': length, 'rotation_degrees': rotation, 'mirrored': mirrored} class PrintFileTests(unittest.TestCase): def setUp(self): self.dir = tempfile.TemporaryDirectory() self.addCleanup(self.dir.cleanup) def save(self, image, name, **options): path = os.path.join(self.dir.name, name) image.save(path, **options) return path def render(self, spec, paths): out = io.BytesIO() detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test') return out.getvalue(), detail def test_page_is_the_film_and_the_layout_length(self): path = self.save(quadrants(), 'a.png') pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)], [source(20, 10, copies=2)], 10), [path]) box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf) self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2) self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2) # One embedded image, drawn once per copy. self.assertEqual(pdf.count(b'/Subtype /Image'), 1) content = self.content(pdf) self.assertEqual(content.count(b' Do '), 2) self.assertEqual(detail['placements'], 2) self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54))) self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF')) def content(self, pdf): # The page content is the last Flate stream before the page object. streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S) return zlib.decompress(streams[-1]) def test_jpeg_bytes_are_embedded_unchanged(self): path = self.save(quadrants(), 'a.jpg', quality=90) pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path]) with open(path, 'rb') as original: self.assertIn(original.read(), pdf) self.assertIn(b'/DCTDecode', pdf) def test_transparency_survives_as_a_soft_mask(self): image = quadrants(alpha=True) image.putpixel((0, 0), (0, 0, 0, 0)) path = self.save(image, 'a.png') pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path]) self.assertIn(b'/SMask', pdf) self.assertEqual(pdf.count(b'/Subtype /Image'), 2) def test_exif_orientation_is_honoured_like_a_browser(self): # Stored landscape, tagged "rotate 90": browsers show it portrait. image = quadrants(80, 40) exif = Image.Exif() exif[0x0112] = 6 path = self.save(image, 'a.jpg', exif=exif) pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path]) self.assertNotIn(b'/DCTDecode', pdf) with self.assertRaises(Unsupported): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path]) def test_refuses_what_it_cannot_reproduce(self): png = self.save(quadrants(), 'a.png') with self.assertRaisesRegex(Unsupported, 'proporções'): self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png]) with self.assertRaisesRegex(Unsupported, 'cobrados'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png]) fake_pdf = os.path.join(self.dir.name, 'art.pdf') with open(fake_pdf, 'wb') as handle: handle.write(b'%PDF-1.4\n%%EOF\n') with self.assertRaisesRegex(Unsupported, 'não é um PDF'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf]) cdr = os.path.join(self.dir.name, 'art.cdr') with open(cdr, 'wb') as handle: handle.write(b'not artwork') with self.assertRaisesRegex(Unsupported, 'não é uma imagem'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr]) def test_long_layouts_scale_user_space_instead_of_splitting(self): # 6 m is longer than a PDF page may be (about 5.08 m). path = self.save(quadrants(40, 800), 'a.png') pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600, billed='6.0'), [path]) self.assertEqual(detail['user_unit'], 2) self.assertIn(b'/UserUnit 2', pdf) def test_matrix_maps_corners_like_the_canvas(self): # Box 20 x 10 cm at the page's top-left; which image corner lands where. page = 10 * PT_PER_CM def corner(matrix, u, v): a, b, c, d, e, f = matrix x, y = a * u + c * v + e, b * u + d * v + f return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6) # Image top-left is unit (0, 1). cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10), (270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)} for (rotation, mirrored), expected in cases.items(): matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page) self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored)) @unittest.skipIf(fitz is None, 'PyMuPDF is not installed') def test_drawn_page_matches_the_layout(self): # Each case: the colour expected in the box's TL, TR, BL, BR quadrant. cases = { (0, False): (RED, GREEN, BLUE, YELLOW), (90, False): (BLUE, RED, YELLOW, GREEN), (180, False): (YELLOW, BLUE, GREEN, RED), (0, True): (GREEN, RED, YELLOW, BLUE), (90, True): (YELLOW, GREEN, BLUE, RED), } image = quadrants(80, 40) for (rotation, mirrored), expected in cases.items(): turned = rotation % 180 == 90 width, length = (10, 20) if turned else (20, 10) path = self.save(image, f'q{rotation}{mirrored}.png') # Sources are described after the customer's turn, like the box. spec = item([placement(12, 5, width, length, rotation, mirrored)], [source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30) pdf, _ = self.render(spec, [path]) page = fitz.open(stream=pdf, filetype='pdf')[0] dpi = 40 pixmap = page.get_pixmap(dpi=dpi, alpha=False) def sample(x_cm, y_cm): px = int(x_cm / 2.54 * dpi) py = int(y_cm / 2.54 * dpi) return pixmap.pixel(px, py) got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25), sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75)) for actual, wanted in zip(got, expected): self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)), f'rotation {rotation} mirrored {mirrored}: {got} != {expected}') # Outside the box the film stays empty. self.assertEqual(sample(2, 2), (255, 255, 255)) class PdfSourceTests(unittest.TestCase): """A customer PDF is placed as a vector form, sized and turned like an image.""" def setUp(self): self.dir = tempfile.TemporaryDirectory() self.addCleanup(self.dir.cleanup) def pdf(self, name, image=None, rotate=None, crop=None, pages=1, password=None): import pikepdf path = os.path.join(self.dir.name, name) image = image or quadrants(80, 40) image.save(path, 'PDF', resolution=72, save_all=pages > 1, append_images=[image] * (pages - 1)) if rotate is not None or crop or password: with pikepdf.open(path, allow_overwriting_input=True) as document: if rotate is not None: document.Root.Pages.Rotate = rotate # inherited, not on the page if crop: document.pages[0].obj.CropBox = pikepdf.Array(crop) encryption = pikepdf.Encryption(owner=password, user=password) if password else None document.save(path, encryption=encryption or False) return path def render(self, spec, paths): out = io.BytesIO() detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test') return out.getvalue(), detail def test_pdf_page_is_a_vector_form_placed_per_copy(self): path = self.pdf('sheet.pdf') pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)], [source(20, 10, copies=2)], 10), [path]) self.assertTrue(pdf.startswith(b'%PDF-1.6')) self.assertEqual(detail['vector_sources'], 1) self.assertIsNone(detail['min_dpi']) import pikepdf with pikepdf.open(io.BytesIO(pdf)) as document: page = document.pages[0] forms = [x for x in page.Resources.XObject.values() if x.Subtype == '/Form'] self.assertEqual(len(forms), 1) self.assertAlmostEqual(float(page.mediabox[2]), 57 * PT_PER_CM, places=2) content = b''.join(s.read_bytes() for s in page.obj.Contents) if isinstance(page.obj.Contents, pikepdf.Array) else page.obj.Contents.read_bytes() self.assertEqual(content.count(b'/Pdf0 Do'), 2) def test_mixed_raster_and_pdf_sources(self): png = os.path.join(self.dir.name, 'a.png') quadrants(80, 40).save(png) pdf_path = self.pdf('b.pdf') spec = item([placement(0, 0, 20, 10), placement(0, 0, 20, 10, index=1)], [source(20, 10), source(20, 10)], 10) spec['production']['placements'][1]['x_cm'] = 25 pdf, detail = self.render(spec, [png, pdf_path]) self.assertEqual((detail['sources'], detail['vector_sources']), (2, 1)) self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54))) def test_refuses_pdfs_it_cannot_place(self): with self.assertRaisesRegex(Unsupported, '2 páginas'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)]) with self.assertRaisesRegex(Unsupported, 'senha'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('locked.pdf', password='secret')]) with self.assertRaisesRegex(Unsupported, 'proporções'): self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')]) @unittest.skipIf(fitz is None, 'PyMuPDF is not installed') def test_drawn_pdf_matches_what_the_site_measured(self): framed = Image.new('RGB', (100, 60), (255, 255, 255)) framed.paste(quadrants(80, 40), (10, 10)) cases = { # (page /Rotate, placement rotation, mirrored) -> TL, TR, BL, BR as drawn 'plain': (self.pdf('plain.pdf'), 0, False, (RED, GREEN, BLUE, YELLOW)), # CropBox trims the white frame, exactly as pdf.js shows the page. 'cropped': (self.pdf('crop.pdf', image=framed, crop=[10, 10, 90, 50]), 0, False, (RED, GREEN, BLUE, YELLOW)), # An inherited /Rotate 90 is displayed turned clockwise. 'rotated page': (self.pdf('rot.pdf', rotate=90), 0, False, (BLUE, RED, YELLOW, GREEN)), 'placement turn': (self.pdf('turn.pdf'), 90, False, (BLUE, RED, YELLOW, GREEN)), 'mirrored': (self.pdf('mirror.pdf'), 0, True, (GREEN, RED, YELLOW, BLUE)), } for label, (path, turn, mirrored, expected) in cases.items(): displayed_portrait = label == 'rotated page' portrait = displayed_portrait != (turn == 90) width, length = (10, 20) if portrait else (20, 10) src = (10, 20) if displayed_portrait else (20, 10) spec = item([placement(12, 5, width, length, turn, mirrored)], [source(*src, rotation=0, mirrored=mirrored)], 30) pdf, _ = self.render(spec, [path]) page = fitz.open(stream=pdf, filetype='pdf')[0] dpi = 40 pixmap = page.get_pixmap(dpi=dpi, alpha=False) def sample(x_cm, y_cm): return pixmap.pixel(int(x_cm / 2.54 * dpi), int(y_cm / 2.54 * dpi)) got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25), sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75)) for actual, wanted in zip(got, expected): self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)), f'{label}: {got} != {expected}') self.assertEqual(sample(2, 2), (255, 255, 255), label) if __name__ == '__main__': unittest.main()