157 lines
8.2 KiB
Python
157 lines
8.2 KiB
Python
"""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('</measure></part>', '</measure><measure number="2"><harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note() + '</measure></part>')
|
|
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()
|