@@ -0,0 +1,190 @@
|
||||
"""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 tempfile
|
||||
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.model
|
||||
|
||||
# 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.model.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.engraving.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):
|
||||
if generator.engraving.cache.has_symlink(entry.parent):
|
||||
raise ValueError('print cache must not contain symlinks')
|
||||
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||
if entry.is_symlink() or entry.is_file():
|
||||
entry.unlink()
|
||||
entry.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.print_', dir=entry.parent) as temporary:
|
||||
staged = pathlib.Path(temporary)
|
||||
shutil.copyfile(target, staged / 'part.pdf')
|
||||
(staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages}))
|
||||
for name in ('part.pdf', 'print.json'):
|
||||
destination = entry / name
|
||||
if destination.is_dir() and not destination.is_symlink():
|
||||
shutil.rmtree(destination)
|
||||
(staged / name).replace(destination)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user