Skip to content

Phase 3: a chord chart as SVG, with text that holds its width on a machine we do not own - #6

Merged
SuperInstance merged 1 commit into
masterfrom
claude/init-1wgqn0
Aug 17, 2026
Merged

Phase 3: a chord chart as SVG, with text that holds its width on a machine we do not own#6
SuperInstance merged 1 commit into
masterfrom
claude/init-1wgqn0

Conversation

@SuperInstance

Copy link
Copy Markdown
Owner
plainsong chart song.song -o chart.svg

a rendered chord chart

A standalone file, so it commits to a repository and embeds with
'<img src="chart.svg">' — the only way a chart appears in markdown on a platform
we do not control, because GitHub strips a raw <svg>.

That one fact decides most of the design:

  • No webfont can be fetched, so the chart is drawn in whatever the reader
    already has — and the layout still has to hold.
  • An <img> cannot inherit the host page's colour, so the chart carries its
    own background. A transparent chart is black ink on black in a dark README.
    It reads prefers-color-scheme and is legible either way.

Text that holds its width

Widths are computed in Python from a table shipped in the package, then declared:

<text textLength="84.03" lengthAdjust="spacingAndGlyphs">Cmaj7</text>

SVG treats both as geometry rather than style, so the browser fits the
rendered string to the width we planned for. Plain spacing cannot do the job —
it distributes the n−1 gaps between characters, so a one-character symbol
like C has nothing to adjust and renders at whatever width the substituted
font gives it.

Regular and bold are measured separately. m, b and j differ between
the faces and those are precisely what chord symbols are made of. Measuring
regular and drawing bold made lengthAdjust squeeze every glyph into a width
the text did not have — visible as smeared letters in the very first screenshot.

Bars are as wide as their contents

Each symbol sits at unit × width, so the next is clear only when
width × (next_unit − unit) covers this symbol's advance plus a gap:

width = max over symbols of  (advance + gap) / (next_unit - unit)

Exact, one pass, no iteration. | Cmaj7#11 Abm7b5 Db7alt Gbmaj9 | and
| C . . . | do not need the same room — a fixed width either wastes the page
or overlaps the symbols, and which one depends on the song.

What Phase 1 bought

Positions come from Arrangement.grid and nowhere else. A chart cannot
disagree with the audio about when a chord arrives, because it computes nothing
that could disagree.

Symbols are read from every row rather than only Chords:. In the relative
dialect a row mixing roman numerals with scale degrees reads as melody, so a
chart taking only the chord row drew a page of empty bars for a piece whose
harmony is written down perfectly plainly. That was visible in the first render,
and is the argument for looking at output rather than only at tests.

Reading the emitted SVG caught a second defect no test would have: the left
edge of the final bar was drawn thick. That is an interior barline however near
the end of the piece it falls. Now fixed and tested.

Accidentals fold to ASCII

Liberation Sans carries U+266F ♯ and carries neither U+266D ♭ nor U+266E ♮ —
the wrong half to lose for a songbook full of flats and a parser that accepts
E7♭9. So E7♭9 draws as E7b9, and the command warns if anything undrawable
somehow reaches the page.

The width table

tools/extract_font_widths.py generates render/fontmetrics.py from a real
TrueType file with nothing but structhead, hhea, hmtx and a format-4
cmap. The table has to ship: a renderer cannot read a font off the machine it
runs on, because it is not there on Windows or macOS and this package hardcodes
no paths.

Widths are Liberation Sans, metric-compatible with Arial — the narrowest
claim the evidence supports. No Helvetica was available to measure, and
Liberation's M is 833/1000 em, Arial's figure rather than Helvetica's 889.

Engraving constants, from primary sources

thin barline 0.16 Bravura thinBarlineThickness
thick barline 0.5 Bravura thickBarlineThickness
1 staff space 0.25 em SMuFL scoring metrics

SMuFL's other figure, 0.2 em, is for text metrics — glyphs set inline in
prose. A chart using Bravura's engraving defaults is on the scoring
registration, so font-size = 4 × staff_space.

