From 4c6baa6e13b2cd3b5b4dd9ee1d5086b8fb1f155e Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Mon, 9 Mar 2026 14:40:02 +0000 Subject: [PATCH] Improve printout PDF layout Support multiple columns in case the tracklist is long, or there's multiple media. --- lib/music_library/records/tracklist_pdf.ex | 107 +++++++++++++----- .../records/tracklist_pdf_test.exs | 47 ++++++++ 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/lib/music_library/records/tracklist_pdf.ex b/lib/music_library/records/tracklist_pdf.ex index 54ce8d7e..22594e2b 100644 --- a/lib/music_library/records/tracklist_pdf.ex +++ b/lib/music_library/records/tracklist_pdf.ex @@ -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 diff --git a/test/music_library/records/tracklist_pdf_test.exs b/test/music_library/records/tracklist_pdf_test.exs index eee75cd6..ad5ecd25 100644 --- a/test/music_library/records/tracklist_pdf_test.exs +++ b/test/music_library/records/tracklist_pdf_test.exs @@ -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 ->