I built a flyer generator. It reads a folder of YAML, one file per show, and
writes an 8.5 by 11 inch SVG. The page bisects, a photograph fills one half and
bleeds off the trim, the type sets in the other half on a grid, one ink on a
coloured ground. The photographs are screened as newsprint halftones.
Then I asked an image model for the same six flyers. Same content, same brief,
typed into a prompt instead of parsed from YAML. The ground colour and the ink
were the ones the generator had already assigned, so method was the only
variable.
The diffusion flyers are good. All six got the register: flat ground, one ink,
halftone photograph, heavy grotesque, a defensible hierarchy. Two of them I
would accept from a designer.
The same six shows, twice: generated from YAML on the left, from one prompt on the right.
Then I measured them.
The page is 8.5 by 11 inches. Every image came back 1024 by 1536, which is 2:3,
the nearest portrait size on offer. There is no parameter for trim size. The
brief asked for one flat ground and exactly one ink; the six images hold between
61,000 and 185,000 distinct colours, and in the worst one the commonest single
colour covers 0.6 percent of the page. ENNIO MORRICONE runs under the
photograph it was told to sit clear of. Two prices lost their dollar sign and a
third went missing. One flyer typeset the words βOpening actsβ because my prompt
used that phrase as a field label.
The hex each flyer was given, beside a fifty pixel square of the ground that came back, at 3x. Every one was specified as a single flat colour.Four crops at full resolution from the generated images.
None of those is a failure of taste; they are all the same failure. The model is
painting a picture of a flyer. It has no page, no measure, no colour value, no
string that has to survive intact. It has a very good idea of what those things
look like.
The generator has all of them. It knows the page is 8.5 by 11 because the page
is a number. It knows DE ANGELIS fits because it read the fontβs metrics table
and added up the advance widths, and when a name does not fit it shrinks that
line until it does. Run it again and you get the same bytes.
That argument is old, and procedural design has always lost it anyway, because
writing the system cost more than drawing the thing. The generator parses
TrueType metrics tables from the spec, because I did not want the dependency. It
resolves every unspecified slot deterministically, sets a column grid, and
screens each photograph at the size it will print. I did not write most of it. I
specified it, in conversation, and corrected it when it went wrong. The
expensive half of procedural design got cheap.
The clearest demonstration was a mistake. Halfway through, the halftone looked
mechanical. The screen was sitting at 45 degrees, which is the classic angle for
a single ink and also the one angle where a square screen lands every dot back
in a straight row. I changed one number to 22.5 and the whole series was right.
With the prompt I would have rolled again and taken what came.
A 45% flat tint at 45 lines per inch, screened twice. A square lattice turned 45 degrees returns every dot to a straight row.
Diffusion is the right tool when you want one image, you will not need it again,
and nothing about it has to be true. Plenty of work is like that.
I could not have written that prompt before I built the generator, though. The
halves, the bleed, the single ink, the gutter against the photograph, the screen
ruling, flush left on a grid: I knew none of it when I started. The build argued
me into it, one wrong version at a time. The prompt is the design document the
generator produced, read back out, and the diffusion flyers came out well on
borrowed work. Someone writing that brief cold would not get close.
The generator was the quick part. What took the time was working out what it
should do, and that is the part worth keeping. Building the system forces those
decisions, because every vagueness comes back as a crash or an ugly page. The
thinking ports to any toolchain, including the one that paints pictures.
readme
Print-ready concert flyers from a photo and a yaml file, in one house style.
quickstart
git clone https://github.com/bobbymeyer/concert-series
cd concert-series
python3 -m pip install pyyaml
override that part of design/design.yaml for this flyer
the design
design/design.yaml. Lengths take in, mm, cm, pt; a bare number is
points.
page
Default
width, height
8.5in, 11in
margin
0.5in, trim edge to text
gutter
0.5in, photo edge to text
grid
Takes
Default
split
auto, vertical, horizontal
auto: vertical unless the photo is landscape
flow
auto, column, row
auto: column on a vertical split
columns.column, columns.row
integer
1, 5
column_gap
length
16
row_gap.column, row_gap.row
length, a floor between grid rows
0, 12
row_align
top, center, bottom
bottom: cells in a row share a last baseline
image
Takes
Default
cell
start, end, random
random
h_align, v_align
start, middle, end, random
random
embed
bool, inline the photo as a data URI
true
treatment.desaturate
0β1, 1 being fully grey
1.0
treatment.blend
auto, multiply, screen
auto: multiply on a light ground, screen on a dark one
treatment.shadow, treatment.highlight
auto, a role, or hex
auto, following the blend
Alignments accept start/middle/end, or top, bottom, left, right,
center interchangeably.
palette
Takes
Default
colors
named grounds, one per flyer
six
ink.dark, ink.light
the only two type colours
#000000, #FFFFFF
switch_at
auto, or a luminance 0β1
auto: whichever ink contrasts more
tint
0β1, ink held back toward the ground
0.72
assign
series rotates grounds by position, independent hashes each flyer
series
typography
Takes
Default
family, fallback
font names
Rethink Sans
align
where the block sits on its free axis
random
text_length
bool, pin each line to its measured width
true
order
one entry per grid cell; a list stacks those fields in one cell
defaults
merged under every field
fields
per-field overrides
Field style
Takes
size
points
weight
400, 500, 700, 800
leading
multiple of the size
tracking
em
case
upper, lower, title, none
color
ink, ground, tint, or hex
space_after
points below the descenders, before the next cap line
span
grid columns, or all
join
joins a list into one run instead of a line per item
max_items
caps a list; more is an error
bind_max
a word this short binds to the next; 0 turns it off
fit
bool, may shrink alone to fit the measure
formats.date and formats.time take one strftime pattern or a list of them,
longest first. A field takes the longest that sets on one line in its column.
recipes
Add a flyer:
mkdir content/piero-piccioni
cp ~/photo.jpg content/piero-piccioni/
$EDITOR content/piero-piccioni/flyer.yaml
make
See what a flyer resolved to without writing:
python3 -m flyer inspect piero-piccioni
Keep the SVGs small while iterating, then inline everything for the printer:
Screen the photos for a one-ink press. tools/halftone.py crops each one to
its cell at the alignment the flyer resolved, then halftones it at the cell's
printed size, so the dots land 1:1 on the page, turned off the page axes so
the screen reads as a screen rather than as a grid. Needs Pillow and
halftoner:
screen angle off the page axes; 22.5. At 45 the dots land back on a straight grid
--dpi N
device resolution; 300
--ink HEX
the one ink, before the flyer's ramp; #141414
--force
screen a photo that is already screened
Screening reads content/<slug>/source.*, the unscreened original, when the
folder keeps one; the flyer never treats that file as its image. Without a
source it reads the photo itself, once: the crop is baked in, so a screened
photo ignores image.h_align and image.v_align, and re-screening it is
refused.
The committed photos are screened at 300 dpi, for the printer. For the web,
rescreen from the sources at half that and build beside them:
make web # out/web, about 1.4MB a flyer
make web WEB_DPI=200
Make the same flyers the other way, to compare. tools/diffuse.py hands the
same fields, the same ground and the same house brief to an image model in one
prompt and keeps what comes back -- no layout engine, no second pass. Needs an
OpenAI key in OPENAI_API_KEY:
1024x1536, the nearest portrait size the model offers
--quality
low, medium, high; high
--out DIR
content/diffusion
The page is 8.5 by 11in, which the model cannot be asked for: 1024x1536 is
2:3. The six it returned are kept in content/diffusion/, as they came back.
Take photos from Wikimedia Commons. tools/fetch_commons.py keeps only CC0,
CC BY, CC BY-SA and public domain, and writes content/PHOTO-CREDITS.md:
python3 tools/fetch_commons.py --search "Alfa Romeo Polizia di Stato"
python3 tools/fetch_commons.py piero-piccioni --file "File:Example.jpg" --write
fetch_commons.py
Does
[slug ...]
report free candidates for each flyer's performer
--write
download; without it nothing is written
--search TERMS
list free files matching the terms and stop
--file FILE
fetch one named Commons file into the one named slug
--index N
take the Nth candidate, 1-based
--query TERMS
search for this instead of the performer
--credits-only
re-record attributions without downloading again
--content DIR
content folder
output
One SVG per flyer in out/, 8.5 Γ 11in, viewBox in points. The photo is
inlined as a data URI and the font weights in use are embedded as @font-face.
Each line of type carries its measured width as textLength. The photo's tone
is an SVG filter, not a CSS blend mode.
About 3.2MB a flyer at the committed 300 dpi screen, 1.4MB from make web.
tech
Python 3.11 and PyYAML. No other dependencies; the type is measured by reading
the font's own tables. The tools in tools/ ask for more, and nothing in
flyer/ needs them.
make test
134 tests. The fourteen covering tools/halftone.py want Pillow and skip
without it; the rest are standard library only. Default branch is main.
Type is Rethink Sans by Hans
Thiessen under the SIL Open Font License 1.1, vendored in design/fonts/ as
four static instances.
The example photographs are archival, from Wikimedia Commons, screened at
45 lpi and credited in content/PHOTO-CREDITS.md. None of them depicts the
composer named on the flyer.