"""Build-only print layout, PDF contents and reproducible cache behavior.""" import generator.score_io import copy import dataclasses import io import json import math 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.model import generator.musicxml import generator.printing 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_io.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_tight_page_uses_the_actual_footer_outline(self): source = tests.solo_fixtures.song(count=32, chords=('C7',)) original = generator.printing.layout_part(source, self.fonts) style = generator.printing.STYLE # Two systems just fit the old nominal 20-unit footer reservation. The # page-number outline needs more space than that arithmetic assumes. height = math.ceil(original.pages[0].systems[1].box.bottom + style.margin_y + 20 + style.page_clearance) with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(style, height=height)): layout = generator.printing.layout_part(source, self.fonts) for page in layout.pages: footer = next(p for p in page.primitives if p.role == 'page_number') self.assertGreaterEqual(footer.box.top - page.systems[-1].box.bottom, style.page_clearance - .01) 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_io.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()