Improve printout PDF layout

Support multiple columns in case the tracklist is long, or there's
multiple media.
This commit is contained in:
Claudio Ortolina
2026-03-09 14:40:02 +00:00
parent 347b80fe59
commit 4c6baa6e13
2 changed files with 127 additions and 27 deletions
+80 -27
View File
@@ -3,6 +3,16 @@ defmodule MusicLibrary.Records.TracklistPdf do
alias MusicLibraryWeb.Duration
alias Typst.Format
@layout_configs [
{1, 8, true},
{2, 8, true},
{2, 7, true},
{3, 7, true},
{3, 6, true},
{4, 6, false},
{4, 5, false}
]
@spec generate(MusicLibrary.Records.Record.t(), Release.t()) ::
{:ok, binary()} | {:error, term()}
def generate(record, release) do
@@ -11,9 +21,18 @@ defmodule MusicLibrary.Records.TracklistPdf do
end
defp build_markup(record, release) do
media_count = Release.media_count(release)
track_count = Enum.sum(Enum.map(release.media, &length(&1.tracks)))
header_count = if media_count > 1, do: media_count, else: 0
total_items = track_count + header_count
{columns, font_size, show_duration} = layout_params(total_items, media_count)
content = build_media(release, media_count, font_size, show_duration)
"""
#set page(width: 120mm, height: 120mm, margin: 5mm)
#set text(font: "Liberation Sans", size: 8pt)
#set page(width: 120mm, height: 120mm, margin: 5mm, background: rect(width: 100%, height: 100%, stroke: 0.5pt + black))
#set text(font: "Liberation Sans", size: #{font_size}pt)
#align(center)[
#text(size: 10pt, weight: "bold")[#{Format.escape(artist_names(record))}]
@@ -23,28 +42,36 @@ defmodule MusicLibrary.Records.TracklistPdf do
#v(3mm)
#{build_media(release)}
#{build_columns(columns, content)}
"""
end
defp build_media(release) do
media_count = Release.media_count(release)
defp build_columns(1, content), do: content
defp build_columns(n, content) do
"""
#columns(#{n}, gutter: 3mm)[
#{content}
]\
"""
end
defp build_media(release, media_count, font_size, show_duration) do
release.media
|> Enum.map_join("\n", fn medium ->
build_medium(medium, media_count)
build_medium(medium, media_count, font_size, show_duration)
end)
end
defp build_medium(medium, media_count) do
defp build_medium(medium, media_count, font_size, show_duration) do
header =
if media_count > 1 do
label = medium_label(medium)
"""
#v(2mm)
#text(size: 8pt, weight: "bold")[#{Format.escape(label)}]
#v(1mm)
#v(1.5mm)
#text(weight: "bold")[#{Format.escape(label)}]
#v(0.5mm)
"""
else
""
@@ -52,31 +79,57 @@ defmodule MusicLibrary.Records.TracklistPdf do
tracks =
medium.tracks
|> Enum.map_join("\n", &build_track/1)
|> Enum.map_join("\n", &build_track(&1, font_size, show_duration))
header <> tracks
end
defp build_track(track) do
defp build_track(track, font_size, show_duration) do
number = Format.escape(track.number || to_string(track.position))
title = Format.escape(track.title)
number_width = font_size + 2
duration =
if track.length do
Format.escape(Duration.format_duration(track.length))
else
""
end
if show_duration do
duration =
if track.length do
Format.escape(Duration.format_duration(track.length))
else
""
end
"""
#grid(
columns: (12pt, 1fr, auto),
gutter: 4pt,
align(right)[#{number}],
[#{title}],
align(right)[#{duration}]
)\
"""
"""
#grid(
columns: (#{number_width}pt, 1fr, auto),
gutter: 2pt,
align(right)[#{number}],
[#{title}],
align(right)[#{duration}]
)\
"""
else
"""
#grid(
columns: (#{number_width}pt, 1fr),
gutter: 2pt,
align(right)[#{number}],
[#{title}]
)\
"""
end
end
@doc false
def layout_params(total_items, _media_count) do
Enum.find(@layout_configs, List.last(@layout_configs), fn {columns, font_size, _} ->
capacity(columns, font_size) >= total_items
end)
end
# Row height in mm: font size converted to mm (× 0.353) scaled by ~1.2 for
# ascender + descender, plus grid gutter (2pt = 0.706mm).
defp capacity(columns, font_size) do
row_height_mm = font_size * 0.353 * 1.2 + 0.706
floor(95 / row_height_mm) * columns
end
defp artist_names(record) do
@@ -80,6 +80,53 @@ defmodule MusicLibrary.Records.TracklistPdfTest do
end
end
describe "layout_params/2" do
test "single column for small track count" do
assert {1, 8, true} = TracklistPdf.layout_params(10, 1)
end
test "two columns for medium track count" do
assert {2, 8, true} = TracklistPdf.layout_params(30, 1)
end
test "scales up columns and reduces font for large track counts" do
assert {3, 7, true} = TracklistPdf.layout_params(55, 1)
assert {3, 6, true} = TracklistPdf.layout_params(80, 1)
assert {4, 6, false} = TracklistPdf.layout_params(100, 1)
assert {4, 5, false} = TracklistPdf.layout_params(120, 1)
end
test "accounts for medium headers in multi-medium releases" do
# 22 tracks + 2 medium headers = 24 items, exceeds single column capacity (23)
assert {2, 8, true} = TracklistPdf.layout_params(24, 2)
end
end
describe "generate/2 with many tracks" do
test "generates single-page PDF for large single-disc release" do
record = build_record(%{title: "Long Album", artists: [%{name: "Prolific Artist"}]})
tracks = Enum.map(1..40, &build_track(&1, "Track #{&1}", 180_000))
release = build_release([build_medium(1, tracks)])
assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release)
end
test "generates single-page PDF for large multi-disc release" do
record = build_record(%{title: "Box Set", artists: [%{name: "Band"}]})
media =
Enum.map(1..4, fn disc ->
tracks = Enum.map(1..20, &build_track(&1, "Disc #{disc} Track #{&1}", 200_000))
build_medium(disc, tracks)
end)
release = build_release(media)
assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release)
end
end
defp build_record(attrs) do
artists =
Enum.map(Map.get(attrs, :artists, []), fn a ->