179 lines
8.8 KiB
Python
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)
|