Files
product-vamp/generator/printing.py
T
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

179 lines
8.8 KiB
Python

"""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
bottom = style.height - style.margin_y - 20 - style.page_clearance
pages, placed, cursor = [], [], top
def finish():
number = len(pages) + 1
# Include clearance for numeric descenders and JPEG block-edge ringing.
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
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), footer)))
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)