"""Build-only print layout, PDF contents and reproducible cache behavior.""" import copy import dataclasses import io import json import pathlib import tempfile import unittest import unittest.mock import PIL.Image import PIL.PdfParser import generator.arrange.generate import generator.engraving.audit import generator.engraving.fonts import generator.engraving.types import generator.model import generator.musicxml import generator.printing import generator.score import tests.solo_fixtures import tests.test_musicxml class PrintableParts(unittest.TestCase): @classmethod def setUpClass(cls): cls.font_path = pathlib.Path('package/assets/fonts/petaluma') cls.fonts = generator.engraving.fonts.Fonts(cls.font_path) def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = pathlib.Path(temporary.name) self.source = tests.solo_fixtures.song(count=2, chords=('C7',)) def tree(self, name, document=None): root = self.root / name (root / 'songs').mkdir(parents=True) generator.score.write_json(root / 'songs/part.json', document or self.source) return root def test_letter_pagination_preserves_complete_order_without_transition_duplicates(self): source = tests.solo_fixtures.song(count=32, chords=('C7',)) before = copy.deepcopy(source) layout = generator.printing.layout_part(source, self.fonts) self.assertGreater(len(layout.pages), 1) self.assertGreater(len(layout.pages[0].systems), 2) self.assertEqual(source, before) systems = [s for p in layout.pages for s in p.systems] self.assertEqual(systems[0].start, 0) self.assertEqual(systems[-1].end, 128) self.assertTrue(all(a.end == b.start for a, b in zip(systems, systems[1:]))) for page in layout.pages: self.assertEqual((page.width, page.height), (1224, 1584)) self.assertTrue(all(p.box.left >= 72 and p.box.right <= 1152 for p in page.primitives)) self.assertTrue(all(p.box.top >= 72 and p.box.bottom <= 1512 for p in page.primitives)) # Sharing content audits must not relax the screen's two-system contract. with self.assertRaisesRegex(ValueError, 'two music systems'): generator.engraving.audit.audit_layout(source, layout, self.fonts) def test_content_audits_remain_active_for_print(self): source = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml')) layout = generator.printing.layout_part(source, self.fonts) roles = {p.role for page in layout.pages for p in page.primitives} self.assertTrue({'tie', 'lyric', 'harmony', 'accidental'} <= roles) page = layout.pages[0] broken = dataclasses.replace(page, primitives=tuple(p for p in page.primitives if p.role != 'tie')) with self.assertRaisesRegex(ValueError, 'tie was omitted'): generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=(broken,)), self.fonts, systems_per_page=None) def test_guitar_tab_pickups_and_final_three_bar_hold_are_included(self): source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True) source = source.replace('', 'Cmajor' + tests.test_musicxml.xml_note() + '') source = tests.test_musicxml.parse(source) for part in ('acoustic', 'electric', 'vocal'): document = generator.arrange.generate.arrange_document(source, part) if part != 'vocal': document['staves'][0]['tablature'] = True before = copy.deepcopy(document) layout = generator.printing.layout_part(document, self.fonts) self.assertEqual(document, before) self.assertEqual(layout.pages[-1].systems[-1].end, sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'])) self.assertTrue(all(m['ending_sustain'] for m in document['staves'][0]['measures'][-3:])) roles = {p.role for page in layout.pages for p in page.primitives} self.assertIn('rest', roles) if part != 'vocal': self.assertTrue({'tab', 'tab_staff', 'tie'} <= roles) def test_pdf_pages_are_letter_300dpi_white_grayscale_and_reproducible(self): source = tests.solo_fixtures.song(count=32, chords=('C7',)) first, second = self.root / 'first.pdf', self.root / 'second.pdf' count = generator.printing.write_pdf(first, source, self.fonts) self.assertGreater(count, 1) generator.printing.write_pdf(second, source, self.fonts) self.assertEqual(first.read_bytes(), second.read_bytes()) pdf = PIL.PdfParser.PdfParser(filename=str(first)) try: self.assertEqual(len(pdf.pages), count) self.assertNotIn(b'CreationDate', pdf.info) self.assertNotIn(b'ModDate', pdf.info) for reference in pdf.pages: page = pdf.read_indirect(reference) self.assertEqual(page[b'MediaBox'], [0, 0, 612, 792]) stream = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image']) with PIL.Image.open(io.BytesIO(stream.buf)) as image: self.assertEqual(image.size, (2550, 3300)) self.assertEqual(image.mode, 'L') self.assertEqual(image.getextrema(), (0, 255)) for box in ((0, 0, 150, 3300), (2400, 0, 2550, 3300), (0, 0, 2550, 150), (0, 3150, 2550, 3300)): with image.crop(box) as margin: self.assertEqual(margin.getextrema(), (255, 255)) finally: pdf.close() with self.assertRaisesRegex(ValueError, 'page count'): generator.printing.validate_pdf(first, count + 1) def test_single_page_cache_hit_corruption_and_stale_removal(self): root, cache = self.tree('assets'), self.root / 'cache' self.assertEqual(generator.printing.generate_tree(root, self.font_path, cache_dir=cache), 1) target = root / 'print/part.pdf' original = target.read_bytes() self.assertEqual(generator.printing.validate_pdf(target), 1) warm = self.tree('warm') with unittest.mock.patch('generator.printing.write_pdf', side_effect=AssertionError('cache miss')): generator.printing.generate_tree(warm, self.font_path, cache_dir=cache) self.assertEqual(original, (warm / 'print/part.pdf').read_bytes()) asset = next(cache.rglob('part.pdf')) asset.write_bytes(original + b'corrupt') with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render: generator.printing.generate_tree(root, self.font_path, cache_dir=cache) render.assert_called_once() self.assertEqual(asset.read_bytes(), original) info = next(cache.rglob('print.json')) metadata = json.loads(info.read_text()) info.write_text(json.dumps({**metadata, 'pages': metadata['pages'] + 1})) generator.printing.generate_tree(root, self.font_path, cache_dir=cache) self.assertEqual(json.loads(info.read_text()), metadata) (root / 'songs/part.json').unlink() generator.printing.generate_tree(root, self.font_path, cache_dir=cache) self.assertFalse(list((root / 'print').iterdir())) def test_print_cache_tracks_music_style_and_fonts(self): root, cache = self.tree('assets'), self.root / 'cache' generator.printing.generate_tree(root, self.font_path, cache_dir=cache) changed = copy.deepcopy(self.source) changed['metadata']['title'] = 'Another title' generator.score.write_json(root / 'songs/part.json', changed) with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render: generator.printing.generate_tree(root, self.font_path, cache_dir=cache) render.assert_called_once() key = generator.printing.renderer_key(self.fonts) with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(generator.printing.STYLE, system_gap=40)): self.assertNotEqual(key, generator.printing.renderer_key(self.fonts)) with unittest.mock.patch.object(self.fonts, 'fingerprint', return_value='changed-fonts'): self.assertNotEqual(key, generator.printing.renderer_key(self.fonts)) def test_print_cache_cannot_overlap_published_assets(self): root = self.tree('assets') for cache in (root, root / 'cache', root.parent): with self.assertRaisesRegex(ValueError, 'must not overlap'): generator.printing.generate_tree(root, self.font_path, cache_dir=cache) if __name__ == '__main__': unittest.main()