PDF Render
ci / checks (push) Successful in 52m51s

This commit is contained in:
khodowany
2026-10-08 00:07:07 -05:00
parent 841a3f974e
commit 300097a37e
12 changed files with 394 additions and 15 deletions
+156
View File
@@ -0,0 +1,156 @@
"""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()