"""Build-only Letter PDFs from the complete arranged parts, never screen PNGs.""" from __future__ import annotations import dataclasses import hashlib import json import math import pathlib import shutil import time import PIL.PdfParser import generator.engraving.audit import generator.engraving.cache import generator.engraving.fonts import generator.engraving.layout import generator.engraving.notation import generator.engraving.raster import generator.engraving.types import generator.validation # Two layout units per PDF point: guitar staff spacing is five points. # Raster resolution is independent of layout and the device's display theme. STYLE = generator.engraving.types.Style(width=1224, height=1584, margin_x=72, margin_y=72, page_clearance=28, system_gap=32, supersampling=1) DPI = 300 PIXELS = (2550, 3300) MEDIA_BOX = [0, 0, 612, 792] VERSION = 'letter-parts-v1' def layout_part(document, fonts): generator.validation.validate_document(document) style = dataclasses.replace(generator.engraving.types.score_style(document, STYLE), system_gap=STYLE.system_gap) # Measure numbers are centered over barlines and can extend left of the # staff. Reserve their actual overhang inside the half-inch paper margin. overhang = max(-fonts.text(str(m['number']), 0, 0, 12, 'measure_number', (), True).box.left for m in document['staves'][0]['measures']) style = dataclasses.replace(style, margin_x=style.margin_x + math.ceil(max(0, overhang)) + 4) index = generator.engraving.notation.ScoreIndex(document) systems = generator.engraving.layout.layout_systems(document, style, fonts, index=index) # The shared header wraps long credits; print it at twice its display size. header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2) header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style)) top = generator.engraving.types.bounds(header).bottom + style.page_clearance # Reserve the measured outline for every possible page number, just as # screen pagination does. A nominal font size is not its actual ascent. footers = tuple(fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True) for number in range(1, len(systems) + 1)) bottom = min(p.box.top for p in footers) - style.page_clearance pages, placed, cursor = [], [], top def finish(): number = len(pages) + 1 pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), (*header, *(p for system in placed for p in system.primitives), footers[number - 1]))) for system in systems: system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2) height = system.box.bottom - system.box.top if height > bottom - top: raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: system cannot fit a Letter page at readable size') if placed and cursor + height > bottom: finish() placed, cursor = [], top dx, dy = style.margin_x + 1, cursor - system.box.top placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))) cursor += height + style.system_gap finish() result = generator.engraving.types.LayoutDocument(tuple(pages), style, fonts.fingerprint(style)) generator.engraving.audit.audit_layout(document, result, fonts, systems_per_page=None) if any(p.box.left < STYLE.margin_x or p.box.right > style.width - STYLE.margin_x or p.box.top < STYLE.margin_y or p.box.bottom > style.height - STYLE.margin_y for page in pages for p in page.primitives): raise generator.engraving.types.EngravingError('print notation exceeds the half-inch printer margins') return result def rasterize(page, fonts): return generator.engraving.raster.rasterize_page(page, fonts, ink=(0, 0, 0), background=(255, 255, 255), output_scale=DPI / 144) def validate_pdf(path, expected_pages=None): """Check the narrow PDF format written by the pinned Pillow asset dependency. This adapter deliberately uses Pillow's parser, not a new general PDF stack. Tests also decode the embedded page images to check print color/resolution. """ try: if path.is_symlink() or not path.is_file(): raise ValueError('expected a regular print PDF') pdf = PIL.PdfParser.PdfParser(filename=str(path)) try: count = len(pdf.pages) if not 0 < count <= 10000 or (expected_pages is not None and count != expected_pages): raise ValueError('invalid print PDF page count') for reference in pdf.pages: page = pdf.read_indirect(reference) if page[b'MediaBox'] != MEDIA_BOX: raise ValueError('print PDF must use portrait US Letter') image = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image']) if (image.dictionary[b'Width'], image.dictionary[b'Height']) != PIXELS or image.dictionary[b'ColorSpace'] != b'DeviceGray': raise ValueError('print PDF must contain 300 DPI grayscale pages') return count finally: pdf.close() except (OSError, KeyError, TypeError, IndexError, PIL.PdfParser.PdfFormatError) as error: raise ValueError(f'{path.name}: invalid print PDF: {error}') from error def write_pdf(path, document, fonts): layout = layout_part(document, fonts) # Append one page at a time so a long arrangement never keeps a chorus of # full-resolution raster images in memory. The enclosing build is staged. for page in layout.pages: with rasterize(page, fonts) as rgb, rgb.convert('L') as image: if image.size != PIXELS or image.getextrema()[0] == 255: raise ValueError('print renderer produced an empty or incorrectly sized page') image.save(path, format='PDF', append=page.number > 1, resolution=DPI, quality=95, title=document['metadata']['title'] + ' - ' + document['staves'][0].get('name', 'Part'), creator='Product Vamp', creationDate=None, modDate=None) validate_pdf(path, len(layout.pages)) return len(layout.pages) def renderer_key(fonts): identity = generator.engraving.cache.renderer_identity(fonts, STYLE) identity.update(print_version=VERSION, print_code=hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), dpi=DPI, ink='black', background='white', quality=95) return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() def restore(entry, key, target): try: if generator.cache.has_symlink(entry): return None pdf, manifest = entry / 'part.pdf', entry / 'print.json' if pdf.is_symlink() or manifest.is_symlink(): return None info = json.loads(manifest.read_text()) if info['key'] != key or info['sha256'] != hashlib.sha256(pdf.read_bytes()).hexdigest(): return None pages = validate_pdf(pdf, info['pages']) shutil.copyfile(pdf, target) return pages except (OSError, ValueError, KeyError, TypeError): return None def store(entry, key, target, pages): with generator.cache.stage_entry(entry, '.print_') as staged: shutil.copyfile(target, staged / 'part.pdf') (staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages})) generator.cache.publish_files(entry, staged, ('part.pdf', 'print.json')) def generate_tree(root, font_path, *, cache_dir=None, progress=False): """Called inside the normal build's atomic staging directory.""" if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())): raise ValueError('print cache and generated assets must not overlap') destination = root / 'print' destination.mkdir(exist_ok=True) paths = sorted((root / 'songs').glob('*.json')) fonts = generator.engraving.fonts.Fonts(font_path) renderer = renderer_key(fonts) expected, pages_total, reused = set(), 0, 0 started = time.monotonic() for path in paths: bundle = json.loads(path.read_text()) document = bundle.get('music', bundle) target = destination / path.with_suffix('.pdf').name expected.add(target) key = generator.engraving.cache.score_fingerprint(document, renderer) entry = cache_dir / 'print' / renderer / key if cache_dir else None pages = restore(entry, key, target) if entry else None if pages is None: pages = write_pdf(target, document, fonts) if entry: store(entry, key, target, pages) else: reused += 1 pages_total += pages if progress: print(f'Print: {target.name}: {pages} pages', flush=True) for stale in destination.glob('*.pdf'): if stale not in expected: stale.unlink() if progress: print(f'Print complete: {len(paths)} PDFs, {pages_total} pages, {reused} cached; {time.monotonic() - started:.1f}s', flush=True) return len(paths)