Skip to main content

chordsketch_render_pdf/
lib.rs

1//! PDF renderer for ChordPro documents.
2//!
3//! Converts a parsed ChordPro AST into a PDF document. ASCII and Latin-1
4//! characters are rendered using built-in Helvetica Type1 fonts. Characters
5//! outside the Latin-1 range (e.g. CJK, Greek, Cyrillic) are rendered with a
6//! bundled Noto Sans CJK JP subset font embedded as a CID composite font
7//! (Type0 / CIDFontType0C). Uses `flate2` for PNG image decompression.
8//!
9//! Supports multi-page output: content automatically flows to new pages when
10//! the current page overflows, and `{new_page}` / `{new_physical_page}`
11//! directives trigger explicit page breaks.
12
13use chordsketch_chordpro::ast::{
14    CommentStyle, DirectiveKind, ImageAttributes, Line, LyricsLine, Song,
15};
16use chordsketch_chordpro::canonical_chord_name;
17use chordsketch_chordpro::config::Config;
18use chordsketch_chordpro::inline_markup::TextSpan;
19use chordsketch_chordpro::notation::NotationKind;
20use chordsketch_chordpro::render_result::{
21    RenderResult, push_warning, validate_capo, validate_multiple_capo, validate_strict_key,
22};
23use chordsketch_chordpro::resolve_diagrams_instrument;
24use chordsketch_chordpro::transpose::{transpose_chord_with_style, transposed_key_prefers_flat};
25use chordsketch_chordpro::typography::{tempo_marking_for, unicode_accidentals};
26
27use flate2::Compression;
28use flate2::read::ZlibDecoder;
29use flate2::write::ZlibEncoder;
30use std::collections::BTreeMap;
31use std::io::{Read as IoRead, Write as IoWrite};
32
33// ---------------------------------------------------------------------------
34// Unicode CID font support
35// ---------------------------------------------------------------------------
36
37/// Bundled Noto Sans CJK JP subset font (OFL-licensed).
38///
39/// Covers Latin Extended A/B (U+0100–U+024F), Greek (U+0370–U+03FF),
40/// Cyrillic (U+0400–U+04FF), Hiragana (U+3040–U+309F), Katakana
41/// (U+30A0–U+30FF), and CJK Unified Ideographs (U+4E00–U+9FFF), plus
42/// CJK punctuation and fullwidth forms.  Characters in this range that are
43/// not already covered by WinAnsiEncoding are rendered using this font.
44static UNICODE_FONT_BYTES: &[u8] = include_bytes!("../assets/NotoSansCJK-subset.otf");
45
46/// Returns a reference to the parsed Unicode CID font face.
47///
48/// The face is parsed once on first access and cached for the lifetime of the
49/// process. Panics if the bundled font data is corrupt, which should never
50/// happen with the shipped asset.
51fn unicode_face() -> &'static ttf_parser::Face<'static> {
52    use std::sync::OnceLock;
53    static FACE: OnceLock<ttf_parser::Face<'static>> = OnceLock::new();
54    FACE.get_or_init(|| {
55        ttf_parser::Face::parse(UNICODE_FONT_BYTES, 0)
56            .expect("bundled NotoSansCJK-subset.otf must be a valid font face")
57    })
58}
59
60/// Extract the raw CFF table bytes from an OpenType font binary.
61///
62/// Returns `None` if `otf_bytes` is not a valid OTF file or contains no `CFF ` table.
63/// The returned slice borrows from `otf_bytes`, so its lifetime matches the input.
64fn extract_cff_table(otf_bytes: &[u8]) -> Option<&[u8]> {
65    if otf_bytes.len() < 12 {
66        return None;
67    }
68    let num_tables = u16::from_be_bytes([otf_bytes[4], otf_bytes[5]]) as usize;
69    for i in 0..num_tables {
70        let rec = 12 + i * 16;
71        if rec + 16 > otf_bytes.len() {
72            return None;
73        }
74        if &otf_bytes[rec..rec + 4] == b"CFF " {
75            let offset = u32::from_be_bytes(otf_bytes[rec + 8..rec + 12].try_into().ok()?) as usize;
76            let length =
77                u32::from_be_bytes(otf_bytes[rec + 12..rec + 16].try_into().ok()?) as usize;
78            let end = offset.checked_add(length)?;
79            if end <= otf_bytes.len() {
80                return Some(&otf_bytes[offset..end]);
81            }
82        }
83    }
84    None
85}
86
87/// Returns a reference to the raw CFF table bytes extracted from the bundled Unicode font.
88///
89/// The PDF spec requires `FontFile3` with `/Subtype /CIDFontType0C` to contain the raw
90/// CFF table, not the full OTF wrapper. This accessor extracts that table once and caches
91/// the result for the lifetime of the process.
92fn unicode_cff_bytes() -> &'static [u8] {
93    use std::sync::OnceLock;
94    static CFF: OnceLock<&'static [u8]> = OnceLock::new();
95    CFF.get_or_init(|| {
96        extract_cff_table(UNICODE_FONT_BYTES)
97            .expect("bundled NotoSansCJK-subset.otf must contain a CFF table")
98    })
99}
100
101/// Returns `true` if `c` must be rendered using the CID Unicode font.
102///
103/// Characters covered by WinAnsiEncoding (ASCII, Latin-1 Supplement
104/// U+00A0–U+00FF, and the 0x80–0x9F WinAnsi special range) use the
105/// built-in Helvetica Type1 fonts. Every other non-ASCII character is
106/// routed to the embedded CID font.
107#[must_use]
108fn needs_cid_font(c: char) -> bool {
109    let code = c as u32;
110    if code <= 0x7F {
111        return false; // ASCII: handled by Helvetica
112    }
113    if (0xA0..=0xFF).contains(&code) {
114        return false; // Latin-1 Supplement: WinAnsiEncoding octal escapes
115    }
116    winansi_byte(c).is_none() // WinAnsiEncoding 0x80–0x9F range: Helvetica
117}
118
119/// Split `text` into alternating Latin-1 and CID segments.
120///
121/// Returns a `Vec<(is_cid, segment_text)>` where `is_cid = true` means the
122/// segment should be rendered using the CID font. Adjacent characters with the
123/// same routing are merged into a single segment.
124fn text_segments(text: &str) -> Vec<(bool, String)> {
125    let mut result: Vec<(bool, String)> = Vec::new();
126    let mut current_cid = false;
127    let mut current = String::new();
128    for c in text.chars() {
129        let cid = needs_cid_font(c);
130        if !current.is_empty() && cid != current_cid {
131            result.push((current_cid, std::mem::take(&mut current)));
132        }
133        current_cid = cid;
134        current.push(c);
135    }
136    if !current.is_empty() {
137        result.push((current_cid, current));
138    }
139    result
140}
141
142/// Encode a CID text segment as a PDF hex string.
143///
144/// Each character is looked up in the Unicode font by codepoint and mapped to
145/// its Glyph ID (GID). The output is a hex string of 2-byte big-endian GIDs,
146/// suitable for use as `<GGGG…> Tj` in a PDF content stream when the current
147/// font is the Identity-H encoded CID font `/F5`.
148///
149/// Also returns a list of `(gid, char)` pairs for populating the ToUnicode CMap.
150fn encode_cid_text(text: &str) -> (String, Vec<(u16, char)>) {
151    let face = unicode_face();
152    let mut hex = String::with_capacity(text.chars().count() * 4);
153    let mut mappings: Vec<(u16, char)> = Vec::with_capacity(text.chars().count());
154    for c in text.chars() {
155        let gid = face.glyph_index(c).map(|g| g.0).unwrap_or(0);
156        hex.push_str(&format!("{:04X}", gid));
157        mappings.push((gid, c));
158    }
159    (hex, mappings)
160}
161
162/// Compute the advance width (in PDF points) of a CID text string.
163fn cid_text_width(text: &str, font_size: f32) -> f32 {
164    let face = unicode_face();
165    let units = face.units_per_em() as f32;
166    text.chars()
167        .map(|c| {
168            let gid = face.glyph_index(c).unwrap_or(ttf_parser::GlyphId(0));
169            face.glyph_hor_advance(gid).unwrap_or(1000) as f32 / units * font_size
170        })
171        .sum()
172}
173
174/// Build a PDF ToUnicode CMap stream body mapping GIDs to Unicode codepoints.
175///
176/// The CMap uses `Identity` ordering so that the GID directly identifies the
177/// character. Output is grouped in blocks of at most 100 entries as required
178/// by the PDF spec.
179fn build_to_unicode_cmap(cid_glyphs: &BTreeMap<u16, char>) -> String {
180    let mut cmap = String::new();
181    cmap.push_str("/CIDInit /ProcSet findresource begin\n");
182    cmap.push_str("12 dict begin\n");
183    cmap.push_str("begincmap\n");
184    cmap.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
185    cmap.push_str("/CMapName /Adobe-Identity-UCS def\n");
186    cmap.push_str("/CMapType 2 def\n");
187
188    // GID 0 (.notdef) must never appear in a ToUnicode CMap (PDF spec §9.10.3).
189    // Filter it here rather than at recording time so that cid_glyphs accurately
190    // reflects whether /F5 was referenced in the content stream.
191    let entries: Vec<_> = cid_glyphs.iter().filter(|&(&gid, _)| gid != 0).collect();
192    for chunk in entries.chunks(100) {
193        cmap.push_str(&format!("{} beginbfchar\n", chunk.len()));
194        for &(gid, ch) in chunk {
195            let cp = *ch as u32;
196            if cp <= 0xFFFF {
197                cmap.push_str(&format!("<{:04X}> <{:04X}>\n", gid, cp));
198            } else {
199                // Encode as UTF-16BE surrogate pair
200                let offset = cp - 0x10000;
201                let hi = 0xD800u32 + (offset >> 10);
202                let lo = 0xDC00u32 + (offset & 0x3FF);
203                cmap.push_str(&format!("<{:04X}> <{:04X}{:04X}>\n", gid, hi, lo));
204            }
205        }
206        cmap.push_str("endbfchar\n");
207    }
208
209    cmap.push_str("endcmap\n");
210    cmap.push_str("CMapName currentdict /CMap defineresource pop\n");
211    cmap.push_str("end\n"); // closes "12 dict begin"
212    cmap.push_str("end\n"); // closes "/CIDInit /ProcSet findresource begin"
213    cmap
214}
215
216/// Encode a string as a UTF-16BE PDF hex-string literal.
217///
218/// Returns a `<FEFF...>` literal: UTF-16BE bytes prefixed by a byte-order
219/// mark, hex-encoded inside angle brackets. The hex form sidesteps PDF
220/// string-escape rules for `(`, `)`, `\`, NUL and CR, so any title — ASCII
221/// or otherwise — round-trips losslessly into the `/Info` dictionary.
222///
223/// Lone surrogates cannot reach this encoder because Rust's `char` is a
224/// Unicode Scalar Value (U+D800..U+DFFF excluded by construction), which is
225/// the load-bearing safety property for the surrogate-pair branch below.
226fn pdf_title_hex_string(s: &str) -> String {
227    use std::fmt::Write as _;
228    let mut out = String::with_capacity(5 + 4 * s.len() + 1);
229    out.push_str("<FEFF");
230    for ch in s.chars() {
231        let cp = ch as u32;
232        if cp <= 0xFFFF {
233            // `write!` into a `String` is infallible.
234            let _ = write!(out, "{cp:04X}");
235        } else {
236            // Encode supplementary-plane codepoints as UTF-16 surrogate pairs.
237            let offset = cp - 0x10000;
238            let hi = 0xD800u32 + (offset >> 10);
239            let lo = 0xDC00u32 + (offset & 0x3FF);
240            let _ = write!(out, "{hi:04X}{lo:04X}");
241        }
242    }
243    out.push('>');
244    out
245}
246
247// ---------------------------------------------------------------------------
248// Formatting state
249// ---------------------------------------------------------------------------
250
251/// Tracks the current font size for an element type.
252///
253/// PDF Type1 fonts are limited to the Helvetica family, so font name changes
254/// from directives are not applicable. Size changes are applied directly.
255#[derive(Default, Clone)]
256struct PdfElementStyle {
257    size: Option<f32>,
258}
259
260/// Formatting state for PDF rendering.
261#[derive(Default, Clone)]
262struct PdfFormattingState {
263    text: PdfElementStyle,
264    chord: PdfElementStyle,
265}
266
267impl PdfFormattingState {
268    /// Apply a formatting directive, updating the appropriate style.
269    fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
270        let size_val = value
271            .as_deref()
272            .and_then(|v| v.parse::<f32>().ok())
273            .map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE));
274        match kind {
275            DirectiveKind::TextSize => self.text.size = size_val,
276            DirectiveKind::ChordSize => self.chord.size = size_val,
277            _ => {}
278        }
279    }
280
281    /// Get the effective lyrics font size.
282    fn lyrics_size(&self) -> f32 {
283        self.text.size.unwrap_or(LYRICS_SIZE)
284    }
285
286    /// Get the effective chord font size.
287    fn chord_size(&self) -> f32 {
288        self.chord.size.unwrap_or(CHORD_SIZE)
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Layout constants (units: PDF points, 1 pt = 1/72 inch)
294// ---------------------------------------------------------------------------
295
296/// A4 page width in points.
297const PAGE_W: f32 = 595.0;
298/// A4 page height in points.
299const PAGE_H: f32 = 842.0;
300/// Left margin in points.
301const MARGIN_LEFT: f32 = 56.0;
302/// Top margin (distance from top of page).
303const MARGIN_TOP: f32 = 56.0;
304/// Bottom margin — content below this Y coordinate triggers a new page.
305const MARGIN_BOTTOM: f32 = 56.0;
306/// Title font size.
307const TITLE_SIZE: f32 = 18.0;
308/// Subtitle font size.
309const SUBTITLE_SIZE: f32 = 13.0;
310/// Chord font size.
311const CHORD_SIZE: f32 = 9.0;
312/// Lyrics font size.
313const LYRICS_SIZE: f32 = 11.0;
314/// Section label font size.
315const SECTION_SIZE: f32 = 10.0;
316/// Comment font size.
317const COMMENT_SIZE: f32 = 9.0;
318/// Spacing between lines.
319const LINE_GAP: f32 = 4.0;
320/// Per-character width as fraction of font size for Helvetica.
321///
322/// Uses standard Helvetica AFM glyph widths (divided by 1000) for ASCII
323/// printable characters. Non-ASCII and control characters fall back to
324/// the average width of 0.52.
325#[must_use]
326fn char_width(c: char) -> f32 {
327    // Helvetica AFM widths for ASCII 32–126, divided by 1000.
328    #[rustfmt::skip]
329    const WIDTHS: [f32; 95] = [
330        0.278, // space
331        0.278, // !
332        0.355, // "
333        0.556, // #
334        0.556, // $
335        0.889, // %
336        0.667, // &
337        0.222, // '
338        0.333, // (
339        0.333, // )
340        0.389, // *
341        0.584, // +
342        0.278, // ,
343        0.333, // -
344        0.278, // .
345        0.278, // /
346        0.556, // 0
347        0.556, // 1
348        0.556, // 2
349        0.556, // 3
350        0.556, // 4
351        0.556, // 5
352        0.556, // 6
353        0.556, // 7
354        0.556, // 8
355        0.556, // 9
356        0.278, // :
357        0.278, // ;
358        0.584, // <
359        0.584, // =
360        0.584, // >
361        0.556, // ?
362        1.015, // @
363        0.667, // A
364        0.667, // B
365        0.722, // C
366        0.722, // D
367        0.667, // E
368        0.611, // F
369        0.778, // G
370        0.722, // H
371        0.278, // I
372        0.500, // J
373        0.667, // K
374        0.556, // L
375        0.833, // M
376        0.722, // N
377        0.778, // O
378        0.667, // P
379        0.778, // Q
380        0.722, // R
381        0.667, // S
382        0.611, // T
383        0.722, // U
384        0.667, // V
385        0.944, // W
386        0.667, // X
387        0.667, // Y
388        0.611, // Z
389        0.278, // [
390        0.278, // backslash
391        0.278, // ]
392        0.469, // ^
393        0.556, // _
394        0.333, // `
395        0.556, // a
396        0.556, // b
397        0.500, // c
398        0.556, // d
399        0.556, // e
400        0.278, // f
401        0.556, // g
402        0.556, // h
403        0.222, // i
404        0.222, // j
405        0.500, // k
406        0.222, // l
407        0.833, // m
408        0.556, // n
409        0.556, // o
410        0.556, // p
411        0.556, // q
412        0.333, // r
413        0.500, // s
414        0.278, // t
415        0.556, // u
416        0.500, // v
417        0.722, // w
418        0.500, // x
419        0.500, // y
420        0.500, // z
421        0.334, // {
422        0.260, // |
423        0.334, // }
424        0.584, // ~
425    ];
426    let code = c as u32;
427    if (32..=126).contains(&code) {
428        return WIDTHS[(code - 32) as usize];
429    }
430    // Non-ASCII characters: use the CID font metrics if available, otherwise
431    // fall back to an approximation of Helvetica's average character width.
432    if needs_cid_font(c) {
433        let face = unicode_face();
434        let gid = face.glyph_index(c).unwrap_or(ttf_parser::GlyphId(0));
435        return face.glyph_hor_advance(gid).unwrap_or(1000) as f32 / face.units_per_em() as f32;
436    }
437    0.52 // WinAnsiEncoding non-ASCII (Latin-1 Supplement, WinAnsi 0x80–0x9F)
438}
439
440/// Compute text width in points for a string at the given font size.
441#[must_use]
442fn text_width(s: &str, font_size: f32) -> f32 {
443    s.chars().map(|c| char_width(c) * font_size).sum()
444}
445
446/// Table of Contents entry font size.
447const TOC_ENTRY_SIZE: f32 = 11.0;
448/// Maximum number of pages a single document can contain.
449/// Prevents resource exhaustion from malicious input.
450const MAX_PAGES: usize = 10_000;
451/// Maximum number of columns allowed.
452/// Prevents degenerate layout and f32 overflow from extreme values.
453const MAX_COLUMNS: u32 = 32;
454/// Minimum font size (in points) accepted from user directives.
455const MIN_FONT_SIZE: f32 = 0.5;
456/// Maximum font size (in points) accepted from user directives.
457const MAX_FONT_SIZE: f32 = 200.0;
458/// Maximum image file size in bytes (50 MB).
459const MAX_IMAGE_FILE_SIZE: u64 = 50 * 1024 * 1024;
460/// Maximum native image dimension in pixels.  JPEG headers can report up to
461/// 65535 pixels; without explicit width/height/scale this maps 1:1 to PDF
462/// points, producing a ~23-metre image.  Clamping to 10 000 keeps the default
463/// within a few A0-sized pages while still being generous for real photographs.
464const MAX_IMAGE_PIXELS: u32 = 10_000;
465/// Maximum number of images that can be embedded in a single PDF document.
466/// Prevents memory exhaustion from documents with many large image directives.
467const MAX_IMAGES: usize = 1_000;
468/// Maximum number of chorus recall directives allowed per song.
469/// Prevents output amplification from malicious inputs with many `{chorus}` lines.
470const MAX_CHORUS_RECALLS: usize = 1000;
471
472/// Maximum number of warnings the renderer accumulates per render pass.
473/// Re-exported from `chordsketch-chordpro::render_result` so callers can
474/// keep importing `chordsketch_render_pdf::MAX_WARNINGS` unchanged
475/// (issue #1874).
476pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
477
478// ---------------------------------------------------------------------------
479// Public API
480// ---------------------------------------------------------------------------
481
482/// Render a [`Song`] AST to PDF bytes.
483///
484/// Returns a complete PDF document as `Vec<u8>` using built-in Helvetica
485/// fonts. No external font files are required.
486///
487/// The `{chorus}` directive recalls the most recently defined chorus section,
488/// re-rendering its content with a "Chorus" label.
489///
490/// Long songs automatically flow across multiple pages. The `{new_page}` and
491/// `{new_physical_page}` directives trigger explicit page breaks.
492#[must_use]
493pub fn render_song(song: &Song) -> Vec<u8> {
494    render_song_with_transpose(song, 0, &Config::defaults())
495}
496
497/// Render a [`Song`] AST to PDF bytes with an additional CLI transposition offset.
498///
499/// The `cli_transpose` parameter is added to any in-file `{transpose}` directive
500/// values, allowing the CLI `--transpose` flag to combine with in-file directives.
501///
502/// Warnings are printed to stderr via `eprintln!`. Use
503/// [`render_song_with_warnings`] to capture them programmatically.
504#[must_use]
505pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> Vec<u8> {
506    let result = render_song_with_warnings(song, cli_transpose, config);
507    for w in &result.warnings {
508        eprintln!("warning: {w}");
509    }
510    result.output
511}
512
513/// Render a [`Song`] AST to PDF bytes, returning warnings programmatically.
514///
515/// This is the structured variant of [`render_song_with_transpose`]. Instead
516/// of printing warnings to stderr, they are collected into
517/// [`RenderResult::warnings`].
518#[must_use = "caller must check warnings in the returned RenderResult"]
519pub fn render_song_with_warnings(
520    song: &Song,
521    cli_transpose: i8,
522    config: &Config,
523) -> RenderResult<Vec<u8>> {
524    let mut warnings = Vec::new();
525    // Apply song-level config overrides before creating the document.
526    let song_overrides = song.config_overrides();
527    let song_config;
528    let effective_config = if song_overrides.is_empty() {
529        config
530    } else {
531        song_config = config
532            .clone()
533            .with_song_overrides(&song_overrides, &mut warnings);
534        &song_config
535    };
536    let mut doc = PdfDocument::from_config_with_warnings(effective_config, &mut warnings);
537    // Mirror upstream ChordPro R6.101.0: default PDF /Title to the song's
538    // {title}, if any. Whitespace-only and missing titles are normalised by
539    // `set_doc_title` to "no /Info object emitted".
540    doc.set_doc_title(song.metadata.title.as_deref());
541    render_song_into_doc(
542        song,
543        cli_transpose,
544        effective_config,
545        &mut doc,
546        &mut warnings,
547    );
548    RenderResult::with_warnings(doc.build_pdf(), warnings)
549}
550
551/// Render multiple [`Song`]s into a single multi-page PDF document.
552///
553/// Each song starts on a new page.
554#[must_use]
555pub fn render_songs(songs: &[Song]) -> Vec<u8> {
556    render_songs_with_transpose(songs, 0, &Config::defaults())
557}
558
559/// Render multiple [`Song`]s into a single PDF with transposition.
560///
561/// Each song starts on a new page (except the first). When there are two or
562/// more songs, a Table of Contents page is prepended with song titles and
563/// page numbers.
564///
565/// Warnings are printed to stderr via `eprintln!`. Use
566/// [`render_songs_with_warnings`] to capture them programmatically.
567#[must_use]
568pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> Vec<u8> {
569    let result = render_songs_with_warnings(songs, cli_transpose, config);
570    for w in &result.warnings {
571        eprintln!("warning: {w}");
572    }
573    result.output
574}
575
576/// Push a `(title, page)` tuple into the ToC entry list, skipping the
577/// candidate when it equals the previous entry (adjacent-only dedup).
578///
579/// Mirrors upstream ChordPro `(PDF) Dedup lines in tables of contents
580/// and outlines.` (commit `55398859`, R6.100.0). The Perl implementation
581/// guards `push @{ $song->{body} }, { ... }` with
582/// `unless %prev && $prev{title} eq $title && $prev{pageno} eq $pageno`,
583/// where `%prev` only retains the immediately preceding entry — so a
584/// non-adjacent repeat is intentionally NOT deduped.
585fn push_toc_entry(entries: &mut Vec<(String, usize)>, title: String, page: usize) {
586    let candidate = (title, page);
587    if entries.last() != Some(&candidate) {
588        entries.push(candidate);
589    }
590}
591
592/// Render multiple [`Song`]s into a single PDF, returning warnings programmatically.
593///
594/// This is the structured variant of [`render_songs_with_transpose`]. Instead
595/// of printing warnings to stderr, they are collected into
596/// [`RenderResult::warnings`].
597///
598/// # PDF `/Title` metadata
599///
600/// When `songs.len() >= 2`, the produced PDF intentionally omits the `/Info`
601/// dictionary. chordsketch has no songbook abstraction, so there is no
602/// aggregate title to embed, and defaulting `/Title` to the first song's
603/// `{title}` would silently lie in viewers' title bars (and would change
604/// when songs are reordered). Upstream ChordPro R6.101.0's "if any" clause
605/// authorises omission when no songbook title exists.
606///
607/// When `songs.len() == 1`, delegates to [`render_song_with_warnings`],
608/// which does emit `/Title` from the single song's `{title}`. When
609/// `songs.len() == 0`, returns an empty result with no PDF bytes (and
610/// therefore no `/Info` either).
611#[must_use = "caller must check warnings in the returned RenderResult"]
612pub fn render_songs_with_warnings(
613    songs: &[Song],
614    cli_transpose: i8,
615    config: &Config,
616) -> RenderResult<Vec<u8>> {
617    let mut warnings = Vec::new();
618
619    if songs.len() <= 1 {
620        return songs
621            .first()
622            .map(|s| render_song_with_warnings(s, cli_transpose, config))
623            .unwrap_or_else(|| RenderResult::with_warnings(Vec::new(), warnings));
624    }
625
626    // Phase 1: render all songs and record which page each starts on.
627    let mut body_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
628    let mut toc_entries: Vec<(String, usize)> = Vec::new(); // (title, page_index)
629
630    for (i, song) in songs.iter().enumerate() {
631        if i > 0 {
632            body_doc.new_page();
633        }
634        // Apply per-song config overrides (e.g. pdf.margins.*), always
635        // resetting to the base-config margins first so that song N's
636        // overrides do not bleed into song N+1.
637        let song_overrides = song.config_overrides();
638        let song_config;
639        let effective_config = if song_overrides.is_empty() {
640            config
641        } else {
642            song_config = config
643                .clone()
644                .with_song_overrides(&song_overrides, &mut warnings);
645            &song_config
646        };
647        body_doc.reset_margins_from_config(effective_config, &mut warnings);
648        let start_page = body_doc.page_count();
649        let title = song
650            .metadata
651            .title
652            .as_deref()
653            .unwrap_or("Untitled")
654            .to_string();
655        push_toc_entry(&mut toc_entries, title, start_page);
656        render_song_into_doc(
657            song,
658            cli_transpose,
659            effective_config,
660            &mut body_doc,
661            &mut warnings,
662        );
663    }
664
665    // Phase 2: generate ToC pages.
666    let mut toc_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
667    toc_doc.text("Table of Contents", Font::HelveticaBold, TITLE_SIZE);
668    toc_doc.newline(TITLE_SIZE + LINE_GAP * 2.0);
669
670    let toc_page_count = {
671        for (title, body_page_idx) in &toc_entries {
672            toc_doc.ensure_space(TOC_ENTRY_SIZE + LINE_GAP);
673            // Page number = toc pages + body page index
674            // (we'll calculate toc_page_count after this loop)
675            let page_num_placeholder = body_page_idx + 1; // 1-based, offset added later
676            let entry_text = format!("{title}  ......  {page_num_placeholder}");
677            toc_doc.text(&entry_text, Font::Helvetica, TOC_ENTRY_SIZE);
678            toc_doc.newline(TOC_ENTRY_SIZE + LINE_GAP);
679        }
680        toc_doc.page_count()
681    };
682
683    // Phase 3: rebuild ToC with correct page numbers (offset by toc_page_count).
684    let mut toc_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
685    toc_doc.text("Table of Contents", Font::HelveticaBold, TITLE_SIZE);
686    toc_doc.newline(TITLE_SIZE + LINE_GAP * 2.0);
687
688    for (title, body_page_idx) in &toc_entries {
689        toc_doc.ensure_space(TOC_ENTRY_SIZE + LINE_GAP);
690        let page_num = body_page_idx + toc_page_count; // 1-based page number
691        let x = toc_doc.margin_left();
692        let y = toc_doc.y();
693
694        // Render title on the left
695        toc_doc.text_at(title, Font::Helvetica, TOC_ENTRY_SIZE, x, y);
696
697        // Render page number right-aligned
698        let num_str = page_num.to_string();
699        let num_width = text_width(&num_str, TOC_ENTRY_SIZE);
700        let right_x = PAGE_W - toc_doc.margin_right - num_width;
701        toc_doc.text_at(&num_str, Font::Helvetica, TOC_ENTRY_SIZE, right_x, y);
702
703        toc_doc.newline(TOC_ENTRY_SIZE + LINE_GAP);
704    }
705
706    // Phase 4: combine ToC pages + body pages.
707    let mut combined = toc_doc;
708    // Merge CID glyph map from body into combined so build_pdf() can emit the full
709    // ToUnicode CMap and FontDescriptor for all glyphs referenced in the document.
710    for (gid, ch) in body_doc.cid_glyphs.iter() {
711        combined.cid_glyphs.entry(*gid).or_insert(*ch);
712    }
713    for page_ops in body_doc.take_pages() {
714        combined.push_page(page_ops);
715    }
716
717    RenderResult::with_warnings(combined.build_pdf(), warnings)
718}
719
720/// Render a single song's content into an existing [`PdfDocument`].
721///
722/// This is the shared implementation used by both [`render_song_with_transpose`]
723/// and [`render_songs_with_transpose`]. It does not call `build_pdf`; the caller
724/// is responsible for finalising the document.
725fn render_song_into_doc(
726    song: &Song,
727    cli_transpose: i8,
728    config: &Config,
729    doc: &mut PdfDocument,
730    warnings: &mut Vec<String>,
731) {
732    // Extract song-level transpose delta from {+config.settings.transpose}.
733    // The base config transpose is already folded into cli_transpose by the caller.
734    let song_overrides = song.config_overrides();
735    let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
736    let (combined_transpose, _) =
737        chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
738    let mut transpose_offset: i8 = combined_transpose;
739    let mut fmt_state = PdfFormattingState::default();
740
741    // Read configurable frets_shown for chord diagrams.
742    let diagram_frets = config.get_path("diagrams.frets").as_f64().map_or(
743        chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
744        |n| (n as usize).max(1),
745    );
746
747    validate_capo(&song.metadata, warnings);
748    validate_multiple_capo(song, warnings);
749    validate_strict_key(&song.metadata, config, warnings);
750
751    // Title
752    if let Some(title) = &song.metadata.title {
753        doc.text(title, Font::HelveticaBold, TITLE_SIZE);
754        doc.newline(TITLE_SIZE + LINE_GAP);
755    }
756    // Subtitles
757    for subtitle in &song.metadata.subtitles {
758        doc.text(subtitle, Font::Helvetica, SUBTITLE_SIZE);
759        doc.newline(SUBTITLE_SIZE + LINE_GAP);
760    }
761
762    // Controls whether chord diagrams are rendered. Set by {diagrams: off/on}.
763    let mut show_diagrams = true;
764
765    // Instrument for the auto-inject diagram block at end of song.
766    let default_instrument = config
767        .get_path("diagrams.instrument")
768        .as_str()
769        .map(str::to_ascii_lowercase)
770        .unwrap_or_else(|| "guitar".to_string());
771    let mut auto_diagrams_instrument: Option<String> = None;
772    // Canonical chord names (sharp form) that were actually rendered inline via
773    // {define} while show_diagrams was true.  Used to exclude them from the
774    // auto-inject grid and avoid duplicates.
775    let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
776
777    // Stores the AST lines of the most recently defined chorus body for replay.
778    let mut chorus_body: Vec<Line> = Vec::new();
779    // Temporary buffer for collecting chorus content while inside a chorus section.
780    let mut chorus_buf: Option<Vec<Line>> = None;
781    let mut saved_fmt_state: Option<PdfFormattingState> = None;
782    let mut chorus_recall_count: usize = 0;
783
784    // #1825: the HTML renderer invokes external tools (abc2svg / lilypond /
785    // musescore) to render notation blocks into embedded SVG. The PDF
786    // renderer currently has no SVG-to-PDF pipeline, so it can't render
787    // the inner content. Rather than spilling the raw notation source
788    // into the PDF as plain text (which is visually noise and almost
789    // certainly what nobody wants), we skip the body of the block and
790    // emit exactly one structured warning per notation kind it sees.
791    // The section label (already produced by `render_section_label`) is
792    // retained so the reader can at least see where the notation would
793    // have been.
794    //
795    // When `in_notation_block` is `Some(kind)` the main loop is inside
796    // a `{start_of_<kind>} … {end_of_<kind>}` pair and must discard
797    // every non-End-directive line until the matching End.
798    let mut in_notation_block: Option<NotationKind> = None;
799
800    // True while the loop is inside a `{start_of_tab}`, `{start_of_grid}`,
801    // or `{start_of_textblock}` block. Body lines in those sections render
802    // through `render_lyrics` with `verbatim = true` so column alignment
803    // (fret rows, chord-grid bars) is preserved by Courier. The body of
804    // ABC/Lilypond/SVG/MusicXML blocks is suppressed via `in_notation_block`
805    // (#1825), so they don't need the same treatment here.
806    let mut in_verbatim_section = false;
807
808    for line in &song.lines {
809        // If we are inside a notation block, discard every line until
810        // the matching `EndOf…` directive is seen. The section label
811        // has already been emitted at StartOf…; the body cannot be
812        // rendered (no SVG→PDF pipeline yet — see #1825), so spilling
813        // the raw notation source into the PDF would just be noise.
814        if let Some(kind) = in_notation_block {
815            match line {
816                Line::Directive(d) if kind.is_end_directive(&d.kind) => {
817                    in_notation_block = None;
818                }
819                _ => {}
820            }
821            continue;
822        }
823        match line {
824            Line::Lyrics(lyrics) => {
825                if let Some(buf) = chorus_buf.as_mut() {
826                    buf.push(line.clone());
827                }
828                let prefer_flat = transposed_key_prefers_flat(&song.metadata, transpose_offset);
829                render_lyrics(
830                    lyrics,
831                    transpose_offset,
832                    prefer_flat,
833                    &fmt_state,
834                    doc,
835                    in_verbatim_section,
836                );
837            }
838            // ChordPro spec: `{key}` / `{tempo}` / `{time}` are
839            // `[Nx] [Pos]`; render a small italic line at the
840            // directive's position so the *position* aspect is
841            // visible (Phase B of #2454, sister-site to
842            // `crates/render-html/src/lib.rs` and
843            // `crates/render-text/src/lib.rs`).
844            Line::Directive(d)
845                if d.kind.is_metadata()
846                    && matches!(
847                        d.kind,
848                        DirectiveKind::Key | DirectiveKind::Tempo | DirectiveKind::Time
849                    ) =>
850            {
851                if let Some(value) = d.value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
852                    // Sister-site comment: the HTML / React surfaces
853                    // drop the `Tempo:` text label because the
854                    // metronome glyph carries the signal — PDF
855                    // doesn't render the glyph, so the label is
856                    // intentionally retained here. Same for
857                    // `Key:` / `Time:`.
858                    let line = match d.kind {
859                        DirectiveKind::Key => format!("Key: {}", unicode_accidentals(value)),
860                        DirectiveKind::Tempo => {
861                            // Append the Italian tempo marking when
862                            // the BPM matches a conventional band.
863                            let marking = value
864                                .parse::<f32>()
865                                .ok()
866                                .and_then(tempo_marking_for)
867                                .map(|m| format!(" ({m})"))
868                                .unwrap_or_default();
869                            format!("Tempo: {value} BPM{marking}")
870                        }
871                        DirectiveKind::Time => format!("Time: {value}"),
872                        _ => unreachable!(),
873                    };
874                    doc.text(&line, Font::HelveticaOblique, COMMENT_SIZE);
875                    doc.newline(COMMENT_SIZE + LINE_GAP);
876                }
877            }
878            Line::Directive(d) if !d.kind.is_metadata() => {
879                if d.kind == DirectiveKind::Diagrams {
880                    auto_diagrams_instrument =
881                        resolve_diagrams_instrument(d.value.as_deref(), &default_instrument);
882                    show_diagrams = auto_diagrams_instrument.is_some();
883                    continue;
884                }
885                if d.kind == DirectiveKind::NoDiagrams {
886                    show_diagrams = false;
887                    auto_diagrams_instrument = None;
888                    continue;
889                }
890                if d.kind == DirectiveKind::Transpose {
891                    // A missing or empty value silently resets to 0; only a
892                    // non-empty value that cannot be parsed as i8 emits a warning.
893                    let file_offset: i8 = match d.value.as_deref() {
894                        None | Some("") => 0,
895                        Some(raw) => match raw.parse() {
896                            Ok(v) => v,
897                            Err(_) => {
898                                push_warning(
899                                    warnings,
900                                    format!(
901                                        "{{transpose}} value {raw:?} cannot be \
902                                         parsed as i8, ignored (using 0)"
903                                    ),
904                                );
905                                0
906                            }
907                        },
908                    };
909                    let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
910                        file_offset,
911                        cli_transpose,
912                    );
913                    if saturated {
914                        push_warning(
915                            warnings,
916                            format!(
917                                "transpose offset {file_offset} + {cli_transpose} \
918                                 exceeds i8 range, clamped to {combined}"
919                            ),
920                        );
921                    }
922                    transpose_offset = combined;
923                    continue;
924                }
925                if d.kind.is_font_size_color() {
926                    fmt_state.apply(&d.kind, &d.value);
927                    continue;
928                }
929                // #1825 — Notation blocks: emit the section label
930                // (so readers see where the block would have been),
931                // push a structured warning, render a short
932                // placeholder line, and enter skip-until-end mode so
933                // the body source does not land in the PDF as plain
934                // text.
935                if let Some(kind) = NotationKind::from_start_directive(&d.kind) {
936                    render_section_label(d, doc);
937                    let label = kind.label();
938                    let tag = kind.tag();
939                    push_warning(
940                        warnings,
941                        format!(
942                            "PDF renderer does not support {label} blocks; body of the \
943                             `{{start_of_{tag}}} … {{end_of_{tag}}}` section has been \
944                             omitted. Use the HTML renderer for full {label} support.",
945                        ),
946                    );
947                    let placeholder = format!(
948                        "[{} block omitted — use the HTML renderer to view it]",
949                        label
950                    );
951                    doc.ensure_space(LYRICS_SIZE + LINE_GAP);
952                    doc.text(&placeholder, Font::HelveticaOblique, LYRICS_SIZE);
953                    doc.newline(LYRICS_SIZE + LINE_GAP);
954                    in_notation_block = Some(kind);
955                    continue;
956                }
957                match &d.kind {
958                    DirectiveKind::StartOfChorus => {
959                        render_section_label(d, doc);
960                        chorus_buf = Some(Vec::new());
961                        saved_fmt_state = Some(fmt_state.clone());
962                    }
963                    DirectiveKind::EndOfChorus => {
964                        if let Some(buf) = chorus_buf.take() {
965                            chorus_body = buf;
966                        }
967                        if let Some(saved) = saved_fmt_state.take() {
968                            fmt_state = saved;
969                        }
970                    }
971                    DirectiveKind::Chorus => {
972                        if chorus_recall_count < MAX_CHORUS_RECALLS {
973                            let prefer_flat =
974                                transposed_key_prefers_flat(&song.metadata, transpose_offset);
975                            render_chorus_recall(
976                                &d.value,
977                                &ChorusRecallCtx {
978                                    chorus_body: &chorus_body,
979                                    transpose_offset,
980                                    prefer_flat,
981                                    fmt_state: &fmt_state,
982                                    show_diagrams,
983                                    diagram_frets,
984                                },
985                                doc,
986                            );
987                            chorus_recall_count += 1;
988                        } else if chorus_recall_count == MAX_CHORUS_RECALLS {
989                            push_warning(
990                                warnings,
991                                format!(
992                                    "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
993                                     further recalls suppressed"
994                                ),
995                            );
996                            chorus_recall_count += 1;
997                        }
998                    }
999                    // All page control directives ({new_page}, {new_physical_page},
1000                    // {column_break}, {columns}) are intentionally excluded from the
1001                    // chorus buffer. These affect global page/column layout, and
1002                    // replaying them during {chorus} recall would produce unexpected
1003                    // layout changes (e.g., duplicate page breaks, column resets).
1004                    DirectiveKind::NewPage => {
1005                        doc.new_page();
1006                    }
1007                    DirectiveKind::NewPhysicalPage => {
1008                        doc.new_page();
1009                        // In duplex printing, recto pages are odd-numbered
1010                        // (1, 3, 5, …).  If the new page is even (verso),
1011                        // insert a blank page so the next content starts on
1012                        // a recto page.
1013                        if doc.page_count() % 2 == 0 {
1014                            doc.new_page();
1015                        }
1016                    }
1017                    DirectiveKind::Columns => {
1018                        // Clamp to 1..=MAX_COLUMNS to prevent degenerate layout.
1019                        // Parsing as u32 already rejects non-numeric and negative input;
1020                        // explicit clamping here mirrors the HTML renderer for parity and
1021                        // makes the constraint visible at the call site.
1022                        let n: u32 = d
1023                            .value
1024                            .as_deref()
1025                            .and_then(|v| v.trim().parse().ok())
1026                            .unwrap_or(1)
1027                            .clamp(1, MAX_COLUMNS);
1028                        doc.set_columns(n);
1029                    }
1030                    DirectiveKind::ColumnBreak => {
1031                        doc.column_break();
1032                    }
1033                    // Verbatim sections (#1825 covers ABC/Lilypond/SVG/MusicXML
1034                    // separately via `in_notation_block`; tab/grid/textblock
1035                    // bodies still flow through the lyrics path and need the
1036                    // mono-font flag flipped so column alignment survives).
1037                    DirectiveKind::StartOfTab
1038                    | DirectiveKind::StartOfGrid
1039                    | DirectiveKind::StartOfTextblock => {
1040                        if let Some(buf) = chorus_buf.as_mut() {
1041                            buf.push(line.clone());
1042                        }
1043                        in_verbatim_section = true;
1044                        render_directive(d, show_diagrams, diagram_frets, doc);
1045                    }
1046                    DirectiveKind::EndOfTab
1047                    | DirectiveKind::EndOfGrid
1048                    | DirectiveKind::EndOfTextblock => {
1049                        if let Some(buf) = chorus_buf.as_mut() {
1050                            buf.push(line.clone());
1051                        }
1052                        in_verbatim_section = false;
1053                    }
1054                    _ => {
1055                        if let Some(buf) = chorus_buf.as_mut() {
1056                            buf.push(line.clone());
1057                        }
1058                        // Track {define} chords that are rendered inline so the
1059                        // auto-inject grid can skip them (dedup for #1211/#1245/#1246).
1060                        if d.kind == DirectiveKind::Define && show_diagrams {
1061                            if let Some(ref val) = d.value {
1062                                let name =
1063                                    chordsketch_chordpro::ast::ChordDefinition::parse_value(val)
1064                                        .name;
1065                                if !name.is_empty() {
1066                                    inline_defined.insert(canonical_chord_name(&name));
1067                                }
1068                            }
1069                        }
1070                        render_directive(d, show_diagrams, diagram_frets, doc);
1071                    }
1072                }
1073            }
1074            Line::Comment(style, text) => {
1075                if let Some(buf) = chorus_buf.as_mut() {
1076                    buf.push(line.clone());
1077                }
1078                render_comment(*style, text, doc);
1079            }
1080            Line::Empty => {
1081                if let Some(buf) = chorus_buf.as_mut() {
1082                    buf.push(line.clone());
1083                }
1084                doc.newline(LINE_GAP * 2.0);
1085            }
1086            _ => {}
1087        }
1088    }
1089
1090    // Auto-inject diagram block when {diagrams} (or {diagrams: piano/guitar/ukulele}) was seen.
1091    if let Some(ref instrument) = auto_diagrams_instrument {
1092        // Skip chords rendered inline via {define} while show_diagrams was true.
1093        let chord_names: Vec<String> = song
1094            .used_chord_names()
1095            .into_iter()
1096            .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
1097            .collect();
1098
1099        if instrument == "piano" {
1100            let kbd_defines = song.keyboard_defines();
1101            for name in chord_names {
1102                if let Some(voicing) =
1103                    chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
1104                {
1105                    render_keyboard_diagram_pdf(&voicing, doc);
1106                }
1107            }
1108        } else {
1109            let defines = song.fretted_defines();
1110            for name in chord_names {
1111                if let Some(diagram) =
1112                    chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, diagram_frets)
1113                {
1114                    render_chord_diagram_pdf(&diagram, doc);
1115                }
1116            }
1117        }
1118    }
1119}
1120
1121/// Parse and render a ChordPro source string to PDF bytes.
1122#[must_use = "parse errors should be handled"]
1123pub fn try_render(input: &str) -> Result<Vec<u8>, chordsketch_chordpro::ParseError> {
1124    let song = chordsketch_chordpro::parse(input)?;
1125    Ok(render_song(&song))
1126}
1127
1128// ---------------------------------------------------------------------------
1129// Content builders
1130// ---------------------------------------------------------------------------
1131
1132fn render_lyrics(
1133    lyrics: &LyricsLine,
1134    transpose_offset: i8,
1135    prefer_flat: bool,
1136    fmt_state: &PdfFormattingState,
1137    doc: &mut PdfDocument,
1138    verbatim: bool,
1139) {
1140    let has_markup = lyrics.segments.iter().any(|s| s.has_markup());
1141    let lyrics_size = fmt_state.lyrics_size();
1142    let chord_size = fmt_state.chord_size();
1143    // Verbatim sections (`{start_of_tab}`, `{start_of_grid}`,
1144    // `{start_of_textblock}`) carry whitespace-significant content where
1145    // column alignment matters; switch to Courier so fret diagrams and
1146    // chord-grid bars stay aligned in the rendered PDF.
1147    let body_font = if verbatim {
1148        Font::Courier
1149    } else {
1150        Font::Helvetica
1151    };
1152
1153    if !lyrics.has_chords() {
1154        doc.ensure_space(lyrics_size + LINE_GAP);
1155        if has_markup {
1156            render_lyrics_spans(lyrics, lyrics_size, doc);
1157        } else {
1158            doc.text(&lyrics.text(), body_font, lyrics_size);
1159        }
1160        doc.newline(lyrics_size + LINE_GAP);
1161        return;
1162    }
1163
1164    // Need space for chord row + lyrics row
1165    doc.ensure_space(chord_size + 2.0 + lyrics_size + LINE_GAP);
1166
1167    // Chord row
1168    let mut x = doc.margin_left();
1169    let start_y = doc.y();
1170    for seg in &lyrics.segments {
1171        // Compute the display name once per segment, reusing it for both
1172        // rendering and width measurement to avoid duplicate transposition.
1173        let chord_display: Option<String> = seg.chord.as_ref().map(|c| {
1174            if transpose_offset != 0 {
1175                transpose_chord_with_style(c, transpose_offset, prefer_flat)
1176                    .display_name()
1177                    .to_string()
1178            } else {
1179                c.display_name().to_string()
1180            }
1181        });
1182        if let Some(ref name) = chord_display {
1183            doc.text_at(name, Font::HelveticaBold, chord_size, x, start_y);
1184        }
1185        let text_w = text_width(&seg.text, lyrics_size);
1186        let chord_w = chord_display
1187            .as_ref()
1188            .map_or(0.0, |name| text_width(name, chord_size) + 2.0);
1189        x += text_w.max(chord_w);
1190    }
1191
1192    // Lyrics row
1193    doc.advance_y(chord_size + 2.0);
1194    if has_markup {
1195        render_lyrics_spans(lyrics, lyrics_size, doc);
1196    } else {
1197        doc.text(&lyrics.text(), Font::Helvetica, lyrics_size);
1198    }
1199    doc.newline(lyrics_size + LINE_GAP);
1200}
1201
1202/// Render lyrics line with inline markup using font changes.
1203///
1204/// Walks the span tree for each segment, switching between Helvetica,
1205/// HelveticaBold, HelveticaOblique, and HelveticaBoldOblique as needed.
1206fn render_lyrics_spans(lyrics: &LyricsLine, font_size: f32, doc: &mut PdfDocument) {
1207    let clip = doc.num_columns > 1;
1208    let col_right = if clip {
1209        doc.margin_left() + doc.column_width()
1210    } else {
1211        0.0
1212    };
1213    let mut x = doc.margin_left();
1214    let y = doc.y();
1215    if clip {
1216        let clip_w = (col_right - x).max(0.0);
1217        let ops = doc.current_page_mut();
1218        ops.push("q".to_string());
1219        ops.push(format!(
1220            "{} {} {} {} re W n",
1221            fmt_f32(x),
1222            fmt_f32(0.0),
1223            fmt_f32(clip_w),
1224            fmt_f32(PAGE_H)
1225        ));
1226    }
1227    for seg in &lyrics.segments {
1228        if seg.has_markup() {
1229            x = render_span_list(&seg.spans, doc, x, y, font_size, false, false);
1230        } else {
1231            doc.text_at_raw(&seg.text, Font::Helvetica, font_size, x, y);
1232            x += text_width(&seg.text, font_size);
1233        }
1234    }
1235    if clip {
1236        let ops = doc.current_page_mut();
1237        ops.push("Q".to_string());
1238    }
1239}
1240
1241/// Recursively render a list of [`TextSpan`]s at the given (x, y) position.
1242///
1243/// Returns the new X position after all text has been emitted.
1244fn render_span_list(
1245    spans: &[TextSpan],
1246    doc: &mut PdfDocument,
1247    mut x: f32,
1248    y: f32,
1249    font_size: f32,
1250    bold: bool,
1251    italic: bool,
1252) -> f32 {
1253    for span in spans {
1254        match span {
1255            TextSpan::Plain(text) => {
1256                let font = match (bold, italic) {
1257                    (true, true) => Font::HelveticaBoldOblique,
1258                    (true, false) => Font::HelveticaBold,
1259                    (false, true) => Font::HelveticaOblique,
1260                    (false, false) => Font::Helvetica,
1261                };
1262                doc.text_at_raw(text, font, font_size, x, y);
1263                x += text_width(text, font_size);
1264            }
1265            TextSpan::Bold(children) => {
1266                x = render_span_list(children, doc, x, y, font_size, true, italic);
1267            }
1268            TextSpan::Italic(children) => {
1269                x = render_span_list(children, doc, x, y, font_size, bold, true);
1270            }
1271            TextSpan::Highlight(children) | TextSpan::Comment(children) => {
1272                // Highlight/comment: render children with current style
1273                // (no distinct visual in basic PDF)
1274                x = render_span_list(children, doc, x, y, font_size, bold, italic);
1275            }
1276            TextSpan::Span(attrs, children) => {
1277                // Apply weight/style from span attributes
1278                let span_bold = bold
1279                    || attrs
1280                        .weight
1281                        .as_deref()
1282                        .is_some_and(|w| w.eq_ignore_ascii_case("bold"));
1283                let span_italic = italic
1284                    || attrs
1285                        .style
1286                        .as_deref()
1287                        .is_some_and(|s| s.eq_ignore_ascii_case("italic"));
1288                x = render_span_list(children, doc, x, y, font_size, span_bold, span_italic);
1289            }
1290        }
1291    }
1292    x
1293}
1294
1295/// Render the section label for a start-of-section directive.
1296fn render_section_label(directive: &chordsketch_chordpro::ast::Directive, doc: &mut PdfDocument) {
1297    let label: Option<String> = match &directive.kind {
1298        DirectiveKind::StartOfChorus => Some("Chorus".to_string()),
1299        DirectiveKind::StartOfVerse => Some("Verse".to_string()),
1300        DirectiveKind::StartOfBridge => Some("Bridge".to_string()),
1301        DirectiveKind::StartOfTab => Some("Tab".to_string()),
1302        DirectiveKind::StartOfGrid => Some("Grid".to_string()),
1303        DirectiveKind::StartOfAbc => Some("ABC".to_string()),
1304        DirectiveKind::StartOfLy => Some("Lilypond".to_string()),
1305        DirectiveKind::StartOfSvg => Some("SVG".to_string()),
1306        DirectiveKind::StartOfTextblock => Some("Textblock".to_string()),
1307        DirectiveKind::StartOfMusicxml => Some("MusicXML".to_string()),
1308        DirectiveKind::StartOfSection(section_name) => {
1309            Some(chordsketch_chordpro::capitalize(section_name))
1310        }
1311        _ => None,
1312    };
1313    if let Some(label) = label {
1314        // For grid sections the `value` may carry an attribute
1315        // payload (`shape="..." label="Intro"`) — prefer the
1316        // structured `label="..."` attribute, otherwise pass
1317        // through plain colon-form labels. Suppress raw
1318        // attribute payloads (any value containing `=`).
1319        // Sister-site to the HTML and text renderers.
1320        let resolved_value: Option<String> = if matches!(directive.kind, DirectiveKind::StartOfGrid)
1321        {
1322            directive.value.as_ref().and_then(|v| {
1323                if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
1324                    Some(label)
1325                } else if !v.contains('=') {
1326                    Some(v.clone())
1327                } else {
1328                    None
1329                }
1330            })
1331        } else {
1332            directive.value.clone()
1333        };
1334        let text = match resolved_value {
1335            Some(v) if !v.is_empty() => format!("{label}: {v}"),
1336            _ => label,
1337        };
1338        doc.ensure_space(SECTION_SIZE + LINE_GAP);
1339        doc.text(&text, Font::HelveticaBoldOblique, SECTION_SIZE);
1340        doc.newline(SECTION_SIZE + LINE_GAP);
1341    }
1342}
1343
1344fn render_directive(
1345    directive: &chordsketch_chordpro::ast::Directive,
1346    show_diagrams: bool,
1347    diagram_frets: usize,
1348    doc: &mut PdfDocument,
1349) {
1350    if directive.kind == DirectiveKind::Define && show_diagrams {
1351        if let Some(ref value) = directive.value {
1352            let def = chordsketch_chordpro::ast::ChordDefinition::parse_value(value);
1353            // Keyboard defines: render a piano keyboard diagram.
1354            if let Some(ref keys_raw) = def.keys {
1355                let keys_u8: Vec<u8> = keys_raw
1356                    .iter()
1357                    .filter_map(|&k| {
1358                        if (0i32..=127).contains(&k) {
1359                            Some(k as u8)
1360                        } else {
1361                            None
1362                        }
1363                    })
1364                    .collect();
1365                if !keys_u8.is_empty() {
1366                    let root = keys_u8[0];
1367                    let voicing = chordsketch_chordpro::chord_diagram::KeyboardVoicing {
1368                        name: def.name.clone(),
1369                        display_name: def.display.clone(),
1370                        keys: keys_u8,
1371                        root_key: root,
1372                    };
1373                    render_keyboard_diagram_pdf(&voicing, doc);
1374                    return;
1375                }
1376            }
1377            // Fretted defines: render the standard fret-grid diagram.
1378            if let Some(ref raw) = def.raw {
1379                if let Some(mut diagram) =
1380                    chordsketch_chordpro::chord_diagram::DiagramData::from_raw_infer_frets(
1381                        &def.name,
1382                        raw,
1383                        diagram_frets,
1384                    )
1385                {
1386                    diagram.display_name = def.display.clone();
1387                    render_chord_diagram_pdf(&diagram, doc);
1388                    return;
1389                }
1390            }
1391        }
1392    }
1393
1394    if let DirectiveKind::Image(ref attrs) = directive.kind {
1395        render_image(attrs, doc);
1396        return;
1397    }
1398
1399    render_section_label(directive, doc);
1400}
1401
1402/// Check whether an image path is safe to open.
1403///
1404/// Rejects empty paths, paths containing null bytes, absolute paths, and
1405/// paths containing `..` components to prevent directory traversal attacks.
1406/// Only relative paths that stay within (or below) the current working
1407/// directory are accepted.
1408fn is_safe_image_path(path: &str) -> bool {
1409    // Reject empty paths and paths with null bytes (defense-in-depth).
1410    if path.is_empty() || path.contains('\0') {
1411        return false;
1412    }
1413
1414    // Reject Windows-style absolute paths on all platforms.  On Unix,
1415    // `Path::is_absolute()` does not flag `C:\…` or `\\…` as absolute,
1416    // so we perform string-level checks for drive letters and UNC paths.
1417    if chordsketch_chordpro::image_path::is_windows_absolute(path) {
1418        return false;
1419    }
1420
1421    let p = std::path::Path::new(path);
1422
1423    // Reject absolute paths (Unix `/…` and Windows `C:\…`).
1424    // Also explicitly check for leading `/` since `is_absolute()` on Windows
1425    // does not consider Unix-style root paths as absolute.
1426    if p.is_absolute() || path.starts_with('/') {
1427        return false;
1428    }
1429
1430    // Reject directory traversal (`..` path components).
1431    // Uses the shared helper that splits on both `/` and `\`, so
1432    // backslash-separated traversal like `images\..\..\etc\passwd` is
1433    // also caught on Unix (where `Path::components()` treats `\` as a
1434    // literal character).
1435    if chordsketch_chordpro::image_path::has_traversal(path) {
1436        return false;
1437    }
1438
1439    true
1440}
1441
1442/// Read an image file, rejecting symlinks and files exceeding
1443/// [`MAX_IMAGE_FILE_SIZE`].
1444///
1445/// On Unix the file is opened with `O_NOFOLLOW` so that opening a symlink
1446/// fails with `ELOOP`, and the size is checked via `fstat` on the open file
1447/// descriptor.  This eliminates the TOCTOU window that would exist if
1448/// `symlink_metadata` and `read` were separate operations on the path.
1449///
1450/// On non-Unix platforms, the function falls back to `symlink_metadata`
1451/// followed by `std::fs::read`, which has a theoretical race window but is
1452/// the best available option.
1453fn read_image_file(path: &str) -> Option<Vec<u8>> {
1454    #[cfg(unix)]
1455    {
1456        use std::io::Read;
1457        use std::os::unix::fs::OpenOptionsExt;
1458
1459        // O_NOFOLLOW: the open call itself fails if path is a symlink.
1460        let mut file = std::fs::OpenOptions::new()
1461            .read(true)
1462            .custom_flags(libc::O_NOFOLLOW)
1463            .open(path)
1464            .ok()?;
1465
1466        // fstat on the fd — no TOCTOU with the open above.
1467        let meta = file.metadata().ok()?;
1468        if meta.len() > MAX_IMAGE_FILE_SIZE {
1469            return None;
1470        }
1471
1472        let mut buf = Vec::with_capacity(meta.len() as usize);
1473        file.read_to_end(&mut buf).ok()?;
1474
1475        // Belt-and-suspenders: verify actual bytes read.
1476        if buf.len() as u64 > MAX_IMAGE_FILE_SIZE {
1477            return None;
1478        }
1479        Some(buf)
1480    }
1481
1482    #[cfg(not(unix))]
1483    {
1484        // Fallback: symlink_metadata + read (has a theoretical TOCTOU gap).
1485        let meta = std::fs::symlink_metadata(path).ok()?;
1486        if meta.file_type().is_symlink() {
1487            return None;
1488        }
1489        if meta.len() > MAX_IMAGE_FILE_SIZE {
1490            return None;
1491        }
1492        let data = std::fs::read(path).ok()?;
1493        if data.len() as u64 > MAX_IMAGE_FILE_SIZE {
1494            return None;
1495        }
1496        Some(data)
1497    }
1498}
1499
1500/// Render an `{image}` directive by embedding an image file into the PDF.
1501///
1502/// Supported formats: JPEG (`.jpg`/`.jpeg`) and PNG (`.png`).
1503/// The image is read from disk, validated, and embedded as an XObject.
1504/// If the file cannot be read, has no recognisable header, has an
1505/// unsupported extension, is a symlink, or exceeds [`MAX_IMAGE_FILE_SIZE`],
1506/// the directive is silently skipped.
1507///
1508/// The `anchor` attribute controls horizontal alignment: `"line"` (default)
1509/// places the image at the column margin, `"column"` centres it within the
1510/// column, and `"paper"` centres it on the full page.
1511fn render_image(attrs: &ImageAttributes, doc: &mut PdfDocument) {
1512    if !attrs.has_src() {
1513        return;
1514    }
1515
1516    // Limit the number of embedded images to prevent memory exhaustion.
1517    if doc.images.len() >= MAX_IMAGES {
1518        return;
1519    }
1520
1521    // Apply the same allowlist the HTML and text renderers use, so a
1522    // single `.cho` cannot produce three different image-handling
1523    // behaviours (issue #1832, renderer-parity.md §Validation Parity).
1524    // `is_safe_image_src` blocks dangerous URI schemes (`javascript:`,
1525    // `file:`, `data:`, `blob:`, `vbscript:`, `mhtml:`) up front.
1526    if !chordsketch_chordpro::image_path::is_safe_image_src(&attrs.src) {
1527        return;
1528    }
1529
1530    // Then the PDF-specific filesystem check — rejects absolute paths
1531    // and `..` traversal because the PDF renderer actually reads the
1532    // file from disk. The HTTP(S) URLs allowed by `is_safe_image_src`
1533    // fall through here harmlessly (they fail the subsequent extension
1534    // check or the filesystem read).
1535    if !is_safe_image_path(&attrs.src) {
1536        return;
1537    }
1538
1539    let src_lower = attrs.src.to_ascii_lowercase();
1540    let is_jpeg = src_lower.ends_with(".jpg") || src_lower.ends_with(".jpeg");
1541    let is_png = src_lower.ends_with(".png");
1542    if !is_jpeg && !is_png {
1543        return;
1544    }
1545
1546    // Read the image file, rejecting symlinks and oversized files.
1547    let data = match read_image_file(&attrs.src) {
1548        Some(d) => d,
1549        None => return,
1550    };
1551
1552    // Parse image dimensions and embed based on format.
1553    let (pixel_w, pixel_h, img_idx) = if is_jpeg {
1554        let (w, h, components) = match parse_jpeg_dimensions(&data) {
1555            Some(dims) => dims,
1556            None => return,
1557        };
1558        if w == 0 || h == 0 {
1559            return;
1560        }
1561        let idx = doc.embed_jpeg(data, w, h, components);
1562        (w, h, idx)
1563    } else {
1564        let info = match parse_png(&data) {
1565            Some(info) => info,
1566            None => return,
1567        };
1568        if info.width == 0 || info.height == 0 {
1569            return;
1570        }
1571        let w = info.width;
1572        let h = info.height;
1573        let idx = doc.embed_png(info);
1574        (w, h, idx)
1575    };
1576
1577    // Clamp native pixel dimensions for rendering, but preserve originals
1578    // for the PDF XObject metadata (which must match the actual stream).
1579    let clamped_w = pixel_w.min(MAX_IMAGE_PIXELS);
1580    let clamped_h = pixel_h.min(MAX_IMAGE_PIXELS);
1581
1582    // Compute rendered dimensions in PDF points (1 pt = 1/72 inch).
1583    let native_w = clamped_w as f32;
1584    let native_h = clamped_h as f32;
1585    let aspect = native_w / native_h;
1586
1587    let (render_w, render_h) = compute_image_dimensions(attrs, native_w, native_h, aspect);
1588
1589    // Clamp to printable area (per-column width in multi-column layouts).
1590    let max_w = doc.column_width();
1591    let max_h = PAGE_H - doc.margin_top - doc.margin_bottom;
1592    let (render_w, render_h) = clamp_to_printable_area(render_w, render_h, max_w, max_h, aspect);
1593
1594    doc.ensure_space(render_h + LINE_GAP);
1595
1596    // Compute horizontal position based on the anchor attribute.
1597    let x = match attrs.anchor.as_deref() {
1598        Some("column") => {
1599            let col_left = doc.margin_left();
1600            let col_w = doc.column_width();
1601            col_left + (col_w - render_w) / 2.0
1602        }
1603        Some("paper") => (PAGE_W - render_w) / 2.0,
1604        _ => doc.margin_left(),
1605    };
1606
1607    // PDF images are placed with the origin at the bottom-left corner.
1608    let y = doc.y() - render_h;
1609    doc.draw_image(img_idx, x, y, render_w, render_h);
1610    doc.advance_y(render_h);
1611    doc.newline(LINE_GAP);
1612}
1613
1614/// Clamp rendered image dimensions to fit within the printable area while
1615/// preserving the aspect ratio.
1616///
1617/// If width exceeds `max_w`, it is clamped and height is scaled down
1618/// proportionally (then clamped to `max_h` if still too tall).
1619/// If height exceeds `max_h`, it is clamped and width is scaled down
1620/// proportionally (then clamped to `max_w` if still too wide).
1621fn clamp_to_printable_area(w: f32, h: f32, max_w: f32, max_h: f32, aspect: f32) -> (f32, f32) {
1622    if w > max_w {
1623        let clamped_h = max_w / aspect;
1624        if clamped_h > max_h {
1625            let clamped_w = (max_h * aspect).min(max_w);
1626            (clamped_w, max_h)
1627        } else {
1628            (max_w, clamped_h)
1629        }
1630    } else if h > max_h {
1631        let clamped_w = (max_h * aspect).min(max_w);
1632        (clamped_w, clamped_w / aspect)
1633    } else {
1634        (w, h)
1635    }
1636}
1637
1638/// Parse a dimension value that may be an absolute number or a percentage.
1639///
1640/// Returns `None` if the value is not a valid positive number (or percentage).
1641/// Percentage values (e.g. `"50%"`) are resolved against `reference`.
1642fn parse_dimension(value: &str, reference: f32) -> Option<f32> {
1643    let trimmed = value.trim();
1644    if let Some(pct_str) = trimmed.strip_suffix('%') {
1645        let pct: f32 = pct_str.trim().parse().ok()?;
1646        let result = reference * pct / 100.0;
1647        if result > 0.0 && result.is_finite() {
1648            Some(result)
1649        } else {
1650            None
1651        }
1652    } else {
1653        let v: f32 = trimmed.parse().ok()?;
1654        if v > 0.0 && v.is_finite() {
1655            Some(v)
1656        } else {
1657            None
1658        }
1659    }
1660}
1661
1662/// Compute the rendered width and height of an image based on the directive
1663/// attributes (`width`, `height`, `scale`).
1664///
1665/// Priority: explicit width/height > scale > native dimensions.
1666/// Width and height values may be absolute points or percentages of the
1667/// native image dimensions (e.g. `"50%"`).
1668fn compute_image_dimensions(
1669    attrs: &ImageAttributes,
1670    native_w: f32,
1671    native_h: f32,
1672    aspect: f32,
1673) -> (f32, f32) {
1674    let parsed_w = attrs
1675        .width
1676        .as_deref()
1677        .and_then(|v| parse_dimension(v, native_w));
1678    let parsed_h = attrs
1679        .height
1680        .as_deref()
1681        .and_then(|v| parse_dimension(v, native_h));
1682    let parsed_scale = attrs
1683        .scale
1684        .as_deref()
1685        .and_then(|v| v.trim().parse::<f32>().ok())
1686        .filter(|&v| v > 0.0 && v.is_finite());
1687
1688    match (parsed_w, parsed_h) {
1689        (Some(w), Some(h)) => (w, h),
1690        (Some(w), None) => (w, w / aspect),
1691        (None, Some(h)) => (h * aspect, h),
1692        (None, None) => {
1693            if let Some(s) = parsed_scale {
1694                (native_w * s, native_h * s)
1695            } else {
1696                (native_w, native_h)
1697            }
1698        }
1699    }
1700}
1701
1702/// Render a chord diagram directly into the PDF content stream.
1703///
1704/// Uses PDF line/circle drawing operations to reproduce the chord grid,
1705/// finger dots, open/muted string markers, and the chord name.
1706fn render_chord_diagram_pdf(
1707    data: &chordsketch_chordpro::chord_diagram::DiagramData,
1708    doc: &mut PdfDocument,
1709) {
1710    // Guard: mirror render_svg bounds checks for strings and frets_shown.
1711    if data.strings < chordsketch_chordpro::chord_diagram::MIN_STRINGS
1712        || data.strings > chordsketch_chordpro::chord_diagram::MAX_STRINGS
1713        || data.frets_shown < chordsketch_chordpro::chord_diagram::MIN_FRETS_SHOWN
1714        || data.frets_shown > chordsketch_chordpro::chord_diagram::MAX_FRETS_SHOWN
1715    {
1716        return;
1717    }
1718
1719    // PDF cell dimensions in points (1 pt = 1/72 inch). Intentionally
1720    // smaller than the SVG renderer's 16x20 px because PDF targets printed
1721    // pages where diagrams sit alongside text and must be compact.
1722    let cell_w: f32 = 10.0;
1723    let cell_h: f32 = 12.0;
1724    let num_strings = data.strings;
1725    let num_frets = data.frets_shown;
1726    let grid_w = (num_strings - 1) as f32 * cell_w;
1727    let grid_h = num_frets as f32 * cell_h;
1728    let total_h = grid_h + 25.0; // title + top markers + grid
1729
1730    doc.ensure_space(total_h);
1731
1732    let base_x = doc.margin_left();
1733    // PDF Y is bottom-up, so top of diagram is at doc.y
1734    let top_y = doc.y();
1735
1736    // Chord name (uses display override if present)
1737    doc.text_at(data.title(), Font::HelveticaBold, 9.0, base_x, top_y);
1738
1739    let grid_top = top_y - 15.0; // below the name
1740
1741    // Nut line (thick for open position)
1742    if data.base_fret == 1 {
1743        doc.line_at(base_x, grid_top, base_x + grid_w, grid_top, 2.0);
1744    } else {
1745        // Show fret number, clamping x to >= 0 to prevent off-page rendering.
1746        let fret_label = format!("{}fr", data.base_fret);
1747        let fret_label_x = (base_x - 16.0).max(0.0);
1748        doc.text_at(
1749            &fret_label,
1750            Font::Helvetica,
1751            6.0,
1752            fret_label_x,
1753            grid_top - cell_h / 2.0,
1754        );
1755    }
1756
1757    // Vertical lines (strings)
1758    for i in 0..num_strings {
1759        let x = base_x + i as f32 * cell_w;
1760        doc.line_at(x, grid_top, x, grid_top - grid_h, 0.5);
1761    }
1762
1763    // Horizontal lines (frets)
1764    for j in 0..=num_frets {
1765        let y = grid_top - j as f32 * cell_h;
1766        doc.line_at(base_x, y, base_x + grid_w, y, 0.5);
1767    }
1768
1769    // Finger positions, open, and muted markers
1770    for (i, &fret) in data.frets.iter().enumerate() {
1771        if i >= num_strings {
1772            break;
1773        }
1774        let x = base_x + i as f32 * cell_w;
1775        if fret == -1 {
1776            // Muted: X above nut
1777            doc.text_at("X", Font::Helvetica, 7.0, x - 2.5, grid_top + 4.0);
1778        } else if fret == 0 {
1779            // Open: circle above nut
1780            doc.stroked_circle_at(x, grid_top + 6.0, 2.5);
1781        } else {
1782            // Fretted: filled dot
1783            let y = grid_top - (fret as f32 - 0.5) * cell_h;
1784            doc.filled_circle_at(x, y, 3.0);
1785            // Finger number inside the dot (if available and non-zero)
1786            if let Some(&finger) = data.fingers.get(i) {
1787                if finger > 0 {
1788                    let label = finger.to_string();
1789                    doc.white_text_at(&label, Font::Helvetica, 5.0, x - 1.5, y - 1.5);
1790                }
1791            }
1792        }
1793    }
1794
1795    doc.advance_y(total_h);
1796    doc.newline(LINE_GAP);
1797}
1798
1799/// Render a keyboard (piano) chord diagram directly into the PDF content stream.
1800///
1801/// Draws a 2-octave piano keyboard strip with filled rectangles for each key,
1802/// highlighting chord tones in blue and the root key in darker blue.
1803fn render_keyboard_diagram_pdf(
1804    voicing: &chordsketch_chordpro::chord_diagram::KeyboardVoicing,
1805    doc: &mut PdfDocument,
1806) {
1807    if voicing.keys.is_empty() {
1808        return;
1809    }
1810
1811    // Normalise pitch-class keys (0–11) to octave 4.
1812    let (keys, root) = chordsketch_chordpro::chord_diagram::normalise_keyboard_keys(
1813        &voicing.keys,
1814        voicing.root_key,
1815    );
1816
1817    let min_key = *keys.iter().min().unwrap_or(&60);
1818    let max_key = *keys.iter().max().unwrap_or(&71);
1819    let start_octave = u32::from(min_key / 12);
1820    let end_octave = u32::from(max_key / 12);
1821    let num_octaves = ((end_octave - start_octave) + 1).clamp(2, 3) as usize;
1822    let start_midi = (start_octave * 12) as u8;
1823
1824    // PDF layout (points). Smaller than SVG to fit on the printed page.
1825    let white_w: f32 = 8.0;
1826    let white_h: f32 = 30.0;
1827    let black_w: f32 = 5.0;
1828    let black_h: f32 = 18.0;
1829    let name_h: f32 = 10.0;
1830    let total_h = name_h + white_h + 6.0;
1831
1832    doc.ensure_space(total_h);
1833
1834    let base_x = doc.margin_left();
1835    let top_y = doc.y();
1836
1837    // Chord name
1838    doc.text_at(voicing.title(), Font::HelveticaBold, 7.0, base_x, top_y);
1839
1840    // Y for top of keyboard (PDF Y goes down when we subtract)
1841    let kbd_top_y = top_y - name_h;
1842
1843    // White key semitone offsets within an octave and x-offset from octave start.
1844    const WHITE_KEYS_PDF: [(u8, f32); 7] = [
1845        (0, 0.0),  // C
1846        (2, 1.0),  // D
1847        (4, 2.0),  // E
1848        (5, 3.0),  // F
1849        (7, 4.0),  // G
1850        (9, 5.0),  // A
1851        (11, 6.0), // B
1852    ];
1853    // Black key semitone offsets and x-offsets within octave.
1854    const BLACK_KEYS_PDF: [(u8, f32); 5] = [
1855        (1, 0.6),  // C#
1856        (3, 1.6),  // D#
1857        (6, 3.6),  // F#
1858        (8, 4.6),  // G#
1859        (10, 5.6), // A#
1860    ];
1861
1862    // Colors used for highlighting keys.
1863    const ROOT_BLUE: (f32, f32, f32) = (0.102, 0.373, 0.706); // dark blue: root key
1864    const CHORD_BLUE: (f32, f32, f32) = (0.290, 0.565, 0.886); // medium blue: chord tone
1865    const WHITE_KEY: (f32, f32, f32) = (1.0, 1.0, 1.0); // unlit white key
1866    const DARK_KEY: (f32, f32, f32) = (0.133, 0.133, 0.133); // unlit black key
1867
1868    // Draw white keys
1869    for oct in 0..num_octaves {
1870        let oct_midi = start_midi.saturating_add((oct * 12) as u8);
1871        let oct_x = base_x + oct as f32 * 7.0 * white_w;
1872        for (semitone, x_idx) in WHITE_KEYS_PDF {
1873            let midi = oct_midi.saturating_add(semitone);
1874            let x = oct_x + x_idx * white_w;
1875            let highlighted = keys.contains(&midi);
1876            let is_root = highlighted && midi == root;
1877            // PDF bottom-up: key bottom is kbd_top_y - white_h
1878            let y_bottom = kbd_top_y - white_h;
1879            let color = if is_root {
1880                ROOT_BLUE
1881            } else if highlighted {
1882                CHORD_BLUE
1883            } else {
1884                WHITE_KEY
1885            };
1886            doc.filled_rect_color(x, y_bottom, white_w - 0.5, white_h, color);
1887            doc.rect_stroke(x, y_bottom, white_w - 0.5, white_h, 0.3);
1888        }
1889    }
1890
1891    // Draw black keys on top
1892    for oct in 0..num_octaves {
1893        let oct_midi = start_midi.saturating_add((oct * 12) as u8);
1894        let oct_x = base_x + oct as f32 * 7.0 * white_w;
1895        for (semitone, x_idx) in BLACK_KEYS_PDF {
1896            let midi = oct_midi.saturating_add(semitone);
1897            let x = oct_x + x_idx * white_w;
1898            let highlighted = keys.contains(&midi);
1899            let is_root = highlighted && midi == root;
1900            let y_bottom = kbd_top_y - black_h;
1901            let color = if is_root {
1902                ROOT_BLUE
1903            } else if highlighted {
1904                CHORD_BLUE
1905            } else {
1906                DARK_KEY
1907            };
1908            doc.filled_rect_color(x, y_bottom, black_w, black_h, color);
1909        }
1910    }
1911
1912    doc.advance_y(total_h);
1913    doc.newline(LINE_GAP);
1914}
1915
1916/// Render a `{chorus}` recall directive in the PDF.
1917///
1918/// Emits a "Chorus" label (with optional custom label) followed by the content
1919/// of the most recently defined chorus section.
1920/// Per-call rendering context for `render_chorus_recall`.
1921///
1922/// Bundles the rendering knobs into one struct so the function
1923/// signature stays under clippy's `too_many_arguments`
1924/// threshold without a per-call `#[allow]`. Sister-site to the
1925/// equivalent `ChorusRecallCtx` in `crates/render-html`.
1926struct ChorusRecallCtx<'a> {
1927    chorus_body: &'a [Line],
1928    transpose_offset: i8,
1929    prefer_flat: bool,
1930    fmt_state: &'a PdfFormattingState,
1931    show_diagrams: bool,
1932    diagram_frets: usize,
1933}
1934
1935fn render_chorus_recall(value: &Option<String>, ctx: &ChorusRecallCtx<'_>, doc: &mut PdfDocument) {
1936    let text = match value {
1937        Some(v) if !v.is_empty() => format!("Chorus: {v}"),
1938        _ => "Chorus".to_string(),
1939    };
1940    doc.ensure_space(SECTION_SIZE + LINE_GAP);
1941    doc.text(&text, Font::HelveticaBoldOblique, SECTION_SIZE);
1942    doc.newline(SECTION_SIZE + LINE_GAP);
1943
1944    // Replay the stored chorus body lines.
1945    for line in ctx.chorus_body {
1946        match line {
1947            Line::Lyrics(lyrics) => {
1948                render_lyrics(
1949                    lyrics,
1950                    ctx.transpose_offset,
1951                    ctx.prefer_flat,
1952                    ctx.fmt_state,
1953                    doc,
1954                    false,
1955                );
1956            }
1957            Line::Comment(style, text) => render_comment(*style, text, doc),
1958            Line::Empty => doc.newline(LINE_GAP * 2.0),
1959            Line::Directive(d) if !d.kind.is_metadata() => {
1960                render_directive(d, ctx.show_diagrams, ctx.diagram_frets, doc);
1961            }
1962            _ => {}
1963        }
1964    }
1965}
1966
1967fn render_comment(style: CommentStyle, text: &str, doc: &mut PdfDocument) {
1968    let font = match style {
1969        CommentStyle::Normal => Font::Helvetica,
1970        CommentStyle::Italic | CommentStyle::Boxed => Font::HelveticaOblique,
1971        // `{highlight}` is rendered in bold to give it the spec-described
1972        // stronger visual emphasis without dragging in a separate font.
1973        // Sister-site to `chordsketch-render-html`'s
1974        // `comment--highlight` class.
1975        CommentStyle::Highlight => Font::HelveticaBold,
1976    };
1977    if style == CommentStyle::Boxed {
1978        let padding = 3.0_f32;
1979        // Approximate Helvetica metrics: cap-height ≈ 0.72 em, descent
1980        // ≈ 0.21 em. The original implementation placed `rect_y` at
1981        // `text_y - COMMENT_SIZE - padding`, which treated the entire em
1982        // box as descent below the baseline. The box bottom landed
1983        // ~10 pt too low and the box top fell below the cap line, so
1984        // characters like `B` and `f` poked above the rectangle.
1985        let cap_height = COMMENT_SIZE * 0.72;
1986        let descent = COMMENT_SIZE * 0.21;
1987        let box_h = cap_height + descent + padding * 2.0;
1988        doc.ensure_space(box_h + LINE_GAP);
1989        let x = doc.margin_left();
1990        let text_y = doc.y();
1991        // PDF rect y is bottom-left; text_y is the glyph baseline.
1992        let rect_y = text_y - descent - padding;
1993        let text_w = text_width(text, COMMENT_SIZE);
1994        let box_w = text_w + padding * 2.0;
1995        doc.rect_stroke(x, rect_y, box_w, box_h, 0.5);
1996        doc.text_at(text, font, COMMENT_SIZE, x + padding, text_y);
1997        doc.newline(box_h + LINE_GAP);
1998    } else {
1999        doc.ensure_space(COMMENT_SIZE + LINE_GAP);
2000        doc.text(text, font, COMMENT_SIZE);
2001        doc.newline(COMMENT_SIZE + LINE_GAP);
2002    }
2003}
2004
2005// ---------------------------------------------------------------------------
2006// Multi-page PDF document builder
2007// ---------------------------------------------------------------------------
2008
2009/// Right margin in points.
2010const MARGIN_RIGHT: f32 = 56.0;
2011/// Gap between columns in points.
2012const COLUMN_GAP: f32 = 20.0;
2013
2014// ---------------------------------------------------------------------------
2015// JPEG header parsing
2016// ---------------------------------------------------------------------------
2017
2018/// Parse JPEG dimensions and component count from raw file data by locating a
2019/// Start of Frame marker.
2020///
2021/// JPEG files consist of a sequence of markers. This function scans for any
2022/// valid SOF marker (SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15) and reads
2023/// the image height (2 bytes at marker+3), width (2 bytes at marker+5), and
2024/// number of color components (1 byte at marker+7).
2025///
2026/// Returns `(width, height, components)` or `None` if the data is too short
2027/// or no SOF marker is found.
2028fn parse_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32, u8)> {
2029    // Maximum number of bytes to scan for the SOF marker.  Real JPEG files
2030    // contain the SOF within the first few KB.  This limit prevents a crafted
2031    // file from forcing a byte-by-byte scan through megabytes of data.
2032    const MAX_SCAN_BYTES: usize = 64 * 1024;
2033
2034    // Minimum valid JPEG: SOI (2 bytes) + at least one marker segment
2035    if data.len() < 4 {
2036        return None;
2037    }
2038    // Verify JPEG SOI marker
2039    if data[0] != 0xFF || data[1] != 0xD8 {
2040        return None;
2041    }
2042
2043    let scan_limit = data.len().min(MAX_SCAN_BYTES);
2044    let mut i = 2;
2045    while i + 1 < scan_limit {
2046        if data[i] != 0xFF {
2047            // Not a valid marker prefix — skip byte
2048            i += 1;
2049            continue;
2050        }
2051        let marker = data[i + 1];
2052
2053        // Skip padding 0xFF bytes
2054        if marker == 0xFF {
2055            i += 1;
2056            continue;
2057        }
2058
2059        // Any SOF marker (SOF0–SOF3, SOF5–SOF7, SOF9–SOF11, SOF13–SOF15).
2060        // Excludes 0xC4 (DHT), 0xC8 (JPG reserved), and 0xCC (DAC).
2061        if matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
2062            // Need at least 8 more bytes after the marker:
2063            // length(2) + precision(1) + height(2) + width(2) + components(1)
2064            if i + 10 > data.len() {
2065                return None;
2066            }
2067            let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2068            let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2069            let components = data[i + 9];
2070            return Some((width, height, components));
2071        }
2072
2073        // SOS marker — image data follows, no more headers to scan
2074        if marker == 0xDA {
2075            return None;
2076        }
2077
2078        // Other markers: skip using the length field
2079        if i + 3 >= data.len() {
2080            return None;
2081        }
2082        let length = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2083        i += 2 + length;
2084    }
2085
2086    None
2087}
2088
2089// ---------------------------------------------------------------------------
2090// PNG parsing
2091// ---------------------------------------------------------------------------
2092
2093/// PNG file signature (8 bytes).
2094const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
2095
2096/// Parsed PNG image data ready for PDF embedding.
2097struct PngInfo {
2098    /// Image width in pixels.
2099    width: u32,
2100    /// Image height in pixels.
2101    height: u32,
2102    /// Bit depth per channel.
2103    bit_depth: u8,
2104    /// Number of color channels in the output stream (after alpha removal).
2105    /// 1 = grayscale, 3 = RGB.  Indexed images are expanded to RGB.
2106    colors: u8,
2107    /// Zlib-compressed pixel data for the main image (alpha stripped if present).
2108    idat_data: Vec<u8>,
2109    /// PLTE chunk data for indexed color images (color type 3).
2110    palette: Option<Vec<u8>>,
2111    /// Zlib-compressed alpha channel data (for color types 4 and 6).
2112    smask: Option<Vec<u8>>,
2113}
2114
2115/// Parse a PNG file and prepare its data for PDF embedding.
2116///
2117/// Extracts dimensions from the IHDR chunk, concatenates IDAT chunks, and
2118/// handles alpha separation for color types 4 (gray+alpha) and 6 (RGBA).
2119///
2120/// Returns `None` if the data is not a valid PNG or cannot be processed.
2121fn parse_png(data: &[u8]) -> Option<PngInfo> {
2122    // Verify PNG signature.
2123    if data.len() < 8 || data[..8] != PNG_SIGNATURE {
2124        return None;
2125    }
2126
2127    // Parse IHDR (must be the first chunk after the signature).
2128    if data.len() < 8 + 4 + 4 + 13 {
2129        return None;
2130    }
2131    let ihdr_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2132    if ihdr_len < 13 {
2133        return None;
2134    }
2135    let chunk_type = &data[12..16];
2136    if chunk_type != b"IHDR" {
2137        return None;
2138    }
2139    let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2140    let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2141    let bit_depth = data[24];
2142    let color_type = data[25];
2143
2144    // Collect IDAT chunks and optionally PLTE.
2145    let mut idat_chunks: Vec<u8> = Vec::new();
2146    let mut palette: Option<Vec<u8>> = None;
2147    let mut pos = 8; // skip signature
2148
2149    while pos + 12 <= data.len() {
2150        let chunk_len =
2151            u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
2152        let ctype = &data[pos + 4..pos + 8];
2153
2154        // Guard against malformed chunk lengths.
2155        if pos + 12 + chunk_len > data.len() + 4 {
2156            break;
2157        }
2158        let chunk_data_start = pos + 8;
2159        let chunk_data_end = chunk_data_start + chunk_len;
2160        if chunk_data_end > data.len() {
2161            break;
2162        }
2163
2164        if ctype == b"IDAT" {
2165            idat_chunks.extend_from_slice(&data[chunk_data_start..chunk_data_end]);
2166        } else if ctype == b"PLTE" {
2167            palette = Some(data[chunk_data_start..chunk_data_end].to_vec());
2168        } else if ctype == b"IEND" {
2169            break;
2170        }
2171
2172        // 4 (length) + 4 (type) + chunk_len + 4 (CRC)
2173        pos += 12 + chunk_len;
2174    }
2175
2176    if idat_chunks.is_empty() {
2177        return None;
2178    }
2179
2180    let has_alpha = color_type == 4 || color_type == 6;
2181
2182    if has_alpha {
2183        // Decompress IDAT, separate color and alpha, recompress both.
2184        separate_alpha(&idat_chunks, width, height, bit_depth, color_type)
2185    } else {
2186        // Color types 0 (gray), 2 (RGB), 3 (indexed): IDAT passthrough.
2187        let colors = match color_type {
2188            0 => 1, // grayscale
2189            3 => 3, // indexed → presented as RGB via palette lookup in PDF
2190            _ => 3, // RGB
2191        };
2192        Some(PngInfo {
2193            width,
2194            height,
2195            bit_depth,
2196            colors,
2197            idat_data: idat_chunks,
2198            palette,
2199            smask: None,
2200        })
2201    }
2202}
2203
2204/// Decompress PNG IDAT data and separate color from alpha channels.
2205///
2206/// For color type 4 (gray+alpha), splits into 1-channel gray + 1-channel alpha.
2207/// For color type 6 (RGBA), splits into 3-channel RGB + 1-channel alpha.
2208///
2209/// Both output streams are zlib-compressed with PNG sub-filter byte prefixes
2210/// suitable for PDF `/FlateDecode` with `/Predictor 15`.
2211fn separate_alpha(
2212    idat_data: &[u8],
2213    width: u32,
2214    height: u32,
2215    bit_depth: u8,
2216    color_type: u8,
2217) -> Option<PngInfo> {
2218    let w = width as usize;
2219    let h = height as usize;
2220    let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 };
2221
2222    // Compute expected decompressed size from IHDR dimensions and apply a
2223    // safety cap to prevent memory exhaustion from high-compression-ratio PNGs.
2224    // Each row has a 1-byte filter prefix plus (width * channels * bytes_per_sample).
2225    let channels: usize = match color_type {
2226        4 => 2, // gray + alpha
2227        6 => 4, // RGBA
2228        _ => return None,
2229    };
2230    let expected_size = h.checked_mul(1 + w * channels * bytes_per_sample)?;
2231    // Hard cap at 256 MB regardless of declared dimensions.
2232    const MAX_DECOMPRESSED_SIZE: u64 = 256 * 1024 * 1024;
2233    let limit = (expected_size as u64).min(MAX_DECOMPRESSED_SIZE);
2234
2235    // Decompress the IDAT zlib stream with size limit.
2236    let mut decoder = ZlibDecoder::new(idat_data).take(limit + 1);
2237    let mut raw = Vec::new();
2238    if decoder.read_to_end(&mut raw).is_err() || raw.len() as u64 > limit {
2239        return None;
2240    }
2241
2242    // Channels in the raw decompressed data (including alpha).
2243    let (color_channels, alpha_channels) = match color_type {
2244        4 => (1, 1), // gray + alpha
2245        6 => (3, 1), // RGB + alpha
2246        _ => return None,
2247    };
2248    let total_channels = color_channels + alpha_channels;
2249    let src_stride = 1 + w * total_channels * bytes_per_sample; // +1 for filter byte
2250
2251    if raw.len() < h * src_stride {
2252        return None;
2253    }
2254
2255    // Un-filter all rows at once, then separate channels.
2256    let bpp = total_channels * bytes_per_sample;
2257    let row_bytes = w * total_channels * bytes_per_sample;
2258    let mut decoded = vec![0u8; h * row_bytes];
2259
2260    for row in 0..h {
2261        let src_start = row * src_stride;
2262        let filter = raw[src_start];
2263        let src_row = &raw[src_start + 1..src_start + src_stride];
2264        let dst_start = row * row_bytes;
2265
2266        decoded[dst_start..dst_start + row_bytes].copy_from_slice(src_row);
2267
2268        match filter {
2269            0 => {} // None
2270            1 => {
2271                // Sub
2272                for i in bpp..row_bytes {
2273                    decoded[dst_start + i] =
2274                        decoded[dst_start + i].wrapping_add(decoded[dst_start + i - bpp]);
2275                }
2276            }
2277            2 => {
2278                // Up
2279                if row > 0 {
2280                    let prev_start = (row - 1) * row_bytes;
2281                    for i in 0..row_bytes {
2282                        decoded[dst_start + i] =
2283                            decoded[dst_start + i].wrapping_add(decoded[prev_start + i]);
2284                    }
2285                }
2286            }
2287            3 => {
2288                // Average
2289                let prev_start = if row > 0 { (row - 1) * row_bytes } else { 0 };
2290                for i in 0..row_bytes {
2291                    let left = if i >= bpp {
2292                        decoded[dst_start + i - bpp]
2293                    } else {
2294                        0
2295                    };
2296                    let up = if row > 0 { decoded[prev_start + i] } else { 0 };
2297                    decoded[dst_start + i] =
2298                        decoded[dst_start + i].wrapping_add(((left as u16 + up as u16) / 2) as u8);
2299                }
2300            }
2301            4 => {
2302                // Paeth
2303                let prev_start = if row > 0 { (row - 1) * row_bytes } else { 0 };
2304                for i in 0..row_bytes {
2305                    let left = if i >= bpp {
2306                        decoded[dst_start + i - bpp] as i16
2307                    } else {
2308                        0
2309                    };
2310                    let up = if row > 0 {
2311                        decoded[prev_start + i] as i16
2312                    } else {
2313                        0
2314                    };
2315                    let up_left = if i >= bpp && row > 0 {
2316                        decoded[prev_start + i - bpp] as i16
2317                    } else {
2318                        0
2319                    };
2320                    decoded[dst_start + i] =
2321                        decoded[dst_start + i].wrapping_add(paeth_predictor(left, up, up_left));
2322                }
2323            }
2324            _ => return None,
2325        }
2326    }
2327
2328    // Separate color and alpha channels, writing with filter 0 (None).
2329    let color_stride = 1 + w * color_channels * bytes_per_sample;
2330    let alpha_stride = 1 + w * bytes_per_sample;
2331    let mut color_raw = Vec::with_capacity(h * color_stride);
2332    let mut alpha_raw = Vec::with_capacity(h * alpha_stride);
2333
2334    for row in 0..h {
2335        color_raw.push(0); // filter byte = None
2336        alpha_raw.push(0); // filter byte = None
2337        let row_start = row * row_bytes;
2338        for x in 0..w {
2339            let pixel_start = row_start + x * total_channels * bytes_per_sample;
2340            // Color channels
2341            for c in 0..color_channels {
2342                let offset = pixel_start + c * bytes_per_sample;
2343                color_raw.extend_from_slice(&decoded[offset..offset + bytes_per_sample]);
2344            }
2345            // Alpha channel
2346            let alpha_offset = pixel_start + color_channels * bytes_per_sample;
2347            alpha_raw.extend_from_slice(&decoded[alpha_offset..alpha_offset + bytes_per_sample]);
2348        }
2349    }
2350
2351    // Recompress both streams with zlib.
2352    let idat_data = zlib_compress(&color_raw)?;
2353    let smask = zlib_compress(&alpha_raw)?;
2354
2355    Some(PngInfo {
2356        width,
2357        height,
2358        bit_depth,
2359        colors: color_channels as u8,
2360        idat_data,
2361        palette: None,
2362        smask: Some(smask),
2363    })
2364}
2365
2366/// Paeth predictor function used by PNG filter type 4.
2367fn paeth_predictor(a: i16, b: i16, c: i16) -> u8 {
2368    let p = a + b - c;
2369    let pa = (p - a).unsigned_abs();
2370    let pb = (p - b).unsigned_abs();
2371    let pc = (p - c).unsigned_abs();
2372    if pa <= pb && pa <= pc {
2373        a as u8
2374    } else if pb <= pc {
2375        b as u8
2376    } else {
2377        c as u8
2378    }
2379}
2380
2381/// Compress data with zlib (deflate).
2382fn zlib_compress(data: &[u8]) -> Option<Vec<u8>> {
2383    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
2384    if encoder.write_all(data).is_err() {
2385        return None;
2386    }
2387    encoder.finish().ok()
2388}
2389
2390// ---------------------------------------------------------------------------
2391// Embedded image types
2392// ---------------------------------------------------------------------------
2393
2394/// Format-specific data for an embedded image.
2395enum ImageFormat {
2396    /// Raw JPEG data — embedded with `/DCTDecode` (passthrough, no re-encoding).
2397    Jpeg {
2398        /// Raw JPEG file bytes.
2399        data: Vec<u8>,
2400        /// Number of color components (1 = gray, 3 = RGB, 4 = CMYK).
2401        components: u8,
2402    },
2403    /// PNG image data — IDAT chunks concatenated, embedded with `/FlateDecode`.
2404    Png {
2405        /// Concatenated raw IDAT chunk payloads (zlib-compressed pixel data).
2406        idat_data: Vec<u8>,
2407        /// Bit depth (typically 8).
2408        bit_depth: u8,
2409        /// Number of color channels (1 = gray, 3 = RGB) after alpha removal.
2410        colors: u8,
2411        /// PLTE chunk data for indexed color images (color type 3).
2412        palette: Option<Vec<u8>>,
2413        /// Zlib-compressed alpha channel for images with transparency.
2414        /// Stored as a separate `/FlateDecode` stream for the PDF SMask.
2415        smask: Option<Vec<u8>>,
2416    },
2417}
2418
2419/// An embedded image with its pixel dimensions and format-specific data.
2420struct EmbeddedImage {
2421    /// Image width in pixels.
2422    width: u32,
2423    /// Image height in pixels.
2424    height: u32,
2425    /// Format-specific data.
2426    format: ImageFormat,
2427}
2428
2429impl EmbeddedImage {
2430    /// Returns the number of PDF objects this image will produce.
2431    ///
2432    /// JPEG images and PNG images without alpha need 1 object (the XObject).
2433    /// PNG images with an alpha channel need 2 objects (XObject + SMask).
2434    fn num_pdf_objects(&self) -> usize {
2435        match &self.format {
2436            ImageFormat::Jpeg { .. } => 1,
2437            ImageFormat::Png { smask, .. } => {
2438                if smask.is_some() {
2439                    2
2440                } else {
2441                    1
2442                }
2443            }
2444        }
2445    }
2446}
2447
2448/// Accumulates content across multiple pages and builds the final PDF.
2449///
2450/// Each page has its own content stream. When the Y cursor drops below the
2451/// bottom margin, content flows to the next column (if multi-column) or a
2452/// new page is started.
2453struct PdfDocument {
2454    /// Content-stream operations for each page.
2455    pages: Vec<Vec<String>>,
2456    /// Current Y position on the current page.
2457    y: f32,
2458    /// Number of columns (1 = single-column layout).
2459    num_columns: u32,
2460    /// Current column index (0-based).
2461    current_column: u32,
2462    /// Embedded images (JPEG or PNG).
2463    images: Vec<EmbeddedImage>,
2464    /// Layout margins (in points), overridable via config.
2465    margin_top: f32,
2466    margin_bottom: f32,
2467    margin_left: f32,
2468    margin_right: f32,
2469    /// GID → char mappings for non-Latin-1 glyphs used in this document.
2470    ///
2471    /// Populated when CID-font segments are rendered (any character outside
2472    /// the WinAnsiEncoding range). Used in `build_pdf` to emit the ToUnicode
2473    /// CMap and the glyph-width `/W` array for the embedded CID font.
2474    ///
2475    /// **GID 0 (`.notdef`) entries may be present.** Characters absent from
2476    /// the bundled font fall back to GID 0 in the content stream, and those
2477    /// GID 0 entries are recorded here so that `cid_needed =
2478    /// !self.cid_glyphs.is_empty()` stays `true` whenever `/F5` was
2479    /// referenced — even when every non-Latin-1 character maps to `.notdef`.
2480    /// GID 0 is excluded from the ToUnicode CMap in `build_to_unicode_cmap`
2481    /// (PDF spec §9.10.3 forbids it as a source entry).
2482    cid_glyphs: BTreeMap<u16, char>,
2483    /// Optional document title for the PDF `/Info` dictionary `/Title` entry.
2484    ///
2485    /// Set via [`set_doc_title`](Self::set_doc_title). When non-empty, an
2486    /// `/Info` indirect object is emitted in [`build_pdf`](Self::build_pdf)
2487    /// and referenced from the trailer. When `None`, no `/Info` object is
2488    /// emitted — matching upstream ChordPro R6.101.0 ("default PDF title
2489    /// to songbook title, if any") and avoiding a misleading title for
2490    /// multi-song output, which has no aggregate title concept.
2491    doc_title: Option<String>,
2492}
2493
2494impl PdfDocument {
2495    /// Create a new document with one empty page using default margins.
2496    #[cfg(test)]
2497    fn new() -> Self {
2498        Self::with_margins(MARGIN_TOP, MARGIN_BOTTOM, MARGIN_LEFT, MARGIN_RIGHT)
2499    }
2500
2501    /// Create a new document with one empty page and custom margins.
2502    fn with_margins(top: f32, bottom: f32, left: f32, right: f32) -> Self {
2503        Self {
2504            pages: vec![Vec::new()],
2505            y: PAGE_H - top,
2506            num_columns: 1,
2507            current_column: 0,
2508            images: Vec::new(),
2509            margin_top: top,
2510            margin_bottom: bottom,
2511            margin_left: left,
2512            margin_right: right,
2513            cid_glyphs: BTreeMap::new(),
2514            doc_title: None,
2515        }
2516    }
2517
2518    /// Maximum number of input chars accepted for the PDF `/Info` `/Title`.
2519    ///
2520    /// PDF readers truncate long titles in their title bar well below this
2521    /// bound. The UTF-16BE hex encoder emits 4 hex bytes per BMP char and
2522    /// 8 per supplementary-plane char, so the strict upper bound on the
2523    /// emitted hex literal is `8 * MAX_TITLE_CHARS + 6` (worst case: all
2524    /// supplementary-plane chars, plus `<FEFF` BOM prefix and closing `>`) —
2525    /// well under any DoS-relevant size. The cap defends against pathological
2526    /// `{title:...}` directives in untrusted `.cho` input (per
2527    /// `.claude/rules/defensive-inputs.md` § Resource Limits).
2528    const MAX_TITLE_CHARS: usize = 1024;
2529
2530    /// Set the document title used in the PDF `/Info` dictionary `/Title` entry.
2531    ///
2532    /// `None`, the empty string, and whitespace-only strings normalise to no
2533    /// `/Info` emission — matching upstream ChordPro R6.101.0's "if any"
2534    /// clause. The chordpro parser already trims directive values (per
2535    /// `parser.rs`'s `value.map(|v| v.trim().to_string())`), but `trim()` is
2536    /// applied here too so this method is robust on its own to any future
2537    /// caller that doesn't pre-normalise.
2538    ///
2539    /// Inputs longer than [`MAX_TITLE_CHARS`](Self::MAX_TITLE_CHARS) are
2540    /// truncated at a char boundary to bound `/Info` allocation.
2541    ///
2542    /// The string is later encoded as a UTF-16BE PDF hex literal so any
2543    /// Unicode title is preserved without escape concerns.
2544    fn set_doc_title(&mut self, title: Option<&str>) {
2545        self.doc_title = title.and_then(|t| {
2546            let trimmed = t.trim();
2547            if trimmed.is_empty() {
2548                None
2549            } else if trimmed.chars().count() > Self::MAX_TITLE_CHARS {
2550                Some(trimmed.chars().take(Self::MAX_TITLE_CHARS).collect())
2551            } else {
2552                Some(trimmed.to_string())
2553            }
2554        });
2555    }
2556
2557    /// Maximum allowed margin value in points. A4 short side is 595pt, so
2558    /// margins above half that are unreasonable.
2559    const MAX_MARGIN: f32 = 297.0;
2560
2561    /// Validate and clamp a margin value. Returns the default if the value is
2562    /// negative, non-finite, or exceeds `MAX_MARGIN`.
2563    ///
2564    /// The warning push is routed through the module-level
2565    /// [`push_warning`] helper so it participates in the `MAX_WARNINGS`
2566    /// cap (issue #1873). Before this fix a pathological config that
2567    /// already filled the warnings vector could produce up to
2568    /// `MAX_WARNINGS + 1 + 4 * N` entries, where `N` is the number of
2569    /// `from_config_with_warnings` calls per render. The extra 4-per-N
2570    /// term came from this function bypassing the cap.
2571    fn validate_margin(value: f32, default: f32, name: &str, warnings: &mut Vec<String>) -> f32 {
2572        if !value.is_finite() || !(0.0..=Self::MAX_MARGIN).contains(&value) {
2573            push_warning(
2574                warnings,
2575                format!("invalid pdf.margins.{name} value {value}, using default {default}"),
2576            );
2577            default
2578        } else {
2579            value
2580        }
2581    }
2582
2583    /// Create a new document reading margins from config.
2584    ///
2585    /// Warnings are printed to stderr. Use [`from_config_with_warnings`](Self::from_config_with_warnings)
2586    /// to capture them programmatically.
2587    #[cfg(test)]
2588    fn from_config(config: &Config) -> Self {
2589        let mut warnings = Vec::new();
2590        let doc = Self::from_config_with_warnings(config, &mut warnings);
2591        for w in &warnings {
2592            eprintln!("warning: {w}");
2593        }
2594        doc
2595    }
2596
2597    /// Create a new document reading margins from config, collecting warnings.
2598    fn from_config_with_warnings(config: &Config, warnings: &mut Vec<String>) -> Self {
2599        let top = config
2600            .get_path("pdf.margins.top")
2601            .as_f64()
2602            .map(|v| Self::validate_margin(v as f32, MARGIN_TOP, "top", warnings))
2603            .unwrap_or(MARGIN_TOP);
2604        let bottom = config
2605            .get_path("pdf.margins.bottom")
2606            .as_f64()
2607            .map(|v| Self::validate_margin(v as f32, MARGIN_BOTTOM, "bottom", warnings))
2608            .unwrap_or(MARGIN_BOTTOM);
2609        let left = config
2610            .get_path("pdf.margins.left")
2611            .as_f64()
2612            .map(|v| Self::validate_margin(v as f32, MARGIN_LEFT, "left", warnings))
2613            .unwrap_or(MARGIN_LEFT);
2614        let right = config
2615            .get_path("pdf.margins.right")
2616            .as_f64()
2617            .map(|v| Self::validate_margin(v as f32, MARGIN_RIGHT, "right", warnings))
2618            .unwrap_or(MARGIN_RIGHT);
2619        Self::with_margins(top, bottom, left, right)
2620    }
2621
2622    /// Reset the document margins from a config, falling back to the built-in
2623    /// defaults for any margin not explicitly set. Used in multi-song rendering
2624    /// so that per-song overrides from song N do not bleed into song N+1.
2625    fn reset_margins_from_config(&mut self, config: &Config, warnings: &mut Vec<String>) {
2626        self.margin_top = config
2627            .get_path("pdf.margins.top")
2628            .as_f64()
2629            .map(|v| Self::validate_margin(v as f32, MARGIN_TOP, "top", warnings))
2630            .unwrap_or(MARGIN_TOP);
2631        self.margin_bottom = config
2632            .get_path("pdf.margins.bottom")
2633            .as_f64()
2634            .map(|v| Self::validate_margin(v as f32, MARGIN_BOTTOM, "bottom", warnings))
2635            .unwrap_or(MARGIN_BOTTOM);
2636        self.margin_left = config
2637            .get_path("pdf.margins.left")
2638            .as_f64()
2639            .map(|v| Self::validate_margin(v as f32, MARGIN_LEFT, "left", warnings))
2640            .unwrap_or(MARGIN_LEFT);
2641        self.margin_right = config
2642            .get_path("pdf.margins.right")
2643            .as_f64()
2644            .map(|v| Self::validate_margin(v as f32, MARGIN_RIGHT, "right", warnings))
2645            .unwrap_or(MARGIN_RIGHT);
2646    }
2647
2648    /// Returns the current Y position.
2649    fn y(&self) -> f32 {
2650        self.y
2651    }
2652
2653    /// Returns the number of pages.
2654    fn page_count(&self) -> usize {
2655        self.pages.len()
2656    }
2657
2658    /// Returns the left margin for the current column.
2659    fn margin_left(&self) -> f32 {
2660        if self.num_columns <= 1 {
2661            return self.margin_left;
2662        }
2663        let col_width = self.column_width();
2664        let result = self.margin_left + self.current_column as f32 * (col_width + COLUMN_GAP);
2665        debug_assert!(
2666            result.is_finite(),
2667            "margin_left() produced non-finite value"
2668        );
2669        result
2670    }
2671
2672    /// Returns the width of a single column in points.
2673    ///
2674    /// For single-column layouts this equals the full printable width.
2675    /// For multi-column layouts it accounts for inter-column gaps.
2676    fn column_width(&self) -> f32 {
2677        let usable_width = PAGE_W - self.margin_left - self.margin_right;
2678        if self.num_columns <= 1 {
2679            return usable_width;
2680        }
2681        let total_gaps = (self.num_columns - 1) as f32 * COLUMN_GAP;
2682        ((usable_width - total_gaps) / self.num_columns as f32).max(0.0)
2683    }
2684
2685    /// Set the number of columns (clamped to 1..=[`MAX_COLUMNS`]). Resets to column 0.
2686    fn set_columns(&mut self, n: u32) {
2687        self.num_columns = n.clamp(1, MAX_COLUMNS);
2688        self.current_column = 0;
2689    }
2690
2691    /// Force a column break. Advances to the next column, or to a new page
2692    /// if already in the last column.
2693    fn column_break(&mut self) {
2694        if self.num_columns <= 1 {
2695            self.new_page();
2696            return;
2697        }
2698        if self.current_column + 1 < self.num_columns {
2699            self.current_column += 1;
2700            self.y = PAGE_H - self.margin_top;
2701        } else {
2702            self.new_page();
2703        }
2704    }
2705
2706    /// Ensure there is at least `needed` points of vertical space remaining.
2707    /// If not, advance to next column or start a new page.
2708    fn ensure_space(&mut self, needed: f32) {
2709        if self.y - needed < self.margin_bottom {
2710            self.next_column_or_page();
2711        }
2712    }
2713
2714    /// Advance to the next column, or to a new page if in the last column.
2715    fn next_column_or_page(&mut self) {
2716        if self.num_columns > 1 && self.current_column + 1 < self.num_columns {
2717            self.current_column += 1;
2718            self.y = PAGE_H - self.margin_top;
2719        } else {
2720            self.new_page();
2721        }
2722    }
2723
2724    /// Start a new page, resetting the Y cursor and column index.
2725    ///
2726    /// Silently does nothing when [`MAX_PAGES`] has been reached so that
2727    /// malicious input cannot cause unbounded memory allocation.
2728    fn new_page(&mut self) {
2729        if self.pages.len() >= MAX_PAGES {
2730            return;
2731        }
2732        self.pages.push(Vec::new());
2733        self.y = PAGE_H - self.margin_top;
2734        self.current_column = 0;
2735    }
2736
2737    /// Emit text at the current column margin and Y position.
2738    fn text(&mut self, text: &str, font: Font, size: f32) {
2739        let x = self.margin_left();
2740        self.text_at(text, font, size, x, self.y);
2741    }
2742
2743    /// Emit text at an explicit (x, y) position without clipping.
2744    ///
2745    /// Used by callers that manage their own clipping context (e.g.,
2746    /// `render_lyrics_spans` which wraps the entire line in a single clip).
2747    ///
2748    /// Text is split into Latin-1 segments (rendered with the specified
2749    /// Helvetica font) and non-Latin-1 segments (rendered with the embedded
2750    /// CID Unicode font `/F5`). Each segment is its own BT…ET block so that
2751    /// font switching works correctly.
2752    fn text_at_raw(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
2753        let segments = text_segments(text);
2754        // Collect CID mappings before mutably borrowing pages.
2755        let mut cid_mappings: Vec<(u16, char)> = Vec::new();
2756        let mut ops_batch: Vec<String> = Vec::new();
2757        let mut cur_x = x;
2758        for (is_cid, seg) in &segments {
2759            ops_batch.push("BT".to_string());
2760            if *is_cid {
2761                let (hex, mappings) = encode_cid_text(seg);
2762                cid_mappings.extend_from_slice(&mappings);
2763                ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
2764                ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2765                ops_batch.push(format!("<{}> Tj", hex));
2766                cur_x += cid_text_width(seg, size);
2767            } else {
2768                ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
2769                ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2770                ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
2771                cur_x += text_width(seg, size);
2772            }
2773            ops_batch.push("ET".to_string());
2774        }
2775        self.current_page_mut().extend(ops_batch);
2776        for (gid, ch) in cid_mappings {
2777            // Record all GIDs (including GID 0 for .notdef) so that cid_needed
2778            // remains true whenever the /F5 font was referenced in the content stream.
2779            // GID 0 is filtered out of the ToUnicode CMap in build_to_unicode_cmap.
2780            self.cid_glyphs.entry(gid).or_insert(ch);
2781        }
2782    }
2783
2784    /// Emit text at an explicit (x, y) position.
2785    ///
2786    /// In multi-column layouts, a clipping rectangle is applied to prevent
2787    /// text from overflowing the column boundary into adjacent columns.
2788    ///
2789    /// Text is split into Latin-1 and non-Latin-1 segments; non-Latin-1
2790    /// characters use the embedded CID font `/F5`.
2791    fn text_at(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
2792        let clip = self.num_columns > 1;
2793        let col_right = if clip {
2794            self.margin_left() + self.column_width()
2795        } else {
2796            0.0
2797        };
2798        let segments = text_segments(text);
2799        let mut cid_mappings: Vec<(u16, char)> = Vec::new();
2800        let mut ops_batch: Vec<String> = Vec::new();
2801        if clip {
2802            let clip_w = (col_right - x).max(0.0);
2803            ops_batch.push("q".to_string());
2804            ops_batch.push(format!(
2805                "{} {} {} {} re W n",
2806                fmt_f32(x),
2807                fmt_f32(0.0),
2808                fmt_f32(clip_w),
2809                fmt_f32(PAGE_H)
2810            ));
2811        }
2812        let mut cur_x = x;
2813        for (is_cid, seg) in &segments {
2814            ops_batch.push("BT".to_string());
2815            if *is_cid {
2816                let (hex, mappings) = encode_cid_text(seg);
2817                cid_mappings.extend_from_slice(&mappings);
2818                ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
2819                ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2820                ops_batch.push(format!("<{}> Tj", hex));
2821                cur_x += cid_text_width(seg, size);
2822            } else {
2823                ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
2824                ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2825                ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
2826                cur_x += text_width(seg, size);
2827            }
2828            ops_batch.push("ET".to_string());
2829        }
2830        if clip {
2831            ops_batch.push("Q".to_string());
2832        }
2833        self.current_page_mut().extend(ops_batch);
2834        for (gid, ch) in cid_mappings {
2835            // Record all GIDs (including GID 0 for .notdef) so that cid_needed
2836            // remains true whenever the /F5 font was referenced in the content stream.
2837            // GID 0 is filtered out of the ToUnicode CMap in build_to_unicode_cmap.
2838            self.cid_glyphs.entry(gid).or_insert(ch);
2839        }
2840    }
2841
2842    /// Emit a text string at absolute coordinates in white.
2843    ///
2844    /// Used for finger numbers inside filled dots in chord diagrams.
2845    /// In multi-column layouts, applies the same clipping as [`text_at`].
2846    ///
2847    /// Text is split into Latin-1 and non-Latin-1 segments; non-Latin-1
2848    /// characters use the embedded CID font `/F5`.
2849    fn white_text_at(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
2850        let clip = self.num_columns > 1;
2851        let col_right = if clip {
2852            self.margin_left() + self.column_width()
2853        } else {
2854            0.0
2855        };
2856        let segments = text_segments(text);
2857        let mut cid_mappings: Vec<(u16, char)> = Vec::new();
2858        let mut ops_batch: Vec<String> = Vec::new();
2859        if clip {
2860            let clip_w = (col_right - x).max(0.0);
2861            ops_batch.push("q".to_string());
2862            ops_batch.push(format!(
2863                "{} {} {} {} re W n",
2864                fmt_f32(x),
2865                fmt_f32(0.0),
2866                fmt_f32(clip_w),
2867                fmt_f32(PAGE_H)
2868            ));
2869        }
2870        let mut cur_x = x;
2871        for (is_cid, seg) in &segments {
2872            ops_batch.push("BT".to_string());
2873            ops_batch.push("1 1 1 rg".to_string()); // white fill
2874            if *is_cid {
2875                let (hex, mappings) = encode_cid_text(seg);
2876                cid_mappings.extend_from_slice(&mappings);
2877                ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
2878                ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2879                ops_batch.push(format!("<{}> Tj", hex));
2880                cur_x += cid_text_width(seg, size);
2881            } else {
2882                ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
2883                ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2884                ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
2885                cur_x += text_width(seg, size);
2886            }
2887            ops_batch.push("ET".to_string());
2888            ops_batch.push("0 0 0 rg".to_string()); // reset to black
2889        }
2890        if clip {
2891            ops_batch.push("Q".to_string());
2892        }
2893        self.current_page_mut().extend(ops_batch);
2894        for (gid, ch) in cid_mappings {
2895            // Record all GIDs (including GID 0 for .notdef) so that cid_needed
2896            // remains true whenever the /F5 font was referenced in the content stream.
2897            // GID 0 is filtered out of the ToUnicode CMap in build_to_unicode_cmap.
2898            self.cid_glyphs.entry(gid).or_insert(ch);
2899        }
2900    }
2901
2902    /// Move the Y cursor down. May trigger a column/page break if past bottom margin.
2903    fn newline(&mut self, amount: f32) {
2904        self.y -= amount;
2905        if self.y < self.margin_bottom {
2906            self.next_column_or_page();
2907        }
2908    }
2909
2910    /// Advance Y cursor without triggering auto page break.
2911    ///
2912    /// Used for intra-element positioning (e.g., chord row to lyrics row).
2913    fn advance_y(&mut self, amount: f32) {
2914        self.y -= amount;
2915    }
2916
2917    /// Draw a line from (x1, y1) to (x2, y2) with the given width.
2918    ///
2919    /// Wraps the operation in `q`/`Q` (save/restore graphics state) so the
2920    /// line width does not leak to subsequent drawing operations.
2921    fn line_at(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, width: f32) {
2922        let ops = self.current_page_mut();
2923        ops.push("q".to_string());
2924        ops.push(format!("{} w", fmt_f32(width)));
2925        ops.push(format!(
2926            "{} {} m {} {} l S",
2927            fmt_f32(x1),
2928            fmt_f32(y1),
2929            fmt_f32(x2),
2930            fmt_f32(y2)
2931        ));
2932        ops.push("Q".to_string());
2933    }
2934
2935    /// Draw a filled rectangle with an RGB colour.
2936    ///
2937    /// `color` is `(r, g, b)` with each component in the 0.0–1.0 range.
2938    /// The fill is solid with no stroke. Wraps the operation in `q`/`Q`
2939    /// to avoid colour leakage.
2940    fn filled_rect_color(&mut self, x: f32, y: f32, w: f32, h: f32, color: (f32, f32, f32)) {
2941        let (r, g, b) = color;
2942        let ops = self.current_page_mut();
2943        ops.push("q".to_string());
2944        ops.push(format!("{} {} {} rg", fmt_f32(r), fmt_f32(g), fmt_f32(b)));
2945        ops.push(format!(
2946            "{} {} {} {} re f",
2947            fmt_f32(x),
2948            fmt_f32(y),
2949            fmt_f32(w),
2950            fmt_f32(h)
2951        ));
2952        ops.push("Q".to_string());
2953    }
2954
2955    /// Draw a stroked (unfilled) rectangle.
2956    ///
2957    /// Wraps the operation in `q`/`Q` (save/restore graphics state) so the
2958    /// line width does not leak to subsequent drawing operations.
2959    fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, line_width: f32) {
2960        let ops = self.current_page_mut();
2961        ops.push("q".to_string());
2962        ops.push(format!("{} w", fmt_f32(line_width)));
2963        ops.push(format!(
2964            "{} {} {} {} re S",
2965            fmt_f32(x),
2966            fmt_f32(y),
2967            fmt_f32(w),
2968            fmt_f32(h)
2969        ));
2970        ops.push("Q".to_string());
2971    }
2972
2973    /// Draw a filled circle at (cx, cy) with the given radius.
2974    fn filled_circle_at(&mut self, cx: f32, cy: f32, r: f32) {
2975        // Approximate circle with 4 Bezier curves (kappa = 0.5523)
2976        let k = r * 0.5523;
2977        let ops = self.current_page_mut();
2978        ops.push(format!(
2979            "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f",
2980            fmt_f32(cx + r), fmt_f32(cy),
2981            fmt_f32(cx + r), fmt_f32(cy + k), fmt_f32(cx + k), fmt_f32(cy + r), fmt_f32(cx), fmt_f32(cy + r),
2982            fmt_f32(cx - k), fmt_f32(cy + r), fmt_f32(cx - r), fmt_f32(cy + k), fmt_f32(cx - r), fmt_f32(cy),
2983            fmt_f32(cx - r), fmt_f32(cy - k), fmt_f32(cx - k), fmt_f32(cy - r), fmt_f32(cx), fmt_f32(cy - r),
2984            fmt_f32(cx + k), fmt_f32(cy - r), fmt_f32(cx + r), fmt_f32(cy - k), fmt_f32(cx + r), fmt_f32(cy),
2985        ));
2986    }
2987
2988    /// Draw a stroked (unfilled) circle at (cx, cy) with the given radius.
2989    fn stroked_circle_at(&mut self, cx: f32, cy: f32, r: f32) {
2990        let k = r * 0.5523;
2991        let ops = self.current_page_mut();
2992        ops.push("0.5 w".to_string());
2993        ops.push(format!(
2994            "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S",
2995            fmt_f32(cx + r), fmt_f32(cy),
2996            fmt_f32(cx + r), fmt_f32(cy + k), fmt_f32(cx + k), fmt_f32(cy + r), fmt_f32(cx), fmt_f32(cy + r),
2997            fmt_f32(cx - k), fmt_f32(cy + r), fmt_f32(cx - r), fmt_f32(cy + k), fmt_f32(cx - r), fmt_f32(cy),
2998            fmt_f32(cx - r), fmt_f32(cy - k), fmt_f32(cx - k), fmt_f32(cy - r), fmt_f32(cx), fmt_f32(cy - r),
2999            fmt_f32(cx + k), fmt_f32(cy - r), fmt_f32(cx + r), fmt_f32(cy - k), fmt_f32(cx + r), fmt_f32(cy),
3000        ));
3001    }
3002
3003    /// Store a JPEG image and return its index for later drawing.
3004    ///
3005    /// The raw JPEG bytes are stored as-is; the PDF will use `/DCTDecode`
3006    /// (JPEG passthrough) so no re-encoding is needed.
3007    fn embed_jpeg(&mut self, data: Vec<u8>, width: u32, height: u32, components: u8) -> usize {
3008        let idx = self.images.len();
3009        self.images.push(EmbeddedImage {
3010            width,
3011            height,
3012            format: ImageFormat::Jpeg { data, components },
3013        });
3014        idx
3015    }
3016
3017    /// Store a PNG image and return its index for later drawing.
3018    ///
3019    /// The IDAT data is stored as zlib-compressed bytes; the PDF will use
3020    /// `/FlateDecode` with PNG predictor parameters.
3021    fn embed_png(&mut self, info: PngInfo) -> usize {
3022        let idx = self.images.len();
3023        self.images.push(EmbeddedImage {
3024            width: info.width,
3025            height: info.height,
3026            format: ImageFormat::Png {
3027                idat_data: info.idat_data,
3028                bit_depth: info.bit_depth,
3029                colors: info.colors,
3030                palette: info.palette,
3031                smask: info.smask,
3032            },
3033        });
3034        idx
3035    }
3036
3037    /// Draw a previously embedded image at the given position and size.
3038    ///
3039    /// Emits PDF `cm` (concat matrix) and `Do` (paint XObject) operators
3040    /// wrapped in `q`/`Q` (save/restore graphics state).
3041    fn draw_image(&mut self, img_idx: usize, x: f32, y: f32, w: f32, h: f32) {
3042        let name = format!("/Im{}", img_idx + 1);
3043        let ops = self.current_page_mut();
3044        ops.push("q".to_string());
3045        ops.push(format!(
3046            "{} 0 0 {} {} {} cm",
3047            fmt_f32(w),
3048            fmt_f32(h),
3049            fmt_f32(x),
3050            fmt_f32(y)
3051        ));
3052        ops.push(format!("{name} Do"));
3053        ops.push("Q".to_string());
3054    }
3055
3056    /// Returns a mutable reference to the current page's operations.
3057    fn current_page_mut(&mut self) -> &mut Vec<String> {
3058        // pages always has at least one element (initialized in new())
3059        self.pages.last_mut().expect("pages is never empty")
3060    }
3061
3062    /// Take all pages out of this document, replacing them with a single
3063    /// empty page so that the "pages is never empty" invariant is preserved.
3064    fn take_pages(&mut self) -> Vec<Vec<String>> {
3065        let pages = std::mem::take(&mut self.pages);
3066        self.pages.push(Vec::new());
3067        self.y = PAGE_H - self.margin_top;
3068        self.current_column = 0;
3069        pages
3070    }
3071
3072    /// Append a pre-built page to this document.
3073    ///
3074    /// Silently drops the page when [`MAX_PAGES`] has been reached, consistent
3075    /// with [`new_page`](Self::new_page).
3076    fn push_page(&mut self, ops: Vec<String>) {
3077        if self.pages.len() >= MAX_PAGES {
3078            return;
3079        }
3080        self.pages.push(ops);
3081    }
3082
3083    /// Build the complete multi-page PDF document.
3084    fn build_pdf(&self) -> Vec<u8> {
3085        let num_pages = self.pages.len();
3086        let num_images = self.images.len();
3087        // CID font chain (Type0 + CIDFontType0 + FontDescriptor + FontFile3 + ToUnicode).
3088        // Emitted only when non-Latin-1 glyphs were actually used in the document.
3089        const CID_OBJ_COUNT: usize = 5;
3090        let cid_needed = !self.cid_glyphs.is_empty();
3091        let extra_objs = if cid_needed { CID_OBJ_COUNT } else { 0 };
3092        let mut offsets: Vec<usize> = Vec::new();
3093        let mut pdf = Vec::<u8>::new();
3094
3095        // Header
3096        pdf.extend_from_slice(b"%PDF-1.4\n");
3097
3098        // Object 1: Catalog
3099        offsets.push(pdf.len());
3100        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
3101
3102        // Object 2: Pages (parent of all page objects)
3103        offsets.push(pdf.len());
3104        let font_refs: String = FONTS
3105            .iter()
3106            .enumerate()
3107            .map(|(i, _)| format!("{} {} 0 R", FONTS[i].pdf_name(), i + 3))
3108            .collect::<Vec<_>>()
3109            .join(" ");
3110
3111        // Add the CID composite font /F5 when non-Latin-1 glyphs are present.
3112        // Object number = 3 + FONTS.len() (first object after the Type1 font block).
3113        let cid_font_ref = if cid_needed {
3114            format!(" /F5 {} 0 R", 3 + FONTS.len())
3115        } else {
3116            String::new()
3117        };
3118
3119        // Image XObject references for the Resources dict.
3120        // Each image is referenced as /Im{i+1}. We compute the actual object
3121        // number by accumulating num_pdf_objects() for each preceding image.
3122        let image_obj_base = 3 + FONTS.len() + extra_objs; // first image object number
3123        let xobject_refs = if num_images > 0 {
3124            let mut refs = Vec::new();
3125            let mut obj_offset = 0;
3126            for (i, img) in self.images.iter().enumerate() {
3127                refs.push(format!("/Im{} {} 0 R", i + 1, image_obj_base + obj_offset));
3128                obj_offset += img.num_pdf_objects();
3129            }
3130            format!(" /XObject << {} >>", refs.join(" "))
3131        } else {
3132            String::new()
3133        };
3134
3135        let procset = if num_images > 0 {
3136            "/ProcSet [/PDF /Text /ImageB /ImageC]"
3137        } else {
3138            "/ProcSet [/PDF /Text]"
3139        };
3140
3141        // Kids: page objects start after fonts + CID objects (if any) + images.
3142        let total_image_objects: usize = self.images.iter().map(|img| img.num_pdf_objects()).sum();
3143        let page_obj_start = 3 + FONTS.len() + extra_objs + total_image_objects;
3144        let kids: String = (0..num_pages)
3145            .map(|i| format!("{} 0 R", page_obj_start + i * 2))
3146            .collect::<Vec<_>>()
3147            .join(" ");
3148        let obj2 = format!(
3149            "2 0 obj\n<< /Type /Pages /MediaBox [0 0 {} {}] /Resources << /Font << {}{} >>{} {} >> /Kids [{}] /Count {} >>\nendobj\n",
3150            fmt_f32(PAGE_W),
3151            fmt_f32(PAGE_H),
3152            font_refs,
3153            cid_font_ref,
3154            xobject_refs,
3155            procset,
3156            kids,
3157            num_pages
3158        );
3159        pdf.extend_from_slice(obj2.as_bytes());
3160
3161        // Font dictionaries: objects 3 .. 3+FONTS.len()-1
3162        for font in &FONTS {
3163            offsets.push(pdf.len());
3164            let obj_num = offsets.len();
3165            let obj = format!(
3166                "{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /{} /Encoding /WinAnsiEncoding >>\nendobj\n",
3167                obj_num,
3168                font.base_name()
3169            );
3170            pdf.extend_from_slice(obj.as_bytes());
3171        }
3172
3173        // CID composite font chain (5 objects), emitted only when used.
3174        // Object layout (relative to 3 + FONTS.len()):
3175        //   +0: Type0 wrapper   (/F5)
3176        //   +1: CIDFontType0 dictionary
3177        //   +2: FontDescriptor
3178        //   +3: FontFile3 stream (raw CFF bytes from the bundled OTF)
3179        //   +4: ToUnicode CMap stream
3180        if cid_needed {
3181            let f5_obj = 3 + FONTS.len();
3182            let cid_dict_obj = f5_obj + 1;
3183            let desc_obj = f5_obj + 2;
3184            let font_file_obj = f5_obj + 3;
3185            let to_unicode_obj = f5_obj + 4;
3186
3187            // Derive scaled font metrics from the bundled face.
3188            //
3189            // The `scale()` closure converts design-unit values to PDF glyph-space
3190            // units (1/1000 em) correctly for any UPM. However, the /W array below
3191            // uses raw glyph_hor_advance values without scaling, which is only correct
3192            // when UPM=1000. If the bundled font is ever swapped for one with a
3193            // different UPM (e.g. 2048), /W would silently produce wrong metrics.
3194            // The debug_assert fires before any metric computation to catch this.
3195            let face = unicode_face();
3196            debug_assert_eq!(
3197                face.units_per_em(),
3198                1000,
3199                "CID font /W values assume UPM=1000; scale advances by 1000/upe if the font changes"
3200            );
3201            let upe = face.units_per_em() as i32;
3202            // Scale a font-design-unit value to PDF glyph-space units (1/1000 em).
3203            let scale = |v: i32| v * 1000 / upe;
3204            let ascender = scale(face.ascender() as i32);
3205            let descender = scale(face.descender() as i32);
3206            let cap_height = scale(
3207                face.capital_height()
3208                    .map(|h| h as i32)
3209                    .unwrap_or(face.ascender() as i32),
3210            );
3211            let bbox = face.global_bounding_box();
3212            let llx = scale(bbox.x_min as i32);
3213            let lly = scale(bbox.y_min as i32);
3214            let urx = scale(bbox.x_max as i32);
3215            let ury = scale(bbox.y_max as i32);
3216
3217            // Build /W width array for glyphs that differ from the default (1000).
3218            // Format: gid [width] gid [width] ...
3219            // Values are raw advances from ttf-parser, valid in PDF glyph-space units
3220            // only because UPM=1000 (see debug_assert above).
3221            const DW: u16 = 1000;
3222            let width_array: String = {
3223                let entries: Vec<String> = self
3224                    .cid_glyphs
3225                    .keys()
3226                    .filter_map(|&gid| {
3227                        let advance = face
3228                            .glyph_hor_advance(ttf_parser::GlyphId(gid))
3229                            .unwrap_or(DW);
3230                        if advance != DW {
3231                            Some(format!("{} [{}]", gid, advance))
3232                        } else {
3233                            None
3234                        }
3235                    })
3236                    .collect();
3237                if entries.is_empty() {
3238                    String::new()
3239                } else {
3240                    format!(" /W [{}]", entries.join(" "))
3241                }
3242            };
3243
3244            // Object F5: Type0 (composite) font wrapper.
3245            offsets.push(pdf.len());
3246            pdf.extend_from_slice(
3247                format!(
3248                    "{f5_obj} 0 obj\n<< /Type /Font /Subtype /Type0 \
3249                     /BaseFont /NotoSansCJK-Regular-Subset /Encoding /Identity-H \
3250                     /DescendantFonts [{cid_dict_obj} 0 R] \
3251                     /ToUnicode {to_unicode_obj} 0 R >>\nendobj\n"
3252                )
3253                .as_bytes(),
3254            );
3255
3256            // Object CIDFontType0: CFF-based CIDFont.
3257            offsets.push(pdf.len());
3258            pdf.extend_from_slice(
3259                format!(
3260                    "{cid_dict_obj} 0 obj\n<< /Type /Font /Subtype /CIDFontType0 \
3261                     /BaseFont /NotoSansCJK-Regular-Subset \
3262                     /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
3263                     /FontDescriptor {desc_obj} 0 R /DW {DW}{width_array} >>\nendobj\n"
3264                )
3265                .as_bytes(),
3266            );
3267
3268            // Object FontDescriptor.
3269            offsets.push(pdf.len());
3270            pdf.extend_from_slice(
3271                format!(
3272                    "{desc_obj} 0 obj\n<< /Type /FontDescriptor \
3273                     /FontName /NotoSansCJK-Regular-Subset /Flags 6 \
3274                     /FontBBox [{llx} {lly} {urx} {ury}] /ItalicAngle 0 \
3275                     /Ascent {ascender} /Descent {descender} /CapHeight {cap_height} \
3276                     /StemV 80 /FontFile3 {font_file_obj} 0 R >>\nendobj\n"
3277                )
3278                .as_bytes(),
3279            );
3280
3281            // Object FontFile3: raw CFF table bytes (not the full OTF wrapper).
3282            // PDF spec §9.9 requires /FontFile3 with /Subtype /CIDFontType0C to contain
3283            // the bare CFF font program, not a complete OpenType container.
3284            let cff_bytes = unicode_cff_bytes();
3285            offsets.push(pdf.len());
3286            pdf.extend_from_slice(
3287                format!(
3288                    "{font_file_obj} 0 obj\n<< /Subtype /CIDFontType0C /Length {} >>\nstream\n",
3289                    cff_bytes.len()
3290                )
3291                .as_bytes(),
3292            );
3293            pdf.extend_from_slice(cff_bytes);
3294            pdf.extend_from_slice(b"\nendstream\nendobj\n");
3295
3296            // Object ToUnicode CMap: maps GIDs back to Unicode for text extraction.
3297            offsets.push(pdf.len());
3298            let cmap_body = build_to_unicode_cmap(&self.cid_glyphs);
3299            pdf.extend_from_slice(
3300                format!(
3301                    "{to_unicode_obj} 0 obj\n<< /Length {} >>\nstream\n",
3302                    cmap_body.len()
3303                )
3304                .as_bytes(),
3305            );
3306            pdf.extend_from_slice(cmap_body.as_bytes());
3307            pdf.extend_from_slice(b"\nendstream\nendobj\n");
3308        }
3309
3310        // Image XObject streams
3311        for img in &self.images {
3312            match &img.format {
3313                ImageFormat::Jpeg { data, components } => {
3314                    offsets.push(pdf.len());
3315                    let obj_num = offsets.len();
3316                    let color_space = match components {
3317                        1 => "/DeviceGray",
3318                        4 => "/DeviceCMYK",
3319                        _ => "/DeviceRGB",
3320                    };
3321                    let header = format!(
3322                        "{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n",
3323                        obj_num,
3324                        img.width,
3325                        img.height,
3326                        color_space,
3327                        data.len()
3328                    );
3329                    pdf.extend_from_slice(header.as_bytes());
3330                    pdf.extend_from_slice(data);
3331                    pdf.extend_from_slice(b"\nendstream\nendobj\n");
3332                }
3333                ImageFormat::Png {
3334                    idat_data,
3335                    bit_depth,
3336                    colors,
3337                    palette,
3338                    smask,
3339                } => {
3340                    // If there is an SMask, write it first so we know its
3341                    // object number when writing the main image.
3342                    let smask_obj_num = if smask.is_some() {
3343                        offsets.push(pdf.len());
3344                        let sobj = offsets.len();
3345                        let smask_data = smask.as_ref().expect("checked above");
3346                        let smask_header = format!(
3347                            "{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent {} /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 1 /BitsPerComponent {} /Columns {} >> /Length {} >>\nstream\n",
3348                            sobj,
3349                            img.width,
3350                            img.height,
3351                            bit_depth,
3352                            bit_depth,
3353                            img.width,
3354                            smask_data.len()
3355                        );
3356                        pdf.extend_from_slice(smask_header.as_bytes());
3357                        pdf.extend_from_slice(smask_data);
3358                        pdf.extend_from_slice(b"\nendstream\nendobj\n");
3359                        Some(sobj)
3360                    } else {
3361                        None
3362                    };
3363
3364                    offsets.push(pdf.len());
3365                    let obj_num = offsets.len();
3366
3367                    let color_space = match (colors, palette) {
3368                        (_, Some(pal)) => {
3369                            // Indexed color: /ColorSpace [/Indexed /DeviceRGB N <hex>]
3370                            let num_entries = pal.len() / 3;
3371                            let max_idx = if num_entries > 0 { num_entries - 1 } else { 0 };
3372                            let hex: String = pal.iter().map(|b| format!("{b:02x}")).collect();
3373                            format!("[/Indexed /DeviceRGB {} <{}>]", max_idx, hex)
3374                        }
3375                        (1, None) => "/DeviceGray".to_string(),
3376                        _ => "/DeviceRGB".to_string(),
3377                    };
3378
3379                    let smask_ref = smask_obj_num
3380                        .map(|n| format!(" /SMask {} 0 R", n))
3381                        .unwrap_or_default();
3382
3383                    let header = format!(
3384                        "{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} /BitsPerComponent {} /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors {} /BitsPerComponent {} /Columns {} >>{} /Length {} >>\nstream\n",
3385                        obj_num,
3386                        img.width,
3387                        img.height,
3388                        color_space,
3389                        bit_depth,
3390                        colors,
3391                        bit_depth,
3392                        img.width,
3393                        smask_ref,
3394                        idat_data.len()
3395                    );
3396                    pdf.extend_from_slice(header.as_bytes());
3397                    pdf.extend_from_slice(idat_data);
3398                    pdf.extend_from_slice(b"\nendstream\nendobj\n");
3399                }
3400            }
3401        }
3402
3403        // Page + content stream pairs
3404        for (i, page_ops) in self.pages.iter().enumerate() {
3405            let page_obj_num = page_obj_start + i * 2;
3406            let content_obj_num = page_obj_num + 1;
3407
3408            // Page object
3409            offsets.push(pdf.len());
3410            let page_obj = format!(
3411                "{} 0 obj\n<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>\nendobj\n",
3412                page_obj_num, content_obj_num
3413            );
3414            pdf.extend_from_slice(page_obj.as_bytes());
3415
3416            // Content stream
3417            let content = page_ops.join("\n");
3418            offsets.push(pdf.len());
3419            // Per ISO 32000: /Length is the number of bytes between `stream\n`
3420            // and the EOL marker before `endstream`. The `\n` preceding
3421            // `endstream` is excluded from the length.
3422            let stream_obj = format!(
3423                "{} 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3424                content_obj_num,
3425                content.len(),
3426                content
3427            );
3428            pdf.extend_from_slice(stream_obj.as_bytes());
3429        }
3430
3431        // /Info dictionary (optional, last object before xref).
3432        //
3433        // Placed after the page+content streams so prior object byte offsets
3434        // are unchanged when /Info is added or removed; only the new object's
3435        // offset and the trailer's `startxref` value shift.
3436        //
3437        // Mirrors upstream ChordPro R6.101.0 emergency-fix item "Default PDF
3438        // title property to songbook title, if any." `set_doc_title` already
3439        // normalised None / empty / whitespace-only to `None`, so reaching
3440        // this branch implies a non-empty title to embed.
3441        let info_obj_num = if let Some(title) = &self.doc_title {
3442            offsets.push(pdf.len());
3443            let n = offsets.len();
3444            let title_hex = pdf_title_hex_string(title);
3445            pdf.extend_from_slice(
3446                format!("{n} 0 obj\n<< /Title {title_hex} >>\nendobj\n").as_bytes(),
3447            );
3448            Some(n)
3449        } else {
3450            None
3451        };
3452
3453        // Cross-reference table
3454        let xref_offset = pdf.len();
3455        let num_objects = offsets.len() + 1; // +1 for object 0
3456        pdf.extend_from_slice(format!("xref\n0 {num_objects}\n").as_bytes());
3457        pdf.extend_from_slice(b"0000000000 65535 f \n");
3458        for offset in &offsets {
3459            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
3460        }
3461
3462        // Trailer
3463        let info_ref = info_obj_num
3464            .map(|n| format!(" /Info {n} 0 R"))
3465            .unwrap_or_default();
3466        pdf.extend_from_slice(
3467            format!(
3468                "trailer\n<< /Size {num_objects} /Root 1 0 R{info_ref} >>\nstartxref\n{xref_offset}\n%%EOF\n"
3469            )
3470            .as_bytes(),
3471        );
3472
3473        pdf
3474    }
3475}
3476
3477// ---------------------------------------------------------------------------
3478// Font enum
3479// ---------------------------------------------------------------------------
3480
3481/// Built-in PDF Type1 fonts (available in all conforming PDF readers).
3482#[derive(Clone, Copy)]
3483enum Font {
3484    Helvetica,
3485    HelveticaBold,
3486    HelveticaOblique,
3487    HelveticaBoldOblique,
3488    /// Monospace Type1 font used for verbatim sections
3489    /// (`{start_of_tab}`, `{start_of_grid}`, `{start_of_textblock}`)
3490    /// where column alignment matters.
3491    Courier,
3492}
3493
3494impl Font {
3495    /// Returns the PDF font resource name (must match the page Resources dict).
3496    ///
3497    /// `/F5` is reserved for the conditionally-emitted Type0 CID font
3498    /// (`unicode_face` — NotoSansCJK), so Courier takes `/F6` to avoid
3499    /// colliding with it.
3500    fn pdf_name(self) -> &'static str {
3501        match self {
3502            Self::Helvetica => "/F1",
3503            Self::HelveticaBold => "/F2",
3504            Self::HelveticaOblique => "/F3",
3505            Self::HelveticaBoldOblique => "/F4",
3506            Self::Courier => "/F6",
3507        }
3508    }
3509
3510    /// Returns the PDF BaseFont name for the font dictionary.
3511    fn base_name(self) -> &'static str {
3512        match self {
3513            Self::Helvetica => "Helvetica",
3514            Self::HelveticaBold => "Helvetica-Bold",
3515            Self::HelveticaOblique => "Helvetica-Oblique",
3516            Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
3517            Self::Courier => "Courier",
3518        }
3519    }
3520}
3521
3522/// The Type1 fonts emitted in the output. Courier (index 4) is included
3523/// here so it gets its Resources/Font entry alongside the Helvetica
3524/// family; the `/F5` slot stays open for the optional Type0 CID font.
3525const FONTS: [Font; 5] = [
3526    Font::Helvetica,
3527    Font::HelveticaBold,
3528    Font::HelveticaOblique,
3529    Font::HelveticaBoldOblique,
3530    Font::Courier,
3531];
3532
3533// ---------------------------------------------------------------------------
3534// PDF helpers
3535// ---------------------------------------------------------------------------
3536
3537/// Escape a string for inclusion in a PDF literal string `(...)`.
3538///
3539/// Characters are handled as follows:
3540/// - ASCII (U+0000–U+007F): passed through (with `\`, `(`, `)` escaped).
3541/// - WinAnsiEncoding 0x80–0x9F: Unicode characters that map to bytes 0x80–0x9F
3542///   in WinAnsiEncoding (Euro sign, smart quotes, dashes, etc.).
3543/// - Latin-1 Supplement (U+00A0–U+00FF): encoded as PDF octal escapes
3544///   (`\NNN`) for WinAnsiEncoding compatibility. This covers most accented
3545///   European characters (é, ü, ñ, ß, etc.).
3546/// - All other non-ASCII characters: replaced with `?` because the built-in
3547///   Type1 fonts (Helvetica) only support WinAnsiEncoding.
3548fn pdf_escape(s: &str) -> String {
3549    let mut out = String::with_capacity(s.len());
3550    for c in s.chars() {
3551        match c {
3552            '\\' => out.push_str("\\\\"),
3553            '(' => out.push_str("\\("),
3554            ')' => out.push_str("\\)"),
3555            _ if c.is_ascii() => out.push(c),
3556            // Latin-1 Supplement: WinAnsiEncoding byte equals the code point.
3557            '\u{00A0}'..='\u{00FF}' => {
3558                let byte = c as u32;
3559                out.push_str(&format!("\\{byte:03o}"));
3560            }
3561            // WinAnsiEncoding 0x80–0x9F: Unicode characters that don't match
3562            // their code points but have specific byte mappings.
3563            _ => {
3564                if let Some(byte) = winansi_byte(c) {
3565                    out.push_str(&format!("\\{byte:03o}"));
3566                } else {
3567                    out.push('?');
3568                }
3569            }
3570        }
3571    }
3572    out
3573}
3574
3575/// Map Unicode characters to WinAnsiEncoding bytes in the 0x80–0x9F range.
3576///
3577/// These characters have code points outside the Latin-1 Supplement range but
3578/// are assigned specific byte values in WinAnsiEncoding. Common in song lyrics:
3579/// smart quotes, em/en dashes, Euro sign, etc.
3580fn winansi_byte(c: char) -> Option<u32> {
3581    match c {
3582        '\u{20AC}' => Some(0x80), // € Euro sign
3583        '\u{201A}' => Some(0x82), // ‚ Single low-9 quotation mark
3584        '\u{0192}' => Some(0x83), // ƒ Latin small f with hook
3585        '\u{201E}' => Some(0x84), // „ Double low-9 quotation mark
3586        '\u{2026}' => Some(0x85), // … Horizontal ellipsis
3587        '\u{2020}' => Some(0x86), // † Dagger
3588        '\u{2021}' => Some(0x87), // ‡ Double dagger
3589        '\u{02C6}' => Some(0x88), // ˆ Modifier letter circumflex accent
3590        '\u{2030}' => Some(0x89), // ‰ Per mille sign
3591        '\u{0160}' => Some(0x8A), // Š Latin capital S with caron
3592        '\u{2039}' => Some(0x8B), // ‹ Single left-pointing angle quotation
3593        '\u{0152}' => Some(0x8C), // Œ Latin capital ligature OE
3594        '\u{017D}' => Some(0x8E), // Ž Latin capital Z with caron
3595        '\u{2018}' => Some(0x91), // ' Left single quotation mark
3596        '\u{2019}' => Some(0x92), // ' Right single quotation mark
3597        '\u{201C}' => Some(0x93), // " Left double quotation mark
3598        '\u{201D}' => Some(0x94), // " Right double quotation mark
3599        '\u{2022}' => Some(0x95), // • Bullet
3600        '\u{2013}' => Some(0x96), // – En dash
3601        '\u{2014}' => Some(0x97), // — Em dash
3602        '\u{02DC}' => Some(0x98), // ˜ Small tilde
3603        '\u{2122}' => Some(0x99), // ™ Trade mark sign
3604        '\u{0161}' => Some(0x9A), // š Latin small s with caron
3605        '\u{203A}' => Some(0x9B), // › Single right-pointing angle quotation
3606        '\u{0153}' => Some(0x9C), // œ Latin small ligature oe
3607        '\u{017E}' => Some(0x9E), // ž Latin small z with caron
3608        '\u{0178}' => Some(0x9F), // Ÿ Latin capital Y with diaeresis
3609        _ => None,
3610    }
3611}
3612
3613/// Format f32 without trailing zeros for compact PDF output.
3614///
3615/// Non-finite values (NaN, ±Infinity) are replaced with `"0"` to prevent
3616/// malformed PDF operators.
3617fn fmt_f32(v: f32) -> String {
3618    if !v.is_finite() {
3619        return "0".to_string();
3620    }
3621    let s = format!("{v:.2}");
3622    // Trim trailing zeros after decimal point.
3623    if s.contains('.') {
3624        s.trim_end_matches('0').trim_end_matches('.').to_string()
3625    } else {
3626        s
3627    }
3628}
3629
3630// ===========================================================================
3631// Tests
3632// ===========================================================================
3633
3634#[cfg(test)]
3635mod tests {
3636    use super::*;
3637
3638    #[test]
3639    fn test_produces_valid_pdf() {
3640        let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello [G]world").unwrap();
3641        let bytes = render_song(&song);
3642        assert!(!bytes.is_empty());
3643        assert!(bytes.starts_with(b"%PDF-1.4"));
3644        assert!(bytes.ends_with(b"%%EOF\n"));
3645    }
3646
3647    #[test]
3648    fn test_empty_song() {
3649        let song = chordsketch_chordpro::parse("").unwrap();
3650        let bytes = render_song(&song);
3651        assert!(bytes.starts_with(b"%PDF"));
3652    }
3653
3654    #[test]
3655    fn test_try_render_success() {
3656        let result = try_render("{title: Test}\n[G]Hello");
3657        assert!(result.is_ok());
3658        assert!(result.unwrap().starts_with(b"%PDF"));
3659    }
3660
3661    #[test]
3662    fn test_try_render_error() {
3663        let result = try_render("{unclosed");
3664        assert!(result.is_err());
3665    }
3666
3667    #[test]
3668    fn test_full_song() {
3669        let input = "\
3670{title: Amazing Grace}
3671{subtitle: Traditional}
3672
3673{start_of_verse}
3674[G]Amazing [G7]grace
3675{end_of_verse}
3676
3677{comment: Repeat}";
3678        let song = chordsketch_chordpro::parse(input).unwrap();
3679        let bytes = render_song(&song);
3680        assert!(bytes.starts_with(b"%PDF"));
3681        // Should contain the title text in the content stream
3682        let content = String::from_utf8_lossy(&bytes);
3683        assert!(content.contains("Amazing Grace"));
3684    }
3685
3686    #[test]
3687    fn test_stream_length_matches_content() {
3688        let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
3689        let bytes = render_song(&song);
3690        let content = String::from_utf8_lossy(&bytes);
3691
3692        // Find all /Length N declarations and verify they match the actual
3693        // stream content between "stream\n" and "\nendstream".
3694        for length_match in content.match_indices("/Length ") {
3695            let after = &content[length_match.0 + 8..];
3696            let end = after.find(' ').or_else(|| after.find('>')).unwrap();
3697            let declared_len: usize = after[..end].trim().parse().unwrap();
3698
3699            // Find the stream start after this /Length
3700            let stream_start_offset =
3701                length_match.0 + content[length_match.0..].find("stream\n").unwrap() + 7;
3702            let endstream_offset =
3703                length_match.0 + content[length_match.0..].find("\nendstream").unwrap();
3704            let actual_len = endstream_offset - stream_start_offset;
3705            assert_eq!(
3706                declared_len, actual_len,
3707                "/Length {declared_len} does not match actual stream size {actual_len}"
3708            );
3709        }
3710    }
3711
3712    #[test]
3713    fn test_pdf_escape() {
3714        assert_eq!(pdf_escape("hello"), "hello");
3715        assert_eq!(pdf_escape("a(b)c"), "a\\(b\\)c");
3716        assert_eq!(pdf_escape("back\\slash"), "back\\\\slash");
3717    }
3718
3719    #[test]
3720    fn test_pdf_escape_latin1_accented() {
3721        // é (U+00E9) → octal 351
3722        assert_eq!(pdf_escape("café"), "caf\\351");
3723        // ü (U+00FC) → octal 374
3724        assert_eq!(pdf_escape("über"), "\\374ber");
3725        // ñ (U+00F1) → octal 361
3726        assert_eq!(pdf_escape("España"), "Espa\\361a");
3727        // ß (U+00DF) → octal 337
3728        assert_eq!(pdf_escape("Straße"), "Stra\\337e");
3729    }
3730
3731    #[test]
3732    fn test_pdf_escape_non_latin1_replaced() {
3733        // pdf_escape() handles WinAnsiEncoding only; CJK characters that arrive
3734        // here are replaced with '?'. In practice, `text_at*` methods route
3735        // non-Latin-1 characters to the CID font path (encode_cid_text) and
3736        // never call pdf_escape() on them.
3737        assert_eq!(pdf_escape("日本語"), "???");
3738        assert_eq!(pdf_escape("hello 世界"), "hello ??");
3739    }
3740
3741    /// CJK characters in song titles and lyrics must appear in the PDF via the
3742    /// CID composite font (/F5), not as '?' placeholders.
3743    #[test]
3744    fn test_cjk_renders_via_cid_font() {
3745        let song = chordsketch_chordpro::parse("{title: 桜}\n日本語の歌詞").unwrap();
3746        let bytes = render_song(&song);
3747        assert!(bytes.starts_with(b"%PDF"), "must produce a PDF");
3748
3749        let text = String::from_utf8_lossy(&bytes);
3750        // The CID composite font object must be present.
3751        assert!(
3752            text.contains("/Subtype /CIDFontType0"),
3753            "CIDFontType0 object must appear when CJK glyphs are used"
3754        );
3755        assert!(
3756            text.contains("/Subtype /Type0"),
3757            "Type0 composite font wrapper must be present"
3758        );
3759        assert!(
3760            text.contains("/Encoding /Identity-H"),
3761            "Identity-H encoding must be specified for the CID font"
3762        );
3763        // CJK text is encoded as hex GID sequences, never as '?' literals.
3764        // The title '桜' is a single kanji; verify at least one <GGGG> sequence
3765        // appears (4 uppercase hex digits enclosed in angle brackets).
3766        assert!(
3767            bytes.windows(6).any(|w| {
3768                w[0] == b'<' && w[1..5].iter().all(|b| b.is_ascii_hexdigit()) && w[5] == b'>'
3769            }),
3770            "CID hex glyph sequence must appear in content stream"
3771        );
3772    }
3773
3774    /// Mixed ASCII and CJK in the same song must produce a valid PDF that
3775    /// contains both Helvetica segments and CID font segments.
3776    #[test]
3777    fn test_mixed_ascii_and_cjk() {
3778        let song =
3779            chordsketch_chordpro::parse("{title: Sakura 桜}\n[Am]Hello [G]世界\nEnd of song")
3780                .unwrap();
3781        let bytes = render_song(&song);
3782        assert!(bytes.starts_with(b"%PDF"));
3783        let text = String::from_utf8_lossy(&bytes);
3784        // Both Latin-1 and CID fonts must be present.
3785        assert!(
3786            text.contains("Helvetica"),
3787            "Helvetica Type1 font must be present"
3788        );
3789        assert!(
3790            text.contains("CIDFontType0"),
3791            "CID font must be present for kanji"
3792        );
3793    }
3794
3795    /// A song with only ASCII content must NOT include the CID font objects —
3796    /// the CID font chain is a conditional overhead.
3797    #[test]
3798    fn test_ascii_only_has_no_cid_font() {
3799        let song = chordsketch_chordpro::parse("{title: Test}\n[G]Hello world").unwrap();
3800        let bytes = render_song(&song);
3801        let text = String::from_utf8_lossy(&bytes);
3802        assert!(
3803            !text.contains("CIDFontType0"),
3804            "CID font must not appear in ASCII-only PDFs"
3805        );
3806    }
3807
3808    #[test]
3809    fn test_missing_glyph_gid0_not_in_to_unicode_cmap() {
3810        // Regression test for #1676: characters absent from the bundled font map to
3811        // GID 0 (.notdef) in the content stream. GID 0 must NOT appear in the
3812        // ToUnicode CMap (PDF spec §9.10.3).
3813        //
3814        // U+1F600 (😀) and U+1F601 (😁) are emoji not present in the Noto Sans CJK
3815        // subset, so both produce GID 0 in the hex string. The CID font chain MUST
3816        // still be emitted (cid_needed=true) because /F5 was referenced in the
3817        // content stream — even though no non-GID-0 mappings exist.
3818        let song = chordsketch_chordpro::parse("{title: T}\n\u{1F600}\u{1F601}").unwrap();
3819        let bytes = render_song(&song);
3820        let text = String::from_utf8_lossy(&bytes);
3821        // The hex content stream should encode both characters as GID 0.
3822        assert!(
3823            text.contains("<00000000>"),
3824            "both missing glyphs should emit GID 0"
3825        );
3826        // GID 0 must not appear as a source entry in the ToUnicode CMap.
3827        assert!(
3828            !text.contains("<0000> <"),
3829            "GID 0 (.notdef) must not appear as a CMap source entry"
3830        );
3831        // The CID font chain must still be present because /F5 was used in the
3832        // content stream. Absence would produce an invalid PDF referencing an
3833        // undeclared font resource (PDF spec §7.8.3).
3834        assert!(
3835            text.contains("CIDFontType0"),
3836            "CID font chain must be emitted even when all glyphs map to GID 0"
3837        );
3838    }
3839
3840    #[test]
3841    fn test_pdf_escape_mixed_ascii_latin1() {
3842        assert_eq!(pdf_escape("résumé"), "r\\351sum\\351");
3843        // Non-breaking space (U+00A0) → octal 240
3844        assert_eq!(pdf_escape("a\u{00A0}b"), "a\\240b");
3845    }
3846
3847    #[test]
3848    fn test_pdf_escape_winansi_0x80_range() {
3849        // Euro sign (U+20AC → 0x80)
3850        assert_eq!(pdf_escape("\u{20AC}"), "\\200");
3851        // Left single quotation mark (U+2018 → 0x91)
3852        assert_eq!(pdf_escape("\u{2018}"), "\\221");
3853        // Right single quotation mark (U+2019 → 0x92)
3854        assert_eq!(pdf_escape("\u{2019}"), "\\222");
3855        // Left double quotation mark (U+201C → 0x93)
3856        assert_eq!(pdf_escape("\u{201C}"), "\\223");
3857        // Right double quotation mark (U+201D → 0x94)
3858        assert_eq!(pdf_escape("\u{201D}"), "\\224");
3859        // En dash (U+2013 → 0x96)
3860        assert_eq!(pdf_escape("\u{2013}"), "\\226");
3861        // Em dash (U+2014 → 0x97)
3862        assert_eq!(pdf_escape("\u{2014}"), "\\227");
3863        // Horizontal ellipsis (U+2026 → 0x85)
3864        assert_eq!(pdf_escape("\u{2026}"), "\\205");
3865        // Trade mark sign (U+2122 → 0x99)
3866        assert_eq!(pdf_escape("\u{2122}"), "\\231");
3867        // Bullet (U+2022 → 0x95)
3868        assert_eq!(pdf_escape("\u{2022}"), "\\225");
3869    }
3870
3871    #[test]
3872    fn test_pdf_escape_winansi_mixed() {
3873        // Smart quotes in lyrics: "Don't stop"
3874        assert_eq!(
3875            pdf_escape("\u{201C}Don\u{2019}t stop\u{201D}"),
3876            "\\223Don\\222t stop\\224"
3877        );
3878        // Price with Euro sign: €50
3879        assert_eq!(pdf_escape("\u{20AC}50"), "\\20050");
3880    }
3881
3882    #[test]
3883    fn test_render_grid_section() {
3884        let input = "{start_of_grid}\n| Am . | C . |\n{end_of_grid}";
3885        let song = chordsketch_chordpro::parse(input).unwrap();
3886        let bytes = render_song(&song);
3887        assert!(bytes.starts_with(b"%PDF"));
3888        let content = String::from_utf8_lossy(&bytes);
3889        assert!(content.contains("Grid"));
3890    }
3891
3892    // --- Custom sections (#108) ---
3893
3894    #[test]
3895    fn test_custom_section_in_pdf() {
3896        let input = "\
3897{title: Test}
3898
3899{start_of_intro: Guitar}
3900[Am]Intro line
3901{end_of_intro}";
3902        let song = chordsketch_chordpro::parse(input).unwrap();
3903        let bytes = render_song(&song);
3904        let content = String::from_utf8_lossy(&bytes);
3905        assert!(content.contains("Intro: Guitar"));
3906    }
3907
3908    // --- Issue #109: {chorus} recall ---
3909
3910    #[test]
3911    fn test_chorus_recall_produces_valid_pdf() {
3912        let input = "\
3913{start_of_chorus}
3914[G]La la la
3915{end_of_chorus}
3916
3917{chorus}";
3918        let song = chordsketch_chordpro::parse(input).unwrap();
3919        let bytes = render_song(&song);
3920        assert!(bytes.starts_with(b"%PDF-1.4"));
3921        assert!(bytes.ends_with(b"%%EOF\n"));
3922        // "Chorus" label should appear at least twice in the content stream
3923        let content = String::from_utf8_lossy(&bytes);
3924        assert!(content.matches("Chorus").count() >= 2);
3925    }
3926
3927    #[test]
3928    fn test_chorus_recall_with_label() {
3929        let input = "\
3930{start_of_chorus}
3931Sing along
3932{end_of_chorus}
3933
3934{chorus: Repeat}";
3935        let song = chordsketch_chordpro::parse(input).unwrap();
3936        let bytes = render_song(&song);
3937        let content = String::from_utf8_lossy(&bytes);
3938        assert!(content.contains("Chorus: Repeat"));
3939    }
3940
3941    #[test]
3942    fn test_chorus_recall_no_chorus_defined() {
3943        let input = "{chorus}";
3944        let song = chordsketch_chordpro::parse(input).unwrap();
3945        let bytes = render_song(&song);
3946        assert!(bytes.starts_with(b"%PDF"));
3947        let content = String::from_utf8_lossy(&bytes);
3948        assert!(content.contains("Chorus"));
3949    }
3950
3951    #[test]
3952    fn test_chorus_recall_limit_exceeded() {
3953        let mut input = String::from("{start_of_chorus}\nChorus line\n{end_of_chorus}\n");
3954        for _ in 0..1005 {
3955            input.push_str("{chorus}\n");
3956        }
3957        let song = chordsketch_chordpro::parse(&input).unwrap();
3958        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3959        assert!(result.output.starts_with(b"%PDF"));
3960        assert!(
3961            result
3962                .warnings
3963                .iter()
3964                .any(|w| w.contains("chorus recall limit")),
3965            "should warn when chorus recall limit is exceeded"
3966        );
3967    }
3968
3969    #[test]
3970    fn test_chorus_recall_respects_diagrams_off() {
3971        // When {diagrams: off} is active, chorus recall should not render
3972        // chord diagrams from {define} directives inside the chorus body.
3973        let input = "\
3974{diagrams: off}
3975{start_of_chorus}
3976{define: Am base-fret 1 frets x 0 2 2 1 0}
3977[Am]Chorus line
3978{end_of_chorus}
3979{chorus}";
3980        let song = chordsketch_chordpro::parse(input).unwrap();
3981        let bytes = render_song(&song);
3982        let content = String::from_utf8_lossy(&bytes);
3983        // Chord diagrams use Bezier curves for finger dots. When diagrams are
3984        // off, no circle drawing operations should appear from the diagram renderer.
3985        // Compare against a render with diagrams on to ensure the assertion is meaningful.
3986        let input_on = "\
3987{start_of_chorus}
3988{define: Am base-fret 1 frets x 0 2 2 1 0}
3989[Am]Chorus line
3990{end_of_chorus}
3991{chorus}";
3992        let song_on = chordsketch_chordpro::parse(input_on).unwrap();
3993        let bytes_on = render_song(&song_on);
3994        let content_on = String::from_utf8_lossy(&bytes_on);
3995        // "l S" counts PDF lineto (l) + stroke (S) operations emitted by
3996        // render_chord_diagram_pdf for fret grid lines, nut, and string lines.
3997        let diagram_lines_off = content.matches("l S").count();
3998        let diagram_lines_on = content_on.matches("l S").count();
3999        assert!(
4000            diagram_lines_on > diagram_lines_off,
4001            "diagrams=on should produce more line ops than diagrams=off"
4002        );
4003    }
4004
4005    // --- Case-insensitive {diagrams} directive (#652) ---
4006
4007    #[test]
4008    fn test_diagrams_off_case_insensitive_pdf() {
4009        let input = "{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}";
4010        let song = chordsketch_chordpro::parse(input).unwrap();
4011        let bytes = render_song(&song);
4012        let content = String::from_utf8_lossy(&bytes);
4013        // When diagrams are suppressed the chord-name title ("Am") written by
4014        // render_chord_diagram_pdf must not appear in the PDF content stream.
4015        // This input has no lyrics, so "Am" would only appear as the diagram title.
4016        assert!(
4017            !content.contains("Am"),
4018            "diagrams=Off should suppress diagrams in PDF (case-insensitive)"
4019        );
4020    }
4021
4022    #[test]
4023    fn test_diagrams_off_uppercase_pdf() {
4024        let input = "{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}";
4025        let song = chordsketch_chordpro::parse(input).unwrap();
4026        let bytes = render_song(&song);
4027        let content = String::from_utf8_lossy(&bytes);
4028        // When diagrams are suppressed the chord-name title ("Am") written by
4029        // render_chord_diagram_pdf must not appear in the PDF content stream.
4030        // This input has no lyrics, so "Am" would only appear as the diagram title.
4031        assert!(
4032            !content.contains("Am"),
4033            "diagrams=OFF should suppress diagrams in PDF (case-insensitive)"
4034        );
4035    }
4036
4037    #[test]
4038    fn test_custom_section_solo_in_pdf() {
4039        let input = "{start_of_solo}\n[Em]Solo\n{end_of_solo}";
4040        let song = chordsketch_chordpro::parse(input).unwrap();
4041        let bytes = render_song(&song);
4042        let content = String::from_utf8_lossy(&bytes);
4043        assert!(content.contains("Solo"));
4044    }
4045
4046    #[test]
4047    fn test_render_grid_section_with_label() {
4048        let input = "{start_of_grid: Intro}\n| Am |\n{end_of_grid}";
4049        let song = chordsketch_chordpro::parse(input).unwrap();
4050        let bytes = render_song(&song);
4051        let content = String::from_utf8_lossy(&bytes);
4052        assert!(content.contains("Grid: Intro"));
4053    }
4054
4055    #[test]
4056    fn test_define_display_name_in_pdf_output() {
4057        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}";
4058        let song = chordsketch_chordpro::parse(input).unwrap();
4059        let bytes = render_song(&song);
4060        let content = String::from_utf8_lossy(&bytes);
4061        assert!(
4062            content.contains("A minor"),
4063            "display name should appear in rendered PDF output"
4064        );
4065    }
4066
4067    #[test]
4068    fn test_define_with_fingers_in_pdf_output() {
4069        let input = "{define: C base-fret 1 frets x 3 2 0 1 0 fingers 0 3 2 0 1 0}";
4070        let song = chordsketch_chordpro::parse(input).unwrap();
4071        let bytes = render_song(&song);
4072        let content = String::from_utf8_lossy(&bytes);
4073        // PDF text streams should contain finger numbers
4074        assert!(
4075            content.contains("(3)"),
4076            "finger numbers should appear in rendered PDF output"
4077        );
4078    }
4079}
4080
4081#[cfg(test)]
4082mod comment_style_tests {
4083    use super::*;
4084
4085    #[test]
4086    fn test_comment_normal_renders_text() {
4087        let input = "{comment: This is normal}";
4088        let song = chordsketch_chordpro::parse(input).unwrap();
4089        let bytes = render_song(&song);
4090        let content = String::from_utf8_lossy(&bytes);
4091        assert!(
4092            content.contains("This is normal"),
4093            "normal comment text should appear in PDF"
4094        );
4095    }
4096
4097    #[test]
4098    fn test_comment_italic_renders_text() {
4099        let input = "{comment_italic: Italic note}";
4100        let song = chordsketch_chordpro::parse(input).unwrap();
4101        let bytes = render_song(&song);
4102        let content = String::from_utf8_lossy(&bytes);
4103        assert!(
4104            content.contains("Italic note"),
4105            "italic comment text should appear in PDF"
4106        );
4107    }
4108
4109    #[test]
4110    fn test_comment_box_renders_with_rect() {
4111        let input = "{comment_box: Boxed note}";
4112        let song = chordsketch_chordpro::parse(input).unwrap();
4113        let bytes = render_song(&song);
4114        let content = String::from_utf8_lossy(&bytes);
4115        assert!(
4116            content.contains("Boxed note"),
4117            "boxed comment text should appear in PDF"
4118        );
4119        // Boxed comments use "re S" (rect stroke) PDF operator.
4120        assert!(
4121            content.contains("re S"),
4122            "boxed comment should draw a rectangle border"
4123        );
4124    }
4125
4126    #[test]
4127    fn test_comment_normal_no_rect() {
4128        let input = "{comment: No box here}";
4129        let song = chordsketch_chordpro::parse(input).unwrap();
4130        let bytes = render_song(&song);
4131        let content = String::from_utf8_lossy(&bytes);
4132        assert!(
4133            !content.contains("re S"),
4134            "normal comment should not draw a rectangle"
4135        );
4136    }
4137}
4138
4139#[cfg(test)]
4140mod transpose_tests {
4141    use super::*;
4142
4143    #[test]
4144    fn test_transpose_directive_produces_pdf() {
4145        let input = "{transpose: 2}\n[G]Hello [C]world";
4146        let song = chordsketch_chordpro::parse(input).unwrap();
4147        let bytes = render_song(&song);
4148        assert!(bytes.starts_with(b"%PDF"));
4149        // Transposed chords should appear in the PDF content: G+2=A, C+2=D
4150        let content = String::from_utf8_lossy(&bytes);
4151        assert!(content.contains("(A)"));
4152        assert!(content.contains("(D)"));
4153    }
4154
4155    #[test]
4156    fn test_transpose_with_cli_offset() {
4157        let input = "{transpose: 2}\n[C]Hello";
4158        let song = chordsketch_chordpro::parse(input).unwrap();
4159        let bytes = render_song_with_transpose(&song, 3, &Config::defaults());
4160        // 2+3=5, C+5=F
4161        let content = String::from_utf8_lossy(&bytes);
4162        assert!(content.contains("(F)"));
4163    }
4164
4165    #[test]
4166    fn test_transpose_out_of_i8_range_emits_warning() {
4167        // 999 cannot be represented as i8; should fall back to 0 with a warning
4168        let input = "{transpose: 999}\n[G]Hello";
4169        let song = chordsketch_chordpro::parse(input).unwrap();
4170        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4171        // G + 0 transposition = G
4172        let content = String::from_utf8_lossy(&result.output);
4173        assert!(content.contains("(G)"), "chord should be untransposed");
4174        assert!(
4175            result.warnings.iter().any(|w| w.contains("\"999\"")),
4176            "expected warning about out-of-range value, got: {:?}",
4177            result.warnings
4178        );
4179    }
4180
4181    #[test]
4182    fn test_transpose_no_value_treated_as_zero() {
4183        // {transpose} with no value should silently reset to 0, no warning.
4184        let input = "{transpose}\n[G]Hello";
4185        let song = chordsketch_chordpro::parse(input).unwrap();
4186        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4187        let content = String::from_utf8_lossy(&result.output);
4188        assert!(content.contains("(G)"), "chord should be untransposed");
4189        assert!(
4190            result.warnings.is_empty(),
4191            "missing {{transpose}} value should not emit a warning; got: {:?}",
4192            result.warnings
4193        );
4194    }
4195
4196    #[test]
4197    fn test_transpose_whitespace_value_treated_as_zero() {
4198        // {transpose:   } with whitespace-only value should silently reset to 0,
4199        // no warning emitted. The parser trims whitespace → Some(""), which the
4200        // Some("") arm converts to 0.
4201        let input = "{transpose:   }\n[G]Hello";
4202        let song = chordsketch_chordpro::parse(input).unwrap();
4203        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4204        let content = String::from_utf8_lossy(&result.output);
4205        assert!(content.contains("(G)"), "chord should be untransposed");
4206        assert!(
4207            result.warnings.is_empty(),
4208            "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
4209            result.warnings
4210        );
4211    }
4212}
4213
4214#[cfg(test)]
4215mod delegate_tests {
4216    use super::*;
4217
4218    #[test]
4219    fn test_abc_section_in_pdf() {
4220        let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
4221        let song = chordsketch_chordpro::parse(input).unwrap();
4222        let bytes = render_song(&song);
4223        assert!(bytes.starts_with(b"%PDF"));
4224        let content = String::from_utf8_lossy(&bytes);
4225        assert!(content.contains("ABC: Melody"));
4226    }
4227
4228    #[test]
4229    fn test_ly_section_in_pdf() {
4230        let input = "{start_of_ly}\nnotes\n{end_of_ly}";
4231        let song = chordsketch_chordpro::parse(input).unwrap();
4232        let bytes = render_song(&song);
4233        let content = String::from_utf8_lossy(&bytes);
4234        assert!(content.contains("Lilypond"));
4235    }
4236
4237    #[test]
4238    fn test_svg_section_in_pdf() {
4239        let input = "{start_of_svg}\n<svg/>\n{end_of_svg}";
4240        let song = chordsketch_chordpro::parse(input).unwrap();
4241        let bytes = render_song(&song);
4242        let content = String::from_utf8_lossy(&bytes);
4243        assert!(content.contains("SVG"));
4244    }
4245
4246    #[test]
4247    fn test_textblock_section_in_pdf() {
4248        let input = "{start_of_textblock}\nText\n{end_of_textblock}";
4249        let song = chordsketch_chordpro::parse(input).unwrap();
4250        let bytes = render_song(&song);
4251        let content = String::from_utf8_lossy(&bytes);
4252        assert!(content.contains("Textblock"));
4253    }
4254
4255    #[test]
4256    fn test_musicxml_section_in_pdf() {
4257        let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
4258        let song = chordsketch_chordpro::parse(input).unwrap();
4259        let bytes = render_song(&song);
4260        let content = String::from_utf8_lossy(&bytes);
4261        assert!(content.contains("MusicXML"));
4262    }
4263
4264    // #1825 — Notation blocks emit a structured warning AND skip the
4265    // body source so it does not land in the PDF as plain text.
4266
4267    #[test]
4268    fn test_abc_block_emits_warning_and_skips_body() {
4269        let input = "{start_of_abc: Melody}\nX:1\nK:C\nCDEF\n{end_of_abc}";
4270        let song = chordsketch_chordpro::parse(input).unwrap();
4271        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4272        assert!(
4273            result
4274                .warnings
4275                .iter()
4276                .any(|w| w.contains("ABC") && w.contains("omitted")),
4277            "expected at least one warning mentioning `ABC` and `omitted`; got {:?}",
4278            result.warnings,
4279        );
4280        let content = String::from_utf8_lossy(&result.output);
4281        // The section label must still appear (so readers see where
4282        // the block was), but the notation source must not.
4283        assert!(content.contains("ABC: Melody"));
4284        assert!(
4285            !content.contains("CDEF"),
4286            "ABC body content must not leak into the PDF as plain text",
4287        );
4288        // The explanatory placeholder line must reach the output.
4289        assert!(content.contains("[ABC block omitted"));
4290    }
4291
4292    #[test]
4293    fn test_ly_block_emits_warning_and_skips_body() {
4294        let input = "{start_of_ly}\n\\relative c' { c4 d }\n{end_of_ly}";
4295        let song = chordsketch_chordpro::parse(input).unwrap();
4296        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4297        assert!(result.warnings.iter().any(|w| w.contains("Lilypond")));
4298        let content = String::from_utf8_lossy(&result.output);
4299        assert!(content.contains("[Lilypond block omitted"));
4300        assert!(
4301            !content.contains("\\relative"),
4302            "Lilypond body content must not leak into the PDF"
4303        );
4304    }
4305
4306    #[test]
4307    fn test_svg_block_emits_warning_and_skips_body() {
4308        let input = "{start_of_svg}\n<svg><circle r=\"10\"/></svg>\n{end_of_svg}";
4309        let song = chordsketch_chordpro::parse(input).unwrap();
4310        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4311        assert!(result.warnings.iter().any(|w| w.contains("SVG")));
4312        let content = String::from_utf8_lossy(&result.output);
4313        assert!(content.contains("[SVG block omitted"));
4314        assert!(
4315            !content.contains("<circle"),
4316            "SVG body content must not leak into the PDF"
4317        );
4318    }
4319
4320    #[test]
4321    fn test_musicxml_block_emits_warning_and_skips_body() {
4322        let input =
4323            "{start_of_musicxml: Score}\n<score-partwise>notes</score-partwise>\n{end_of_musicxml}";
4324        let song = chordsketch_chordpro::parse(input).unwrap();
4325        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4326        assert!(result.warnings.iter().any(|w| w.contains("MusicXML")));
4327        let content = String::from_utf8_lossy(&result.output);
4328        assert!(content.contains("[MusicXML block omitted"));
4329        assert!(
4330            !content.contains("<score-partwise"),
4331            "MusicXML body content must not leak into the PDF",
4332        );
4333    }
4334
4335    #[test]
4336    fn test_content_after_notation_block_still_renders() {
4337        // The skip-until-end window must close at the matching
4338        // `EndOf…` directive. Content after the block is rendered
4339        // normally.
4340        let input = "{title: T}\n{start_of_abc}\nbody\n{end_of_abc}\n[C]Hello world\n";
4341        let song = chordsketch_chordpro::parse(input).unwrap();
4342        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4343        assert!(result.warnings.iter().any(|w| w.contains("ABC")));
4344        let content = String::from_utf8_lossy(&result.output);
4345        assert!(content.contains("Hello world"));
4346        assert!(!content.contains("body"));
4347    }
4348
4349    // #1969 — edge-case coverage for the notation-block skip window.
4350
4351    #[test]
4352    fn test_notation_block_inside_chorus_is_excluded_from_recall() {
4353        // A notation block INSIDE a chorus body must:
4354        //   (a) still produce its structured warning at the initial
4355        //       render, and
4356        //   (b) NOT be replayed by a subsequent `{chorus}` recall —
4357        //       lines are only appended to the chorus buffer from the
4358        //       default match arm, which the notation-block
4359        //       short-circuit bypasses. A recall after this source
4360        //       therefore only replays the surrounding lyrics, not
4361        //       the placeholder.
4362        let input = "{start_of_chorus}\n\
4363                     [G]Sing along\n\
4364                     {start_of_abc}\n\
4365                     X:1\n\
4366                     {end_of_abc}\n\
4367                     [C]another line\n\
4368                     {end_of_chorus}\n\
4369                     {chorus}\n";
4370        let song = chordsketch_chordpro::parse(input).unwrap();
4371        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4372        // One ABC block seen once → at most one ABC warning. The
4373        // recall must NOT produce a second.
4374        let abc_warnings = result.warnings.iter().filter(|w| w.contains("ABC")).count();
4375        assert_eq!(
4376            abc_warnings, 1,
4377            "exactly one ABC warning expected (recall must not re-emit); got {:?}",
4378            result.warnings,
4379        );
4380        let content = String::from_utf8_lossy(&result.output);
4381        // The surrounding chorus lyrics are recalled normally.
4382        assert!(content.contains("Sing along"));
4383        assert!(content.contains("another line"));
4384    }
4385
4386    #[test]
4387    fn test_unterminated_notation_block_renders_without_panic() {
4388        // A file that enters a notation block and ends before the
4389        // matching `end_of_<tag>` must not panic. The section label,
4390        // warning, and placeholder all land; any content after the
4391        // unterminated StartOf is simply swallowed by the skip
4392        // window.
4393        let input = "{title: T}\n[C]Before\n{start_of_abc}\nX:1\nK:C\n";
4394        let song = chordsketch_chordpro::parse(input).unwrap();
4395        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4396        assert!(
4397            result.warnings.iter().any(|w| w.contains("ABC")),
4398            "unterminated ABC block should still emit the warning; got {:?}",
4399            result.warnings,
4400        );
4401        let content = String::from_utf8_lossy(&result.output);
4402        assert!(content.contains("Before"));
4403        assert!(content.contains("[ABC block omitted"));
4404        // Body must not leak even though no EndOf was seen.
4405        assert!(!content.contains("X:1"));
4406        assert!(!content.contains("K:C"));
4407    }
4408
4409    #[test]
4410    fn test_stray_end_of_notation_is_silently_ignored() {
4411        // A stray `{end_of_abc}` outside any notation block must not
4412        // panic and must not produce a spurious warning. The skip
4413        // state is `None` at that point, so the End directive flows
4414        // through the default match arm (which treats it like any
4415        // other unknown directive — rendered via `render_directive`
4416        // but with no special behaviour for EndOf variants).
4417        let input = "{title: T}\n[C]Hello\n{end_of_abc}\n[D]World\n";
4418        let song = chordsketch_chordpro::parse(input).unwrap();
4419        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4420        assert!(
4421            !result
4422                .warnings
4423                .iter()
4424                .any(|w| w.contains("ABC") && w.contains("omitted")),
4425            "stray `end_of_abc` must not trigger the notation-block warning; got {:?}",
4426            result.warnings,
4427        );
4428        let content = String::from_utf8_lossy(&result.output);
4429        assert!(content.contains("Hello"));
4430        assert!(content.contains("World"));
4431    }
4432}
4433
4434#[cfg(test)]
4435mod inline_markup_tests {
4436    use super::*;
4437
4438    #[test]
4439    fn test_bold_markup_uses_bold_font() {
4440        let input = "Hello <b>bold</b> world";
4441        let song = chordsketch_chordpro::parse(input).unwrap();
4442        let bytes = render_song(&song);
4443        let content = String::from_utf8_lossy(&bytes);
4444        // PDF should contain both Helvetica (regular) and HelveticaBold
4445        assert!(content.contains("/F1"));
4446        assert!(content.contains("/F2"));
4447        assert!(content.contains("bold"));
4448    }
4449
4450    #[test]
4451    fn test_italic_markup_uses_oblique_font() {
4452        let input = "Hello <i>italic</i> text";
4453        let song = chordsketch_chordpro::parse(input).unwrap();
4454        let bytes = render_song(&song);
4455        let content = String::from_utf8_lossy(&bytes);
4456        assert!(content.contains("/F3")); // HelveticaOblique
4457        assert!(content.contains("italic"));
4458    }
4459
4460    #[test]
4461    fn test_bold_italic_markup_uses_bold_oblique_font() {
4462        let input = "<b><i>bold italic</i></b>";
4463        let song = chordsketch_chordpro::parse(input).unwrap();
4464        let bytes = render_song(&song);
4465        let content = String::from_utf8_lossy(&bytes);
4466        assert!(content.contains("/F4")); // HelveticaBoldOblique
4467        assert!(content.contains("bold italic"));
4468    }
4469
4470    #[test]
4471    fn test_markup_with_chords_produces_valid_pdf() {
4472        let input = "[Am]Hello <b>bold</b> world";
4473        let song = chordsketch_chordpro::parse(input).unwrap();
4474        let bytes = render_song(&song);
4475        assert!(bytes.starts_with(b"%PDF"));
4476        let content = String::from_utf8_lossy(&bytes);
4477        assert!(content.contains("Am"));
4478        assert!(content.contains("bold"));
4479    }
4480
4481    #[test]
4482    fn test_span_weight_bold_uses_bold_font() {
4483        let input = r#"<span weight="bold">weighted</span>"#;
4484        let song = chordsketch_chordpro::parse(input).unwrap();
4485        let bytes = render_song(&song);
4486        let content = String::from_utf8_lossy(&bytes);
4487        assert!(content.contains("/F2")); // HelveticaBold
4488        assert!(content.contains("weighted"));
4489    }
4490}
4491
4492#[cfg(test)]
4493mod formatting_directive_tests {
4494    use super::*;
4495
4496    #[test]
4497    fn test_textsize_directive_changes_font_size() {
4498        let input = "{textsize: 14}\nHello world";
4499        let song = chordsketch_chordpro::parse(input).unwrap();
4500        let bytes = render_song(&song);
4501        let content = String::from_utf8_lossy(&bytes);
4502        // The PDF should use 14pt for lyrics text
4503        assert!(content.contains("14"));
4504        assert!(content.contains("Hello world"));
4505    }
4506
4507    #[test]
4508    fn test_chordsize_directive_changes_chord_size() {
4509        let input = "{chordsize: 16}\n[Am]Hello";
4510        let song = chordsketch_chordpro::parse(input).unwrap();
4511        let bytes = render_song(&song);
4512        let content = String::from_utf8_lossy(&bytes);
4513        assert!(content.contains("Am"));
4514    }
4515
4516    #[test]
4517    fn test_formatting_directive_produces_valid_pdf() {
4518        let input = "{textsize: 14}\n{chordsize: 12}\n[Am]Hello <b>bold</b> world";
4519        let song = chordsketch_chordpro::parse(input).unwrap();
4520        let bytes = render_song(&song);
4521        assert!(bytes.starts_with(b"%PDF"));
4522    }
4523
4524    #[test]
4525    fn test_textsize_clamped_to_max() {
4526        let input = "{textsize: 99999}\nHello";
4527        let song = chordsketch_chordpro::parse(input).unwrap();
4528        let bytes = render_song(&song);
4529        let content = String::from_utf8_lossy(&bytes);
4530        // Font size must be clamped to MAX_FONT_SIZE (200), not 99999.
4531        assert!(!content.contains("99999"));
4532        assert!(content.contains("200"));
4533    }
4534
4535    #[test]
4536    fn test_textsize_clamped_to_min() {
4537        let input = "{textsize: -5}\nHello";
4538        let song = chordsketch_chordpro::parse(input).unwrap();
4539        let bytes = render_song(&song);
4540        let content = String::from_utf8_lossy(&bytes);
4541        // Negative size must be clamped to MIN_FONT_SIZE (0.5).
4542        assert!(content.contains("0.5"));
4543    }
4544
4545    #[test]
4546    fn test_chordsize_clamped_to_max() {
4547        let input = "{chordsize: 500}\n[Am]Hello";
4548        let song = chordsketch_chordpro::parse(input).unwrap();
4549        let bytes = render_song(&song);
4550        let content = String::from_utf8_lossy(&bytes);
4551        assert!(!content.contains("500"));
4552        assert!(content.contains("200"));
4553    }
4554}
4555
4556#[cfg(test)]
4557mod multipage_tests {
4558    use super::*;
4559
4560    #[test]
4561    fn test_new_page_directive_creates_two_pages() {
4562        let input = "{title: Test}\nPage one\n{new_page}\nPage two";
4563        let song = chordsketch_chordpro::parse(input).unwrap();
4564        let bytes = render_song(&song);
4565        assert!(bytes.starts_with(b"%PDF"));
4566        let content = String::from_utf8_lossy(&bytes);
4567        // Should have /Count 2 in the Pages object
4568        assert!(content.contains("/Count 2"));
4569        assert!(content.contains("Page one"));
4570        assert!(content.contains("Page two"));
4571    }
4572
4573    #[test]
4574    fn test_new_physical_page_from_recto_inserts_blank() {
4575        // Page 1 (recto) -> {npp} -> blank page 2 (verso) -> page 3 (recto)
4576        let input = "Page one\n{new_physical_page}\nPage two";
4577        let song = chordsketch_chordpro::parse(input).unwrap();
4578        let bytes = render_song(&song);
4579        let content = String::from_utf8_lossy(&bytes);
4580        assert!(
4581            content.contains("/Count 3"),
4582            "new_physical_page from recto should insert blank page to reach next recto"
4583        );
4584    }
4585
4586    #[test]
4587    fn test_new_physical_page_from_verso_no_extra_blank() {
4588        // Page 1 (recto) -> {np} -> page 2 (verso) -> {npp} -> page 3 (recto)
4589        let input = "Page one\n{new_page}\nPage two\n{new_physical_page}\nPage three";
4590        let song = chordsketch_chordpro::parse(input).unwrap();
4591        let bytes = render_song(&song);
4592        let content = String::from_utf8_lossy(&bytes);
4593        assert!(
4594            content.contains("/Count 3"),
4595            "new_physical_page from verso should go directly to next recto (no extra blank)"
4596        );
4597    }
4598
4599    #[test]
4600    fn test_single_page_has_count_one() {
4601        let input = "{title: Short Song}\n[Am]Hello";
4602        let song = chordsketch_chordpro::parse(input).unwrap();
4603        let bytes = render_song(&song);
4604        let content = String::from_utf8_lossy(&bytes);
4605        assert!(content.contains("/Count 1"));
4606    }
4607
4608    #[test]
4609    fn test_automatic_page_break_for_long_content() {
4610        // Generate enough lines to overflow a single A4 page
4611        let mut lines = vec!["{title: Long Song}".to_string()];
4612        for i in 0..80 {
4613            lines.push(format!("[Am]Line number {i}"));
4614        }
4615        let input = lines.join("\n");
4616        let song = chordsketch_chordpro::parse(&input).unwrap();
4617        let bytes = render_song(&song);
4618        let content = String::from_utf8_lossy(&bytes);
4619        // Should have more than one page
4620        assert!(
4621            !content.contains("/Count 1"),
4622            "80 chord-lyrics lines should overflow one page"
4623        );
4624    }
4625
4626    #[test]
4627    fn test_multiple_new_page_directives() {
4628        let input = "Page 1\n{new_page}\nPage 2\n{new_page}\nPage 3";
4629        let song = chordsketch_chordpro::parse(input).unwrap();
4630        let bytes = render_song(&song);
4631        let content = String::from_utf8_lossy(&bytes);
4632        assert!(content.contains("/Count 3"));
4633    }
4634
4635    #[test]
4636    fn test_multipage_pdf_structure_valid() {
4637        let input = "First page\n{new_page}\nSecond page";
4638        let song = chordsketch_chordpro::parse(input).unwrap();
4639        let bytes = render_song(&song);
4640        assert!(bytes.starts_with(b"%PDF-1.4"));
4641        assert!(bytes.ends_with(b"%%EOF\n"));
4642        // Verify both pages have content
4643        let content = String::from_utf8_lossy(&bytes);
4644        assert!(content.contains("First page"));
4645        assert!(content.contains("Second page"));
4646    }
4647
4648    #[test]
4649    fn test_page_count_method() {
4650        let mut doc = PdfDocument::new();
4651        assert_eq!(doc.page_count(), 1);
4652        doc.new_page();
4653        assert_eq!(doc.page_count(), 2);
4654        doc.new_page();
4655        assert_eq!(doc.page_count(), 3);
4656    }
4657
4658    #[test]
4659    fn test_new_page_respects_max_limit() {
4660        let mut doc = PdfDocument::new();
4661        // Already has 1 page; add MAX_PAGES more attempts.
4662        for _ in 0..MAX_PAGES {
4663            doc.new_page();
4664        }
4665        assert_eq!(doc.page_count(), MAX_PAGES);
4666    }
4667
4668    #[test]
4669    fn test_take_pages_preserves_invariant() {
4670        let mut doc = PdfDocument::new();
4671        doc.new_page();
4672        assert_eq!(doc.page_count(), 2);
4673
4674        let taken = doc.take_pages();
4675        assert_eq!(taken.len(), 2);
4676        // Document is usable after take — invariant preserved.
4677        assert_eq!(doc.page_count(), 1);
4678        // current_page_mut must not panic.
4679        let _ = doc.current_page_mut();
4680    }
4681
4682    #[test]
4683    fn test_new_page_works_after_take_pages() {
4684        let mut doc = PdfDocument::new();
4685        let _ = doc.take_pages();
4686        doc.new_page();
4687        assert_eq!(doc.page_count(), 2);
4688    }
4689
4690    #[test]
4691    fn test_push_page_respects_max_limit() {
4692        let mut doc = PdfDocument::new();
4693        // Fill to MAX_PAGES via new_page.
4694        for _ in 1..MAX_PAGES {
4695            doc.new_page();
4696        }
4697        assert_eq!(doc.page_count(), MAX_PAGES);
4698
4699        // push_page should be silently dropped.
4700        doc.push_page(vec!["BT (overflow) Tj ET".to_string()]);
4701        assert_eq!(doc.page_count(), MAX_PAGES);
4702    }
4703
4704    #[test]
4705    fn test_combined_toc_and_body_respects_max_limit() {
4706        let mut toc_doc = PdfDocument::new();
4707        // Simulate ToC with a few pages.
4708        for _ in 1..5 {
4709            toc_doc.new_page();
4710        }
4711        assert_eq!(toc_doc.page_count(), 5);
4712
4713        let mut body_doc = PdfDocument::new();
4714        // Fill body to MAX_PAGES.
4715        for _ in 1..MAX_PAGES {
4716            body_doc.new_page();
4717        }
4718        assert_eq!(body_doc.page_count(), MAX_PAGES);
4719
4720        // Combine: push body pages into toc_doc.
4721        for page_ops in body_doc.take_pages() {
4722            toc_doc.push_page(page_ops);
4723        }
4724        // Combined must not exceed MAX_PAGES.
4725        assert_eq!(toc_doc.page_count(), MAX_PAGES);
4726    }
4727
4728    #[test]
4729    fn test_page_control_not_replayed_in_chorus_recall() {
4730        // {new_page} inside a chorus must NOT create an extra page on {chorus} recall.
4731        let input = "\
4732{start_of_chorus}\n\
4733{new_page}\n\
4734[G]La la la\n\
4735{end_of_chorus}\n\
4736Verse text\n\
4737{chorus}";
4738        let song = chordsketch_chordpro::parse(input).unwrap();
4739        let bytes = render_song(&song);
4740        let content = String::from_utf8_lossy(&bytes);
4741        // The initial chorus {new_page} creates page 2.
4742        // The chorus recall should NOT create another page break.
4743        // Expected: exactly 2 pages (initial page + one {new_page}).
4744        assert!(
4745            content.contains("/Count 2"),
4746            "chorus recall must not replay page breaks"
4747        );
4748    }
4749}
4750
4751#[cfg(test)]
4752mod column_tests {
4753    use super::*;
4754
4755    #[test]
4756    fn test_columns_directive_produces_valid_pdf() {
4757        let input = "{columns: 2}\nColumn one\n{column_break}\nColumn two";
4758        let song = chordsketch_chordpro::parse(input).unwrap();
4759        let bytes = render_song(&song);
4760        assert!(bytes.starts_with(b"%PDF"));
4761        let content = String::from_utf8_lossy(&bytes);
4762        assert!(content.contains("Column one"));
4763        assert!(content.contains("Column two"));
4764    }
4765
4766    #[test]
4767    fn test_column_break_in_single_column_creates_new_page() {
4768        let input = "Page one\n{column_break}\nPage two";
4769        let song = chordsketch_chordpro::parse(input).unwrap();
4770        let bytes = render_song(&song);
4771        let content = String::from_utf8_lossy(&bytes);
4772        assert!(content.contains("/Count 2"));
4773    }
4774
4775    #[test]
4776    fn test_columns_reset_to_one() {
4777        let input = "{columns: 2}\nTwo cols\n{columns: 1}\nOne col";
4778        let song = chordsketch_chordpro::parse(input).unwrap();
4779        let bytes = render_song(&song);
4780        assert!(bytes.starts_with(b"%PDF"));
4781        let content = String::from_utf8_lossy(&bytes);
4782        assert!(content.contains("Two cols"));
4783        assert!(content.contains("One col"));
4784    }
4785
4786    #[test]
4787    fn test_margin_left_single_column() {
4788        let doc = PdfDocument::new();
4789        assert!((doc.margin_left() - MARGIN_LEFT).abs() < 0.01);
4790    }
4791
4792    #[test]
4793    fn test_margin_left_multi_column() {
4794        let mut doc = PdfDocument::new();
4795        doc.set_columns(2);
4796        // First column should start at MARGIN_LEFT
4797        assert!((doc.margin_left() - MARGIN_LEFT).abs() < 0.01);
4798        // Second column should be offset
4799        doc.current_column = 1;
4800        assert!(doc.margin_left() > MARGIN_LEFT);
4801    }
4802
4803    #[test]
4804    fn test_margin_left_all_column_counts_positive() {
4805        for n in 1..=MAX_COLUMNS {
4806            let mut doc = PdfDocument::new();
4807            doc.set_columns(n);
4808            for col in 0..n {
4809                doc.current_column = col;
4810                let m = doc.margin_left();
4811                assert!(
4812                    m >= 0.0 && m.is_finite(),
4813                    "margin_left() must be non-negative and finite for columns={n}, col={col}, got {m}"
4814                );
4815            }
4816        }
4817    }
4818
4819    #[test]
4820    fn test_column_break_advances_column() {
4821        let mut doc = PdfDocument::new();
4822        doc.set_columns(2);
4823        assert_eq!(doc.current_column, 0);
4824        doc.column_break();
4825        assert_eq!(doc.current_column, 1);
4826    }
4827
4828    #[test]
4829    fn test_set_columns_clamps_to_max() {
4830        let mut doc = PdfDocument::new();
4831        doc.set_columns(999);
4832        assert_eq!(doc.num_columns, MAX_COLUMNS);
4833    }
4834
4835    #[test]
4836    fn test_set_columns_clamps_zero_to_one() {
4837        let mut doc = PdfDocument::new();
4838        doc.set_columns(0);
4839        assert_eq!(doc.num_columns, 1);
4840    }
4841
4842    #[test]
4843    fn test_margin_left_at_max_columns_no_overflow() {
4844        let mut doc = PdfDocument::new();
4845        doc.set_columns(MAX_COLUMNS);
4846        // Verify margin_left produces a finite, non-negative value for every column.
4847        for col in 0..MAX_COLUMNS {
4848            doc.current_column = col;
4849            let m = doc.margin_left();
4850            assert!(m.is_finite(), "margin_left must be finite for column {col}");
4851            assert!(
4852                m >= 0.0,
4853                "margin_left must be non-negative for column {col}"
4854            );
4855        }
4856    }
4857
4858    #[test]
4859    fn test_column_break_last_column_new_page() {
4860        let mut doc = PdfDocument::new();
4861        doc.set_columns(2);
4862        doc.column_break(); // → column 1
4863        assert_eq!(doc.page_count(), 1);
4864        doc.column_break(); // → new page, column 0
4865        assert_eq!(doc.page_count(), 2);
4866        assert_eq!(doc.current_column, 0);
4867    }
4868
4869    #[test]
4870    fn test_columns_non_numeric_defaults_to_one() {
4871        let input = "{columns: abc}\n[Am]Hello";
4872        let song = chordsketch_chordpro::parse(input).unwrap();
4873        let bytes = render_song(&song);
4874        let content = String::from_utf8_lossy(&bytes);
4875        // Non-numeric value defaults to 1 column — should still render.
4876        assert!(content.contains("Am"));
4877        assert!(content.contains("Hello"));
4878    }
4879
4880    #[test]
4881    fn test_columns_out_of_range_clamped() {
4882        // An absurdly large {columns} value must not cause a panic or degenerate
4883        // layout — it is clamped to MAX_COLUMNS at the directive call site.
4884        let input = "{columns: 4294967295}\n[Am]Hello";
4885        let song = chordsketch_chordpro::parse(input).unwrap();
4886        let bytes = render_song(&song);
4887        let content = String::from_utf8_lossy(&bytes);
4888        assert!(content.contains("Am"));
4889        assert!(content.contains("Hello"));
4890    }
4891
4892    #[test]
4893    fn test_multi_column_text_clipped() {
4894        // In a 2-column layout, text_at should emit clipping operators
4895        // (q/re W n/Q) to prevent overflow into adjacent columns.
4896        let input = "{columns: 2}\n[Am]Hello world this is a very long line of lyrics";
4897        let song = chordsketch_chordpro::parse(input).unwrap();
4898        let bytes = render_song(&song);
4899        let content = String::from_utf8_lossy(&bytes);
4900        // Multi-column layout should include clipping rectangle operator.
4901        assert!(
4902            content.contains("re W n"),
4903            "multi-column PDF should contain clipping rectangle operator"
4904        );
4905        // Verify clipping rectangle has reasonable column width.
4906        // Default 2-column: usable = 595 - 56 - 56 = 483, col_w = (483-20)/2 = 231.5
4907        // The clip rect should contain a width value around 231.
4908        let clip_line = content
4909            .lines()
4910            .find(|l| l.contains("re W n"))
4911            .expect("should find clip rect line");
4912        let parts: Vec<&str> = clip_line.split_whitespace().collect();
4913        // Format: "{x} {y} {w} {h} re W n"
4914        assert!(parts.len() >= 6, "clip rect should have x y w h re W n");
4915        let w: f32 = parts[2].parse().expect("width should be a number");
4916        assert!(
4917            w > 100.0 && w < 300.0,
4918            "clip width {w} should be a reasonable column width"
4919        );
4920    }
4921
4922    #[test]
4923    fn test_single_column_no_clipping() {
4924        // Single-column layout should NOT emit clipping operators.
4925        let input = "[Am]Hello world";
4926        let song = chordsketch_chordpro::parse(input).unwrap();
4927        let bytes = render_song(&song);
4928        let content = String::from_utf8_lossy(&bytes);
4929        assert!(
4930            !content.contains("re W n"),
4931            "single-column PDF should not contain clipping operator"
4932        );
4933    }
4934
4935    #[test]
4936    fn test_multi_column_inline_markup_single_clip_per_line() {
4937        // A lyrics line with inline markup (no chords) in 2-column mode
4938        // should produce exactly 1 clip rect from render_lyrics_spans,
4939        // not one per markup segment.
4940        let input = "{columns: 2}\nHello <b>bold</b> and <i>italic</i> text";
4941        let song = chordsketch_chordpro::parse(input).unwrap();
4942        let bytes = render_song(&song);
4943        let content = String::from_utf8_lossy(&bytes);
4944        let clip_count = content.matches("re W n").count();
4945        assert_eq!(
4946            clip_count, 1,
4947            "inline markup line should produce exactly 1 clip rect (got {clip_count})"
4948        );
4949    }
4950
4951    // --- Multi-song rendering ---
4952
4953    #[test]
4954    fn test_render_songs_single() {
4955        let songs = chordsketch_chordpro::parse_multi("{title: Only}\n[Am]Hello").unwrap();
4956        let bytes = render_songs(&songs);
4957        assert!(bytes.starts_with(b"%PDF-1.4"));
4958        assert!(bytes.ends_with(b"%%EOF\n"));
4959        // Single song: output should match render_song
4960        assert_eq!(bytes, render_song(&songs[0]));
4961    }
4962
4963    #[test]
4964    fn test_render_songs_two_songs_multi_page() {
4965        let songs = chordsketch_chordpro::parse_multi(
4966            "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
4967        )
4968        .unwrap();
4969        let bytes = render_songs(&songs);
4970        assert!(bytes.starts_with(b"%PDF-1.4"));
4971        assert!(bytes.ends_with(b"%%EOF\n"));
4972        let content = String::from_utf8_lossy(&bytes);
4973        // Both songs should be present
4974        assert!(content.contains("Song A"));
4975        assert!(content.contains("Song B"));
4976        // ToC page + 2 song pages = 3 pages
4977        assert!(content.contains("/Count 3"));
4978        // Should contain "Table of Contents"
4979        assert!(content.contains("Table of Contents"));
4980    }
4981
4982    #[test]
4983    fn test_render_songs_with_transpose() {
4984        let songs =
4985            chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
4986                .unwrap();
4987        let bytes = render_songs_with_transpose(&songs, 2, &Config::defaults());
4988        let content = String::from_utf8_lossy(&bytes);
4989        // C+2=D, G+2=A — both transposed chords should appear
4990        assert!(content.contains("(D)"));
4991        assert!(content.contains("(A)"));
4992    }
4993
4994    #[test]
4995    fn test_render_song_into_doc_helper() {
4996        let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
4997        let mut doc = PdfDocument::new();
4998        let mut warnings = Vec::new();
4999        render_song_into_doc(&song, 0, &Config::defaults(), &mut doc, &mut warnings);
5000        // Document should have 1 page with content
5001        assert_eq!(doc.page_count(), 1);
5002        let pdf = doc.build_pdf();
5003        assert!(pdf.starts_with(b"%PDF-1.4"));
5004        let content = String::from_utf8_lossy(&pdf);
5005        assert!(content.contains("Test"));
5006    }
5007}
5008
5009#[cfg(test)]
5010mod toc_tests {
5011    use super::*;
5012
5013    #[test]
5014    fn test_toc_generated_for_multi_song() {
5015        let songs = chordsketch_chordpro::parse_multi(
5016            "{title: First}\nLyrics 1\n{new_song}\n{title: Second}\nLyrics 2",
5017        )
5018        .unwrap();
5019        let bytes = render_songs(&songs);
5020        let content = String::from_utf8_lossy(&bytes);
5021        assert!(content.contains("Table of Contents"));
5022        assert!(content.contains("First"));
5023        assert!(content.contains("Second"));
5024    }
5025
5026    #[test]
5027    fn test_toc_not_generated_for_single_song() {
5028        let song = chordsketch_chordpro::parse("{title: Only Song}\nLyrics").unwrap();
5029        let bytes = render_song(&song);
5030        let content = String::from_utf8_lossy(&bytes);
5031        assert!(!content.contains("Table of Contents"));
5032    }
5033
5034    #[test]
5035    fn test_toc_page_numbers_present() {
5036        let songs = chordsketch_chordpro::parse_multi(
5037            "{title: Song A}\nA\n{new_song}\n{title: Song B}\nB\n{new_song}\n{title: Song C}\nC",
5038        )
5039        .unwrap();
5040        let bytes = render_songs(&songs);
5041        let content = String::from_utf8_lossy(&bytes);
5042        // ToC is 1 page, then Song A=page 2, Song B=page 3, Song C=page 4
5043        assert!(content.contains("/Count 4"));
5044        assert!(content.contains("Table of Contents"));
5045    }
5046
5047    #[test]
5048    fn test_toc_valid_pdf_structure() {
5049        let songs =
5050            chordsketch_chordpro::parse_multi("{title: A}\nText\n{new_song}\n{title: B}\nText")
5051                .unwrap();
5052        let bytes = render_songs(&songs);
5053        assert!(bytes.starts_with(b"%PDF-1.4"));
5054        assert!(bytes.ends_with(b"%%EOF\n"));
5055    }
5056
5057    #[test]
5058    fn test_toc_with_custom_margins_produces_valid_pdf() {
5059        use chordsketch_chordpro::config::Config;
5060        let songs =
5061            chordsketch_chordpro::parse_multi("{title: Song A}\nA\n{new_song}\n{title: Song B}\nB")
5062                .unwrap();
5063        // Use large custom margins to exercise the from_config path in the ToC rebuild.
5064        let config = Config::parse(
5065            r#"{ "pdf": { "margintop": 100, "marginbottom": 100, "marginleft": 100, "marginright": 100 } }"#,
5066        )
5067        .unwrap();
5068        let bytes = render_songs_with_transpose(&songs, 0, &config);
5069        assert!(bytes.starts_with(b"%PDF-1.4"));
5070        assert!(bytes.ends_with(b"%%EOF\n"));
5071        let content = String::from_utf8_lossy(&bytes);
5072        assert!(content.contains("Table of Contents"));
5073    }
5074
5075    // -- adjacent dedup (R6.100.0, upstream commit 55398859, #2294) -------
5076
5077    #[test]
5078    fn test_push_toc_entry_skips_adjacent_duplicate() {
5079        let mut entries = vec![("Song A".to_string(), 1)];
5080        push_toc_entry(&mut entries, "Song A".to_string(), 1);
5081        assert_eq!(entries, vec![("Song A".to_string(), 1)]);
5082    }
5083
5084    #[test]
5085    fn test_push_toc_entry_keeps_different_page() {
5086        let mut entries = vec![("Song A".to_string(), 1)];
5087        push_toc_entry(&mut entries, "Song A".to_string(), 2);
5088        assert_eq!(
5089            entries,
5090            vec![("Song A".to_string(), 1), ("Song A".to_string(), 2)]
5091        );
5092    }
5093
5094    #[test]
5095    fn test_push_toc_entry_keeps_different_title() {
5096        let mut entries = vec![("Song A".to_string(), 1)];
5097        push_toc_entry(&mut entries, "Song B".to_string(), 1);
5098        assert_eq!(
5099            entries,
5100            vec![("Song A".to_string(), 1), ("Song B".to_string(), 1)]
5101        );
5102    }
5103
5104    #[test]
5105    fn test_push_toc_entry_dedup_is_adjacent_only() {
5106        // Upstream uses `%prev = ()` reset on section breaks and only stores
5107        // the immediately preceding entry. Non-adjacent repeats MUST NOT be
5108        // deduped.
5109        let mut entries = vec![("Song A".to_string(), 1), ("Song B".to_string(), 2)];
5110        push_toc_entry(&mut entries, "Song A".to_string(), 1);
5111        assert_eq!(
5112            entries,
5113            vec![
5114                ("Song A".to_string(), 1),
5115                ("Song B".to_string(), 2),
5116                ("Song A".to_string(), 1),
5117            ],
5118            "adjacent-only dedup must keep non-adjacent repeats"
5119        );
5120    }
5121
5122    #[test]
5123    fn test_push_toc_entry_into_empty() {
5124        let mut entries: Vec<(String, usize)> = Vec::new();
5125        push_toc_entry(&mut entries, "Song A".to_string(), 1);
5126        assert_eq!(entries, vec![("Song A".to_string(), 1)]);
5127    }
5128
5129    #[test]
5130    fn test_toc_multi_song_cjk_includes_cid_font() {
5131        // Regression test for the HIGH finding: when CJK characters appear in body
5132        // pages of a multi-song document, render_songs_with_warnings must merge
5133        // body_doc.cid_glyphs into combined.cid_glyphs so that build_pdf() emits
5134        // the CID font objects. Without the merge, body pages reference /F5 but no
5135        // CID font dictionary is written, producing a corrupt PDF.
5136        let songs = chordsketch_chordpro::parse_multi(
5137            "{title: Song A}\nこんにちは\n{new_song}\n{title: Song B}\n日本語",
5138        )
5139        .unwrap();
5140        let bytes = render_songs(&songs);
5141        let content = String::from_utf8_lossy(&bytes);
5142        // CID font chain must be present.
5143        assert!(
5144            content.contains("/Type /Font") && content.contains("/Subtype /Type0"),
5145            "multi-song CJK PDF must contain a Type0 CID font"
5146        );
5147        assert!(
5148            content.contains("Identity-H"),
5149            "multi-song CJK PDF must use Identity-H encoding"
5150        );
5151        assert!(
5152            content.contains("/ToUnicode"),
5153            "multi-song CJK PDF must contain a ToUnicode CMap"
5154        );
5155        assert!(bytes.starts_with(b"%PDF-1.4"));
5156        assert!(bytes.ends_with(b"%%EOF\n"));
5157    }
5158}
5159
5160#[cfg(test)]
5161mod chord_diagram_pdf_tests {
5162    use super::*;
5163
5164    #[test]
5165    fn test_define_renders_diagram_in_pdf() {
5166        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello";
5167        let song = chordsketch_chordpro::parse(input).unwrap();
5168        let bytes = render_song(&song);
5169        assert!(bytes.starts_with(b"%PDF"));
5170        let content = String::from_utf8_lossy(&bytes);
5171        // Should contain the chord name
5172        assert!(content.contains("Am"));
5173        // Should contain circle drawing operations (Bezier curves)
5174        assert!(content.contains(" c "));
5175    }
5176
5177    #[test]
5178    fn test_define_keyboard_renders_in_pdf() {
5179        // {define: Am keys 0 3 7} should produce a valid PDF (keyboard diagram rendered).
5180        let input = "{define: Am keys 0 3 7}\n[Am]Hello";
5181        let song = chordsketch_chordpro::parse(input).unwrap();
5182        let bytes = render_song(&song);
5183        assert!(bytes.starts_with(b"%PDF"));
5184        assert!(bytes.ends_with(b"%%EOF\n"));
5185    }
5186
5187    #[test]
5188    fn test_define_keyboard_absolute_midi_pdf() {
5189        let input = "{define: Cmaj7 keys 60 64 67 71}\n[Cmaj7]Hello";
5190        let song = chordsketch_chordpro::parse(input).unwrap();
5191        let bytes = render_song(&song);
5192        assert!(bytes.starts_with(b"%PDF-1.4"));
5193        assert!(bytes.ends_with(b"%%EOF\n"));
5194    }
5195
5196    #[test]
5197    fn test_diagrams_piano_auto_inject_pdf() {
5198        let input = "{diagrams: piano}\n[Am]Hello [C]world";
5199        let song = chordsketch_chordpro::parse(input).unwrap();
5200        let bytes = render_song(&song);
5201        assert!(bytes.starts_with(b"%PDF-1.4"));
5202        assert!(bytes.ends_with(b"%%EOF\n"));
5203    }
5204
5205    #[test]
5206    fn test_define_diagram_valid_pdf() {
5207        let input = "{define: F base-fret 1 frets 1 1 2 3 3 1}\n[F]Lyrics";
5208        let song = chordsketch_chordpro::parse(input).unwrap();
5209        let bytes = render_song(&song);
5210        assert!(bytes.starts_with(b"%PDF-1.4"));
5211        assert!(bytes.ends_with(b"%%EOF\n"));
5212    }
5213
5214    #[test]
5215    fn test_define_ukulele_diagram_in_pdf() {
5216        let input = "{define: C frets 0 0 0 3}\n[C]Hello";
5217        let song = chordsketch_chordpro::parse(input).unwrap();
5218        let bytes = render_song(&song);
5219        assert!(bytes.starts_with(b"%PDF"));
5220        let content = String::from_utf8_lossy(&bytes);
5221        assert!(content.contains("C"));
5222    }
5223
5224    #[test]
5225    fn test_define_banjo_diagram_in_pdf() {
5226        let input = "{define: G frets 0 0 0 0 0}\n[G]Hello";
5227        let song = chordsketch_chordpro::parse(input).unwrap();
5228        let bytes = render_song(&song);
5229        assert!(bytes.starts_with(b"%PDF"));
5230    }
5231
5232    #[test]
5233    fn test_diagrams_frets_config_affects_pdf_output() {
5234        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello";
5235        let song = chordsketch_chordpro::parse(input).unwrap();
5236        let config_4 = chordsketch_chordpro::config::Config::defaults()
5237            .with_define("diagrams.frets=4")
5238            .unwrap();
5239        let config_7 = chordsketch_chordpro::config::Config::defaults()
5240            .with_define("diagrams.frets=7")
5241            .unwrap();
5242        let bytes_4 = render_song_with_transpose(&song, 0, &config_4);
5243        let bytes_7 = render_song_with_transpose(&song, 0, &config_7);
5244        let content_4 = String::from_utf8_lossy(&bytes_4);
5245        let content_7 = String::from_utf8_lossy(&bytes_7);
5246        // Each line_at() emits "m ... l S". Count line-drawing operations:
5247        // frets=4 → 5 horizontal + 6 vertical + 1 nut = 12 lines
5248        // frets=7 → 8 horizontal + 6 vertical + 1 nut = 15 lines
5249        // The difference of 3 corresponds to the 3 extra fret lines.
5250        let lines_4 = content_4.matches("l S").count();
5251        let lines_7 = content_7.matches("l S").count();
5252        assert!(
5253            lines_7 >= lines_4,
5254            "frets=7 ({lines_7}) should have at least as many line ops as frets=4 ({lines_4})"
5255        );
5256        assert_eq!(
5257            lines_7 - lines_4,
5258            3,
5259            "frets=7 should produce exactly 3 more line-drawing ops than frets=4 \
5260             (got {lines_7} vs {lines_4})"
5261        );
5262    }
5263
5264    #[test]
5265    fn test_render_chord_diagram_pdf_single_string_no_panic() {
5266        // Direct construction with strings=1 (below MIN_STRINGS) should
5267        // return early without panicking.
5268        let data = chordsketch_chordpro::chord_diagram::DiagramData {
5269            name: "X".to_string(),
5270            display_name: None,
5271            strings: 1,
5272            frets_shown: 5,
5273            base_fret: 1,
5274            frets: vec![0],
5275            fingers: vec![],
5276        };
5277        let mut doc = PdfDocument::new();
5278        render_chord_diagram_pdf(&data, &mut doc);
5279        // No panic = pass. The guard returned early.
5280    }
5281
5282    #[test]
5283    fn test_render_chord_diagram_pdf_zero_strings_no_panic() {
5284        let data = chordsketch_chordpro::chord_diagram::DiagramData {
5285            name: "X".to_string(),
5286            display_name: None,
5287            strings: 0,
5288            frets_shown: 5,
5289            base_fret: 1,
5290            frets: vec![],
5291            fingers: vec![],
5292        };
5293        let mut doc = PdfDocument::new();
5294        render_chord_diagram_pdf(&data, &mut doc);
5295        // No panic = pass. The guard returned early.
5296    }
5297
5298    #[test]
5299    fn test_render_chord_diagram_pdf_exceeding_max_strings_no_panic() {
5300        let data = chordsketch_chordpro::chord_diagram::DiagramData {
5301            name: "X".to_string(),
5302            display_name: None,
5303            strings: chordsketch_chordpro::chord_diagram::MAX_STRINGS + 1,
5304            frets_shown: 5,
5305            base_fret: 1,
5306            frets: vec![0; chordsketch_chordpro::chord_diagram::MAX_STRINGS + 1],
5307            fingers: vec![],
5308        };
5309        let mut doc = PdfDocument::new();
5310        render_chord_diagram_pdf(&data, &mut doc);
5311        // No panic = pass. The guard returned early.
5312    }
5313
5314    #[test]
5315    fn test_render_chord_diagram_pdf_zero_frets_shown_no_panic() {
5316        let data = chordsketch_chordpro::chord_diagram::DiagramData {
5317            name: "X".to_string(),
5318            display_name: None,
5319            strings: 6,
5320            frets_shown: 0,
5321            base_fret: 1,
5322            frets: vec![0; 6],
5323            fingers: vec![],
5324        };
5325        let mut doc = PdfDocument::new();
5326        render_chord_diagram_pdf(&data, &mut doc);
5327        // No panic = pass. The guard returned early.
5328    }
5329
5330    #[test]
5331    fn test_render_chord_diagram_pdf_exceeding_max_frets_shown_no_panic() {
5332        let data = chordsketch_chordpro::chord_diagram::DiagramData {
5333            name: "X".to_string(),
5334            display_name: None,
5335            strings: 6,
5336            frets_shown: chordsketch_chordpro::chord_diagram::MAX_FRETS_SHOWN + 1,
5337            base_fret: 1,
5338            frets: vec![0; 6],
5339            fingers: vec![],
5340        };
5341        let mut doc = PdfDocument::new();
5342        render_chord_diagram_pdf(&data, &mut doc);
5343        // No panic = pass. The guard returned early.
5344    }
5345
5346    #[test]
5347    fn test_define_chord_not_duplicated_in_auto_inject_grid() {
5348        // Regression test for #1211/#1247: a chord with a {define} entry rendered
5349        // inline must NOT appear a second time in the auto-inject grid.
5350        //
5351        // Strategy: render with {define: Am} + {diagrams} + [Am] lyrics and a
5352        // non-defined chord [G].  Count occurrences of "Am" in the PDF content
5353        // stream:
5354        //  - chord-over-lyrics text: 1 occurrence
5355        //  - inline diagram title at {define}: 1 occurrence
5356        //  - auto-inject grid (should be absent due to dedup): 0
5357        // Without the fix there would be 3 occurrences.
5358        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n";
5359        let song = chordsketch_chordpro::parse(input).unwrap();
5360        let bytes = render_song(&song);
5361        assert!(bytes.starts_with(b"%PDF-1.4"), "must produce a valid PDF");
5362        let content = String::from_utf8_lossy(&bytes);
5363        // G has no {define} and should appear in the auto-inject grid.
5364        assert!(content.contains("G"), "G should appear (auto-inject grid)");
5365        // Am should appear at most twice (chord label + inline diagram).
5366        // A third occurrence would mean it was also added to the auto-inject grid.
5367        let am_count = content.matches("Am").count();
5368        assert!(
5369            am_count <= 2,
5370            "Am should appear at most twice (chord label + inline diagram), got {am_count}"
5371        );
5372    }
5373
5374    #[test]
5375    fn test_define_after_nodiagrams_appears_in_grid() {
5376        // {define} encountered while show_diagrams=false must NOT be tracked as
5377        // inline-rendered; the chord should appear in the auto-inject grid.
5378        // Regression test for #1245 / parity with HTML renderer (#1251).
5379        let input =
5380            "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n";
5381        let song = chordsketch_chordpro::parse(input).unwrap();
5382        let bytes = render_song(&song);
5383        assert!(bytes.starts_with(b"%PDF-1.4"), "must produce a valid PDF");
5384        let content = String::from_utf8_lossy(&bytes);
5385        // Am was NOT rendered inline ({no_diagrams} was active at {define} time).
5386        // It should appear in the auto-inject grid.
5387        // Count occurrences: chord-over-lyrics label (1) + auto-inject grid title (1) = 2.
5388        // If the bug is reintroduced, Am would be excluded from the grid (count = 1).
5389        let am_count = content.matches("Am").count();
5390        assert!(
5391            am_count >= 2,
5392            "Am should appear in the auto-inject grid (found {am_count} occurrences, expected ≥ 2)"
5393        );
5394    }
5395}
5396
5397#[cfg(test)]
5398mod jpeg_tests {
5399    use super::*;
5400
5401    /// Build a minimal valid JPEG byte sequence with a SOF0 marker.
5402    ///
5403    /// This is not a displayable image but contains a structurally correct
5404    /// JPEG header that `parse_jpeg_dimensions` can parse.
5405    /// Uses 3 color components (RGB) by default.
5406    fn minimal_jpeg(width: u16, height: u16) -> Vec<u8> {
5407        minimal_jpeg_with_components(width, height, 3)
5408    }
5409
5410    /// Build a minimal JPEG with a specific number of color components.
5411    fn minimal_jpeg_with_components(width: u16, height: u16, components: u8) -> Vec<u8> {
5412        let mut data = Vec::new();
5413        // SOI marker
5414        data.extend_from_slice(&[0xFF, 0xD8]);
5415        // APP0 marker (minimal, 2-byte length = 2 means no payload beyond length)
5416        data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]);
5417        // SOF0 marker
5418        data.extend_from_slice(&[0xFF, 0xC0]);
5419        // Length: 8 bytes (length field itself + precision + height + width + components)
5420        data.extend_from_slice(&[0x00, 0x08]);
5421        // Precision: 8 bits
5422        data.push(0x08);
5423        // Height (big-endian)
5424        data.extend_from_slice(&height.to_be_bytes());
5425        // Width (big-endian)
5426        data.extend_from_slice(&width.to_be_bytes());
5427        // Number of components
5428        data.push(components);
5429        data
5430    }
5431
5432    #[test]
5433    fn test_parse_jpeg_dimensions_basic() {
5434        let jpeg = minimal_jpeg(640, 480);
5435        let dims = parse_jpeg_dimensions(&jpeg);
5436        assert_eq!(dims, Some((640, 480, 3)));
5437    }
5438
5439    #[test]
5440    fn test_parse_jpeg_dimensions_square() {
5441        let jpeg = minimal_jpeg(100, 100);
5442        let dims = parse_jpeg_dimensions(&jpeg);
5443        assert_eq!(dims, Some((100, 100, 3)));
5444    }
5445
5446    #[test]
5447    fn test_parse_jpeg_dimensions_too_short() {
5448        assert_eq!(parse_jpeg_dimensions(&[0xFF]), None);
5449        assert_eq!(parse_jpeg_dimensions(&[]), None);
5450    }
5451
5452    #[test]
5453    fn test_parse_jpeg_dimensions_not_jpeg() {
5454        // PNG signature
5455        assert_eq!(
5456            parse_jpeg_dimensions(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]),
5457            None
5458        );
5459    }
5460
5461    #[test]
5462    fn test_parse_jpeg_dimensions_exceeds_scan_limit() {
5463        // Build a JPEG with valid SOI, then >64 KB of padding before the SOF.
5464        // The parser should bail out before reaching the SOF marker.
5465        let mut data = vec![0xFF, 0xD8]; // SOI
5466        // Fill with non-marker bytes (not 0xFF) to force byte-by-byte scanning
5467        data.resize(70_000, 0x00);
5468        // Append a valid SOF0 marker well past the 64 KB scan limit
5469        data.extend_from_slice(&[0xFF, 0xC0]);
5470        data.extend_from_slice(&[0x00, 0x08]);
5471        data.push(0x08);
5472        data.extend_from_slice(&100_u16.to_be_bytes());
5473        data.extend_from_slice(&200_u16.to_be_bytes());
5474        data.push(3);
5475        assert_eq!(
5476            parse_jpeg_dimensions(&data),
5477            None,
5478            "SOF beyond scan limit should not be found"
5479        );
5480    }
5481
5482    #[test]
5483    fn test_parse_jpeg_dimensions_sof2_progressive() {
5484        let mut data = Vec::new();
5485        // SOI
5486        data.extend_from_slice(&[0xFF, 0xD8]);
5487        // APP0 with minimal length
5488        data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]);
5489        // SOF2 (progressive DCT)
5490        data.extend_from_slice(&[0xFF, 0xC2]);
5491        data.extend_from_slice(&[0x00, 0x08]);
5492        data.push(0x08);
5493        data.extend_from_slice(&300_u16.to_be_bytes()); // height
5494        data.extend_from_slice(&400_u16.to_be_bytes()); // width
5495        data.push(0x00); // components
5496        let dims = parse_jpeg_dimensions(&data);
5497        assert_eq!(dims, Some((400, 300, 0)));
5498    }
5499
5500    /// Build a minimal valid JPEG byte sequence with an arbitrary SOF marker.
5501    fn minimal_jpeg_with_sof(sof_marker: u8, width: u16, height: u16) -> Vec<u8> {
5502        let mut data = Vec::new();
5503        data.extend_from_slice(&[0xFF, 0xD8]); // SOI
5504        data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]); // APP0
5505        data.extend_from_slice(&[0xFF, sof_marker]); // SOF marker
5506        data.extend_from_slice(&[0x00, 0x08]); // length
5507        data.push(0x08); // precision
5508        data.extend_from_slice(&height.to_be_bytes());
5509        data.extend_from_slice(&width.to_be_bytes());
5510        data.push(0x00); // components
5511        data
5512    }
5513
5514    #[test]
5515    fn test_parse_jpeg_dimensions_sof1_extended_sequential() {
5516        let data = minimal_jpeg_with_sof(0xC1, 800, 600);
5517        assert_eq!(parse_jpeg_dimensions(&data), Some((800, 600, 0)));
5518    }
5519
5520    #[test]
5521    fn test_parse_jpeg_dimensions_sof3_lossless() {
5522        let data = minimal_jpeg_with_sof(0xC3, 1024, 768);
5523        assert_eq!(parse_jpeg_dimensions(&data), Some((1024, 768, 0)));
5524    }
5525
5526    #[test]
5527    fn test_parse_jpeg_dimensions_sof9_arithmetic_sequential() {
5528        let data = minimal_jpeg_with_sof(0xC9, 320, 240);
5529        assert_eq!(parse_jpeg_dimensions(&data), Some((320, 240, 0)));
5530    }
5531
5532    #[test]
5533    fn test_parse_jpeg_dimensions_sof10_arithmetic_progressive() {
5534        let data = minimal_jpeg_with_sof(0xCA, 1920, 1080);
5535        assert_eq!(parse_jpeg_dimensions(&data), Some((1920, 1080, 0)));
5536    }
5537
5538    #[test]
5539    fn test_parse_jpeg_dimensions_sof11_arithmetic_lossless() {
5540        let data = minimal_jpeg_with_sof(0xCB, 256, 256);
5541        assert_eq!(parse_jpeg_dimensions(&data), Some((256, 256, 0)));
5542    }
5543
5544    #[test]
5545    fn test_parse_jpeg_dimensions_all_sof_markers() {
5546        let sof_markers = [
5547            0xC0, 0xC1, 0xC2, 0xC3, // SOF0–SOF3
5548            0xC5, 0xC6, 0xC7, // SOF5–SOF7
5549            0xC9, 0xCA, 0xCB, // SOF9–SOF11
5550            0xCD, 0xCE, 0xCF, // SOF13–SOF15
5551        ];
5552        for marker in sof_markers {
5553            let data = minimal_jpeg_with_sof(marker, 500, 400);
5554            assert_eq!(
5555                parse_jpeg_dimensions(&data),
5556                Some((500, 400, 0)),
5557                "SOF marker 0x{marker:02X} should be recognized"
5558            );
5559        }
5560    }
5561
5562    #[test]
5563    fn test_parse_jpeg_dimensions_non_sof_markers_not_matched() {
5564        // 0xC4 (DHT), 0xC8 (reserved), 0xCC (DAC) must NOT be treated as SOF.
5565        for marker in [0xC4, 0xC8, 0xCC] {
5566            let data = minimal_jpeg_with_sof(marker, 500, 400);
5567            assert_eq!(
5568                parse_jpeg_dimensions(&data),
5569                None,
5570                "Marker 0x{marker:02X} should NOT be recognized as SOF"
5571            );
5572        }
5573    }
5574
5575    #[test]
5576    fn test_image_directive_nonexistent_file_no_crash() {
5577        let input = "{image: src=nonexistent_file_that_does_not_exist.jpg}";
5578        let song = chordsketch_chordpro::parse(input).unwrap();
5579        let bytes = render_song(&song);
5580        // Should produce a valid PDF without crashing
5581        assert!(bytes.starts_with(b"%PDF-1.4"));
5582        assert!(bytes.ends_with(b"%%EOF\n"));
5583    }
5584
5585    #[test]
5586    fn test_image_directive_non_jpeg_skipped() {
5587        let input = "{image: src=photo.png}";
5588        let song = chordsketch_chordpro::parse(input).unwrap();
5589        let bytes = render_song(&song);
5590        assert!(bytes.starts_with(b"%PDF-1.4"));
5591        assert!(bytes.ends_with(b"%%EOF\n"));
5592    }
5593
5594    #[test]
5595    fn test_image_directive_dangerous_scheme_rejected() {
5596        // #1832: PDF must reject the same URI schemes the HTML / text
5597        // renderers reject via `is_safe_image_src`.
5598        let input = "{image: src=\"javascript:alert(1)\"}";
5599        let song = chordsketch_chordpro::parse(input).unwrap();
5600        let bytes = render_song(&song);
5601        // No crash, no embedded image, no JS literal in the PDF stream.
5602        assert!(bytes.starts_with(b"%PDF-1.4"));
5603        let as_str = String::from_utf8_lossy(&bytes);
5604        assert!(
5605            !as_str.contains("javascript:"),
5606            "javascript: URI must not appear in PDF output"
5607        );
5608    }
5609
5610    // -- {capo} validation parity (#1834) ---------------------------------
5611
5612    #[test]
5613    fn test_capo_out_of_range_emits_warning() {
5614        let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
5615        let result = render_song_with_warnings(&song, 0, &Config::defaults());
5616        assert!(
5617            result
5618                .warnings
5619                .iter()
5620                .any(|w| w.contains("capo") && w.contains("999")),
5621            "expected out-of-range {{capo}} warning; got {:?}",
5622            result.warnings
5623        );
5624    }
5625
5626    #[test]
5627    fn test_capo_non_numeric_emits_warning() {
5628        let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
5629        let result = render_song_with_warnings(&song, 0, &Config::defaults());
5630        assert!(
5631            result
5632                .warnings
5633                .iter()
5634                .any(|w| w.contains("capo") && w.contains("foo")),
5635            "expected non-integer {{capo}} warning; got {:?}",
5636            result.warnings
5637        );
5638    }
5639
5640    #[test]
5641    fn test_capo_in_range_is_silent() {
5642        let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
5643        let result = render_song_with_warnings(&song, 0, &Config::defaults());
5644        assert!(
5645            !result.warnings.iter().any(|w| w.contains("capo")),
5646            "valid {{capo: 5}} should not warn; got {:?}",
5647            result.warnings
5648        );
5649    }
5650
5651    // -- settings.strict missing-{key} warning (R6.100.0, #2291) ----------
5652
5653    #[test]
5654    fn test_strict_off_with_missing_key_is_silent() {
5655        let song = chordsketch_chordpro::parse("{title: T}").unwrap();
5656        let result = render_song_with_warnings(&song, 0, &Config::defaults());
5657        assert!(
5658            !result
5659                .warnings
5660                .iter()
5661                .any(|w| w.contains("settings.strict")),
5662            "default settings.strict=false must not warn on missing {{key}}; got {:?}",
5663            result.warnings
5664        );
5665    }
5666
5667    #[test]
5668    fn test_strict_on_with_missing_key_warns() {
5669        let song = chordsketch_chordpro::parse("{title: T}").unwrap();
5670        let cfg = Config::defaults()
5671            .with_define("settings.strict=true")
5672            .unwrap();
5673        let result = render_song_with_warnings(&song, 0, &cfg);
5674        assert!(
5675            result
5676                .warnings
5677                .iter()
5678                .any(|w| w.contains("{key}") && w.contains("settings.strict")),
5679            "expected missing-{{key}} warning under settings.strict; got {:?}",
5680            result.warnings
5681        );
5682    }
5683
5684    #[test]
5685    fn test_strict_on_with_present_key_is_silent() {
5686        let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
5687        let cfg = Config::defaults()
5688            .with_define("settings.strict=true")
5689            .unwrap();
5690        let result = render_song_with_warnings(&song, 0, &cfg);
5691        assert!(
5692            !result
5693                .warnings
5694                .iter()
5695                .any(|w| w.contains("settings.strict")),
5696            "settings.strict warning must not fire when {{key}} is present; got {:?}",
5697            result.warnings
5698        );
5699    }
5700
5701    // -- MAX_WARNINGS cap (#1833) -----------------------------------------
5702
5703    #[test]
5704    fn test_max_warnings_truncates() {
5705        let mut input = String::from("{title: T}\n");
5706        for _ in 0..(MAX_WARNINGS + 50) {
5707            input.push_str("{transpose: not-a-number}\n");
5708        }
5709        let song = chordsketch_chordpro::parse(&input).unwrap();
5710        let result = render_song_with_warnings(&song, 0, &Config::defaults());
5711        assert_eq!(
5712            result.warnings.len(),
5713            MAX_WARNINGS + 1,
5714            "expected exactly MAX_WARNINGS warnings plus one truncation marker"
5715        );
5716        assert!(
5717            result.warnings.last().unwrap().contains("MAX_WARNINGS"),
5718            "last entry must be the truncation marker; got {:?}",
5719            result.warnings.last()
5720        );
5721    }
5722
5723    #[test]
5724    fn test_validate_margin_respects_max_warnings_cap() {
5725        // #1899: `validate_margin` previously called `warnings.push` directly
5726        // and bypassed the `MAX_WARNINGS` cap. Regression guard: fill the
5727        // vector to exactly MAX_WARNINGS via the canonical `push_warning`
5728        // path (which appends the truncation marker on the first overflow),
5729        // then hit the margin validator with an invalid value. If the
5730        // function ever reverts to bypassing the cap, the vector will grow
5731        // past MAX_WARNINGS + 1 and this assertion will fire.
5732        let mut warnings: Vec<String> = Vec::new();
5733        for i in 0..MAX_WARNINGS {
5734            push_warning(&mut warnings, format!("filler warning {i}"));
5735        }
5736        // The cap helper appends a single truncation marker on the first
5737        // overflow and then short-circuits; future pushes must not grow
5738        // the vector further.
5739        push_warning(&mut warnings, "overflow warning".to_string());
5740        assert_eq!(
5741            warnings.len(),
5742            MAX_WARNINGS + 1,
5743            "precondition: vector is at MAX_WARNINGS + 1 after first overflow",
5744        );
5745
5746        // Now ask the margin validator for a value that would emit a
5747        // warning. Because the vector is already at the truncation point,
5748        // nothing must be appended.
5749        let _ = PdfDocument::validate_margin(-100.0, MARGIN_TOP, "top", &mut warnings);
5750        assert_eq!(
5751            warnings.len(),
5752            MAX_WARNINGS + 1,
5753            "validate_margin must route through push_warning and respect the cap",
5754        );
5755    }
5756
5757    #[test]
5758    fn test_embed_jpeg_produces_xobject() {
5759        let jpeg = minimal_jpeg(320, 240);
5760        let mut doc = PdfDocument::new();
5761        let idx = doc.embed_jpeg(jpeg, 320, 240, 3);
5762        assert_eq!(idx, 0);
5763        doc.draw_image(idx, 56.0, 700.0, 320.0, 240.0);
5764        let pdf = doc.build_pdf();
5765        let content = String::from_utf8_lossy(&pdf);
5766        // The PDF should contain image XObject references
5767        assert!(content.contains("/XObject"));
5768        assert!(content.contains("/Im1"));
5769        assert!(content.contains("/DCTDecode"));
5770        assert!(content.contains("/Subtype /Image"));
5771    }
5772
5773    #[test]
5774    fn test_xobject_uses_actual_pixel_dimensions() {
5775        // Even if pixel dimensions exceed MAX_IMAGE_PIXELS, the XObject
5776        // /Width and /Height must reflect the actual JPEG stream data.
5777        let large_w: u32 = 20_000;
5778        let large_h: u32 = 15_000;
5779        let jpeg = minimal_jpeg_with_components(large_w as u16, large_h as u16, 3);
5780        let mut doc = PdfDocument::new();
5781        let idx = doc.embed_jpeg(jpeg, large_w, large_h, 3);
5782        doc.draw_image(idx, 56.0, 700.0, 100.0, 75.0);
5783        let pdf = doc.build_pdf();
5784        let content = String::from_utf8_lossy(&pdf);
5785        let width_str = format!("/Width {large_w}");
5786        let height_str = format!("/Height {large_h}");
5787        assert!(
5788            content.contains(&width_str),
5789            "XObject must contain actual width {large_w}"
5790        );
5791        assert!(
5792            content.contains(&height_str),
5793            "XObject must contain actual height {large_h}"
5794        );
5795    }
5796
5797    #[test]
5798    fn test_embed_multiple_jpegs() {
5799        let jpeg1 = minimal_jpeg(100, 50);
5800        let jpeg2 = minimal_jpeg(200, 150);
5801        let mut doc = PdfDocument::new();
5802        let idx1 = doc.embed_jpeg(jpeg1, 100, 50, 3);
5803        let idx2 = doc.embed_jpeg(jpeg2, 200, 150, 3);
5804        assert_eq!(idx1, 0);
5805        assert_eq!(idx2, 1);
5806        doc.draw_image(idx1, 56.0, 700.0, 100.0, 50.0);
5807        doc.draw_image(idx2, 56.0, 600.0, 200.0, 150.0);
5808        let pdf = doc.build_pdf();
5809        let content = String::from_utf8_lossy(&pdf);
5810        assert!(content.contains("/Im1"));
5811        assert!(content.contains("/Im2"));
5812    }
5813
5814    #[test]
5815    fn test_embed_jpeg_grayscale_uses_device_gray() {
5816        let jpeg = minimal_jpeg_with_components(100, 100, 1);
5817        let mut doc = PdfDocument::new();
5818        let idx = doc.embed_jpeg(jpeg, 100, 100, 1);
5819        doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
5820        let pdf = doc.build_pdf();
5821        let content = String::from_utf8_lossy(&pdf);
5822        assert!(
5823            content.contains("/ColorSpace /DeviceGray"),
5824            "grayscale JPEG should use /DeviceGray"
5825        );
5826        assert!(
5827            !content.contains("/ColorSpace /DeviceRGB"),
5828            "grayscale JPEG should not use /DeviceRGB"
5829        );
5830    }
5831
5832    #[test]
5833    fn test_embed_jpeg_rgb_uses_device_rgb() {
5834        let jpeg = minimal_jpeg_with_components(100, 100, 3);
5835        let mut doc = PdfDocument::new();
5836        let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
5837        doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
5838        let pdf = doc.build_pdf();
5839        let content = String::from_utf8_lossy(&pdf);
5840        assert!(
5841            content.contains("/ColorSpace /DeviceRGB"),
5842            "RGB JPEG should use /DeviceRGB"
5843        );
5844    }
5845
5846    #[test]
5847    fn test_embed_jpeg_cmyk_uses_device_cmyk() {
5848        let jpeg = minimal_jpeg_with_components(100, 100, 4);
5849        let mut doc = PdfDocument::new();
5850        let idx = doc.embed_jpeg(jpeg, 100, 100, 4);
5851        doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
5852        let pdf = doc.build_pdf();
5853        let content = String::from_utf8_lossy(&pdf);
5854        assert!(
5855            content.contains("/ColorSpace /DeviceCMYK"),
5856            "CMYK JPEG should use /DeviceCMYK"
5857        );
5858    }
5859
5860    #[test]
5861    fn test_parse_jpeg_dimensions_grayscale_component_count() {
5862        let jpeg = minimal_jpeg_with_components(200, 150, 1);
5863        let dims = parse_jpeg_dimensions(&jpeg);
5864        assert_eq!(dims, Some((200, 150, 1)));
5865    }
5866
5867    #[test]
5868    fn test_no_images_no_xobject_dict() {
5869        let doc = PdfDocument::new();
5870        let pdf = doc.build_pdf();
5871        let content = String::from_utf8_lossy(&pdf);
5872        // Without images, there should be no XObject dictionary
5873        assert!(!content.contains("/XObject"));
5874    }
5875
5876    #[test]
5877    fn test_draw_image_emits_cm_do_operators() {
5878        let jpeg = minimal_jpeg(50, 50);
5879        let mut doc = PdfDocument::new();
5880        let idx = doc.embed_jpeg(jpeg, 50, 50, 3);
5881        doc.draw_image(idx, 100.0, 200.0, 50.0, 50.0);
5882        let ops = &doc.pages[0];
5883        assert!(ops.iter().any(|op| op == "q"));
5884        assert!(ops.iter().any(|op| op.contains("cm")));
5885        assert!(ops.iter().any(|op| op.contains("/Im1 Do")));
5886        assert!(ops.iter().any(|op| op == "Q"));
5887    }
5888
5889    #[test]
5890    fn test_anchor_line_uses_margin_left() {
5891        // anchor=line (default) should place image at margin_left.
5892        let mut doc = PdfDocument::new();
5893        let jpeg = minimal_jpeg(100, 100);
5894        let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
5895        let x = doc.margin_left();
5896        doc.draw_image(idx, x, 500.0, 100.0, 100.0);
5897        let cm_op = doc.pages[0]
5898            .iter()
5899            .find(|op| op.contains("cm"))
5900            .expect("cm operator");
5901        // The cm matrix puts tx at position 5 (0-indexed): "w 0 0 h tx ty cm"
5902        let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
5903        assert!(
5904            (tx - MARGIN_LEFT).abs() < 0.01,
5905            "expected tx ~{MARGIN_LEFT}, got {tx}"
5906        );
5907    }
5908
5909    #[test]
5910    fn test_anchor_paper_centers_on_page() {
5911        // anchor=paper should place image centered on the full page width.
5912        let render_w: f32 = 200.0;
5913        let expected_x = (PAGE_W - render_w) / 2.0;
5914        let mut doc = PdfDocument::new();
5915        let jpeg = minimal_jpeg(200, 100);
5916        let idx = doc.embed_jpeg(jpeg, 200, 100, 3);
5917        doc.draw_image(idx, expected_x, 500.0, render_w, 100.0);
5918        let cm_op = doc.pages[0]
5919            .iter()
5920            .find(|op| op.contains("cm"))
5921            .expect("cm operator");
5922        let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
5923        assert!(
5924            (tx - expected_x).abs() < 0.01,
5925            "expected tx ~{expected_x}, got {tx}"
5926        );
5927    }
5928
5929    #[test]
5930    fn test_anchor_column_centers_in_column_multicolumn() {
5931        // In a 2-column layout, anchor=column should center the image within
5932        // the column width that accounts for COLUMN_GAP, matching the formula
5933        // used by margin_left() and column_width().
5934        let mut doc = PdfDocument::new();
5935        doc.set_columns(2);
5936        // Column 0
5937        let col_w = doc.column_width();
5938        let col_left = doc.margin_left();
5939
5940        let render_w: f32 = 100.0;
5941        let expected_x = col_left + (col_w - render_w) / 2.0;
5942
5943        let jpeg = minimal_jpeg(100, 100);
5944        let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
5945        doc.draw_image(idx, expected_x, 500.0, render_w, 100.0);
5946        let cm_op = doc.pages[0]
5947            .iter()
5948            .find(|op| op.contains("cm"))
5949            .expect("cm operator");
5950        let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
5951        assert!(
5952            (tx - expected_x).abs() < 0.01,
5953            "expected tx ~{expected_x}, got {tx}"
5954        );
5955    }
5956
5957    #[test]
5958    fn test_column_width_single_column() {
5959        let doc = PdfDocument::new();
5960        let expected = PAGE_W - doc.margin_left - doc.margin_right;
5961        assert!((doc.column_width() - expected).abs() < 0.01);
5962    }
5963
5964    #[test]
5965    fn test_column_width_multi_column() {
5966        let mut doc = PdfDocument::new();
5967        doc.set_columns(2);
5968        let usable = PAGE_W - doc.margin_left - doc.margin_right;
5969        let expected = (usable - COLUMN_GAP) / 2.0;
5970        assert!((doc.column_width() - expected).abs() < 0.01);
5971    }
5972
5973    #[test]
5974    fn test_compute_image_dimensions_explicit_width() {
5975        let attrs = ImageAttributes {
5976            src: "test.jpg".to_string(),
5977            width: Some("200".to_string()),
5978            height: None,
5979            scale: None,
5980            title: None,
5981            anchor: None,
5982        };
5983        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
5984        assert!((w - 200.0).abs() < 0.01);
5985        assert!((h - 150.0).abs() < 0.01); // preserves aspect ratio
5986    }
5987
5988    #[test]
5989    fn test_compute_image_dimensions_explicit_height() {
5990        let attrs = ImageAttributes {
5991            src: "test.jpg".to_string(),
5992            width: None,
5993            height: Some("100".to_string()),
5994            scale: None,
5995            title: None,
5996            anchor: None,
5997        };
5998        let (w, h) = compute_image_dimensions(&attrs, 400.0, 200.0, 2.0);
5999        assert!((w - 200.0).abs() < 0.01);
6000        assert!((h - 100.0).abs() < 0.01);
6001    }
6002
6003    #[test]
6004    fn test_compute_image_dimensions_scale() {
6005        let attrs = ImageAttributes {
6006            src: "test.jpg".to_string(),
6007            width: None,
6008            height: None,
6009            scale: Some("0.5".to_string()),
6010            title: None,
6011            anchor: None,
6012        };
6013        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6014        assert!((w - 200.0).abs() < 0.01);
6015        assert!((h - 150.0).abs() < 0.01);
6016    }
6017
6018    #[test]
6019    fn test_compute_image_dimensions_native() {
6020        let attrs = ImageAttributes::default();
6021        let (w, h) = compute_image_dimensions(&attrs, 800.0, 600.0, 800.0 / 600.0);
6022        assert!((w - 800.0).abs() < 0.01);
6023        assert!((h - 600.0).abs() < 0.01);
6024    }
6025
6026    #[test]
6027    fn test_compute_image_dimensions_percentage_width() {
6028        let attrs = ImageAttributes {
6029            width: Some("50%".to_string()),
6030            ..Default::default()
6031        };
6032        // 50% of 400 = 200, height derived from aspect ratio.
6033        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6034        assert!((w - 200.0).abs() < 0.01);
6035        assert!((h - 150.0).abs() < 0.01);
6036    }
6037
6038    #[test]
6039    fn test_compute_image_dimensions_percentage_height() {
6040        let attrs = ImageAttributes {
6041            height: Some("50%".to_string()),
6042            ..Default::default()
6043        };
6044        // 50% of 300 = 150, width derived from aspect ratio.
6045        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6046        assert!((w - 200.0).abs() < 0.01);
6047        assert!((h - 150.0).abs() < 0.01);
6048    }
6049
6050    #[test]
6051    fn test_compute_image_dimensions_percentage_both() {
6052        let attrs = ImageAttributes {
6053            width: Some("75%".to_string()),
6054            height: Some("50%".to_string()),
6055            ..Default::default()
6056        };
6057        // 75% of 400 = 300, 50% of 300 = 150
6058        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6059        assert!((w - 300.0).abs() < 0.01);
6060        assert!((h - 150.0).abs() < 0.01);
6061    }
6062
6063    #[test]
6064    fn test_parse_dimension_absolute() {
6065        assert!((parse_dimension("200", 400.0).unwrap() - 200.0).abs() < 0.01);
6066    }
6067
6068    #[test]
6069    fn test_parse_dimension_percentage() {
6070        assert!((parse_dimension("50%", 400.0).unwrap() - 200.0).abs() < 0.01);
6071        assert!((parse_dimension(" 25% ", 800.0).unwrap() - 200.0).abs() < 0.01);
6072    }
6073
6074    #[test]
6075    fn test_parse_dimension_invalid() {
6076        assert!(parse_dimension("", 400.0).is_none());
6077        assert!(parse_dimension("abc", 400.0).is_none());
6078        assert!(parse_dimension("-10", 400.0).is_none());
6079        assert!(parse_dimension("0%", 400.0).is_none());
6080        assert!(parse_dimension("-5%", 400.0).is_none());
6081    }
6082
6083    #[test]
6084    fn test_parse_dimension_rejects_non_finite() {
6085        // Infinity via str::parse::<f32>
6086        assert!(parse_dimension("inf", 400.0).is_none());
6087        assert!(parse_dimension("infinity", 400.0).is_none());
6088        assert!(parse_dimension("Infinity", 400.0).is_none());
6089        // NaN
6090        assert!(parse_dimension("NaN", 400.0).is_none());
6091        // Infinity percentage
6092        assert!(parse_dimension("inf%", 400.0).is_none());
6093    }
6094
6095    #[test]
6096    fn test_compute_image_dimensions_infinite_scale_rejected() {
6097        let attrs = ImageAttributes {
6098            src: String::new(),
6099            width: None,
6100            height: None,
6101            scale: Some("inf".to_string()),
6102            title: None,
6103            anchor: None,
6104        };
6105        // With infinite scale rejected, should fall back to native dimensions.
6106        let (w, h) = compute_image_dimensions(&attrs, 100.0, 200.0, 0.5);
6107        assert!((w - 100.0).abs() < 0.01);
6108        assert!((h - 200.0).abs() < 0.01);
6109    }
6110
6111    #[test]
6112    fn test_compute_image_dimensions_nan_scale_rejected() {
6113        let attrs = ImageAttributes {
6114            src: String::new(),
6115            width: None,
6116            height: None,
6117            scale: Some("NaN".to_string()),
6118            title: None,
6119            anchor: None,
6120        };
6121        let (w, h) = compute_image_dimensions(&attrs, 100.0, 200.0, 0.5);
6122        assert!((w - 100.0).abs() < 0.01);
6123        assert!((h - 200.0).abs() < 0.01);
6124    }
6125
6126    #[test]
6127    fn test_oversized_image_file_is_skipped() {
6128        // Create a sparse file that exceeds MAX_IMAGE_FILE_SIZE using a
6129        // relative path so that is_safe_image_path() passes and the size
6130        // limit code path is actually exercised.
6131        //
6132        // Use a unique subdirectory name (PID + thread name) so parallel
6133        // test threads never collide on the same directory.
6134        let thread_name = std::thread::current()
6135            .name()
6136            .unwrap_or("main")
6137            .replace("::", "_");
6138        let subdir = format!("_test_oversized_img_{}_{}", std::process::id(), thread_name);
6139        let _ = std::fs::remove_dir_all(&subdir);
6140        std::fs::create_dir_all(&subdir).expect("create test dir");
6141        let rel_path = format!("{subdir}/huge.jpg");
6142
6143        // Write a file that is exactly 1 byte over the limit.
6144        let f = std::fs::File::create(&rel_path).unwrap();
6145        f.set_len(MAX_IMAGE_FILE_SIZE + 1).unwrap();
6146        drop(f);
6147
6148        let input = format!("{{image: src={rel_path}}}");
6149        let song = chordsketch_chordpro::parse(&input).unwrap();
6150        // Should not panic or crash — the oversized image is silently skipped.
6151        let pdf = render_song(&song);
6152        let content = String::from_utf8_lossy(&pdf);
6153        // The PDF must not contain an image XObject.
6154        assert!(
6155            !content.contains("/Subtype /Image"),
6156            "oversized image must be rejected"
6157        );
6158
6159        let _ = std::fs::remove_dir_all(subdir);
6160    }
6161
6162    #[test]
6163    fn test_negative_scale_falls_back_to_native() {
6164        let attrs = ImageAttributes {
6165            scale: Some("-1".to_string()),
6166            ..Default::default()
6167        };
6168        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6169        // Negative scale is rejected; native dimensions are used.
6170        assert!((w - 400.0).abs() < 0.01);
6171        assert!((h - 300.0).abs() < 0.01);
6172    }
6173
6174    #[test]
6175    fn test_negative_width_falls_back_to_native() {
6176        let attrs = ImageAttributes {
6177            width: Some("-200".to_string()),
6178            ..Default::default()
6179        };
6180        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6181        assert!((w - 400.0).abs() < 0.01);
6182        assert!((h - 300.0).abs() < 0.01);
6183    }
6184
6185    #[test]
6186    fn test_negative_height_falls_back_to_native() {
6187        let attrs = ImageAttributes {
6188            height: Some("-150".to_string()),
6189            ..Default::default()
6190        };
6191        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6192        assert!((w - 400.0).abs() < 0.01);
6193        assert!((h - 300.0).abs() < 0.01);
6194    }
6195
6196    #[test]
6197    fn test_zero_scale_falls_back_to_native() {
6198        let attrs = ImageAttributes {
6199            scale: Some("0".to_string()),
6200            ..Default::default()
6201        };
6202        let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6203        assert!((w - 400.0).abs() < 0.01);
6204        assert!((h - 300.0).abs() < 0.01);
6205    }
6206
6207    #[test]
6208    fn test_clamp_to_printable_no_clamping_needed() {
6209        let (w, h) = clamp_to_printable_area(200.0, 150.0, 500.0, 700.0, 200.0 / 150.0);
6210        assert!((w - 200.0).abs() < 0.01);
6211        assert!((h - 150.0).abs() < 0.01);
6212    }
6213
6214    #[test]
6215    fn test_clamp_to_printable_width_exceeds() {
6216        // 800x200 image, max 500x700 area, aspect 4.0
6217        let (w, h) = clamp_to_printable_area(800.0, 200.0, 500.0, 700.0, 4.0);
6218        assert!((w - 500.0).abs() < 0.01);
6219        assert!((h - 125.0).abs() < 0.01); // 500 / 4.0
6220    }
6221
6222    #[test]
6223    fn test_clamp_to_printable_height_exceeds() {
6224        // 200x800 image, max 500x700 area, aspect 0.25
6225        let (w, h) = clamp_to_printable_area(200.0, 800.0, 500.0, 700.0, 0.25);
6226        assert!((w - 175.0).abs() < 0.01); // 700 * 0.25
6227        assert!((h - 700.0).abs() < 0.01); // 175 / 0.25
6228    }
6229
6230    #[test]
6231    fn test_clamp_to_printable_height_exceeds_extreme_aspect_reclamps_width() {
6232        // Extreme wide image: aspect 4.0, height clamped to 700 would produce
6233        // width 2800 which exceeds max_w 500. Width must be re-clamped.
6234        let (w, h) = clamp_to_printable_area(2800.0, 700.0, 500.0, 700.0, 4.0);
6235        // Width-clamping branch fires first (2800 > 500)
6236        assert!((w - 500.0).abs() < 0.01);
6237        assert!((h - 125.0).abs() < 0.01); // 500 / 4.0
6238    }
6239
6240    #[test]
6241    fn test_clamp_to_printable_height_clamp_triggers_width_reclamp() {
6242        // Image that only exceeds height: 400x800 in 500x700 area, aspect 4.0
6243        // height 800 > max_h 700, so height-clamping branch runs.
6244        // max_h * aspect = 700 * 4.0 = 2800, which exceeds max_w 500.
6245        // Width must be re-clamped to 500, height adjusted to 500 / 4.0 = 125.
6246        let (w, h) = clamp_to_printable_area(400.0, 800.0, 500.0, 700.0, 4.0);
6247        assert!(w <= 500.0, "width {} must not exceed max_w 500", w);
6248        assert!((w - 500.0).abs() < 0.01);
6249        assert!((h - 125.0).abs() < 0.01); // 500 / 4.0
6250    }
6251
6252    #[test]
6253    fn test_clamp_to_printable_width_exceeds_then_height_reclamps() {
6254        // Square image (aspect=1.0) in a very short printable area.
6255        // Width-clamping branch fires first (2000 > 500), computing
6256        // clamped_h = 500/1.0 = 500, but max_h is only 50.
6257        // Height must be clamped to 50, then width re-adjusted to 50.
6258        let (w, h) = clamp_to_printable_area(2000.0, 2000.0, 500.0, 50.0, 1.0);
6259        assert!((w - 50.0).abs() < 0.01, "width {} should be 50.0", w);
6260        assert!((h - 50.0).abs() < 0.01, "height {} should be 50.0", h);
6261    }
6262
6263    #[test]
6264    fn test_safe_image_path_relative() {
6265        assert!(is_safe_image_path("photo.jpg"));
6266        assert!(is_safe_image_path("images/photo.jpg"));
6267        assert!(is_safe_image_path("sub/dir/photo.jpg"));
6268    }
6269
6270    #[test]
6271    fn test_safe_image_path_rejects_empty() {
6272        assert!(!is_safe_image_path(""));
6273    }
6274
6275    #[test]
6276    fn test_safe_image_path_rejects_null_bytes() {
6277        assert!(!is_safe_image_path("photo\0.jpg"));
6278        assert!(!is_safe_image_path("images/photo.jpg\0../../etc/shadow"));
6279    }
6280
6281    #[test]
6282    fn test_safe_image_path_rejects_absolute() {
6283        assert!(!is_safe_image_path("/etc/shadow.jpeg"));
6284        assert!(!is_safe_image_path("/home/user/photo.jpg"));
6285    }
6286
6287    #[test]
6288    fn test_safe_image_path_rejects_traversal() {
6289        assert!(!is_safe_image_path("../photo.jpg"));
6290        assert!(!is_safe_image_path("images/../../etc/shadow.jpeg"));
6291        assert!(!is_safe_image_path("sub/../../../photo.jpg"));
6292    }
6293
6294    #[test]
6295    fn test_safe_image_path_windows_style_strings() {
6296        // Platform-agnostic string checks for Windows-style paths.
6297        // On Unix, `Path::is_absolute()` doesn't flag `C:\` as absolute,
6298        // but `C:\` starts with a prefix that std::path::Component::Prefix
6299        // detects on Windows. We test the string patterns here to ensure
6300        // coverage regardless of platform.
6301
6302        // Backslash-separated relative paths: allowed (treated as a single
6303        // filename component on Unix, or valid relative on Windows).
6304        assert!(is_safe_image_path(r"images\photo.jpg"));
6305
6306        // Unix-style absolute path with leading slash: always rejected.
6307        assert!(!is_safe_image_path("/images/photo.jpg"));
6308    }
6309
6310    #[test]
6311    fn test_safe_image_path_windows_absolute_rejected() {
6312        // String-level checks reject Windows-style absolute paths on all platforms.
6313        assert!(!is_safe_image_path(r"C:\photo.jpg"));
6314        assert!(!is_safe_image_path(r"D:\Users\photo.jpg"));
6315        assert!(!is_safe_image_path(r"\\server\share\photo.jpg"));
6316        assert!(!is_safe_image_path("C:/photo.jpg"));
6317    }
6318
6319    #[test]
6320    fn test_safe_image_path_backslash_traversal_rejected() {
6321        // The shared `has_traversal` helper splits on both `/` and `\`,
6322        // so backslash-based traversal is now detected on all platforms.
6323        assert!(!is_safe_image_path(r"..\photo.jpg"));
6324        assert!(!is_safe_image_path(r"images\..\..\photo.jpg"));
6325    }
6326
6327    #[cfg(unix)]
6328    #[test]
6329    fn test_symlink_image_is_rejected() {
6330        use std::os::unix::fs::symlink;
6331
6332        // Use a unique subdirectory name (PID + thread name) so parallel test
6333        // threads never collide on the same directory.  The path must be
6334        // relative because `is_safe_image_path()` rejects absolute paths.
6335        let subdir = format!(
6336            "_test_symlink_img_{}_{}",
6337            std::process::id(),
6338            std::thread::current().name().unwrap_or("main")
6339        );
6340        let _ = std::fs::remove_dir_all(&subdir);
6341        std::fs::create_dir_all(&subdir).expect("create test dir");
6342
6343        let target = format!("{subdir}/real.jpg");
6344        std::fs::write(&target, b"\xFF\xD8\xFF").expect("write target");
6345        let link = format!("{subdir}/link.jpg");
6346        symlink(&target, &link).expect("create symlink");
6347
6348        let input = format!("{{title: T}}\n{{image: src={link}}}");
6349        let song = chordsketch_chordpro::parse(&input).expect("parse");
6350        let pdf = render_song(&song);
6351        let content = String::from_utf8_lossy(&pdf);
6352        // Image must NOT be embedded because src is a symlink.
6353        assert!(
6354            !content.contains("/Subtype /Image"),
6355            "symlink images must be rejected"
6356        );
6357
6358        let _ = std::fs::remove_dir_all(&subdir);
6359    }
6360
6361    #[test]
6362    fn test_custom_margins_from_config() {
6363        let config = Config::defaults()
6364            .with_define("pdf.margins.top=100")
6365            .unwrap();
6366        let doc = PdfDocument::from_config(&config);
6367        assert!((doc.margin_top - 100.0).abs() < 0.01);
6368        // Other margins should keep defaults.
6369        assert!((doc.margin_bottom - MARGIN_BOTTOM).abs() < 0.01);
6370        assert!((doc.margin_left - MARGIN_LEFT).abs() < 0.01);
6371        assert!((doc.margin_right - MARGIN_RIGHT).abs() < 0.01);
6372    }
6373
6374    #[test]
6375    fn test_negative_margin_falls_back_to_default() {
6376        let config = Config::defaults()
6377            .with_define("pdf.margins.top=-100")
6378            .unwrap();
6379        let doc = PdfDocument::from_config(&config);
6380        assert!((doc.margin_top - MARGIN_TOP).abs() < 0.01);
6381    }
6382
6383    #[test]
6384    fn test_zero_margin_is_valid() {
6385        let config = Config::defaults().with_define("pdf.margins.top=0").unwrap();
6386        let doc = PdfDocument::from_config(&config);
6387        assert!(doc.margin_top.abs() < 0.01);
6388    }
6389
6390    #[test]
6391    fn test_excessive_margin_falls_back_to_default() {
6392        let config = Config::defaults()
6393            .with_define("pdf.margins.left=1000")
6394            .unwrap();
6395        let doc = PdfDocument::from_config(&config);
6396        assert!((doc.margin_left - MARGIN_LEFT).abs() < 0.01);
6397    }
6398
6399    #[test]
6400    fn test_custom_margins_affect_output() {
6401        let song = chordsketch_chordpro::parse("{title: Test}\nHello").unwrap();
6402        let default_pdf = render_song(&song);
6403        let config = Config::defaults()
6404            .with_define("pdf.margins.top=200")
6405            .unwrap();
6406        let custom_pdf = render_song_with_transpose(&song, 0, &config);
6407        // Different margins produce different PDF output.
6408        assert_ne!(default_pdf, custom_pdf);
6409    }
6410
6411    #[test]
6412    fn test_fmt_f32_nan_produces_zero() {
6413        assert_eq!(fmt_f32(f32::NAN), "0");
6414    }
6415
6416    #[test]
6417    fn test_fmt_f32_infinity_produces_zero() {
6418        assert_eq!(fmt_f32(f32::INFINITY), "0");
6419        assert_eq!(fmt_f32(f32::NEG_INFINITY), "0");
6420    }
6421
6422    #[test]
6423    fn test_fmt_f32_normal_values() {
6424        assert_eq!(fmt_f32(1.0), "1");
6425        assert_eq!(fmt_f32(3.25), "3.25");
6426        assert_eq!(fmt_f32(0.0), "0");
6427        assert_eq!(fmt_f32(-5.5), "-5.5");
6428    }
6429}
6430
6431#[cfg(test)]
6432mod png_tests {
6433    use super::*;
6434
6435    /// Build a minimal valid PNG file with the given pixel data.
6436    ///
6437    /// `color_type`: 0=gray, 2=RGB, 4=gray+alpha, 6=RGBA
6438    /// `pixels`: raw pixel data (row-major, no filter bytes — filter 0 is added).
6439    fn build_png(width: u32, height: u32, bit_depth: u8, color_type: u8, pixels: &[u8]) -> Vec<u8> {
6440        let channels: usize = match color_type {
6441            0 => 1,
6442            2 => 3,
6443            4 => 2,
6444            6 => 4,
6445            _ => panic!("unsupported color type"),
6446        };
6447        let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 };
6448        let row_bytes = width as usize * channels * bytes_per_sample;
6449
6450        // Build raw data with filter byte 0 (None) per row.
6451        let mut raw = Vec::new();
6452        for row in 0..height as usize {
6453            raw.push(0); // filter None
6454            let start = row * row_bytes;
6455            raw.extend_from_slice(&pixels[start..start + row_bytes]);
6456        }
6457
6458        // Compress with zlib.
6459        let idat_payload = zlib_compress(&raw).expect("compression should succeed");
6460
6461        let mut png = Vec::new();
6462        png.extend_from_slice(&PNG_SIGNATURE);
6463
6464        // IHDR
6465        let mut ihdr = Vec::new();
6466        ihdr.extend_from_slice(&width.to_be_bytes());
6467        ihdr.extend_from_slice(&height.to_be_bytes());
6468        ihdr.push(bit_depth);
6469        ihdr.push(color_type);
6470        ihdr.push(0); // compression
6471        ihdr.push(0); // filter
6472        ihdr.push(0); // interlace
6473        write_png_chunk(&mut png, b"IHDR", &ihdr);
6474
6475        // IDAT
6476        write_png_chunk(&mut png, b"IDAT", &idat_payload);
6477
6478        // IEND
6479        write_png_chunk(&mut png, b"IEND", &[]);
6480
6481        png
6482    }
6483
6484    /// Build a minimal indexed PNG (color type 3) with a PLTE chunk.
6485    fn build_indexed_png(width: u32, height: u32, palette: &[u8], indices: &[u8]) -> Vec<u8> {
6486        let row_bytes = width as usize;
6487
6488        let mut raw = Vec::new();
6489        for row in 0..height as usize {
6490            raw.push(0); // filter None
6491            let start = row * row_bytes;
6492            raw.extend_from_slice(&indices[start..start + row_bytes]);
6493        }
6494
6495        let idat_payload = zlib_compress(&raw).expect("compression should succeed");
6496
6497        let mut png = Vec::new();
6498        png.extend_from_slice(&PNG_SIGNATURE);
6499
6500        // IHDR
6501        let mut ihdr = Vec::new();
6502        ihdr.extend_from_slice(&width.to_be_bytes());
6503        ihdr.extend_from_slice(&height.to_be_bytes());
6504        ihdr.push(8); // bit depth
6505        ihdr.push(3); // color type indexed
6506        ihdr.push(0);
6507        ihdr.push(0);
6508        ihdr.push(0);
6509        write_png_chunk(&mut png, b"IHDR", &ihdr);
6510
6511        // PLTE
6512        write_png_chunk(&mut png, b"PLTE", palette);
6513
6514        // IDAT
6515        write_png_chunk(&mut png, b"IDAT", &idat_payload);
6516
6517        // IEND
6518        write_png_chunk(&mut png, b"IEND", &[]);
6519
6520        png
6521    }
6522
6523    fn write_png_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
6524        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
6525        out.extend_from_slice(chunk_type);
6526        out.extend_from_slice(data);
6527        // CRC32 over type + data
6528        let mut crc_data = Vec::new();
6529        crc_data.extend_from_slice(chunk_type);
6530        crc_data.extend_from_slice(data);
6531        let crc = crc32(&crc_data);
6532        out.extend_from_slice(&crc.to_be_bytes());
6533    }
6534
6535    /// Simple CRC-32 (PNG uses CRC-32/ISO-3309).
6536    fn crc32(data: &[u8]) -> u32 {
6537        let mut crc: u32 = 0xFFFF_FFFF;
6538        for &byte in data {
6539            crc ^= byte as u32;
6540            for _ in 0..8 {
6541                if crc & 1 != 0 {
6542                    crc = (crc >> 1) ^ 0xEDB8_8320;
6543                } else {
6544                    crc >>= 1;
6545                }
6546            }
6547        }
6548        !crc
6549    }
6550
6551    #[test]
6552    fn test_parse_png_rgb() {
6553        // 2x2 RGB image (color type 2)
6554        let pixels = vec![
6555            255, 0, 0, 0, 255, 0, // row 0: red, green
6556            0, 0, 255, 255, 255, 255, // row 1: blue, white
6557        ];
6558        let png = build_png(2, 2, 8, 2, &pixels);
6559        let info = parse_png(&png).expect("should parse");
6560        assert_eq!(info.width, 2);
6561        assert_eq!(info.height, 2);
6562        assert_eq!(info.bit_depth, 8);
6563        assert_eq!(info.colors, 3);
6564        assert!(info.palette.is_none());
6565        assert!(info.smask.is_none());
6566    }
6567
6568    #[test]
6569    fn test_parse_png_grayscale() {
6570        // 3x1 grayscale image (color type 0)
6571        let pixels = vec![0, 128, 255];
6572        let png = build_png(3, 1, 8, 0, &pixels);
6573        let info = parse_png(&png).expect("should parse");
6574        assert_eq!(info.width, 3);
6575        assert_eq!(info.height, 1);
6576        assert_eq!(info.colors, 1);
6577        assert!(info.smask.is_none());
6578    }
6579
6580    #[test]
6581    fn test_parse_png_rgba_separates_alpha() {
6582        // 2x1 RGBA image (color type 6)
6583        let pixels = vec![
6584            255, 0, 0, 128, // red, alpha 128
6585            0, 255, 0, 255, // green, alpha 255
6586        ];
6587        let png = build_png(2, 1, 8, 6, &pixels);
6588        let info = parse_png(&png).expect("should parse RGBA");
6589        assert_eq!(info.width, 2);
6590        assert_eq!(info.height, 1);
6591        assert_eq!(info.colors, 3); // RGB after alpha removal
6592        assert!(info.smask.is_some());
6593
6594        // Verify the color data by decompressing the IDAT.
6595        let mut decoder = ZlibDecoder::new(info.idat_data.as_slice());
6596        let mut color = Vec::new();
6597        decoder.read_to_end(&mut color).unwrap();
6598        // Row: filter(0) + R G B R G B
6599        assert_eq!(color, vec![0, 255, 0, 0, 0, 255, 0]);
6600
6601        // Verify alpha data.
6602        let mut decoder = ZlibDecoder::new(info.smask.as_ref().unwrap().as_slice());
6603        let mut alpha = Vec::new();
6604        decoder.read_to_end(&mut alpha).unwrap();
6605        // Row: filter(0) + alpha alpha
6606        assert_eq!(alpha, vec![0, 128, 255]);
6607    }
6608
6609    #[test]
6610    fn test_parse_png_gray_alpha() {
6611        // 2x1 gray+alpha image (color type 4)
6612        let pixels = vec![
6613            100, 200, // gray 100, alpha 200
6614            50, 100, // gray 50, alpha 100
6615        ];
6616        let png = build_png(2, 1, 8, 4, &pixels);
6617        let info = parse_png(&png).expect("should parse gray+alpha");
6618        assert_eq!(info.colors, 1); // grayscale after alpha removal
6619        assert!(info.smask.is_some());
6620
6621        let mut decoder = ZlibDecoder::new(info.idat_data.as_slice());
6622        let mut color = Vec::new();
6623        decoder.read_to_end(&mut color).unwrap();
6624        assert_eq!(color, vec![0, 100, 50]);
6625
6626        let mut decoder = ZlibDecoder::new(info.smask.as_ref().unwrap().as_slice());
6627        let mut alpha = Vec::new();
6628        decoder.read_to_end(&mut alpha).unwrap();
6629        assert_eq!(alpha, vec![0, 200, 100]);
6630    }
6631
6632    #[test]
6633    fn test_parse_png_indexed() {
6634        // 2x1 indexed image with 2-color palette
6635        let palette = vec![255, 0, 0, 0, 0, 255]; // red, blue
6636        let indices = vec![0, 1]; // pixel 0 = red, pixel 1 = blue
6637        let png = build_indexed_png(2, 1, &palette, &indices);
6638        let info = parse_png(&png).expect("should parse indexed");
6639        assert_eq!(info.colors, 3); // presented as RGB via palette
6640        assert!(info.palette.is_some());
6641        assert_eq!(info.palette.as_ref().unwrap(), &palette);
6642    }
6643
6644    #[test]
6645    fn test_parse_png_invalid_signature() {
6646        assert!(parse_png(b"not a png").is_none());
6647        assert!(parse_png(&[]).is_none());
6648    }
6649
6650    #[test]
6651    fn test_parse_png_no_idat() {
6652        let mut png = Vec::new();
6653        png.extend_from_slice(&PNG_SIGNATURE);
6654        // IHDR only, no IDAT
6655        let mut ihdr = Vec::new();
6656        ihdr.extend_from_slice(&2u32.to_be_bytes());
6657        ihdr.extend_from_slice(&2u32.to_be_bytes());
6658        ihdr.push(8);
6659        ihdr.push(2); // RGB
6660        ihdr.extend_from_slice(&[0, 0, 0]);
6661        write_png_chunk(&mut png, b"IHDR", &ihdr);
6662        write_png_chunk(&mut png, b"IEND", &[]);
6663        assert!(parse_png(&png).is_none());
6664    }
6665
6666    #[test]
6667    fn test_embedded_image_num_objects_jpeg() {
6668        let img = EmbeddedImage {
6669            width: 10,
6670            height: 10,
6671            format: ImageFormat::Jpeg {
6672                data: vec![],
6673                components: 3,
6674            },
6675        };
6676        assert_eq!(img.num_pdf_objects(), 1);
6677    }
6678
6679    #[test]
6680    fn test_embedded_image_num_objects_png_no_alpha() {
6681        let img = EmbeddedImage {
6682            width: 10,
6683            height: 10,
6684            format: ImageFormat::Png {
6685                idat_data: vec![],
6686                bit_depth: 8,
6687                colors: 3,
6688                palette: None,
6689                smask: None,
6690            },
6691        };
6692        assert_eq!(img.num_pdf_objects(), 1);
6693    }
6694
6695    #[test]
6696    fn test_embedded_image_num_objects_png_with_alpha() {
6697        let img = EmbeddedImage {
6698            width: 10,
6699            height: 10,
6700            format: ImageFormat::Png {
6701                idat_data: vec![],
6702                bit_depth: 8,
6703                colors: 3,
6704                palette: None,
6705                smask: Some(vec![1, 2, 3]),
6706            },
6707        };
6708        assert_eq!(img.num_pdf_objects(), 2);
6709    }
6710
6711    #[test]
6712    fn test_paeth_predictor() {
6713        // Standard cases from the PNG specification.
6714        // Paeth(a, b, c): p = a + b - c
6715        assert_eq!(paeth_predictor(0, 0, 0), 0);
6716        // a=10, b=20, c=15: p=15, pa=5, pb=5, pc=0 → c (pc smallest)
6717        assert_eq!(paeth_predictor(10, 20, 15), 15);
6718        // a=10, b=10, c=10: p=10, pa=0, pb=0, pc=0 → a (pa<=pb and pa<=pc)
6719        assert_eq!(paeth_predictor(10, 10, 10), 10);
6720    }
6721
6722    #[test]
6723    fn test_render_songs_with_warnings_empty_slice() {
6724        let songs: Vec<chordsketch_chordpro::ast::Song> = Vec::new();
6725        let result = render_songs_with_warnings(&songs, 0, &Config::defaults());
6726        // Should not panic and should return empty output.
6727        assert!(result.output.is_empty());
6728    }
6729}
6730
6731#[cfg(test)]
6732mod info_title_tests {
6733    use super::*;
6734
6735    #[test]
6736    fn pdf_title_hex_string_encodes_ascii_with_bom() {
6737        assert_eq!(pdf_title_hex_string("AB"), "<FEFF00410042>");
6738    }
6739
6740    #[test]
6741    fn pdf_title_hex_string_encodes_bmp_codepoints() {
6742        // U+65E5 日 + U+672C 本 → UTF-16BE 65E5 672C
6743        assert_eq!(pdf_title_hex_string("日本"), "<FEFF65E5672C>");
6744    }
6745
6746    #[test]
6747    fn pdf_title_hex_string_encodes_supplementary_plane_with_surrogate_pair() {
6748        // U+1F3B5 🎵 → high surrogate D83C, low surrogate DFB5
6749        assert_eq!(pdf_title_hex_string("🎵"), "<FEFFD83CDFB5>");
6750    }
6751
6752    #[test]
6753    fn render_song_emits_info_title_for_titled_song() {
6754        let input = "{title: Hello}\n\nHello world\n";
6755        let song = chordsketch_chordpro::parse(input).expect("parse");
6756        let pdf = render_song(&song);
6757        let s = String::from_utf8_lossy(&pdf);
6758        // /Info reference in trailer.
6759        assert!(
6760            s.contains("/Info "),
6761            "trailer should reference /Info when {{title}} is set; got: {s}"
6762        );
6763        // /Title hex literal with UTF-16BE BOM + ASCII codepoints of "Hello".
6764        // The hex case is pinned uppercase by `pdf_title_hex_string`'s
6765        // `{cp:04X}` formatter; if that ever changes, this assertion will
6766        // fail loudly rather than drift silently.
6767        assert!(
6768            s.contains("<FEFF00480065006C006C006F>"),
6769            "PDF should contain UTF-16BE-encoded title bytes; got: {s}"
6770        );
6771    }
6772
6773    #[test]
6774    fn set_doc_title_caps_oversized_input() {
6775        let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6776        // Build a title 100x larger than the cap.
6777        let big = "A".repeat(PdfDocument::MAX_TITLE_CHARS * 100);
6778        doc.set_doc_title(Some(&big));
6779        let stored = doc.doc_title.expect("title should be stored");
6780        assert_eq!(
6781            stored.chars().count(),
6782            PdfDocument::MAX_TITLE_CHARS,
6783            "oversized title must be truncated to MAX_TITLE_CHARS"
6784        );
6785        // Hex output is bounded too: 5 (BOM literal) + 4*N + 1 (closing >).
6786        let hex = pdf_title_hex_string(&stored);
6787        let expected_max = 5 + 4 * PdfDocument::MAX_TITLE_CHARS + 1;
6788        assert!(
6789            hex.len() <= expected_max,
6790            "hex literal must be bounded; got {} bytes, max {expected_max}",
6791            hex.len()
6792        );
6793    }
6794
6795    #[test]
6796    fn set_doc_title_passes_through_at_cap_and_truncates_one_over() {
6797        let mut at_cap = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6798        at_cap.set_doc_title(Some(&"A".repeat(PdfDocument::MAX_TITLE_CHARS)));
6799        assert_eq!(
6800            at_cap.doc_title.as_deref().map(|s| s.chars().count()),
6801            Some(PdfDocument::MAX_TITLE_CHARS),
6802            "input exactly at cap must pass through unchanged"
6803        );
6804
6805        let mut over_cap = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6806        over_cap.set_doc_title(Some(&"A".repeat(PdfDocument::MAX_TITLE_CHARS + 1)));
6807        assert_eq!(
6808            over_cap.doc_title.as_deref().map(|s| s.chars().count()),
6809            Some(PdfDocument::MAX_TITLE_CHARS),
6810            "input one char over cap must truncate"
6811        );
6812    }
6813
6814    #[test]
6815    fn set_doc_title_truncates_at_char_boundary_for_multibyte_input() {
6816        let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6817        // Each '日' is 3 bytes in UTF-8 but counts as 1 char.
6818        let big: String = "日".repeat(PdfDocument::MAX_TITLE_CHARS + 50);
6819        doc.set_doc_title(Some(&big));
6820        let stored = doc.doc_title.expect("title should be stored");
6821        assert_eq!(stored.chars().count(), PdfDocument::MAX_TITLE_CHARS);
6822        // The truncation must not split a multi-byte codepoint.
6823        assert!(
6824            stored.is_char_boundary(stored.len()),
6825            "truncation produced an invalid UTF-8 boundary"
6826        );
6827    }
6828
6829    #[test]
6830    fn set_doc_title_trims_leading_and_trailing_whitespace() {
6831        let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6832        doc.set_doc_title(Some("   Hello World   "));
6833        assert_eq!(doc.doc_title.as_deref(), Some("Hello World"));
6834    }
6835
6836    #[test]
6837    fn render_song_omits_info_when_title_missing() {
6838        let input = "Just a lyric line\n";
6839        let song = chordsketch_chordpro::parse(input).expect("parse");
6840        let pdf = render_song(&song);
6841        let s = String::from_utf8_lossy(&pdf);
6842        assert!(
6843            !s.contains("/Info "),
6844            "no /Info should be emitted when {{title}} is absent"
6845        );
6846        assert!(
6847            !s.contains("/Title "),
6848            "no /Title should be emitted when {{title}} is absent"
6849        );
6850    }
6851
6852    #[test]
6853    fn render_song_omits_info_for_whitespace_only_title() {
6854        let input = "{title:    }\n\nbody\n";
6855        let song = chordsketch_chordpro::parse(input).expect("parse");
6856        let pdf = render_song(&song);
6857        let s = String::from_utf8_lossy(&pdf);
6858        assert!(
6859            !s.contains("/Info "),
6860            "whitespace-only title must normalise to no /Info"
6861        );
6862    }
6863
6864    #[test]
6865    fn render_songs_omits_info_for_multi_song_output() {
6866        // Two songs trigger the multi-song TOC path. There is no songbook
6867        // abstraction, so /Info must NOT default to the first song's title.
6868        let input = "{title: One}\n\nfirst body\n\n{new_song}\n{title: Two}\n\nsecond body\n";
6869        let songs = chordsketch_chordpro::parse_multi(input).expect("parse_multi");
6870        assert!(songs.len() >= 2, "expected multi-song parse");
6871        let pdf = render_songs(&songs);
6872        let s = String::from_utf8_lossy(&pdf);
6873        assert!(
6874            !s.contains("/Info "),
6875            "multi-song render must not emit /Info; got trailer fragment: {s}"
6876        );
6877    }
6878}