Scope

It is a chord chart, not an engraver: no noteheads, no staff, no beaming,
and the melody is not drawn. docs/chart.md lists what it deliberately does not
do — repeats, musical line breaking, font subsetting — rather than leaving
someone to discover it.

Verification

655 tests, 7 specs, ruff clean, ok 6340 file(s) checked, 3 warning(s), and
6321 file(s) compile exactly as recorded.

Rendered and inspected in headless Chromium in light and dark, and exercised
from a wheel installed into a clean venv outside the source tree — the
width table ships, which is the failure mode this repository has had before with
packaged data.

One thing I could not verify: that GitHub renders the committed .svg through
an <img> as intended. The design follows from the documented behaviour, and
the image at the top of this description is the actual file — so this PR page is
itself the test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc


Generated by Claude Code

… own

`plainsong chart song.song -o chart.svg` renders a chord chart, standalone,
with nothing installed. It commits to a repository and embeds with
`<img src="chart.svg">`, which is the only way a chart appears in markdown
on a platform we do not control: GitHub strips a raw `<svg>`.

That one fact decides most of the design. No webfont can be fetched, so the
chart is drawn in whatever the reader already has -- and the layout still
has to hold. Widths are computed in Python from a table shipped in the
package and then declared with `textLength` and
`lengthAdjust="spacingAndGlyphs"`, which SVG treats as geometry rather than
style, so the browser fits the string to the width we planned for. Plain
`spacing` cannot do it: it distributes the n-1 gaps between characters, so a
one-character symbol like `C` has nothing to adjust. An `<img>` also cannot
inherit the host page's colour, so the chart carries its own background and
reads prefers-color-scheme to stay legible in a dark README.

Bars are as wide as their contents. Each symbol sits at `unit * width`, so
the next one is clear only when `width * (next_unit - unit)` covers this
symbol's advance plus a gap; the largest such requirement across the chart
solves it exactly, in one pass, no iteration. `| Cmaj7#11 Abm7b5 Db7alt
Gbmaj9 |` and `| C . . . |` do not need the same room, and a fixed width
either wastes the page or overlaps the symbols depending on the song.

Positions come from Arrangement.grid and nowhere else, which is what Phase 1
was for: a chart cannot disagree with the audio about when a chord arrives,
because it computes nothing that could disagree. Symbols are read from every
row rather than only `Chords:` -- in the relative dialect a row mixing roman
numerals with scale degrees reads as melody, and a chart taking only the
chord row drew a page of empty bars for a piece whose harmony is written
down perfectly plainly. That was visible in the first render and is the
reason to look at output rather than at tests.

Accidentals fold to ASCII on the way out. Liberation Sans carries U+266F
sharp and carries neither U+266D flat nor U+266E natural, which is the wrong
half to lose for a songbook full of flats and a parser that accepts E7-flat-9.

tools/extract_font_widths.py generates render/fontmetrics.py from a real
TrueType file with nothing but `struct` -- head, hhea, hmtx and a format 4
cmap. It measures the bold face separately, because `m`, `b` and `j` differ
between the weights and those are precisely what chord symbols are made of;
measuring regular and drawing bold made lengthAdjust squeeze every glyph and
it was visible as smeared letters in the first screenshot.

Reading the emitted SVG also caught a defect no test would have: the left
edge of the final bar was drawn thick. That is an interior barline however
near the end of the piece it falls, and only the line closing the last bar
is heavy. Now tested.

It is a chord chart and not an engraver: no noteheads, no staff, no beaming,
and docs/chart.md lists what it deliberately does not do rather than leaving
someone to find out.

655 tests, 7 specs, ruff clean, 6,340 sources checked, 6,321 files compiling
to exactly the music they did. Rendered and inspected in headless Chromium
in light and dark, and exercised from a wheel installed into a clean venv
outside the source tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBAjxy7cD6DzJ72NX8TJEc
@SuperInstance
SuperInstance merged commit 47fdc3d into master Aug 17, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants