@@ -18,6 +18,7 @@ import generator.engraving.types
|
||||
import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.performance
|
||||
import generator.printing
|
||||
import generator.score
|
||||
import generator.summary
|
||||
import platform
|
||||
@@ -103,6 +104,8 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
generator.performance.validate_recording_lengths(staged, audio_assets)
|
||||
report('engraving pages')
|
||||
validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
|
||||
report('generating printable parts')
|
||||
generator.printing.generate_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, progress=progress)
|
||||
report('rendering Pico titles')
|
||||
generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma')
|
||||
report('validating score, page, and audio assets')
|
||||
|
||||
@@ -19,16 +19,17 @@ def audit_primitives(primitives, width, height, fonts):
|
||||
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
|
||||
raise generator.engraving.types.EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
|
||||
|
||||
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts) -> None:
|
||||
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts, *, systems_per_page: int | None = 2) -> None:
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = fractions.Fraction()
|
||||
scale = layout.pages[0].systems[0].scale
|
||||
for page in layout.pages:
|
||||
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
|
||||
if not page.systems or (systems_per_page is not None and len(page.systems) != systems_per_page and (page is not layout.pages[-1] or not 1 <= len(page.systems) < systems_per_page)):
|
||||
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
|
||||
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
|
||||
for first, second in zip(page.systems, page.systems[1:]):
|
||||
if first.box.bottom + layout.style.system_gap > second.box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
|
||||
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
|
||||
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
|
||||
|
||||
@@ -30,12 +30,14 @@ class OutlinePen(fontTools.pens.basePen.BasePen):
|
||||
def _endPath(self):
|
||||
pass
|
||||
|
||||
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
|
||||
return rasterize_band(page, page.primitives, 0, page.height, fonts)
|
||||
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
|
||||
return rasterize_band(page, page.primitives, 0, page.height, fonts, ink=ink, background=background, output_scale=output_scale)
|
||||
|
||||
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
|
||||
scale = page.supersampling
|
||||
image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), generator.theme.rgb('background'))
|
||||
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
|
||||
ink = generator.theme.rgb('ink') if ink is None else ink
|
||||
background = generator.theme.rgb('background') if background is None else background
|
||||
scale = page.supersampling * output_scale
|
||||
image = PIL.Image.new('RGB', (round(page.width * scale), round((bottom - top) * scale)), background)
|
||||
context = aggdraw.Draw(image)
|
||||
for item in primitives:
|
||||
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
|
||||
@@ -56,16 +58,18 @@ def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[gener
|
||||
if item.kind == 'polygon':
|
||||
path.close()
|
||||
if item.kind in ('glyph', 'polygon'):
|
||||
context.path(path, None, aggdraw.Brush(generator.theme.rgb('ink')))
|
||||
context.path(path, None, aggdraw.Brush(ink))
|
||||
else:
|
||||
context.path(path, aggdraw.Pen(generator.theme.rgb('ink'), item.thickness * scale))
|
||||
context.path(path, aggdraw.Pen(ink, item.thickness * scale))
|
||||
context.flush()
|
||||
drawing = PIL.ImageDraw.Draw(image)
|
||||
for item in primitives:
|
||||
if item.kind == 'text':
|
||||
x, y = item.points[0]
|
||||
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=generator.theme.rgb('ink'))
|
||||
return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS)
|
||||
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=ink)
|
||||
result = image.resize((round(page.width * output_scale), round((bottom - top) * output_scale)), PIL.Image.Resampling.LANCZOS)
|
||||
image.close()
|
||||
return result
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Band:
|
||||
|
||||
@@ -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