use chordsketch_chordpro::ast::{
CommentStyle, DirectiveKind, ImageAttributes, Line, LyricsLine, Song,
};
use chordsketch_chordpro::canonical_chord_name;
use chordsketch_chordpro::config::Config;
use chordsketch_chordpro::inline_markup::TextSpan;
use chordsketch_chordpro::notation::NotationKind;
use chordsketch_chordpro::render_result::{
RenderResult, push_warning, validate_capo, validate_multiple_capo, validate_strict_key,
};
use chordsketch_chordpro::resolve_diagrams_instrument;
use chordsketch_chordpro::transpose::{transpose_chord_with_style, transposed_key_prefers_flat};
use chordsketch_chordpro::typography::{tempo_marking_for, unicode_accidentals};
use flate2::Compression;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use std::collections::BTreeMap;
use std::io::{Read as IoRead, Write as IoWrite};
static UNICODE_FONT_BYTES: &[u8] = include_bytes!("../assets/NotoSansCJK-subset.otf");
fn unicode_face() -> &'static ttf_parser::Face<'static> {
use std::sync::OnceLock;
static FACE: OnceLock<ttf_parser::Face<'static>> = OnceLock::new();
FACE.get_or_init(|| {
ttf_parser::Face::parse(UNICODE_FONT_BYTES, 0)
.expect("bundled NotoSansCJK-subset.otf must be a valid font face")
})
}
fn extract_cff_table(otf_bytes: &[u8]) -> Option<&[u8]> {
if otf_bytes.len() < 12 {
return None;
}
let num_tables = u16::from_be_bytes([otf_bytes[4], otf_bytes[5]]) as usize;
for i in 0..num_tables {
let rec = 12 + i * 16;
if rec + 16 > otf_bytes.len() {
return None;
}
if &otf_bytes[rec..rec + 4] == b"CFF " {
let offset = u32::from_be_bytes(otf_bytes[rec + 8..rec + 12].try_into().ok()?) as usize;
let length =
u32::from_be_bytes(otf_bytes[rec + 12..rec + 16].try_into().ok()?) as usize;
let end = offset.checked_add(length)?;
if end <= otf_bytes.len() {
return Some(&otf_bytes[offset..end]);
}
}
}
None
}
fn unicode_cff_bytes() -> &'static [u8] {
use std::sync::OnceLock;
static CFF: OnceLock<&'static [u8]> = OnceLock::new();
CFF.get_or_init(|| {
extract_cff_table(UNICODE_FONT_BYTES)
.expect("bundled NotoSansCJK-subset.otf must contain a CFF table")
})
}
#[must_use]
fn needs_cid_font(c: char) -> bool {
let code = c as u32;
if code <= 0x7F {
return false; }
if (0xA0..=0xFF).contains(&code) {
return false; }
winansi_byte(c).is_none() }
fn text_segments(text: &str) -> Vec<(bool, String)> {
let mut result: Vec<(bool, String)> = Vec::new();
let mut current_cid = false;
let mut current = String::new();
for c in text.chars() {
let cid = needs_cid_font(c);
if !current.is_empty() && cid != current_cid {
result.push((current_cid, std::mem::take(&mut current)));
}
current_cid = cid;
current.push(c);
}
if !current.is_empty() {
result.push((current_cid, current));
}
result
}
fn encode_cid_text(text: &str) -> (String, Vec<(u16, char)>) {
let face = unicode_face();
let mut hex = String::with_capacity(text.chars().count() * 4);
let mut mappings: Vec<(u16, char)> = Vec::with_capacity(text.chars().count());
for c in text.chars() {
let gid = face.glyph_index(c).map(|g| g.0).unwrap_or(0);
hex.push_str(&format!("{:04X}", gid));
mappings.push((gid, c));
}
(hex, mappings)
}
fn cid_text_width(text: &str, font_size: f32) -> f32 {
let face = unicode_face();
let units = face.units_per_em() as f32;
text.chars()
.map(|c| {
let gid = face.glyph_index(c).unwrap_or(ttf_parser::GlyphId(0));
face.glyph_hor_advance(gid).unwrap_or(1000) as f32 / units * font_size
})
.sum()
}
fn build_to_unicode_cmap(cid_glyphs: &BTreeMap<u16, char>) -> String {
let mut cmap = String::new();
cmap.push_str("/CIDInit /ProcSet findresource begin\n");
cmap.push_str("12 dict begin\n");
cmap.push_str("begincmap\n");
cmap.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
cmap.push_str("/CMapName /Adobe-Identity-UCS def\n");
cmap.push_str("/CMapType 2 def\n");
let entries: Vec<_> = cid_glyphs.iter().filter(|&(&gid, _)| gid != 0).collect();
for chunk in entries.chunks(100) {
cmap.push_str(&format!("{} beginbfchar\n", chunk.len()));
for &(gid, ch) in chunk {
let cp = *ch as u32;
if cp <= 0xFFFF {
cmap.push_str(&format!("<{:04X}> <{:04X}>\n", gid, cp));
} else {
let offset = cp - 0x10000;
let hi = 0xD800u32 + (offset >> 10);
let lo = 0xDC00u32 + (offset & 0x3FF);
cmap.push_str(&format!("<{:04X}> <{:04X}{:04X}>\n", gid, hi, lo));
}
}
cmap.push_str("endbfchar\n");
}
cmap.push_str("endcmap\n");
cmap.push_str("CMapName currentdict /CMap defineresource pop\n");
cmap.push_str("end\n"); cmap.push_str("end\n"); cmap
}
fn pdf_title_hex_string(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(5 + 4 * s.len() + 1);
out.push_str("<FEFF");
for ch in s.chars() {
let cp = ch as u32;
if cp <= 0xFFFF {
let _ = write!(out, "{cp:04X}");
} else {
let offset = cp - 0x10000;
let hi = 0xD800u32 + (offset >> 10);
let lo = 0xDC00u32 + (offset & 0x3FF);
let _ = write!(out, "{hi:04X}{lo:04X}");
}
}
out.push('>');
out
}
#[derive(Default, Clone)]
struct PdfElementStyle {
size: Option<f32>,
}
#[derive(Default, Clone)]
struct PdfFormattingState {
text: PdfElementStyle,
chord: PdfElementStyle,
}
impl PdfFormattingState {
fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
let size_val = value
.as_deref()
.and_then(|v| v.parse::<f32>().ok())
.map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE));
match kind {
DirectiveKind::TextSize => self.text.size = size_val,
DirectiveKind::ChordSize => self.chord.size = size_val,
_ => {}
}
}
fn lyrics_size(&self) -> f32 {
self.text.size.unwrap_or(LYRICS_SIZE)
}
fn chord_size(&self) -> f32 {
self.chord.size.unwrap_or(CHORD_SIZE)
}
}
const PAGE_W: f32 = 595.0;
const PAGE_H: f32 = 842.0;
const MARGIN_LEFT: f32 = 56.0;
const MARGIN_TOP: f32 = 56.0;
const MARGIN_BOTTOM: f32 = 56.0;
const TITLE_SIZE: f32 = 18.0;
const SUBTITLE_SIZE: f32 = 13.0;
const CHORD_SIZE: f32 = 9.0;
const LYRICS_SIZE: f32 = 11.0;
const SECTION_SIZE: f32 = 10.0;
const COMMENT_SIZE: f32 = 9.0;
const LINE_GAP: f32 = 4.0;
#[must_use]
fn char_width(c: char) -> f32 {
#[rustfmt::skip]
const WIDTHS: [f32; 95] = [
0.278, 0.278, 0.355, 0.556, 0.556, 0.889, 0.667, 0.222, 0.333, 0.333, 0.389, 0.584, 0.278, 0.333, 0.278, 0.278, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.556, 0.278, 0.278, 0.584, 0.584, 0.584, 0.556, 1.015, 0.667, 0.667, 0.722, 0.722, 0.667, 0.611, 0.778, 0.722, 0.278, 0.500, 0.667, 0.556, 0.833, 0.722, 0.778, 0.667, 0.778, 0.722, 0.667, 0.611, 0.722, 0.667, 0.944, 0.667, 0.667, 0.611, 0.278, 0.278, 0.278, 0.469, 0.556, 0.333, 0.556, 0.556, 0.500, 0.556, 0.556, 0.278, 0.556, 0.556, 0.222, 0.222, 0.500, 0.222, 0.833, 0.556, 0.556, 0.556, 0.556, 0.333, 0.500, 0.278, 0.556, 0.500, 0.722, 0.500, 0.500, 0.500, 0.334, 0.260, 0.334, 0.584, ];
let code = c as u32;
if (32..=126).contains(&code) {
return WIDTHS[(code - 32) as usize];
}
if needs_cid_font(c) {
let face = unicode_face();
let gid = face.glyph_index(c).unwrap_or(ttf_parser::GlyphId(0));
return face.glyph_hor_advance(gid).unwrap_or(1000) as f32 / face.units_per_em() as f32;
}
0.52 }
#[must_use]
fn text_width(s: &str, font_size: f32) -> f32 {
s.chars().map(|c| char_width(c) * font_size).sum()
}
const TOC_ENTRY_SIZE: f32 = 11.0;
const MAX_PAGES: usize = 10_000;
const MAX_COLUMNS: u32 = 32;
const MIN_FONT_SIZE: f32 = 0.5;
const MAX_FONT_SIZE: f32 = 200.0;
const MAX_IMAGE_FILE_SIZE: u64 = 50 * 1024 * 1024;
const MAX_IMAGE_PIXELS: u32 = 10_000;
const MAX_IMAGES: usize = 1_000;
const MAX_CHORUS_RECALLS: usize = 1000;
pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
#[must_use]
pub fn render_song(song: &Song) -> Vec<u8> {
render_song_with_transpose(song, 0, &Config::defaults())
}
#[must_use]
pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> Vec<u8> {
let result = render_song_with_warnings(song, cli_transpose, config);
for w in &result.warnings {
eprintln!("warning: {w}");
}
result.output
}
#[must_use = "caller must check warnings in the returned RenderResult"]
pub fn render_song_with_warnings(
song: &Song,
cli_transpose: i8,
config: &Config,
) -> RenderResult<Vec<u8>> {
let mut warnings = Vec::new();
let song_overrides = song.config_overrides();
let song_config;
let effective_config = if song_overrides.is_empty() {
config
} else {
song_config = config
.clone()
.with_song_overrides(&song_overrides, &mut warnings);
&song_config
};
let mut doc = PdfDocument::from_config_with_warnings(effective_config, &mut warnings);
doc.set_doc_title(song.metadata.title.as_deref());
render_song_into_doc(
song,
cli_transpose,
effective_config,
&mut doc,
&mut warnings,
);
RenderResult::with_warnings(doc.build_pdf(), warnings)
}
#[must_use]
pub fn render_songs(songs: &[Song]) -> Vec<u8> {
render_songs_with_transpose(songs, 0, &Config::defaults())
}
#[must_use]
pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> Vec<u8> {
let result = render_songs_with_warnings(songs, cli_transpose, config);
for w in &result.warnings {
eprintln!("warning: {w}");
}
result.output
}
fn push_toc_entry(entries: &mut Vec<(String, usize)>, title: String, page: usize) {
let candidate = (title, page);
if entries.last() != Some(&candidate) {
entries.push(candidate);
}
}
#[must_use = "caller must check warnings in the returned RenderResult"]
pub fn render_songs_with_warnings(
songs: &[Song],
cli_transpose: i8,
config: &Config,
) -> RenderResult<Vec<u8>> {
let mut warnings = Vec::new();
if songs.len() <= 1 {
return songs
.first()
.map(|s| render_song_with_warnings(s, cli_transpose, config))
.unwrap_or_else(|| RenderResult::with_warnings(Vec::new(), warnings));
}
let mut body_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
let mut toc_entries: Vec<(String, usize)> = Vec::new();
for (i, song) in songs.iter().enumerate() {
if i > 0 {
body_doc.new_page();
}
let song_overrides = song.config_overrides();
let song_config;
let effective_config = if song_overrides.is_empty() {
config
} else {
song_config = config
.clone()
.with_song_overrides(&song_overrides, &mut warnings);
&song_config
};
body_doc.reset_margins_from_config(effective_config, &mut warnings);
let start_page = body_doc.page_count();
let title = song
.metadata
.title
.as_deref()
.unwrap_or("Untitled")
.to_string();
push_toc_entry(&mut toc_entries, title, start_page);
render_song_into_doc(
song,
cli_transpose,
effective_config,
&mut body_doc,
&mut warnings,
);
}
let mut toc_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
toc_doc.text("Table of Contents", Font::HelveticaBold, TITLE_SIZE);
toc_doc.newline(TITLE_SIZE + LINE_GAP * 2.0);
let toc_page_count = {
for (title, body_page_idx) in &toc_entries {
toc_doc.ensure_space(TOC_ENTRY_SIZE + LINE_GAP);
let page_num_placeholder = body_page_idx + 1; let entry_text = format!("{title} ...... {page_num_placeholder}");
toc_doc.text(&entry_text, Font::Helvetica, TOC_ENTRY_SIZE);
toc_doc.newline(TOC_ENTRY_SIZE + LINE_GAP);
}
toc_doc.page_count()
};
let mut toc_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
toc_doc.text("Table of Contents", Font::HelveticaBold, TITLE_SIZE);
toc_doc.newline(TITLE_SIZE + LINE_GAP * 2.0);
for (title, body_page_idx) in &toc_entries {
toc_doc.ensure_space(TOC_ENTRY_SIZE + LINE_GAP);
let page_num = body_page_idx + toc_page_count; let x = toc_doc.margin_left();
let y = toc_doc.y();
toc_doc.text_at(title, Font::Helvetica, TOC_ENTRY_SIZE, x, y);
let num_str = page_num.to_string();
let num_width = text_width(&num_str, TOC_ENTRY_SIZE);
let right_x = PAGE_W - toc_doc.margin_right - num_width;
toc_doc.text_at(&num_str, Font::Helvetica, TOC_ENTRY_SIZE, right_x, y);
toc_doc.newline(TOC_ENTRY_SIZE + LINE_GAP);
}
let mut combined = toc_doc;
for (gid, ch) in body_doc.cid_glyphs.iter() {
combined.cid_glyphs.entry(*gid).or_insert(*ch);
}
for page_ops in body_doc.take_pages() {
combined.push_page(page_ops);
}
RenderResult::with_warnings(combined.build_pdf(), warnings)
}
fn render_song_into_doc(
song: &Song,
cli_transpose: i8,
config: &Config,
doc: &mut PdfDocument,
warnings: &mut Vec<String>,
) {
let song_overrides = song.config_overrides();
let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
let (combined_transpose, _) =
chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
let mut transpose_offset: i8 = combined_transpose;
let mut fmt_state = PdfFormattingState::default();
let diagram_frets = config.get_path("diagrams.frets").as_f64().map_or(
chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
|n| (n as usize).max(1),
);
validate_capo(&song.metadata, warnings);
validate_multiple_capo(song, warnings);
validate_strict_key(&song.metadata, config, warnings);
if let Some(title) = &song.metadata.title {
doc.text(title, Font::HelveticaBold, TITLE_SIZE);
doc.newline(TITLE_SIZE + LINE_GAP);
}
for subtitle in &song.metadata.subtitles {
doc.text(subtitle, Font::Helvetica, SUBTITLE_SIZE);
doc.newline(SUBTITLE_SIZE + LINE_GAP);
}
let mut show_diagrams = true;
let default_instrument = config
.get_path("diagrams.instrument")
.as_str()
.map(str::to_ascii_lowercase)
.unwrap_or_else(|| "guitar".to_string());
let mut auto_diagrams_instrument: Option<String> = None;
let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut chorus_body: Vec<Line> = Vec::new();
let mut chorus_buf: Option<Vec<Line>> = None;
let mut saved_fmt_state: Option<PdfFormattingState> = None;
let mut chorus_recall_count: usize = 0;
let mut in_notation_block: Option<NotationKind> = None;
let mut in_verbatim_section = false;
for line in &song.lines {
if let Some(kind) = in_notation_block {
match line {
Line::Directive(d) if kind.is_end_directive(&d.kind) => {
in_notation_block = None;
}
_ => {}
}
continue;
}
match line {
Line::Lyrics(lyrics) => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
let prefer_flat = transposed_key_prefers_flat(&song.metadata, transpose_offset);
render_lyrics(
lyrics,
transpose_offset,
prefer_flat,
&fmt_state,
doc,
in_verbatim_section,
);
}
Line::Directive(d)
if d.kind.is_metadata()
&& matches!(
d.kind,
DirectiveKind::Key | DirectiveKind::Tempo | DirectiveKind::Time
) =>
{
if let Some(value) = d.value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
let line = match d.kind {
DirectiveKind::Key => format!("Key: {}", unicode_accidentals(value)),
DirectiveKind::Tempo => {
let marking = value
.parse::<f32>()
.ok()
.and_then(tempo_marking_for)
.map(|m| format!(" ({m})"))
.unwrap_or_default();
format!("Tempo: {value} BPM{marking}")
}
DirectiveKind::Time => format!("Time: {value}"),
_ => unreachable!(),
};
doc.text(&line, Font::HelveticaOblique, COMMENT_SIZE);
doc.newline(COMMENT_SIZE + LINE_GAP);
}
}
Line::Directive(d) if !d.kind.is_metadata() => {
if d.kind == DirectiveKind::Diagrams {
auto_diagrams_instrument =
resolve_diagrams_instrument(d.value.as_deref(), &default_instrument);
show_diagrams = auto_diagrams_instrument.is_some();
continue;
}
if d.kind == DirectiveKind::NoDiagrams {
show_diagrams = false;
auto_diagrams_instrument = None;
continue;
}
if d.kind == DirectiveKind::Transpose {
let file_offset: i8 = match d.value.as_deref() {
None | Some("") => 0,
Some(raw) => match raw.parse() {
Ok(v) => v,
Err(_) => {
push_warning(
warnings,
format!(
"{{transpose}} value {raw:?} cannot be \
parsed as i8, ignored (using 0)"
),
);
0
}
},
};
let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
file_offset,
cli_transpose,
);
if saturated {
push_warning(
warnings,
format!(
"transpose offset {file_offset} + {cli_transpose} \
exceeds i8 range, clamped to {combined}"
),
);
}
transpose_offset = combined;
continue;
}
if d.kind.is_font_size_color() {
fmt_state.apply(&d.kind, &d.value);
continue;
}
if let Some(kind) = NotationKind::from_start_directive(&d.kind) {
render_section_label(d, doc);
let label = kind.label();
let tag = kind.tag();
push_warning(
warnings,
format!(
"PDF renderer does not support {label} blocks; body of the \
`{{start_of_{tag}}} … {{end_of_{tag}}}` section has been \
omitted. Use the HTML renderer for full {label} support.",
),
);
let placeholder = format!(
"[{} block omitted — use the HTML renderer to view it]",
label
);
doc.ensure_space(LYRICS_SIZE + LINE_GAP);
doc.text(&placeholder, Font::HelveticaOblique, LYRICS_SIZE);
doc.newline(LYRICS_SIZE + LINE_GAP);
in_notation_block = Some(kind);
continue;
}
match &d.kind {
DirectiveKind::StartOfChorus => {
render_section_label(d, doc);
chorus_buf = Some(Vec::new());
saved_fmt_state = Some(fmt_state.clone());
}
DirectiveKind::EndOfChorus => {
if let Some(buf) = chorus_buf.take() {
chorus_body = buf;
}
if let Some(saved) = saved_fmt_state.take() {
fmt_state = saved;
}
}
DirectiveKind::Chorus => {
if chorus_recall_count < MAX_CHORUS_RECALLS {
let prefer_flat =
transposed_key_prefers_flat(&song.metadata, transpose_offset);
render_chorus_recall(
&d.value,
&ChorusRecallCtx {
chorus_body: &chorus_body,
transpose_offset,
prefer_flat,
fmt_state: &fmt_state,
show_diagrams,
diagram_frets,
},
doc,
);
chorus_recall_count += 1;
} else if chorus_recall_count == MAX_CHORUS_RECALLS {
push_warning(
warnings,
format!(
"chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
further recalls suppressed"
),
);
chorus_recall_count += 1;
}
}
DirectiveKind::NewPage => {
doc.new_page();
}
DirectiveKind::NewPhysicalPage => {
doc.new_page();
if doc.page_count() % 2 == 0 {
doc.new_page();
}
}
DirectiveKind::Columns => {
let n: u32 = d
.value
.as_deref()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(1)
.clamp(1, MAX_COLUMNS);
doc.set_columns(n);
}
DirectiveKind::ColumnBreak => {
doc.column_break();
}
DirectiveKind::StartOfTab
| DirectiveKind::StartOfGrid
| DirectiveKind::StartOfTextblock => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
in_verbatim_section = true;
render_directive(d, show_diagrams, diagram_frets, doc);
}
DirectiveKind::EndOfTab
| DirectiveKind::EndOfGrid
| DirectiveKind::EndOfTextblock => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
in_verbatim_section = false;
}
_ => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
if d.kind == DirectiveKind::Define && show_diagrams {
if let Some(ref val) = d.value {
let name =
chordsketch_chordpro::ast::ChordDefinition::parse_value(val)
.name;
if !name.is_empty() {
inline_defined.insert(canonical_chord_name(&name));
}
}
}
render_directive(d, show_diagrams, diagram_frets, doc);
}
}
}
Line::Comment(style, text) => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_comment(*style, text, doc);
}
Line::Empty => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
doc.newline(LINE_GAP * 2.0);
}
_ => {}
}
}
if let Some(ref instrument) = auto_diagrams_instrument {
let chord_names: Vec<String> = song
.used_chord_names()
.into_iter()
.filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
.collect();
if instrument == "piano" {
let kbd_defines = song.keyboard_defines();
for name in chord_names {
if let Some(voicing) =
chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
{
render_keyboard_diagram_pdf(&voicing, doc);
}
}
} else {
let defines = song.fretted_defines();
for name in chord_names {
if let Some(diagram) =
chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, diagram_frets)
{
render_chord_diagram_pdf(&diagram, doc);
}
}
}
}
}
#[must_use = "parse errors should be handled"]
pub fn try_render(input: &str) -> Result<Vec<u8>, chordsketch_chordpro::ParseError> {
let song = chordsketch_chordpro::parse(input)?;
Ok(render_song(&song))
}
fn render_lyrics(
lyrics: &LyricsLine,
transpose_offset: i8,
prefer_flat: bool,
fmt_state: &PdfFormattingState,
doc: &mut PdfDocument,
verbatim: bool,
) {
let has_markup = lyrics.segments.iter().any(|s| s.has_markup());
let lyrics_size = fmt_state.lyrics_size();
let chord_size = fmt_state.chord_size();
let body_font = if verbatim {
Font::Courier
} else {
Font::Helvetica
};
if !lyrics.has_chords() {
doc.ensure_space(lyrics_size + LINE_GAP);
if has_markup {
render_lyrics_spans(lyrics, lyrics_size, doc);
} else {
doc.text(&lyrics.text(), body_font, lyrics_size);
}
doc.newline(lyrics_size + LINE_GAP);
return;
}
doc.ensure_space(chord_size + 2.0 + lyrics_size + LINE_GAP);
let mut x = doc.margin_left();
let start_y = doc.y();
for seg in &lyrics.segments {
let chord_display: Option<String> = seg.chord.as_ref().map(|c| {
if transpose_offset != 0 {
transpose_chord_with_style(c, transpose_offset, prefer_flat)
.display_name()
.to_string()
} else {
c.display_name().to_string()
}
});
if let Some(ref name) = chord_display {
doc.text_at(name, Font::HelveticaBold, chord_size, x, start_y);
}
let text_w = text_width(&seg.text, lyrics_size);
let chord_w = chord_display
.as_ref()
.map_or(0.0, |name| text_width(name, chord_size) + 2.0);
x += text_w.max(chord_w);
}
doc.advance_y(chord_size + 2.0);
if has_markup {
render_lyrics_spans(lyrics, lyrics_size, doc);
} else {
doc.text(&lyrics.text(), Font::Helvetica, lyrics_size);
}
doc.newline(lyrics_size + LINE_GAP);
}
fn render_lyrics_spans(lyrics: &LyricsLine, font_size: f32, doc: &mut PdfDocument) {
let clip = doc.num_columns > 1;
let col_right = if clip {
doc.margin_left() + doc.column_width()
} else {
0.0
};
let mut x = doc.margin_left();
let y = doc.y();
if clip {
let clip_w = (col_right - x).max(0.0);
let ops = doc.current_page_mut();
ops.push("q".to_string());
ops.push(format!(
"{} {} {} {} re W n",
fmt_f32(x),
fmt_f32(0.0),
fmt_f32(clip_w),
fmt_f32(PAGE_H)
));
}
for seg in &lyrics.segments {
if seg.has_markup() {
x = render_span_list(&seg.spans, doc, x, y, font_size, false, false);
} else {
doc.text_at_raw(&seg.text, Font::Helvetica, font_size, x, y);
x += text_width(&seg.text, font_size);
}
}
if clip {
let ops = doc.current_page_mut();
ops.push("Q".to_string());
}
}
fn render_span_list(
spans: &[TextSpan],
doc: &mut PdfDocument,
mut x: f32,
y: f32,
font_size: f32,
bold: bool,
italic: bool,
) -> f32 {
for span in spans {
match span {
TextSpan::Plain(text) => {
let font = match (bold, italic) {
(true, true) => Font::HelveticaBoldOblique,
(true, false) => Font::HelveticaBold,
(false, true) => Font::HelveticaOblique,
(false, false) => Font::Helvetica,
};
doc.text_at_raw(text, font, font_size, x, y);
x += text_width(text, font_size);
}
TextSpan::Bold(children) => {
x = render_span_list(children, doc, x, y, font_size, true, italic);
}
TextSpan::Italic(children) => {
x = render_span_list(children, doc, x, y, font_size, bold, true);
}
TextSpan::Highlight(children) | TextSpan::Comment(children) => {
x = render_span_list(children, doc, x, y, font_size, bold, italic);
}
TextSpan::Span(attrs, children) => {
let span_bold = bold
|| attrs
.weight
.as_deref()
.is_some_and(|w| w.eq_ignore_ascii_case("bold"));
let span_italic = italic
|| attrs
.style
.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case("italic"));
x = render_span_list(children, doc, x, y, font_size, span_bold, span_italic);
}
}
}
x
}
fn render_section_label(directive: &chordsketch_chordpro::ast::Directive, doc: &mut PdfDocument) {
let label: Option<String> = match &directive.kind {
DirectiveKind::StartOfChorus => Some("Chorus".to_string()),
DirectiveKind::StartOfVerse => Some("Verse".to_string()),
DirectiveKind::StartOfBridge => Some("Bridge".to_string()),
DirectiveKind::StartOfTab => Some("Tab".to_string()),
DirectiveKind::StartOfGrid => Some("Grid".to_string()),
DirectiveKind::StartOfAbc => Some("ABC".to_string()),
DirectiveKind::StartOfLy => Some("Lilypond".to_string()),
DirectiveKind::StartOfSvg => Some("SVG".to_string()),
DirectiveKind::StartOfTextblock => Some("Textblock".to_string()),
DirectiveKind::StartOfMusicxml => Some("MusicXML".to_string()),
DirectiveKind::StartOfSection(section_name) => {
Some(chordsketch_chordpro::capitalize(section_name))
}
_ => None,
};
if let Some(label) = label {
let resolved_value: Option<String> = if matches!(directive.kind, DirectiveKind::StartOfGrid)
{
directive.value.as_ref().and_then(|v| {
if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
Some(label)
} else if !v.contains('=') {
Some(v.clone())
} else {
None
}
})
} else {
directive.value.clone()
};
let text = match resolved_value {
Some(v) if !v.is_empty() => format!("{label}: {v}"),
_ => label,
};
doc.ensure_space(SECTION_SIZE + LINE_GAP);
doc.text(&text, Font::HelveticaBoldOblique, SECTION_SIZE);
doc.newline(SECTION_SIZE + LINE_GAP);
}
}
fn render_directive(
directive: &chordsketch_chordpro::ast::Directive,
show_diagrams: bool,
diagram_frets: usize,
doc: &mut PdfDocument,
) {
if directive.kind == DirectiveKind::Define && show_diagrams {
if let Some(ref value) = directive.value {
let def = chordsketch_chordpro::ast::ChordDefinition::parse_value(value);
if let Some(ref keys_raw) = def.keys {
let keys_u8: Vec<u8> = keys_raw
.iter()
.filter_map(|&k| {
if (0i32..=127).contains(&k) {
Some(k as u8)
} else {
None
}
})
.collect();
if !keys_u8.is_empty() {
let root = keys_u8[0];
let voicing = chordsketch_chordpro::chord_diagram::KeyboardVoicing {
name: def.name.clone(),
display_name: def.display.clone(),
keys: keys_u8,
root_key: root,
};
render_keyboard_diagram_pdf(&voicing, doc);
return;
}
}
if let Some(ref raw) = def.raw {
if let Some(mut diagram) =
chordsketch_chordpro::chord_diagram::DiagramData::from_raw_infer_frets(
&def.name,
raw,
diagram_frets,
)
{
diagram.display_name = def.display.clone();
render_chord_diagram_pdf(&diagram, doc);
return;
}
}
}
}
if let DirectiveKind::Image(ref attrs) = directive.kind {
render_image(attrs, doc);
return;
}
render_section_label(directive, doc);
}
fn is_safe_image_path(path: &str) -> bool {
if path.is_empty() || path.contains('\0') {
return false;
}
if chordsketch_chordpro::image_path::is_windows_absolute(path) {
return false;
}
let p = std::path::Path::new(path);
if p.is_absolute() || path.starts_with('/') {
return false;
}
if chordsketch_chordpro::image_path::has_traversal(path) {
return false;
}
true
}
fn read_image_file(path: &str) -> Option<Vec<u8>> {
#[cfg(unix)]
{
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.ok()?;
let meta = file.metadata().ok()?;
if meta.len() > MAX_IMAGE_FILE_SIZE {
return None;
}
let mut buf = Vec::with_capacity(meta.len() as usize);
file.read_to_end(&mut buf).ok()?;
if buf.len() as u64 > MAX_IMAGE_FILE_SIZE {
return None;
}
Some(buf)
}
#[cfg(not(unix))]
{
let meta = std::fs::symlink_metadata(path).ok()?;
if meta.file_type().is_symlink() {
return None;
}
if meta.len() > MAX_IMAGE_FILE_SIZE {
return None;
}
let data = std::fs::read(path).ok()?;
if data.len() as u64 > MAX_IMAGE_FILE_SIZE {
return None;
}
Some(data)
}
}
fn render_image(attrs: &ImageAttributes, doc: &mut PdfDocument) {
if !attrs.has_src() {
return;
}
if doc.images.len() >= MAX_IMAGES {
return;
}
if !chordsketch_chordpro::image_path::is_safe_image_src(&attrs.src) {
return;
}
if !is_safe_image_path(&attrs.src) {
return;
}
let src_lower = attrs.src.to_ascii_lowercase();
let is_jpeg = src_lower.ends_with(".jpg") || src_lower.ends_with(".jpeg");
let is_png = src_lower.ends_with(".png");
if !is_jpeg && !is_png {
return;
}
let data = match read_image_file(&attrs.src) {
Some(d) => d,
None => return,
};
let (pixel_w, pixel_h, img_idx) = if is_jpeg {
let (w, h, components) = match parse_jpeg_dimensions(&data) {
Some(dims) => dims,
None => return,
};
if w == 0 || h == 0 {
return;
}
let idx = doc.embed_jpeg(data, w, h, components);
(w, h, idx)
} else {
let info = match parse_png(&data) {
Some(info) => info,
None => return,
};
if info.width == 0 || info.height == 0 {
return;
}
let w = info.width;
let h = info.height;
let idx = doc.embed_png(info);
(w, h, idx)
};
let clamped_w = pixel_w.min(MAX_IMAGE_PIXELS);
let clamped_h = pixel_h.min(MAX_IMAGE_PIXELS);
let native_w = clamped_w as f32;
let native_h = clamped_h as f32;
let aspect = native_w / native_h;
let (render_w, render_h) = compute_image_dimensions(attrs, native_w, native_h, aspect);
let max_w = doc.column_width();
let max_h = PAGE_H - doc.margin_top - doc.margin_bottom;
let (render_w, render_h) = clamp_to_printable_area(render_w, render_h, max_w, max_h, aspect);
doc.ensure_space(render_h + LINE_GAP);
let x = match attrs.anchor.as_deref() {
Some("column") => {
let col_left = doc.margin_left();
let col_w = doc.column_width();
col_left + (col_w - render_w) / 2.0
}
Some("paper") => (PAGE_W - render_w) / 2.0,
_ => doc.margin_left(),
};
let y = doc.y() - render_h;
doc.draw_image(img_idx, x, y, render_w, render_h);
doc.advance_y(render_h);
doc.newline(LINE_GAP);
}
fn clamp_to_printable_area(w: f32, h: f32, max_w: f32, max_h: f32, aspect: f32) -> (f32, f32) {
if w > max_w {
let clamped_h = max_w / aspect;
if clamped_h > max_h {
let clamped_w = (max_h * aspect).min(max_w);
(clamped_w, max_h)
} else {
(max_w, clamped_h)
}
} else if h > max_h {
let clamped_w = (max_h * aspect).min(max_w);
(clamped_w, clamped_w / aspect)
} else {
(w, h)
}
}
fn parse_dimension(value: &str, reference: f32) -> Option<f32> {
let trimmed = value.trim();
if let Some(pct_str) = trimmed.strip_suffix('%') {
let pct: f32 = pct_str.trim().parse().ok()?;
let result = reference * pct / 100.0;
if result > 0.0 && result.is_finite() {
Some(result)
} else {
None
}
} else {
let v: f32 = trimmed.parse().ok()?;
if v > 0.0 && v.is_finite() {
Some(v)
} else {
None
}
}
}
fn compute_image_dimensions(
attrs: &ImageAttributes,
native_w: f32,
native_h: f32,
aspect: f32,
) -> (f32, f32) {
let parsed_w = attrs
.width
.as_deref()
.and_then(|v| parse_dimension(v, native_w));
let parsed_h = attrs
.height
.as_deref()
.and_then(|v| parse_dimension(v, native_h));
let parsed_scale = attrs
.scale
.as_deref()
.and_then(|v| v.trim().parse::<f32>().ok())
.filter(|&v| v > 0.0 && v.is_finite());
match (parsed_w, parsed_h) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w / aspect),
(None, Some(h)) => (h * aspect, h),
(None, None) => {
if let Some(s) = parsed_scale {
(native_w * s, native_h * s)
} else {
(native_w, native_h)
}
}
}
}
fn render_chord_diagram_pdf(
data: &chordsketch_chordpro::chord_diagram::DiagramData,
doc: &mut PdfDocument,
) {
if data.strings < chordsketch_chordpro::chord_diagram::MIN_STRINGS
|| data.strings > chordsketch_chordpro::chord_diagram::MAX_STRINGS
|| data.frets_shown < chordsketch_chordpro::chord_diagram::MIN_FRETS_SHOWN
|| data.frets_shown > chordsketch_chordpro::chord_diagram::MAX_FRETS_SHOWN
{
return;
}
let cell_w: f32 = 10.0;
let cell_h: f32 = 12.0;
let num_strings = data.strings;
let num_frets = data.frets_shown;
let grid_w = (num_strings - 1) as f32 * cell_w;
let grid_h = num_frets as f32 * cell_h;
let total_h = grid_h + 25.0;
doc.ensure_space(total_h);
let base_x = doc.margin_left();
let top_y = doc.y();
doc.text_at(data.title(), Font::HelveticaBold, 9.0, base_x, top_y);
let grid_top = top_y - 15.0;
if data.base_fret == 1 {
doc.line_at(base_x, grid_top, base_x + grid_w, grid_top, 2.0);
} else {
let fret_label = format!("{}fr", data.base_fret);
let fret_label_x = (base_x - 16.0).max(0.0);
doc.text_at(
&fret_label,
Font::Helvetica,
6.0,
fret_label_x,
grid_top - cell_h / 2.0,
);
}
for i in 0..num_strings {
let x = base_x + i as f32 * cell_w;
doc.line_at(x, grid_top, x, grid_top - grid_h, 0.5);
}
for j in 0..=num_frets {
let y = grid_top - j as f32 * cell_h;
doc.line_at(base_x, y, base_x + grid_w, y, 0.5);
}
for (i, &fret) in data.frets.iter().enumerate() {
if i >= num_strings {
break;
}
let x = base_x + i as f32 * cell_w;
if fret == -1 {
doc.text_at("X", Font::Helvetica, 7.0, x - 2.5, grid_top + 4.0);
} else if fret == 0 {
doc.stroked_circle_at(x, grid_top + 6.0, 2.5);
} else {
let y = grid_top - (fret as f32 - 0.5) * cell_h;
doc.filled_circle_at(x, y, 3.0);
if let Some(&finger) = data.fingers.get(i) {
if finger > 0 {
let label = finger.to_string();
doc.white_text_at(&label, Font::Helvetica, 5.0, x - 1.5, y - 1.5);
}
}
}
}
doc.advance_y(total_h);
doc.newline(LINE_GAP);
}
fn render_keyboard_diagram_pdf(
voicing: &chordsketch_chordpro::chord_diagram::KeyboardVoicing,
doc: &mut PdfDocument,
) {
if voicing.keys.is_empty() {
return;
}
let (keys, root) = chordsketch_chordpro::chord_diagram::normalise_keyboard_keys(
&voicing.keys,
voicing.root_key,
);
let min_key = *keys.iter().min().unwrap_or(&60);
let max_key = *keys.iter().max().unwrap_or(&71);
let start_octave = u32::from(min_key / 12);
let end_octave = u32::from(max_key / 12);
let num_octaves = ((end_octave - start_octave) + 1).clamp(2, 3) as usize;
let start_midi = (start_octave * 12) as u8;
let white_w: f32 = 8.0;
let white_h: f32 = 30.0;
let black_w: f32 = 5.0;
let black_h: f32 = 18.0;
let name_h: f32 = 10.0;
let total_h = name_h + white_h + 6.0;
doc.ensure_space(total_h);
let base_x = doc.margin_left();
let top_y = doc.y();
doc.text_at(voicing.title(), Font::HelveticaBold, 7.0, base_x, top_y);
let kbd_top_y = top_y - name_h;
const WHITE_KEYS_PDF: [(u8, f32); 7] = [
(0, 0.0), (2, 1.0), (4, 2.0), (5, 3.0), (7, 4.0), (9, 5.0), (11, 6.0), ];
const BLACK_KEYS_PDF: [(u8, f32); 5] = [
(1, 0.6), (3, 1.6), (6, 3.6), (8, 4.6), (10, 5.6), ];
const ROOT_BLUE: (f32, f32, f32) = (0.102, 0.373, 0.706); const CHORD_BLUE: (f32, f32, f32) = (0.290, 0.565, 0.886); const WHITE_KEY: (f32, f32, f32) = (1.0, 1.0, 1.0); const DARK_KEY: (f32, f32, f32) = (0.133, 0.133, 0.133);
for oct in 0..num_octaves {
let oct_midi = start_midi.saturating_add((oct * 12) as u8);
let oct_x = base_x + oct as f32 * 7.0 * white_w;
for (semitone, x_idx) in WHITE_KEYS_PDF {
let midi = oct_midi.saturating_add(semitone);
let x = oct_x + x_idx * white_w;
let highlighted = keys.contains(&midi);
let is_root = highlighted && midi == root;
let y_bottom = kbd_top_y - white_h;
let color = if is_root {
ROOT_BLUE
} else if highlighted {
CHORD_BLUE
} else {
WHITE_KEY
};
doc.filled_rect_color(x, y_bottom, white_w - 0.5, white_h, color);
doc.rect_stroke(x, y_bottom, white_w - 0.5, white_h, 0.3);
}
}
for oct in 0..num_octaves {
let oct_midi = start_midi.saturating_add((oct * 12) as u8);
let oct_x = base_x + oct as f32 * 7.0 * white_w;
for (semitone, x_idx) in BLACK_KEYS_PDF {
let midi = oct_midi.saturating_add(semitone);
let x = oct_x + x_idx * white_w;
let highlighted = keys.contains(&midi);
let is_root = highlighted && midi == root;
let y_bottom = kbd_top_y - black_h;
let color = if is_root {
ROOT_BLUE
} else if highlighted {
CHORD_BLUE
} else {
DARK_KEY
};
doc.filled_rect_color(x, y_bottom, black_w, black_h, color);
}
}
doc.advance_y(total_h);
doc.newline(LINE_GAP);
}
struct ChorusRecallCtx<'a> {
chorus_body: &'a [Line],
transpose_offset: i8,
prefer_flat: bool,
fmt_state: &'a PdfFormattingState,
show_diagrams: bool,
diagram_frets: usize,
}
fn render_chorus_recall(value: &Option<String>, ctx: &ChorusRecallCtx<'_>, doc: &mut PdfDocument) {
let text = match value {
Some(v) if !v.is_empty() => format!("Chorus: {v}"),
_ => "Chorus".to_string(),
};
doc.ensure_space(SECTION_SIZE + LINE_GAP);
doc.text(&text, Font::HelveticaBoldOblique, SECTION_SIZE);
doc.newline(SECTION_SIZE + LINE_GAP);
for line in ctx.chorus_body {
match line {
Line::Lyrics(lyrics) => {
render_lyrics(
lyrics,
ctx.transpose_offset,
ctx.prefer_flat,
ctx.fmt_state,
doc,
false,
);
}
Line::Comment(style, text) => render_comment(*style, text, doc),
Line::Empty => doc.newline(LINE_GAP * 2.0),
Line::Directive(d) if !d.kind.is_metadata() => {
render_directive(d, ctx.show_diagrams, ctx.diagram_frets, doc);
}
_ => {}
}
}
}
fn render_comment(style: CommentStyle, text: &str, doc: &mut PdfDocument) {
let font = match style {
CommentStyle::Normal => Font::Helvetica,
CommentStyle::Italic | CommentStyle::Boxed => Font::HelveticaOblique,
CommentStyle::Highlight => Font::HelveticaBold,
};
if style == CommentStyle::Boxed {
let padding = 3.0_f32;
let cap_height = COMMENT_SIZE * 0.72;
let descent = COMMENT_SIZE * 0.21;
let box_h = cap_height + descent + padding * 2.0;
doc.ensure_space(box_h + LINE_GAP);
let x = doc.margin_left();
let text_y = doc.y();
let rect_y = text_y - descent - padding;
let text_w = text_width(text, COMMENT_SIZE);
let box_w = text_w + padding * 2.0;
doc.rect_stroke(x, rect_y, box_w, box_h, 0.5);
doc.text_at(text, font, COMMENT_SIZE, x + padding, text_y);
doc.newline(box_h + LINE_GAP);
} else {
doc.ensure_space(COMMENT_SIZE + LINE_GAP);
doc.text(text, font, COMMENT_SIZE);
doc.newline(COMMENT_SIZE + LINE_GAP);
}
}
const MARGIN_RIGHT: f32 = 56.0;
const COLUMN_GAP: f32 = 20.0;
fn parse_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32, u8)> {
const MAX_SCAN_BYTES: usize = 64 * 1024;
if data.len() < 4 {
return None;
}
if data[0] != 0xFF || data[1] != 0xD8 {
return None;
}
let scan_limit = data.len().min(MAX_SCAN_BYTES);
let mut i = 2;
while i + 1 < scan_limit {
if data[i] != 0xFF {
i += 1;
continue;
}
let marker = data[i + 1];
if marker == 0xFF {
i += 1;
continue;
}
if matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
if i + 10 > data.len() {
return None;
}
let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
let components = data[i + 9];
return Some((width, height, components));
}
if marker == 0xDA {
return None;
}
if i + 3 >= data.len() {
return None;
}
let length = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
i += 2 + length;
}
None
}
const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
struct PngInfo {
width: u32,
height: u32,
bit_depth: u8,
colors: u8,
idat_data: Vec<u8>,
palette: Option<Vec<u8>>,
smask: Option<Vec<u8>>,
}
fn parse_png(data: &[u8]) -> Option<PngInfo> {
if data.len() < 8 || data[..8] != PNG_SIGNATURE {
return None;
}
if data.len() < 8 + 4 + 4 + 13 {
return None;
}
let ihdr_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
if ihdr_len < 13 {
return None;
}
let chunk_type = &data[12..16];
if chunk_type != b"IHDR" {
return None;
}
let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
let bit_depth = data[24];
let color_type = data[25];
let mut idat_chunks: Vec<u8> = Vec::new();
let mut palette: Option<Vec<u8>> = None;
let mut pos = 8;
while pos + 12 <= data.len() {
let chunk_len =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
let ctype = &data[pos + 4..pos + 8];
if pos + 12 + chunk_len > data.len() + 4 {
break;
}
let chunk_data_start = pos + 8;
let chunk_data_end = chunk_data_start + chunk_len;
if chunk_data_end > data.len() {
break;
}
if ctype == b"IDAT" {
idat_chunks.extend_from_slice(&data[chunk_data_start..chunk_data_end]);
} else if ctype == b"PLTE" {
palette = Some(data[chunk_data_start..chunk_data_end].to_vec());
} else if ctype == b"IEND" {
break;
}
pos += 12 + chunk_len;
}
if idat_chunks.is_empty() {
return None;
}
let has_alpha = color_type == 4 || color_type == 6;
if has_alpha {
separate_alpha(&idat_chunks, width, height, bit_depth, color_type)
} else {
let colors = match color_type {
0 => 1, 3 => 3, _ => 3, };
Some(PngInfo {
width,
height,
bit_depth,
colors,
idat_data: idat_chunks,
palette,
smask: None,
})
}
}
fn separate_alpha(
idat_data: &[u8],
width: u32,
height: u32,
bit_depth: u8,
color_type: u8,
) -> Option<PngInfo> {
let w = width as usize;
let h = height as usize;
let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 };
let channels: usize = match color_type {
4 => 2, 6 => 4, _ => return None,
};
let expected_size = h.checked_mul(1 + w * channels * bytes_per_sample)?;
const MAX_DECOMPRESSED_SIZE: u64 = 256 * 1024 * 1024;
let limit = (expected_size as u64).min(MAX_DECOMPRESSED_SIZE);
let mut decoder = ZlibDecoder::new(idat_data).take(limit + 1);
let mut raw = Vec::new();
if decoder.read_to_end(&mut raw).is_err() || raw.len() as u64 > limit {
return None;
}
let (color_channels, alpha_channels) = match color_type {
4 => (1, 1), 6 => (3, 1), _ => return None,
};
let total_channels = color_channels + alpha_channels;
let src_stride = 1 + w * total_channels * bytes_per_sample;
if raw.len() < h * src_stride {
return None;
}
let bpp = total_channels * bytes_per_sample;
let row_bytes = w * total_channels * bytes_per_sample;
let mut decoded = vec![0u8; h * row_bytes];
for row in 0..h {
let src_start = row * src_stride;
let filter = raw[src_start];
let src_row = &raw[src_start + 1..src_start + src_stride];
let dst_start = row * row_bytes;
decoded[dst_start..dst_start + row_bytes].copy_from_slice(src_row);
match filter {
0 => {} 1 => {
for i in bpp..row_bytes {
decoded[dst_start + i] =
decoded[dst_start + i].wrapping_add(decoded[dst_start + i - bpp]);
}
}
2 => {
if row > 0 {
let prev_start = (row - 1) * row_bytes;
for i in 0..row_bytes {
decoded[dst_start + i] =
decoded[dst_start + i].wrapping_add(decoded[prev_start + i]);
}
}
}
3 => {
let prev_start = if row > 0 { (row - 1) * row_bytes } else { 0 };
for i in 0..row_bytes {
let left = if i >= bpp {
decoded[dst_start + i - bpp]
} else {
0
};
let up = if row > 0 { decoded[prev_start + i] } else { 0 };
decoded[dst_start + i] =
decoded[dst_start + i].wrapping_add(((left as u16 + up as u16) / 2) as u8);
}
}
4 => {
let prev_start = if row > 0 { (row - 1) * row_bytes } else { 0 };
for i in 0..row_bytes {
let left = if i >= bpp {
decoded[dst_start + i - bpp] as i16
} else {
0
};
let up = if row > 0 {
decoded[prev_start + i] as i16
} else {
0
};
let up_left = if i >= bpp && row > 0 {
decoded[prev_start + i - bpp] as i16
} else {
0
};
decoded[dst_start + i] =
decoded[dst_start + i].wrapping_add(paeth_predictor(left, up, up_left));
}
}
_ => return None,
}
}
let color_stride = 1 + w * color_channels * bytes_per_sample;
let alpha_stride = 1 + w * bytes_per_sample;
let mut color_raw = Vec::with_capacity(h * color_stride);
let mut alpha_raw = Vec::with_capacity(h * alpha_stride);
for row in 0..h {
color_raw.push(0); alpha_raw.push(0); let row_start = row * row_bytes;
for x in 0..w {
let pixel_start = row_start + x * total_channels * bytes_per_sample;
for c in 0..color_channels {
let offset = pixel_start + c * bytes_per_sample;
color_raw.extend_from_slice(&decoded[offset..offset + bytes_per_sample]);
}
let alpha_offset = pixel_start + color_channels * bytes_per_sample;
alpha_raw.extend_from_slice(&decoded[alpha_offset..alpha_offset + bytes_per_sample]);
}
}
let idat_data = zlib_compress(&color_raw)?;
let smask = zlib_compress(&alpha_raw)?;
Some(PngInfo {
width,
height,
bit_depth,
colors: color_channels as u8,
idat_data,
palette: None,
smask: Some(smask),
})
}
fn paeth_predictor(a: i16, b: i16, c: i16) -> u8 {
let p = a + b - c;
let pa = (p - a).unsigned_abs();
let pb = (p - b).unsigned_abs();
let pc = (p - c).unsigned_abs();
if pa <= pb && pa <= pc {
a as u8
} else if pb <= pc {
b as u8
} else {
c as u8
}
}
fn zlib_compress(data: &[u8]) -> Option<Vec<u8>> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
if encoder.write_all(data).is_err() {
return None;
}
encoder.finish().ok()
}
enum ImageFormat {
Jpeg {
data: Vec<u8>,
components: u8,
},
Png {
idat_data: Vec<u8>,
bit_depth: u8,
colors: u8,
palette: Option<Vec<u8>>,
smask: Option<Vec<u8>>,
},
}
struct EmbeddedImage {
width: u32,
height: u32,
format: ImageFormat,
}
impl EmbeddedImage {
fn num_pdf_objects(&self) -> usize {
match &self.format {
ImageFormat::Jpeg { .. } => 1,
ImageFormat::Png { smask, .. } => {
if smask.is_some() {
2
} else {
1
}
}
}
}
}
struct PdfDocument {
pages: Vec<Vec<String>>,
y: f32,
num_columns: u32,
current_column: u32,
images: Vec<EmbeddedImage>,
margin_top: f32,
margin_bottom: f32,
margin_left: f32,
margin_right: f32,
cid_glyphs: BTreeMap<u16, char>,
doc_title: Option<String>,
}
impl PdfDocument {
#[cfg(test)]
fn new() -> Self {
Self::with_margins(MARGIN_TOP, MARGIN_BOTTOM, MARGIN_LEFT, MARGIN_RIGHT)
}
fn with_margins(top: f32, bottom: f32, left: f32, right: f32) -> Self {
Self {
pages: vec![Vec::new()],
y: PAGE_H - top,
num_columns: 1,
current_column: 0,
images: Vec::new(),
margin_top: top,
margin_bottom: bottom,
margin_left: left,
margin_right: right,
cid_glyphs: BTreeMap::new(),
doc_title: None,
}
}
const MAX_TITLE_CHARS: usize = 1024;
fn set_doc_title(&mut self, title: Option<&str>) {
self.doc_title = title.and_then(|t| {
let trimmed = t.trim();
if trimmed.is_empty() {
None
} else if trimmed.chars().count() > Self::MAX_TITLE_CHARS {
Some(trimmed.chars().take(Self::MAX_TITLE_CHARS).collect())
} else {
Some(trimmed.to_string())
}
});
}
const MAX_MARGIN: f32 = 297.0;
fn validate_margin(value: f32, default: f32, name: &str, warnings: &mut Vec<String>) -> f32 {
if !value.is_finite() || !(0.0..=Self::MAX_MARGIN).contains(&value) {
push_warning(
warnings,
format!("invalid pdf.margins.{name} value {value}, using default {default}"),
);
default
} else {
value
}
}
#[cfg(test)]
fn from_config(config: &Config) -> Self {
let mut warnings = Vec::new();
let doc = Self::from_config_with_warnings(config, &mut warnings);
for w in &warnings {
eprintln!("warning: {w}");
}
doc
}
fn from_config_with_warnings(config: &Config, warnings: &mut Vec<String>) -> Self {
let top = config
.get_path("pdf.margins.top")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_TOP, "top", warnings))
.unwrap_or(MARGIN_TOP);
let bottom = config
.get_path("pdf.margins.bottom")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_BOTTOM, "bottom", warnings))
.unwrap_or(MARGIN_BOTTOM);
let left = config
.get_path("pdf.margins.left")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_LEFT, "left", warnings))
.unwrap_or(MARGIN_LEFT);
let right = config
.get_path("pdf.margins.right")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_RIGHT, "right", warnings))
.unwrap_or(MARGIN_RIGHT);
Self::with_margins(top, bottom, left, right)
}
fn reset_margins_from_config(&mut self, config: &Config, warnings: &mut Vec<String>) {
self.margin_top = config
.get_path("pdf.margins.top")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_TOP, "top", warnings))
.unwrap_or(MARGIN_TOP);
self.margin_bottom = config
.get_path("pdf.margins.bottom")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_BOTTOM, "bottom", warnings))
.unwrap_or(MARGIN_BOTTOM);
self.margin_left = config
.get_path("pdf.margins.left")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_LEFT, "left", warnings))
.unwrap_or(MARGIN_LEFT);
self.margin_right = config
.get_path("pdf.margins.right")
.as_f64()
.map(|v| Self::validate_margin(v as f32, MARGIN_RIGHT, "right", warnings))
.unwrap_or(MARGIN_RIGHT);
}
fn y(&self) -> f32 {
self.y
}
fn page_count(&self) -> usize {
self.pages.len()
}
fn margin_left(&self) -> f32 {
if self.num_columns <= 1 {
return self.margin_left;
}
let col_width = self.column_width();
let result = self.margin_left + self.current_column as f32 * (col_width + COLUMN_GAP);
debug_assert!(
result.is_finite(),
"margin_left() produced non-finite value"
);
result
}
fn column_width(&self) -> f32 {
let usable_width = PAGE_W - self.margin_left - self.margin_right;
if self.num_columns <= 1 {
return usable_width;
}
let total_gaps = (self.num_columns - 1) as f32 * COLUMN_GAP;
((usable_width - total_gaps) / self.num_columns as f32).max(0.0)
}
fn set_columns(&mut self, n: u32) {
self.num_columns = n.clamp(1, MAX_COLUMNS);
self.current_column = 0;
}
fn column_break(&mut self) {
if self.num_columns <= 1 {
self.new_page();
return;
}
if self.current_column + 1 < self.num_columns {
self.current_column += 1;
self.y = PAGE_H - self.margin_top;
} else {
self.new_page();
}
}
fn ensure_space(&mut self, needed: f32) {
if self.y - needed < self.margin_bottom {
self.next_column_or_page();
}
}
fn next_column_or_page(&mut self) {
if self.num_columns > 1 && self.current_column + 1 < self.num_columns {
self.current_column += 1;
self.y = PAGE_H - self.margin_top;
} else {
self.new_page();
}
}
fn new_page(&mut self) {
if self.pages.len() >= MAX_PAGES {
return;
}
self.pages.push(Vec::new());
self.y = PAGE_H - self.margin_top;
self.current_column = 0;
}
fn text(&mut self, text: &str, font: Font, size: f32) {
let x = self.margin_left();
self.text_at(text, font, size, x, self.y);
}
fn text_at_raw(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
let segments = text_segments(text);
let mut cid_mappings: Vec<(u16, char)> = Vec::new();
let mut ops_batch: Vec<String> = Vec::new();
let mut cur_x = x;
for (is_cid, seg) in &segments {
ops_batch.push("BT".to_string());
if *is_cid {
let (hex, mappings) = encode_cid_text(seg);
cid_mappings.extend_from_slice(&mappings);
ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
ops_batch.push(format!("<{}> Tj", hex));
cur_x += cid_text_width(seg, size);
} else {
ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
cur_x += text_width(seg, size);
}
ops_batch.push("ET".to_string());
}
self.current_page_mut().extend(ops_batch);
for (gid, ch) in cid_mappings {
self.cid_glyphs.entry(gid).or_insert(ch);
}
}
fn text_at(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
let clip = self.num_columns > 1;
let col_right = if clip {
self.margin_left() + self.column_width()
} else {
0.0
};
let segments = text_segments(text);
let mut cid_mappings: Vec<(u16, char)> = Vec::new();
let mut ops_batch: Vec<String> = Vec::new();
if clip {
let clip_w = (col_right - x).max(0.0);
ops_batch.push("q".to_string());
ops_batch.push(format!(
"{} {} {} {} re W n",
fmt_f32(x),
fmt_f32(0.0),
fmt_f32(clip_w),
fmt_f32(PAGE_H)
));
}
let mut cur_x = x;
for (is_cid, seg) in &segments {
ops_batch.push("BT".to_string());
if *is_cid {
let (hex, mappings) = encode_cid_text(seg);
cid_mappings.extend_from_slice(&mappings);
ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
ops_batch.push(format!("<{}> Tj", hex));
cur_x += cid_text_width(seg, size);
} else {
ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
cur_x += text_width(seg, size);
}
ops_batch.push("ET".to_string());
}
if clip {
ops_batch.push("Q".to_string());
}
self.current_page_mut().extend(ops_batch);
for (gid, ch) in cid_mappings {
self.cid_glyphs.entry(gid).or_insert(ch);
}
}
fn white_text_at(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
let clip = self.num_columns > 1;
let col_right = if clip {
self.margin_left() + self.column_width()
} else {
0.0
};
let segments = text_segments(text);
let mut cid_mappings: Vec<(u16, char)> = Vec::new();
let mut ops_batch: Vec<String> = Vec::new();
if clip {
let clip_w = (col_right - x).max(0.0);
ops_batch.push("q".to_string());
ops_batch.push(format!(
"{} {} {} {} re W n",
fmt_f32(x),
fmt_f32(0.0),
fmt_f32(clip_w),
fmt_f32(PAGE_H)
));
}
let mut cur_x = x;
for (is_cid, seg) in &segments {
ops_batch.push("BT".to_string());
ops_batch.push("1 1 1 rg".to_string()); if *is_cid {
let (hex, mappings) = encode_cid_text(seg);
cid_mappings.extend_from_slice(&mappings);
ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
ops_batch.push(format!("<{}> Tj", hex));
cur_x += cid_text_width(seg, size);
} else {
ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
cur_x += text_width(seg, size);
}
ops_batch.push("ET".to_string());
ops_batch.push("0 0 0 rg".to_string()); }
if clip {
ops_batch.push("Q".to_string());
}
self.current_page_mut().extend(ops_batch);
for (gid, ch) in cid_mappings {
self.cid_glyphs.entry(gid).or_insert(ch);
}
}
fn newline(&mut self, amount: f32) {
self.y -= amount;
if self.y < self.margin_bottom {
self.next_column_or_page();
}
}
fn advance_y(&mut self, amount: f32) {
self.y -= amount;
}
fn line_at(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, width: f32) {
let ops = self.current_page_mut();
ops.push("q".to_string());
ops.push(format!("{} w", fmt_f32(width)));
ops.push(format!(
"{} {} m {} {} l S",
fmt_f32(x1),
fmt_f32(y1),
fmt_f32(x2),
fmt_f32(y2)
));
ops.push("Q".to_string());
}
fn filled_rect_color(&mut self, x: f32, y: f32, w: f32, h: f32, color: (f32, f32, f32)) {
let (r, g, b) = color;
let ops = self.current_page_mut();
ops.push("q".to_string());
ops.push(format!("{} {} {} rg", fmt_f32(r), fmt_f32(g), fmt_f32(b)));
ops.push(format!(
"{} {} {} {} re f",
fmt_f32(x),
fmt_f32(y),
fmt_f32(w),
fmt_f32(h)
));
ops.push("Q".to_string());
}
fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, line_width: f32) {
let ops = self.current_page_mut();
ops.push("q".to_string());
ops.push(format!("{} w", fmt_f32(line_width)));
ops.push(format!(
"{} {} {} {} re S",
fmt_f32(x),
fmt_f32(y),
fmt_f32(w),
fmt_f32(h)
));
ops.push("Q".to_string());
}
fn filled_circle_at(&mut self, cx: f32, cy: f32, r: f32) {
let k = r * 0.5523;
let ops = self.current_page_mut();
ops.push(format!(
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f",
fmt_f32(cx + r), fmt_f32(cy),
fmt_f32(cx + r), fmt_f32(cy + k), fmt_f32(cx + k), fmt_f32(cy + r), fmt_f32(cx), fmt_f32(cy + r),
fmt_f32(cx - k), fmt_f32(cy + r), fmt_f32(cx - r), fmt_f32(cy + k), fmt_f32(cx - r), fmt_f32(cy),
fmt_f32(cx - r), fmt_f32(cy - k), fmt_f32(cx - k), fmt_f32(cy - r), fmt_f32(cx), fmt_f32(cy - r),
fmt_f32(cx + k), fmt_f32(cy - r), fmt_f32(cx + r), fmt_f32(cy - k), fmt_f32(cx + r), fmt_f32(cy),
));
}
fn stroked_circle_at(&mut self, cx: f32, cy: f32, r: f32) {
let k = r * 0.5523;
let ops = self.current_page_mut();
ops.push("0.5 w".to_string());
ops.push(format!(
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S",
fmt_f32(cx + r), fmt_f32(cy),
fmt_f32(cx + r), fmt_f32(cy + k), fmt_f32(cx + k), fmt_f32(cy + r), fmt_f32(cx), fmt_f32(cy + r),
fmt_f32(cx - k), fmt_f32(cy + r), fmt_f32(cx - r), fmt_f32(cy + k), fmt_f32(cx - r), fmt_f32(cy),
fmt_f32(cx - r), fmt_f32(cy - k), fmt_f32(cx - k), fmt_f32(cy - r), fmt_f32(cx), fmt_f32(cy - r),
fmt_f32(cx + k), fmt_f32(cy - r), fmt_f32(cx + r), fmt_f32(cy - k), fmt_f32(cx + r), fmt_f32(cy),
));
}
fn embed_jpeg(&mut self, data: Vec<u8>, width: u32, height: u32, components: u8) -> usize {
let idx = self.images.len();
self.images.push(EmbeddedImage {
width,
height,
format: ImageFormat::Jpeg { data, components },
});
idx
}
fn embed_png(&mut self, info: PngInfo) -> usize {
let idx = self.images.len();
self.images.push(EmbeddedImage {
width: info.width,
height: info.height,
format: ImageFormat::Png {
idat_data: info.idat_data,
bit_depth: info.bit_depth,
colors: info.colors,
palette: info.palette,
smask: info.smask,
},
});
idx
}
fn draw_image(&mut self, img_idx: usize, x: f32, y: f32, w: f32, h: f32) {
let name = format!("/Im{}", img_idx + 1);
let ops = self.current_page_mut();
ops.push("q".to_string());
ops.push(format!(
"{} 0 0 {} {} {} cm",
fmt_f32(w),
fmt_f32(h),
fmt_f32(x),
fmt_f32(y)
));
ops.push(format!("{name} Do"));
ops.push("Q".to_string());
}
fn current_page_mut(&mut self) -> &mut Vec<String> {
self.pages.last_mut().expect("pages is never empty")
}
fn take_pages(&mut self) -> Vec<Vec<String>> {
let pages = std::mem::take(&mut self.pages);
self.pages.push(Vec::new());
self.y = PAGE_H - self.margin_top;
self.current_column = 0;
pages
}
fn push_page(&mut self, ops: Vec<String>) {
if self.pages.len() >= MAX_PAGES {
return;
}
self.pages.push(ops);
}
fn build_pdf(&self) -> Vec<u8> {
let num_pages = self.pages.len();
let num_images = self.images.len();
const CID_OBJ_COUNT: usize = 5;
let cid_needed = !self.cid_glyphs.is_empty();
let extra_objs = if cid_needed { CID_OBJ_COUNT } else { 0 };
let mut offsets: Vec<usize> = Vec::new();
let mut pdf = Vec::<u8>::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
offsets.push(pdf.len());
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets.push(pdf.len());
let font_refs: String = FONTS
.iter()
.enumerate()
.map(|(i, _)| format!("{} {} 0 R", FONTS[i].pdf_name(), i + 3))
.collect::<Vec<_>>()
.join(" ");
let cid_font_ref = if cid_needed {
format!(" /F5 {} 0 R", 3 + FONTS.len())
} else {
String::new()
};
let image_obj_base = 3 + FONTS.len() + extra_objs; let xobject_refs = if num_images > 0 {
let mut refs = Vec::new();
let mut obj_offset = 0;
for (i, img) in self.images.iter().enumerate() {
refs.push(format!("/Im{} {} 0 R", i + 1, image_obj_base + obj_offset));
obj_offset += img.num_pdf_objects();
}
format!(" /XObject << {} >>", refs.join(" "))
} else {
String::new()
};
let procset = if num_images > 0 {
"/ProcSet [/PDF /Text /ImageB /ImageC]"
} else {
"/ProcSet [/PDF /Text]"
};
let total_image_objects: usize = self.images.iter().map(|img| img.num_pdf_objects()).sum();
let page_obj_start = 3 + FONTS.len() + extra_objs + total_image_objects;
let kids: String = (0..num_pages)
.map(|i| format!("{} 0 R", page_obj_start + i * 2))
.collect::<Vec<_>>()
.join(" ");
let obj2 = format!(
"2 0 obj\n<< /Type /Pages /MediaBox [0 0 {} {}] /Resources << /Font << {}{} >>{} {} >> /Kids [{}] /Count {} >>\nendobj\n",
fmt_f32(PAGE_W),
fmt_f32(PAGE_H),
font_refs,
cid_font_ref,
xobject_refs,
procset,
kids,
num_pages
);
pdf.extend_from_slice(obj2.as_bytes());
for font in &FONTS {
offsets.push(pdf.len());
let obj_num = offsets.len();
let obj = format!(
"{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /{} /Encoding /WinAnsiEncoding >>\nendobj\n",
obj_num,
font.base_name()
);
pdf.extend_from_slice(obj.as_bytes());
}
if cid_needed {
let f5_obj = 3 + FONTS.len();
let cid_dict_obj = f5_obj + 1;
let desc_obj = f5_obj + 2;
let font_file_obj = f5_obj + 3;
let to_unicode_obj = f5_obj + 4;
let face = unicode_face();
debug_assert_eq!(
face.units_per_em(),
1000,
"CID font /W values assume UPM=1000; scale advances by 1000/upe if the font changes"
);
let upe = face.units_per_em() as i32;
let scale = |v: i32| v * 1000 / upe;
let ascender = scale(face.ascender() as i32);
let descender = scale(face.descender() as i32);
let cap_height = scale(
face.capital_height()
.map(|h| h as i32)
.unwrap_or(face.ascender() as i32),
);
let bbox = face.global_bounding_box();
let llx = scale(bbox.x_min as i32);
let lly = scale(bbox.y_min as i32);
let urx = scale(bbox.x_max as i32);
let ury = scale(bbox.y_max as i32);
const DW: u16 = 1000;
let width_array: String = {
let entries: Vec<String> = self
.cid_glyphs
.keys()
.filter_map(|&gid| {
let advance = face
.glyph_hor_advance(ttf_parser::GlyphId(gid))
.unwrap_or(DW);
if advance != DW {
Some(format!("{} [{}]", gid, advance))
} else {
None
}
})
.collect();
if entries.is_empty() {
String::new()
} else {
format!(" /W [{}]", entries.join(" "))
}
};
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{f5_obj} 0 obj\n<< /Type /Font /Subtype /Type0 \
/BaseFont /NotoSansCJK-Regular-Subset /Encoding /Identity-H \
/DescendantFonts [{cid_dict_obj} 0 R] \
/ToUnicode {to_unicode_obj} 0 R >>\nendobj\n"
)
.as_bytes(),
);
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{cid_dict_obj} 0 obj\n<< /Type /Font /Subtype /CIDFontType0 \
/BaseFont /NotoSansCJK-Regular-Subset \
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
/FontDescriptor {desc_obj} 0 R /DW {DW}{width_array} >>\nendobj\n"
)
.as_bytes(),
);
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{desc_obj} 0 obj\n<< /Type /FontDescriptor \
/FontName /NotoSansCJK-Regular-Subset /Flags 6 \
/FontBBox [{llx} {lly} {urx} {ury}] /ItalicAngle 0 \
/Ascent {ascender} /Descent {descender} /CapHeight {cap_height} \
/StemV 80 /FontFile3 {font_file_obj} 0 R >>\nendobj\n"
)
.as_bytes(),
);
let cff_bytes = unicode_cff_bytes();
offsets.push(pdf.len());
pdf.extend_from_slice(
format!(
"{font_file_obj} 0 obj\n<< /Subtype /CIDFontType0C /Length {} >>\nstream\n",
cff_bytes.len()
)
.as_bytes(),
);
pdf.extend_from_slice(cff_bytes);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
offsets.push(pdf.len());
let cmap_body = build_to_unicode_cmap(&self.cid_glyphs);
pdf.extend_from_slice(
format!(
"{to_unicode_obj} 0 obj\n<< /Length {} >>\nstream\n",
cmap_body.len()
)
.as_bytes(),
);
pdf.extend_from_slice(cmap_body.as_bytes());
pdf.extend_from_slice(b"\nendstream\nendobj\n");
}
for img in &self.images {
match &img.format {
ImageFormat::Jpeg { data, components } => {
offsets.push(pdf.len());
let obj_num = offsets.len();
let color_space = match components {
1 => "/DeviceGray",
4 => "/DeviceCMYK",
_ => "/DeviceRGB",
};
let header = format!(
"{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n",
obj_num,
img.width,
img.height,
color_space,
data.len()
);
pdf.extend_from_slice(header.as_bytes());
pdf.extend_from_slice(data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
}
ImageFormat::Png {
idat_data,
bit_depth,
colors,
palette,
smask,
} => {
let smask_obj_num = if smask.is_some() {
offsets.push(pdf.len());
let sobj = offsets.len();
let smask_data = smask.as_ref().expect("checked above");
let smask_header = format!(
"{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent {} /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 1 /BitsPerComponent {} /Columns {} >> /Length {} >>\nstream\n",
sobj,
img.width,
img.height,
bit_depth,
bit_depth,
img.width,
smask_data.len()
);
pdf.extend_from_slice(smask_header.as_bytes());
pdf.extend_from_slice(smask_data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
Some(sobj)
} else {
None
};
offsets.push(pdf.len());
let obj_num = offsets.len();
let color_space = match (colors, palette) {
(_, Some(pal)) => {
let num_entries = pal.len() / 3;
let max_idx = if num_entries > 0 { num_entries - 1 } else { 0 };
let hex: String = pal.iter().map(|b| format!("{b:02x}")).collect();
format!("[/Indexed /DeviceRGB {} <{}>]", max_idx, hex)
}
(1, None) => "/DeviceGray".to_string(),
_ => "/DeviceRGB".to_string(),
};
let smask_ref = smask_obj_num
.map(|n| format!(" /SMask {} 0 R", n))
.unwrap_or_default();
let header = format!(
"{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} /BitsPerComponent {} /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors {} /BitsPerComponent {} /Columns {} >>{} /Length {} >>\nstream\n",
obj_num,
img.width,
img.height,
color_space,
bit_depth,
colors,
bit_depth,
img.width,
smask_ref,
idat_data.len()
);
pdf.extend_from_slice(header.as_bytes());
pdf.extend_from_slice(idat_data);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
}
}
}
for (i, page_ops) in self.pages.iter().enumerate() {
let page_obj_num = page_obj_start + i * 2;
let content_obj_num = page_obj_num + 1;
offsets.push(pdf.len());
let page_obj = format!(
"{} 0 obj\n<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>\nendobj\n",
page_obj_num, content_obj_num
);
pdf.extend_from_slice(page_obj.as_bytes());
let content = page_ops.join("\n");
offsets.push(pdf.len());
let stream_obj = format!(
"{} 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
content_obj_num,
content.len(),
content
);
pdf.extend_from_slice(stream_obj.as_bytes());
}
let info_obj_num = if let Some(title) = &self.doc_title {
offsets.push(pdf.len());
let n = offsets.len();
let title_hex = pdf_title_hex_string(title);
pdf.extend_from_slice(
format!("{n} 0 obj\n<< /Title {title_hex} >>\nendobj\n").as_bytes(),
);
Some(n)
} else {
None
};
let xref_offset = pdf.len();
let num_objects = offsets.len() + 1; pdf.extend_from_slice(format!("xref\n0 {num_objects}\n").as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for offset in &offsets {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
let info_ref = info_obj_num
.map(|n| format!(" /Info {n} 0 R"))
.unwrap_or_default();
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {num_objects} /Root 1 0 R{info_ref} >>\nstartxref\n{xref_offset}\n%%EOF\n"
)
.as_bytes(),
);
pdf
}
}
#[derive(Clone, Copy)]
enum Font {
Helvetica,
HelveticaBold,
HelveticaOblique,
HelveticaBoldOblique,
Courier,
}
impl Font {
fn pdf_name(self) -> &'static str {
match self {
Self::Helvetica => "/F1",
Self::HelveticaBold => "/F2",
Self::HelveticaOblique => "/F3",
Self::HelveticaBoldOblique => "/F4",
Self::Courier => "/F6",
}
}
fn base_name(self) -> &'static str {
match self {
Self::Helvetica => "Helvetica",
Self::HelveticaBold => "Helvetica-Bold",
Self::HelveticaOblique => "Helvetica-Oblique",
Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
Self::Courier => "Courier",
}
}
}
const FONTS: [Font; 5] = [
Font::Helvetica,
Font::HelveticaBold,
Font::HelveticaOblique,
Font::HelveticaBoldOblique,
Font::Courier,
];
fn pdf_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'(' => out.push_str("\\("),
')' => out.push_str("\\)"),
_ if c.is_ascii() => out.push(c),
'\u{00A0}'..='\u{00FF}' => {
let byte = c as u32;
out.push_str(&format!("\\{byte:03o}"));
}
_ => {
if let Some(byte) = winansi_byte(c) {
out.push_str(&format!("\\{byte:03o}"));
} else {
out.push('?');
}
}
}
}
out
}
fn winansi_byte(c: char) -> Option<u32> {
match c {
'\u{20AC}' => Some(0x80), '\u{201A}' => Some(0x82), '\u{0192}' => Some(0x83), '\u{201E}' => Some(0x84), '\u{2026}' => Some(0x85), '\u{2020}' => Some(0x86), '\u{2021}' => Some(0x87), '\u{02C6}' => Some(0x88), '\u{2030}' => Some(0x89), '\u{0160}' => Some(0x8A), '\u{2039}' => Some(0x8B), '\u{0152}' => Some(0x8C), '\u{017D}' => Some(0x8E), '\u{2018}' => Some(0x91), '\u{2019}' => Some(0x92), '\u{201C}' => Some(0x93), '\u{201D}' => Some(0x94), '\u{2022}' => Some(0x95), '\u{2013}' => Some(0x96), '\u{2014}' => Some(0x97), '\u{02DC}' => Some(0x98), '\u{2122}' => Some(0x99), '\u{0161}' => Some(0x9A), '\u{203A}' => Some(0x9B), '\u{0153}' => Some(0x9C), '\u{017E}' => Some(0x9E), '\u{0178}' => Some(0x9F), _ => None,
}
}
fn fmt_f32(v: f32) -> String {
if !v.is_finite() {
return "0".to_string();
}
let s = format!("{v:.2}");
if s.contains('.') {
s.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_produces_valid_pdf() {
let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello [G]world").unwrap();
let bytes = render_song(&song);
assert!(!bytes.is_empty());
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_empty_song() {
let song = chordsketch_chordpro::parse("").unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn test_try_render_success() {
let result = try_render("{title: Test}\n[G]Hello");
assert!(result.is_ok());
assert!(result.unwrap().starts_with(b"%PDF"));
}
#[test]
fn test_try_render_error() {
let result = try_render("{unclosed");
assert!(result.is_err());
}
#[test]
fn test_full_song() {
let input = "\
{title: Amazing Grace}
{subtitle: Traditional}
{start_of_verse}
[G]Amazing [G7]grace
{end_of_verse}
{comment: Repeat}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Amazing Grace"));
}
#[test]
fn test_stream_length_matches_content() {
let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
for length_match in content.match_indices("/Length ") {
let after = &content[length_match.0 + 8..];
let end = after.find(' ').or_else(|| after.find('>')).unwrap();
let declared_len: usize = after[..end].trim().parse().unwrap();
let stream_start_offset =
length_match.0 + content[length_match.0..].find("stream\n").unwrap() + 7;
let endstream_offset =
length_match.0 + content[length_match.0..].find("\nendstream").unwrap();
let actual_len = endstream_offset - stream_start_offset;
assert_eq!(
declared_len, actual_len,
"/Length {declared_len} does not match actual stream size {actual_len}"
);
}
}
#[test]
fn test_pdf_escape() {
assert_eq!(pdf_escape("hello"), "hello");
assert_eq!(pdf_escape("a(b)c"), "a\\(b\\)c");
assert_eq!(pdf_escape("back\\slash"), "back\\\\slash");
}
#[test]
fn test_pdf_escape_latin1_accented() {
assert_eq!(pdf_escape("café"), "caf\\351");
assert_eq!(pdf_escape("über"), "\\374ber");
assert_eq!(pdf_escape("España"), "Espa\\361a");
assert_eq!(pdf_escape("Straße"), "Stra\\337e");
}
#[test]
fn test_pdf_escape_non_latin1_replaced() {
assert_eq!(pdf_escape("日本語"), "???");
assert_eq!(pdf_escape("hello 世界"), "hello ??");
}
#[test]
fn test_cjk_renders_via_cid_font() {
let song = chordsketch_chordpro::parse("{title: 桜}\n日本語の歌詞").unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"), "must produce a PDF");
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Subtype /CIDFontType0"),
"CIDFontType0 object must appear when CJK glyphs are used"
);
assert!(
text.contains("/Subtype /Type0"),
"Type0 composite font wrapper must be present"
);
assert!(
text.contains("/Encoding /Identity-H"),
"Identity-H encoding must be specified for the CID font"
);
assert!(
bytes.windows(6).any(|w| {
w[0] == b'<' && w[1..5].iter().all(|b| b.is_ascii_hexdigit()) && w[5] == b'>'
}),
"CID hex glyph sequence must appear in content stream"
);
}
#[test]
fn test_mixed_ascii_and_cjk() {
let song =
chordsketch_chordpro::parse("{title: Sakura 桜}\n[Am]Hello [G]世界\nEnd of song")
.unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("Helvetica"),
"Helvetica Type1 font must be present"
);
assert!(
text.contains("CIDFontType0"),
"CID font must be present for kanji"
);
}
#[test]
fn test_ascii_only_has_no_cid_font() {
let song = chordsketch_chordpro::parse("{title: Test}\n[G]Hello world").unwrap();
let bytes = render_song(&song);
let text = String::from_utf8_lossy(&bytes);
assert!(
!text.contains("CIDFontType0"),
"CID font must not appear in ASCII-only PDFs"
);
}
#[test]
fn test_missing_glyph_gid0_not_in_to_unicode_cmap() {
let song = chordsketch_chordpro::parse("{title: T}\n\u{1F600}\u{1F601}").unwrap();
let bytes = render_song(&song);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("<00000000>"),
"both missing glyphs should emit GID 0"
);
assert!(
!text.contains("<0000> <"),
"GID 0 (.notdef) must not appear as a CMap source entry"
);
assert!(
text.contains("CIDFontType0"),
"CID font chain must be emitted even when all glyphs map to GID 0"
);
}
#[test]
fn test_pdf_escape_mixed_ascii_latin1() {
assert_eq!(pdf_escape("résumé"), "r\\351sum\\351");
assert_eq!(pdf_escape("a\u{00A0}b"), "a\\240b");
}
#[test]
fn test_pdf_escape_winansi_0x80_range() {
assert_eq!(pdf_escape("\u{20AC}"), "\\200");
assert_eq!(pdf_escape("\u{2018}"), "\\221");
assert_eq!(pdf_escape("\u{2019}"), "\\222");
assert_eq!(pdf_escape("\u{201C}"), "\\223");
assert_eq!(pdf_escape("\u{201D}"), "\\224");
assert_eq!(pdf_escape("\u{2013}"), "\\226");
assert_eq!(pdf_escape("\u{2014}"), "\\227");
assert_eq!(pdf_escape("\u{2026}"), "\\205");
assert_eq!(pdf_escape("\u{2122}"), "\\231");
assert_eq!(pdf_escape("\u{2022}"), "\\225");
}
#[test]
fn test_pdf_escape_winansi_mixed() {
assert_eq!(
pdf_escape("\u{201C}Don\u{2019}t stop\u{201D}"),
"\\223Don\\222t stop\\224"
);
assert_eq!(pdf_escape("\u{20AC}50"), "\\20050");
}
#[test]
fn test_render_grid_section() {
let input = "{start_of_grid}\n| Am . | C . |\n{end_of_grid}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Grid"));
}
#[test]
fn test_custom_section_in_pdf() {
let input = "\
{title: Test}
{start_of_intro: Guitar}
[Am]Intro line
{end_of_intro}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Intro: Guitar"));
}
#[test]
fn test_chorus_recall_produces_valid_pdf() {
let input = "\
{start_of_chorus}
[G]La la la
{end_of_chorus}
{chorus}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.matches("Chorus").count() >= 2);
}
#[test]
fn test_chorus_recall_with_label() {
let input = "\
{start_of_chorus}
Sing along
{end_of_chorus}
{chorus: Repeat}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Chorus: Repeat"));
}
#[test]
fn test_chorus_recall_no_chorus_defined() {
let input = "{chorus}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Chorus"));
}
#[test]
fn test_chorus_recall_limit_exceeded() {
let mut input = String::from("{start_of_chorus}\nChorus line\n{end_of_chorus}\n");
for _ in 0..1005 {
input.push_str("{chorus}\n");
}
let song = chordsketch_chordpro::parse(&input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(result.output.starts_with(b"%PDF"));
assert!(
result
.warnings
.iter()
.any(|w| w.contains("chorus recall limit")),
"should warn when chorus recall limit is exceeded"
);
}
#[test]
fn test_chorus_recall_respects_diagrams_off() {
let input = "\
{diagrams: off}
{start_of_chorus}
{define: Am base-fret 1 frets x 0 2 2 1 0}
[Am]Chorus line
{end_of_chorus}
{chorus}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
let input_on = "\
{start_of_chorus}
{define: Am base-fret 1 frets x 0 2 2 1 0}
[Am]Chorus line
{end_of_chorus}
{chorus}";
let song_on = chordsketch_chordpro::parse(input_on).unwrap();
let bytes_on = render_song(&song_on);
let content_on = String::from_utf8_lossy(&bytes_on);
let diagram_lines_off = content.matches("l S").count();
let diagram_lines_on = content_on.matches("l S").count();
assert!(
diagram_lines_on > diagram_lines_off,
"diagrams=on should produce more line ops than diagrams=off"
);
}
#[test]
fn test_diagrams_off_case_insensitive_pdf() {
let input = "{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
!content.contains("Am"),
"diagrams=Off should suppress diagrams in PDF (case-insensitive)"
);
}
#[test]
fn test_diagrams_off_uppercase_pdf() {
let input = "{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
!content.contains("Am"),
"diagrams=OFF should suppress diagrams in PDF (case-insensitive)"
);
}
#[test]
fn test_custom_section_solo_in_pdf() {
let input = "{start_of_solo}\n[Em]Solo\n{end_of_solo}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Solo"));
}
#[test]
fn test_render_grid_section_with_label() {
let input = "{start_of_grid: Intro}\n| Am |\n{end_of_grid}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Grid: Intro"));
}
#[test]
fn test_define_display_name_in_pdf_output() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("A minor"),
"display name should appear in rendered PDF output"
);
}
#[test]
fn test_define_with_fingers_in_pdf_output() {
let input = "{define: C base-fret 1 frets x 3 2 0 1 0 fingers 0 3 2 0 1 0}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("(3)"),
"finger numbers should appear in rendered PDF output"
);
}
}
#[cfg(test)]
mod comment_style_tests {
use super::*;
#[test]
fn test_comment_normal_renders_text() {
let input = "{comment: This is normal}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("This is normal"),
"normal comment text should appear in PDF"
);
}
#[test]
fn test_comment_italic_renders_text() {
let input = "{comment_italic: Italic note}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("Italic note"),
"italic comment text should appear in PDF"
);
}
#[test]
fn test_comment_box_renders_with_rect() {
let input = "{comment_box: Boxed note}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("Boxed note"),
"boxed comment text should appear in PDF"
);
assert!(
content.contains("re S"),
"boxed comment should draw a rectangle border"
);
}
#[test]
fn test_comment_normal_no_rect() {
let input = "{comment: No box here}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
!content.contains("re S"),
"normal comment should not draw a rectangle"
);
}
}
#[cfg(test)]
mod transpose_tests {
use super::*;
#[test]
fn test_transpose_directive_produces_pdf() {
let input = "{transpose: 2}\n[G]Hello [C]world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("(A)"));
assert!(content.contains("(D)"));
}
#[test]
fn test_transpose_with_cli_offset() {
let input = "{transpose: 2}\n[C]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song_with_transpose(&song, 3, &Config::defaults());
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("(F)"));
}
#[test]
fn test_transpose_out_of_i8_range_emits_warning() {
let input = "{transpose: 999}\n[G]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("(G)"), "chord should be untransposed");
assert!(
result.warnings.iter().any(|w| w.contains("\"999\"")),
"expected warning about out-of-range value, got: {:?}",
result.warnings
);
}
#[test]
fn test_transpose_no_value_treated_as_zero() {
let input = "{transpose}\n[G]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("(G)"), "chord should be untransposed");
assert!(
result.warnings.is_empty(),
"missing {{transpose}} value should not emit a warning; got: {:?}",
result.warnings
);
}
#[test]
fn test_transpose_whitespace_value_treated_as_zero() {
let input = "{transpose: }\n[G]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("(G)"), "chord should be untransposed");
assert!(
result.warnings.is_empty(),
"whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
result.warnings
);
}
}
#[cfg(test)]
mod delegate_tests {
use super::*;
#[test]
fn test_abc_section_in_pdf() {
let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("ABC: Melody"));
}
#[test]
fn test_ly_section_in_pdf() {
let input = "{start_of_ly}\nnotes\n{end_of_ly}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Lilypond"));
}
#[test]
fn test_svg_section_in_pdf() {
let input = "{start_of_svg}\n<svg/>\n{end_of_svg}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("SVG"));
}
#[test]
fn test_textblock_section_in_pdf() {
let input = "{start_of_textblock}\nText\n{end_of_textblock}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Textblock"));
}
#[test]
fn test_musicxml_section_in_pdf() {
let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("MusicXML"));
}
#[test]
fn test_abc_block_emits_warning_and_skips_body() {
let input = "{start_of_abc: Melody}\nX:1\nK:C\nCDEF\n{end_of_abc}";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result
.warnings
.iter()
.any(|w| w.contains("ABC") && w.contains("omitted")),
"expected at least one warning mentioning `ABC` and `omitted`; got {:?}",
result.warnings,
);
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("ABC: Melody"));
assert!(
!content.contains("CDEF"),
"ABC body content must not leak into the PDF as plain text",
);
assert!(content.contains("[ABC block omitted"));
}
#[test]
fn test_ly_block_emits_warning_and_skips_body() {
let input = "{start_of_ly}\n\\relative c' { c4 d }\n{end_of_ly}";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(result.warnings.iter().any(|w| w.contains("Lilypond")));
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("[Lilypond block omitted"));
assert!(
!content.contains("\\relative"),
"Lilypond body content must not leak into the PDF"
);
}
#[test]
fn test_svg_block_emits_warning_and_skips_body() {
let input = "{start_of_svg}\n<svg><circle r=\"10\"/></svg>\n{end_of_svg}";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(result.warnings.iter().any(|w| w.contains("SVG")));
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("[SVG block omitted"));
assert!(
!content.contains("<circle"),
"SVG body content must not leak into the PDF"
);
}
#[test]
fn test_musicxml_block_emits_warning_and_skips_body() {
let input =
"{start_of_musicxml: Score}\n<score-partwise>notes</score-partwise>\n{end_of_musicxml}";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(result.warnings.iter().any(|w| w.contains("MusicXML")));
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("[MusicXML block omitted"));
assert!(
!content.contains("<score-partwise"),
"MusicXML body content must not leak into the PDF",
);
}
#[test]
fn test_content_after_notation_block_still_renders() {
let input = "{title: T}\n{start_of_abc}\nbody\n{end_of_abc}\n[C]Hello world\n";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(result.warnings.iter().any(|w| w.contains("ABC")));
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("Hello world"));
assert!(!content.contains("body"));
}
#[test]
fn test_notation_block_inside_chorus_is_excluded_from_recall() {
let input = "{start_of_chorus}\n\
[G]Sing along\n\
{start_of_abc}\n\
X:1\n\
{end_of_abc}\n\
[C]another line\n\
{end_of_chorus}\n\
{chorus}\n";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
let abc_warnings = result.warnings.iter().filter(|w| w.contains("ABC")).count();
assert_eq!(
abc_warnings, 1,
"exactly one ABC warning expected (recall must not re-emit); got {:?}",
result.warnings,
);
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("Sing along"));
assert!(content.contains("another line"));
}
#[test]
fn test_unterminated_notation_block_renders_without_panic() {
let input = "{title: T}\n[C]Before\n{start_of_abc}\nX:1\nK:C\n";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result.warnings.iter().any(|w| w.contains("ABC")),
"unterminated ABC block should still emit the warning; got {:?}",
result.warnings,
);
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("Before"));
assert!(content.contains("[ABC block omitted"));
assert!(!content.contains("X:1"));
assert!(!content.contains("K:C"));
}
#[test]
fn test_stray_end_of_notation_is_silently_ignored() {
let input = "{title: T}\n[C]Hello\n{end_of_abc}\n[D]World\n";
let song = chordsketch_chordpro::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
!result
.warnings
.iter()
.any(|w| w.contains("ABC") && w.contains("omitted")),
"stray `end_of_abc` must not trigger the notation-block warning; got {:?}",
result.warnings,
);
let content = String::from_utf8_lossy(&result.output);
assert!(content.contains("Hello"));
assert!(content.contains("World"));
}
}
#[cfg(test)]
mod inline_markup_tests {
use super::*;
#[test]
fn test_bold_markup_uses_bold_font() {
let input = "Hello <b>bold</b> world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/F1"));
assert!(content.contains("/F2"));
assert!(content.contains("bold"));
}
#[test]
fn test_italic_markup_uses_oblique_font() {
let input = "Hello <i>italic</i> text";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/F3")); assert!(content.contains("italic"));
}
#[test]
fn test_bold_italic_markup_uses_bold_oblique_font() {
let input = "<b><i>bold italic</i></b>";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/F4")); assert!(content.contains("bold italic"));
}
#[test]
fn test_markup_with_chords_produces_valid_pdf() {
let input = "[Am]Hello <b>bold</b> world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Am"));
assert!(content.contains("bold"));
}
#[test]
fn test_span_weight_bold_uses_bold_font() {
let input = r#"<span weight="bold">weighted</span>"#;
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/F2")); assert!(content.contains("weighted"));
}
}
#[cfg(test)]
mod formatting_directive_tests {
use super::*;
#[test]
fn test_textsize_directive_changes_font_size() {
let input = "{textsize: 14}\nHello world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("14"));
assert!(content.contains("Hello world"));
}
#[test]
fn test_chordsize_directive_changes_chord_size() {
let input = "{chordsize: 16}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Am"));
}
#[test]
fn test_formatting_directive_produces_valid_pdf() {
let input = "{textsize: 14}\n{chordsize: 12}\n[Am]Hello <b>bold</b> world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn test_textsize_clamped_to_max() {
let input = "{textsize: 99999}\nHello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("99999"));
assert!(content.contains("200"));
}
#[test]
fn test_textsize_clamped_to_min() {
let input = "{textsize: -5}\nHello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.5"));
}
#[test]
fn test_chordsize_clamped_to_max() {
let input = "{chordsize: 500}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("500"));
assert!(content.contains("200"));
}
}
#[cfg(test)]
mod multipage_tests {
use super::*;
#[test]
fn test_new_page_directive_creates_two_pages() {
let input = "{title: Test}\nPage one\n{new_page}\nPage two";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Count 2"));
assert!(content.contains("Page one"));
assert!(content.contains("Page two"));
}
#[test]
fn test_new_physical_page_from_recto_inserts_blank() {
let input = "Page one\n{new_physical_page}\nPage two";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("/Count 3"),
"new_physical_page from recto should insert blank page to reach next recto"
);
}
#[test]
fn test_new_physical_page_from_verso_no_extra_blank() {
let input = "Page one\n{new_page}\nPage two\n{new_physical_page}\nPage three";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("/Count 3"),
"new_physical_page from verso should go directly to next recto (no extra blank)"
);
}
#[test]
fn test_single_page_has_count_one() {
let input = "{title: Short Song}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Count 1"));
}
#[test]
fn test_automatic_page_break_for_long_content() {
let mut lines = vec!["{title: Long Song}".to_string()];
for i in 0..80 {
lines.push(format!("[Am]Line number {i}"));
}
let input = lines.join("\n");
let song = chordsketch_chordpro::parse(&input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
!content.contains("/Count 1"),
"80 chord-lyrics lines should overflow one page"
);
}
#[test]
fn test_multiple_new_page_directives() {
let input = "Page 1\n{new_page}\nPage 2\n{new_page}\nPage 3";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Count 3"));
}
#[test]
fn test_multipage_pdf_structure_valid() {
let input = "First page\n{new_page}\nSecond page";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("First page"));
assert!(content.contains("Second page"));
}
#[test]
fn test_page_count_method() {
let mut doc = PdfDocument::new();
assert_eq!(doc.page_count(), 1);
doc.new_page();
assert_eq!(doc.page_count(), 2);
doc.new_page();
assert_eq!(doc.page_count(), 3);
}
#[test]
fn test_new_page_respects_max_limit() {
let mut doc = PdfDocument::new();
for _ in 0..MAX_PAGES {
doc.new_page();
}
assert_eq!(doc.page_count(), MAX_PAGES);
}
#[test]
fn test_take_pages_preserves_invariant() {
let mut doc = PdfDocument::new();
doc.new_page();
assert_eq!(doc.page_count(), 2);
let taken = doc.take_pages();
assert_eq!(taken.len(), 2);
assert_eq!(doc.page_count(), 1);
let _ = doc.current_page_mut();
}
#[test]
fn test_new_page_works_after_take_pages() {
let mut doc = PdfDocument::new();
let _ = doc.take_pages();
doc.new_page();
assert_eq!(doc.page_count(), 2);
}
#[test]
fn test_push_page_respects_max_limit() {
let mut doc = PdfDocument::new();
for _ in 1..MAX_PAGES {
doc.new_page();
}
assert_eq!(doc.page_count(), MAX_PAGES);
doc.push_page(vec!["BT (overflow) Tj ET".to_string()]);
assert_eq!(doc.page_count(), MAX_PAGES);
}
#[test]
fn test_combined_toc_and_body_respects_max_limit() {
let mut toc_doc = PdfDocument::new();
for _ in 1..5 {
toc_doc.new_page();
}
assert_eq!(toc_doc.page_count(), 5);
let mut body_doc = PdfDocument::new();
for _ in 1..MAX_PAGES {
body_doc.new_page();
}
assert_eq!(body_doc.page_count(), MAX_PAGES);
for page_ops in body_doc.take_pages() {
toc_doc.push_page(page_ops);
}
assert_eq!(toc_doc.page_count(), MAX_PAGES);
}
#[test]
fn test_page_control_not_replayed_in_chorus_recall() {
let input = "\
{start_of_chorus}\n\
{new_page}\n\
[G]La la la\n\
{end_of_chorus}\n\
Verse text\n\
{chorus}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("/Count 2"),
"chorus recall must not replay page breaks"
);
}
}
#[cfg(test)]
mod column_tests {
use super::*;
#[test]
fn test_columns_directive_produces_valid_pdf() {
let input = "{columns: 2}\nColumn one\n{column_break}\nColumn two";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Column one"));
assert!(content.contains("Column two"));
}
#[test]
fn test_column_break_in_single_column_creates_new_page() {
let input = "Page one\n{column_break}\nPage two";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Count 2"));
}
#[test]
fn test_columns_reset_to_one() {
let input = "{columns: 2}\nTwo cols\n{columns: 1}\nOne col";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Two cols"));
assert!(content.contains("One col"));
}
#[test]
fn test_margin_left_single_column() {
let doc = PdfDocument::new();
assert!((doc.margin_left() - MARGIN_LEFT).abs() < 0.01);
}
#[test]
fn test_margin_left_multi_column() {
let mut doc = PdfDocument::new();
doc.set_columns(2);
assert!((doc.margin_left() - MARGIN_LEFT).abs() < 0.01);
doc.current_column = 1;
assert!(doc.margin_left() > MARGIN_LEFT);
}
#[test]
fn test_margin_left_all_column_counts_positive() {
for n in 1..=MAX_COLUMNS {
let mut doc = PdfDocument::new();
doc.set_columns(n);
for col in 0..n {
doc.current_column = col;
let m = doc.margin_left();
assert!(
m >= 0.0 && m.is_finite(),
"margin_left() must be non-negative and finite for columns={n}, col={col}, got {m}"
);
}
}
}
#[test]
fn test_column_break_advances_column() {
let mut doc = PdfDocument::new();
doc.set_columns(2);
assert_eq!(doc.current_column, 0);
doc.column_break();
assert_eq!(doc.current_column, 1);
}
#[test]
fn test_set_columns_clamps_to_max() {
let mut doc = PdfDocument::new();
doc.set_columns(999);
assert_eq!(doc.num_columns, MAX_COLUMNS);
}
#[test]
fn test_set_columns_clamps_zero_to_one() {
let mut doc = PdfDocument::new();
doc.set_columns(0);
assert_eq!(doc.num_columns, 1);
}
#[test]
fn test_margin_left_at_max_columns_no_overflow() {
let mut doc = PdfDocument::new();
doc.set_columns(MAX_COLUMNS);
for col in 0..MAX_COLUMNS {
doc.current_column = col;
let m = doc.margin_left();
assert!(m.is_finite(), "margin_left must be finite for column {col}");
assert!(
m >= 0.0,
"margin_left must be non-negative for column {col}"
);
}
}
#[test]
fn test_column_break_last_column_new_page() {
let mut doc = PdfDocument::new();
doc.set_columns(2);
doc.column_break(); assert_eq!(doc.page_count(), 1);
doc.column_break(); assert_eq!(doc.page_count(), 2);
assert_eq!(doc.current_column, 0);
}
#[test]
fn test_columns_non_numeric_defaults_to_one() {
let input = "{columns: abc}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Am"));
assert!(content.contains("Hello"));
}
#[test]
fn test_columns_out_of_range_clamped() {
let input = "{columns: 4294967295}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Am"));
assert!(content.contains("Hello"));
}
#[test]
fn test_multi_column_text_clipped() {
let input = "{columns: 2}\n[Am]Hello world this is a very long line of lyrics";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("re W n"),
"multi-column PDF should contain clipping rectangle operator"
);
let clip_line = content
.lines()
.find(|l| l.contains("re W n"))
.expect("should find clip rect line");
let parts: Vec<&str> = clip_line.split_whitespace().collect();
assert!(parts.len() >= 6, "clip rect should have x y w h re W n");
let w: f32 = parts[2].parse().expect("width should be a number");
assert!(
w > 100.0 && w < 300.0,
"clip width {w} should be a reasonable column width"
);
}
#[test]
fn test_single_column_no_clipping() {
let input = "[Am]Hello world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(
!content.contains("re W n"),
"single-column PDF should not contain clipping operator"
);
}
#[test]
fn test_multi_column_inline_markup_single_clip_per_line() {
let input = "{columns: 2}\nHello <b>bold</b> and <i>italic</i> text";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
let clip_count = content.matches("re W n").count();
assert_eq!(
clip_count, 1,
"inline markup line should produce exactly 1 clip rect (got {clip_count})"
);
}
#[test]
fn test_render_songs_single() {
let songs = chordsketch_chordpro::parse_multi("{title: Only}\n[Am]Hello").unwrap();
let bytes = render_songs(&songs);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
assert_eq!(bytes, render_song(&songs[0]));
}
#[test]
fn test_render_songs_two_songs_multi_page() {
let songs = chordsketch_chordpro::parse_multi(
"{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
)
.unwrap();
let bytes = render_songs(&songs);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Song A"));
assert!(content.contains("Song B"));
assert!(content.contains("/Count 3"));
assert!(content.contains("Table of Contents"));
}
#[test]
fn test_render_songs_with_transpose() {
let songs =
chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
.unwrap();
let bytes = render_songs_with_transpose(&songs, 2, &Config::defaults());
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("(D)"));
assert!(content.contains("(A)"));
}
#[test]
fn test_render_song_into_doc_helper() {
let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
let mut doc = PdfDocument::new();
let mut warnings = Vec::new();
render_song_into_doc(&song, 0, &Config::defaults(), &mut doc, &mut warnings);
assert_eq!(doc.page_count(), 1);
let pdf = doc.build_pdf();
assert!(pdf.starts_with(b"%PDF-1.4"));
let content = String::from_utf8_lossy(&pdf);
assert!(content.contains("Test"));
}
}
#[cfg(test)]
mod toc_tests {
use super::*;
#[test]
fn test_toc_generated_for_multi_song() {
let songs = chordsketch_chordpro::parse_multi(
"{title: First}\nLyrics 1\n{new_song}\n{title: Second}\nLyrics 2",
)
.unwrap();
let bytes = render_songs(&songs);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Table of Contents"));
assert!(content.contains("First"));
assert!(content.contains("Second"));
}
#[test]
fn test_toc_not_generated_for_single_song() {
let song = chordsketch_chordpro::parse("{title: Only Song}\nLyrics").unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("Table of Contents"));
}
#[test]
fn test_toc_page_numbers_present() {
let songs = chordsketch_chordpro::parse_multi(
"{title: Song A}\nA\n{new_song}\n{title: Song B}\nB\n{new_song}\n{title: Song C}\nC",
)
.unwrap();
let bytes = render_songs(&songs);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Count 4"));
assert!(content.contains("Table of Contents"));
}
#[test]
fn test_toc_valid_pdf_structure() {
let songs =
chordsketch_chordpro::parse_multi("{title: A}\nText\n{new_song}\n{title: B}\nText")
.unwrap();
let bytes = render_songs(&songs);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_toc_with_custom_margins_produces_valid_pdf() {
use chordsketch_chordpro::config::Config;
let songs =
chordsketch_chordpro::parse_multi("{title: Song A}\nA\n{new_song}\n{title: Song B}\nB")
.unwrap();
let config = Config::parse(
r#"{ "pdf": { "margintop": 100, "marginbottom": 100, "marginleft": 100, "marginright": 100 } }"#,
)
.unwrap();
let bytes = render_songs_with_transpose(&songs, 0, &config);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Table of Contents"));
}
#[test]
fn test_push_toc_entry_skips_adjacent_duplicate() {
let mut entries = vec![("Song A".to_string(), 1)];
push_toc_entry(&mut entries, "Song A".to_string(), 1);
assert_eq!(entries, vec![("Song A".to_string(), 1)]);
}
#[test]
fn test_push_toc_entry_keeps_different_page() {
let mut entries = vec![("Song A".to_string(), 1)];
push_toc_entry(&mut entries, "Song A".to_string(), 2);
assert_eq!(
entries,
vec![("Song A".to_string(), 1), ("Song A".to_string(), 2)]
);
}
#[test]
fn test_push_toc_entry_keeps_different_title() {
let mut entries = vec![("Song A".to_string(), 1)];
push_toc_entry(&mut entries, "Song B".to_string(), 1);
assert_eq!(
entries,
vec![("Song A".to_string(), 1), ("Song B".to_string(), 1)]
);
}
#[test]
fn test_push_toc_entry_dedup_is_adjacent_only() {
let mut entries = vec![("Song A".to_string(), 1), ("Song B".to_string(), 2)];
push_toc_entry(&mut entries, "Song A".to_string(), 1);
assert_eq!(
entries,
vec![
("Song A".to_string(), 1),
("Song B".to_string(), 2),
("Song A".to_string(), 1),
],
"adjacent-only dedup must keep non-adjacent repeats"
);
}
#[test]
fn test_push_toc_entry_into_empty() {
let mut entries: Vec<(String, usize)> = Vec::new();
push_toc_entry(&mut entries, "Song A".to_string(), 1);
assert_eq!(entries, vec![("Song A".to_string(), 1)]);
}
#[test]
fn test_toc_multi_song_cjk_includes_cid_font() {
let songs = chordsketch_chordpro::parse_multi(
"{title: Song A}\nこんにちは\n{new_song}\n{title: Song B}\n日本語",
)
.unwrap();
let bytes = render_songs(&songs);
let content = String::from_utf8_lossy(&bytes);
assert!(
content.contains("/Type /Font") && content.contains("/Subtype /Type0"),
"multi-song CJK PDF must contain a Type0 CID font"
);
assert!(
content.contains("Identity-H"),
"multi-song CJK PDF must use Identity-H encoding"
);
assert!(
content.contains("/ToUnicode"),
"multi-song CJK PDF must contain a ToUnicode CMap"
);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
}
#[cfg(test)]
mod chord_diagram_pdf_tests {
use super::*;
#[test]
fn test_define_renders_diagram_in_pdf() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("Am"));
assert!(content.contains(" c "));
}
#[test]
fn test_define_keyboard_renders_in_pdf() {
let input = "{define: Am keys 0 3 7}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_define_keyboard_absolute_midi_pdf() {
let input = "{define: Cmaj7 keys 60 64 67 71}\n[Cmaj7]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_diagrams_piano_auto_inject_pdf() {
let input = "{diagrams: piano}\n[Am]Hello [C]world";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_define_diagram_valid_pdf() {
let input = "{define: F base-fret 1 frets 1 1 2 3 3 1}\n[F]Lyrics";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_define_ukulele_diagram_in_pdf() {
let input = "{define: C frets 0 0 0 3}\n[C]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("C"));
}
#[test]
fn test_define_banjo_diagram_in_pdf() {
let input = "{define: G frets 0 0 0 0 0}\n[G]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn test_diagrams_frets_config_affects_pdf_output() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello";
let song = chordsketch_chordpro::parse(input).unwrap();
let config_4 = chordsketch_chordpro::config::Config::defaults()
.with_define("diagrams.frets=4")
.unwrap();
let config_7 = chordsketch_chordpro::config::Config::defaults()
.with_define("diagrams.frets=7")
.unwrap();
let bytes_4 = render_song_with_transpose(&song, 0, &config_4);
let bytes_7 = render_song_with_transpose(&song, 0, &config_7);
let content_4 = String::from_utf8_lossy(&bytes_4);
let content_7 = String::from_utf8_lossy(&bytes_7);
let lines_4 = content_4.matches("l S").count();
let lines_7 = content_7.matches("l S").count();
assert!(
lines_7 >= lines_4,
"frets=7 ({lines_7}) should have at least as many line ops as frets=4 ({lines_4})"
);
assert_eq!(
lines_7 - lines_4,
3,
"frets=7 should produce exactly 3 more line-drawing ops than frets=4 \
(got {lines_7} vs {lines_4})"
);
}
#[test]
fn test_render_chord_diagram_pdf_single_string_no_panic() {
let data = chordsketch_chordpro::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: 1,
frets_shown: 5,
base_fret: 1,
frets: vec![0],
fingers: vec![],
};
let mut doc = PdfDocument::new();
render_chord_diagram_pdf(&data, &mut doc);
}
#[test]
fn test_render_chord_diagram_pdf_zero_strings_no_panic() {
let data = chordsketch_chordpro::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: 0,
frets_shown: 5,
base_fret: 1,
frets: vec![],
fingers: vec![],
};
let mut doc = PdfDocument::new();
render_chord_diagram_pdf(&data, &mut doc);
}
#[test]
fn test_render_chord_diagram_pdf_exceeding_max_strings_no_panic() {
let data = chordsketch_chordpro::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: chordsketch_chordpro::chord_diagram::MAX_STRINGS + 1,
frets_shown: 5,
base_fret: 1,
frets: vec![0; chordsketch_chordpro::chord_diagram::MAX_STRINGS + 1],
fingers: vec![],
};
let mut doc = PdfDocument::new();
render_chord_diagram_pdf(&data, &mut doc);
}
#[test]
fn test_render_chord_diagram_pdf_zero_frets_shown_no_panic() {
let data = chordsketch_chordpro::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: 6,
frets_shown: 0,
base_fret: 1,
frets: vec![0; 6],
fingers: vec![],
};
let mut doc = PdfDocument::new();
render_chord_diagram_pdf(&data, &mut doc);
}
#[test]
fn test_render_chord_diagram_pdf_exceeding_max_frets_shown_no_panic() {
let data = chordsketch_chordpro::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: 6,
frets_shown: chordsketch_chordpro::chord_diagram::MAX_FRETS_SHOWN + 1,
base_fret: 1,
frets: vec![0; 6],
fingers: vec![],
};
let mut doc = PdfDocument::new();
render_chord_diagram_pdf(&data, &mut doc);
}
#[test]
fn test_define_chord_not_duplicated_in_auto_inject_grid() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"), "must produce a valid PDF");
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("G"), "G should appear (auto-inject grid)");
let am_count = content.matches("Am").count();
assert!(
am_count <= 2,
"Am should appear at most twice (chord label + inline diagram), got {am_count}"
);
}
#[test]
fn test_define_after_nodiagrams_appears_in_grid() {
let input =
"{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"), "must produce a valid PDF");
let content = String::from_utf8_lossy(&bytes);
let am_count = content.matches("Am").count();
assert!(
am_count >= 2,
"Am should appear in the auto-inject grid (found {am_count} occurrences, expected ≥ 2)"
);
}
}
#[cfg(test)]
mod jpeg_tests {
use super::*;
fn minimal_jpeg(width: u16, height: u16) -> Vec<u8> {
minimal_jpeg_with_components(width, height, 3)
}
fn minimal_jpeg_with_components(width: u16, height: u16, components: u8) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, 0xD8]);
data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]);
data.extend_from_slice(&[0xFF, 0xC0]);
data.extend_from_slice(&[0x00, 0x08]);
data.push(0x08);
data.extend_from_slice(&height.to_be_bytes());
data.extend_from_slice(&width.to_be_bytes());
data.push(components);
data
}
#[test]
fn test_parse_jpeg_dimensions_basic() {
let jpeg = minimal_jpeg(640, 480);
let dims = parse_jpeg_dimensions(&jpeg);
assert_eq!(dims, Some((640, 480, 3)));
}
#[test]
fn test_parse_jpeg_dimensions_square() {
let jpeg = minimal_jpeg(100, 100);
let dims = parse_jpeg_dimensions(&jpeg);
assert_eq!(dims, Some((100, 100, 3)));
}
#[test]
fn test_parse_jpeg_dimensions_too_short() {
assert_eq!(parse_jpeg_dimensions(&[0xFF]), None);
assert_eq!(parse_jpeg_dimensions(&[]), None);
}
#[test]
fn test_parse_jpeg_dimensions_not_jpeg() {
assert_eq!(
parse_jpeg_dimensions(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]),
None
);
}
#[test]
fn test_parse_jpeg_dimensions_exceeds_scan_limit() {
let mut data = vec![0xFF, 0xD8]; data.resize(70_000, 0x00);
data.extend_from_slice(&[0xFF, 0xC0]);
data.extend_from_slice(&[0x00, 0x08]);
data.push(0x08);
data.extend_from_slice(&100_u16.to_be_bytes());
data.extend_from_slice(&200_u16.to_be_bytes());
data.push(3);
assert_eq!(
parse_jpeg_dimensions(&data),
None,
"SOF beyond scan limit should not be found"
);
}
#[test]
fn test_parse_jpeg_dimensions_sof2_progressive() {
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, 0xD8]);
data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]);
data.extend_from_slice(&[0xFF, 0xC2]);
data.extend_from_slice(&[0x00, 0x08]);
data.push(0x08);
data.extend_from_slice(&300_u16.to_be_bytes()); data.extend_from_slice(&400_u16.to_be_bytes()); data.push(0x00); let dims = parse_jpeg_dimensions(&data);
assert_eq!(dims, Some((400, 300, 0)));
}
fn minimal_jpeg_with_sof(sof_marker: u8, width: u16, height: u16) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&[0xFF, 0xD8]); data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]); data.extend_from_slice(&[0xFF, sof_marker]); data.extend_from_slice(&[0x00, 0x08]); data.push(0x08); data.extend_from_slice(&height.to_be_bytes());
data.extend_from_slice(&width.to_be_bytes());
data.push(0x00); data
}
#[test]
fn test_parse_jpeg_dimensions_sof1_extended_sequential() {
let data = minimal_jpeg_with_sof(0xC1, 800, 600);
assert_eq!(parse_jpeg_dimensions(&data), Some((800, 600, 0)));
}
#[test]
fn test_parse_jpeg_dimensions_sof3_lossless() {
let data = minimal_jpeg_with_sof(0xC3, 1024, 768);
assert_eq!(parse_jpeg_dimensions(&data), Some((1024, 768, 0)));
}
#[test]
fn test_parse_jpeg_dimensions_sof9_arithmetic_sequential() {
let data = minimal_jpeg_with_sof(0xC9, 320, 240);
assert_eq!(parse_jpeg_dimensions(&data), Some((320, 240, 0)));
}
#[test]
fn test_parse_jpeg_dimensions_sof10_arithmetic_progressive() {
let data = minimal_jpeg_with_sof(0xCA, 1920, 1080);
assert_eq!(parse_jpeg_dimensions(&data), Some((1920, 1080, 0)));
}
#[test]
fn test_parse_jpeg_dimensions_sof11_arithmetic_lossless() {
let data = minimal_jpeg_with_sof(0xCB, 256, 256);
assert_eq!(parse_jpeg_dimensions(&data), Some((256, 256, 0)));
}
#[test]
fn test_parse_jpeg_dimensions_all_sof_markers() {
let sof_markers = [
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF, ];
for marker in sof_markers {
let data = minimal_jpeg_with_sof(marker, 500, 400);
assert_eq!(
parse_jpeg_dimensions(&data),
Some((500, 400, 0)),
"SOF marker 0x{marker:02X} should be recognized"
);
}
}
#[test]
fn test_parse_jpeg_dimensions_non_sof_markers_not_matched() {
for marker in [0xC4, 0xC8, 0xCC] {
let data = minimal_jpeg_with_sof(marker, 500, 400);
assert_eq!(
parse_jpeg_dimensions(&data),
None,
"Marker 0x{marker:02X} should NOT be recognized as SOF"
);
}
}
#[test]
fn test_image_directive_nonexistent_file_no_crash() {
let input = "{image: src=nonexistent_file_that_does_not_exist.jpg}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_image_directive_non_jpeg_skipped() {
let input = "{image: src=photo.png}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
}
#[test]
fn test_image_directive_dangerous_scheme_rejected() {
let input = "{image: src=\"javascript:alert(1)\"}";
let song = chordsketch_chordpro::parse(input).unwrap();
let bytes = render_song(&song);
assert!(bytes.starts_with(b"%PDF-1.4"));
let as_str = String::from_utf8_lossy(&bytes);
assert!(
!as_str.contains("javascript:"),
"javascript: URI must not appear in PDF output"
);
}
#[test]
fn test_capo_out_of_range_emits_warning() {
let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result
.warnings
.iter()
.any(|w| w.contains("capo") && w.contains("999")),
"expected out-of-range {{capo}} warning; got {:?}",
result.warnings
);
}
#[test]
fn test_capo_non_numeric_emits_warning() {
let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result
.warnings
.iter()
.any(|w| w.contains("capo") && w.contains("foo")),
"expected non-integer {{capo}} warning; got {:?}",
result.warnings
);
}
#[test]
fn test_capo_in_range_is_silent() {
let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
!result.warnings.iter().any(|w| w.contains("capo")),
"valid {{capo: 5}} should not warn; got {:?}",
result.warnings
);
}
#[test]
fn test_strict_off_with_missing_key_is_silent() {
let song = chordsketch_chordpro::parse("{title: T}").unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
!result
.warnings
.iter()
.any(|w| w.contains("settings.strict")),
"default settings.strict=false must not warn on missing {{key}}; got {:?}",
result.warnings
);
}
#[test]
fn test_strict_on_with_missing_key_warns() {
let song = chordsketch_chordpro::parse("{title: T}").unwrap();
let cfg = Config::defaults()
.with_define("settings.strict=true")
.unwrap();
let result = render_song_with_warnings(&song, 0, &cfg);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("{key}") && w.contains("settings.strict")),
"expected missing-{{key}} warning under settings.strict; got {:?}",
result.warnings
);
}
#[test]
fn test_strict_on_with_present_key_is_silent() {
let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
let cfg = Config::defaults()
.with_define("settings.strict=true")
.unwrap();
let result = render_song_with_warnings(&song, 0, &cfg);
assert!(
!result
.warnings
.iter()
.any(|w| w.contains("settings.strict")),
"settings.strict warning must not fire when {{key}} is present; got {:?}",
result.warnings
);
}
#[test]
fn test_max_warnings_truncates() {
let mut input = String::from("{title: T}\n");
for _ in 0..(MAX_WARNINGS + 50) {
input.push_str("{transpose: not-a-number}\n");
}
let song = chordsketch_chordpro::parse(&input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert_eq!(
result.warnings.len(),
MAX_WARNINGS + 1,
"expected exactly MAX_WARNINGS warnings plus one truncation marker"
);
assert!(
result.warnings.last().unwrap().contains("MAX_WARNINGS"),
"last entry must be the truncation marker; got {:?}",
result.warnings.last()
);
}
#[test]
fn test_validate_margin_respects_max_warnings_cap() {
let mut warnings: Vec<String> = Vec::new();
for i in 0..MAX_WARNINGS {
push_warning(&mut warnings, format!("filler warning {i}"));
}
push_warning(&mut warnings, "overflow warning".to_string());
assert_eq!(
warnings.len(),
MAX_WARNINGS + 1,
"precondition: vector is at MAX_WARNINGS + 1 after first overflow",
);
let _ = PdfDocument::validate_margin(-100.0, MARGIN_TOP, "top", &mut warnings);
assert_eq!(
warnings.len(),
MAX_WARNINGS + 1,
"validate_margin must route through push_warning and respect the cap",
);
}
#[test]
fn test_embed_jpeg_produces_xobject() {
let jpeg = minimal_jpeg(320, 240);
let mut doc = PdfDocument::new();
let idx = doc.embed_jpeg(jpeg, 320, 240, 3);
assert_eq!(idx, 0);
doc.draw_image(idx, 56.0, 700.0, 320.0, 240.0);
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
assert!(content.contains("/XObject"));
assert!(content.contains("/Im1"));
assert!(content.contains("/DCTDecode"));
assert!(content.contains("/Subtype /Image"));
}
#[test]
fn test_xobject_uses_actual_pixel_dimensions() {
let large_w: u32 = 20_000;
let large_h: u32 = 15_000;
let jpeg = minimal_jpeg_with_components(large_w as u16, large_h as u16, 3);
let mut doc = PdfDocument::new();
let idx = doc.embed_jpeg(jpeg, large_w, large_h, 3);
doc.draw_image(idx, 56.0, 700.0, 100.0, 75.0);
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
let width_str = format!("/Width {large_w}");
let height_str = format!("/Height {large_h}");
assert!(
content.contains(&width_str),
"XObject must contain actual width {large_w}"
);
assert!(
content.contains(&height_str),
"XObject must contain actual height {large_h}"
);
}
#[test]
fn test_embed_multiple_jpegs() {
let jpeg1 = minimal_jpeg(100, 50);
let jpeg2 = minimal_jpeg(200, 150);
let mut doc = PdfDocument::new();
let idx1 = doc.embed_jpeg(jpeg1, 100, 50, 3);
let idx2 = doc.embed_jpeg(jpeg2, 200, 150, 3);
assert_eq!(idx1, 0);
assert_eq!(idx2, 1);
doc.draw_image(idx1, 56.0, 700.0, 100.0, 50.0);
doc.draw_image(idx2, 56.0, 600.0, 200.0, 150.0);
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
assert!(content.contains("/Im1"));
assert!(content.contains("/Im2"));
}
#[test]
fn test_embed_jpeg_grayscale_uses_device_gray() {
let jpeg = minimal_jpeg_with_components(100, 100, 1);
let mut doc = PdfDocument::new();
let idx = doc.embed_jpeg(jpeg, 100, 100, 1);
doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
assert!(
content.contains("/ColorSpace /DeviceGray"),
"grayscale JPEG should use /DeviceGray"
);
assert!(
!content.contains("/ColorSpace /DeviceRGB"),
"grayscale JPEG should not use /DeviceRGB"
);
}
#[test]
fn test_embed_jpeg_rgb_uses_device_rgb() {
let jpeg = minimal_jpeg_with_components(100, 100, 3);
let mut doc = PdfDocument::new();
let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
assert!(
content.contains("/ColorSpace /DeviceRGB"),
"RGB JPEG should use /DeviceRGB"
);
}
#[test]
fn test_embed_jpeg_cmyk_uses_device_cmyk() {
let jpeg = minimal_jpeg_with_components(100, 100, 4);
let mut doc = PdfDocument::new();
let idx = doc.embed_jpeg(jpeg, 100, 100, 4);
doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
assert!(
content.contains("/ColorSpace /DeviceCMYK"),
"CMYK JPEG should use /DeviceCMYK"
);
}
#[test]
fn test_parse_jpeg_dimensions_grayscale_component_count() {
let jpeg = minimal_jpeg_with_components(200, 150, 1);
let dims = parse_jpeg_dimensions(&jpeg);
assert_eq!(dims, Some((200, 150, 1)));
}
#[test]
fn test_no_images_no_xobject_dict() {
let doc = PdfDocument::new();
let pdf = doc.build_pdf();
let content = String::from_utf8_lossy(&pdf);
assert!(!content.contains("/XObject"));
}
#[test]
fn test_draw_image_emits_cm_do_operators() {
let jpeg = minimal_jpeg(50, 50);
let mut doc = PdfDocument::new();
let idx = doc.embed_jpeg(jpeg, 50, 50, 3);
doc.draw_image(idx, 100.0, 200.0, 50.0, 50.0);
let ops = &doc.pages[0];
assert!(ops.iter().any(|op| op == "q"));
assert!(ops.iter().any(|op| op.contains("cm")));
assert!(ops.iter().any(|op| op.contains("/Im1 Do")));
assert!(ops.iter().any(|op| op == "Q"));
}
#[test]
fn test_anchor_line_uses_margin_left() {
let mut doc = PdfDocument::new();
let jpeg = minimal_jpeg(100, 100);
let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
let x = doc.margin_left();
doc.draw_image(idx, x, 500.0, 100.0, 100.0);
let cm_op = doc.pages[0]
.iter()
.find(|op| op.contains("cm"))
.expect("cm operator");
let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
assert!(
(tx - MARGIN_LEFT).abs() < 0.01,
"expected tx ~{MARGIN_LEFT}, got {tx}"
);
}
#[test]
fn test_anchor_paper_centers_on_page() {
let render_w: f32 = 200.0;
let expected_x = (PAGE_W - render_w) / 2.0;
let mut doc = PdfDocument::new();
let jpeg = minimal_jpeg(200, 100);
let idx = doc.embed_jpeg(jpeg, 200, 100, 3);
doc.draw_image(idx, expected_x, 500.0, render_w, 100.0);
let cm_op = doc.pages[0]
.iter()
.find(|op| op.contains("cm"))
.expect("cm operator");
let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
assert!(
(tx - expected_x).abs() < 0.01,
"expected tx ~{expected_x}, got {tx}"
);
}
#[test]
fn test_anchor_column_centers_in_column_multicolumn() {
let mut doc = PdfDocument::new();
doc.set_columns(2);
let col_w = doc.column_width();
let col_left = doc.margin_left();
let render_w: f32 = 100.0;
let expected_x = col_left + (col_w - render_w) / 2.0;
let jpeg = minimal_jpeg(100, 100);
let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
doc.draw_image(idx, expected_x, 500.0, render_w, 100.0);
let cm_op = doc.pages[0]
.iter()
.find(|op| op.contains("cm"))
.expect("cm operator");
let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
assert!(
(tx - expected_x).abs() < 0.01,
"expected tx ~{expected_x}, got {tx}"
);
}
#[test]
fn test_column_width_single_column() {
let doc = PdfDocument::new();
let expected = PAGE_W - doc.margin_left - doc.margin_right;
assert!((doc.column_width() - expected).abs() < 0.01);
}
#[test]
fn test_column_width_multi_column() {
let mut doc = PdfDocument::new();
doc.set_columns(2);
let usable = PAGE_W - doc.margin_left - doc.margin_right;
let expected = (usable - COLUMN_GAP) / 2.0;
assert!((doc.column_width() - expected).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_explicit_width() {
let attrs = ImageAttributes {
src: "test.jpg".to_string(),
width: Some("200".to_string()),
height: None,
scale: None,
title: None,
anchor: None,
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 150.0).abs() < 0.01); }
#[test]
fn test_compute_image_dimensions_explicit_height() {
let attrs = ImageAttributes {
src: "test.jpg".to_string(),
width: None,
height: Some("100".to_string()),
scale: None,
title: None,
anchor: None,
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 200.0, 2.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 100.0).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_scale() {
let attrs = ImageAttributes {
src: "test.jpg".to_string(),
width: None,
height: None,
scale: Some("0.5".to_string()),
title: None,
anchor: None,
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 150.0).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_native() {
let attrs = ImageAttributes::default();
let (w, h) = compute_image_dimensions(&attrs, 800.0, 600.0, 800.0 / 600.0);
assert!((w - 800.0).abs() < 0.01);
assert!((h - 600.0).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_percentage_width() {
let attrs = ImageAttributes {
width: Some("50%".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 150.0).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_percentage_height() {
let attrs = ImageAttributes {
height: Some("50%".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 150.0).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_percentage_both() {
let attrs = ImageAttributes {
width: Some("75%".to_string()),
height: Some("50%".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 300.0).abs() < 0.01);
assert!((h - 150.0).abs() < 0.01);
}
#[test]
fn test_parse_dimension_absolute() {
assert!((parse_dimension("200", 400.0).unwrap() - 200.0).abs() < 0.01);
}
#[test]
fn test_parse_dimension_percentage() {
assert!((parse_dimension("50%", 400.0).unwrap() - 200.0).abs() < 0.01);
assert!((parse_dimension(" 25% ", 800.0).unwrap() - 200.0).abs() < 0.01);
}
#[test]
fn test_parse_dimension_invalid() {
assert!(parse_dimension("", 400.0).is_none());
assert!(parse_dimension("abc", 400.0).is_none());
assert!(parse_dimension("-10", 400.0).is_none());
assert!(parse_dimension("0%", 400.0).is_none());
assert!(parse_dimension("-5%", 400.0).is_none());
}
#[test]
fn test_parse_dimension_rejects_non_finite() {
assert!(parse_dimension("inf", 400.0).is_none());
assert!(parse_dimension("infinity", 400.0).is_none());
assert!(parse_dimension("Infinity", 400.0).is_none());
assert!(parse_dimension("NaN", 400.0).is_none());
assert!(parse_dimension("inf%", 400.0).is_none());
}
#[test]
fn test_compute_image_dimensions_infinite_scale_rejected() {
let attrs = ImageAttributes {
src: String::new(),
width: None,
height: None,
scale: Some("inf".to_string()),
title: None,
anchor: None,
};
let (w, h) = compute_image_dimensions(&attrs, 100.0, 200.0, 0.5);
assert!((w - 100.0).abs() < 0.01);
assert!((h - 200.0).abs() < 0.01);
}
#[test]
fn test_compute_image_dimensions_nan_scale_rejected() {
let attrs = ImageAttributes {
src: String::new(),
width: None,
height: None,
scale: Some("NaN".to_string()),
title: None,
anchor: None,
};
let (w, h) = compute_image_dimensions(&attrs, 100.0, 200.0, 0.5);
assert!((w - 100.0).abs() < 0.01);
assert!((h - 200.0).abs() < 0.01);
}
#[test]
fn test_oversized_image_file_is_skipped() {
let thread_name = std::thread::current()
.name()
.unwrap_or("main")
.replace("::", "_");
let subdir = format!("_test_oversized_img_{}_{}", std::process::id(), thread_name);
let _ = std::fs::remove_dir_all(&subdir);
std::fs::create_dir_all(&subdir).expect("create test dir");
let rel_path = format!("{subdir}/huge.jpg");
let f = std::fs::File::create(&rel_path).unwrap();
f.set_len(MAX_IMAGE_FILE_SIZE + 1).unwrap();
drop(f);
let input = format!("{{image: src={rel_path}}}");
let song = chordsketch_chordpro::parse(&input).unwrap();
let pdf = render_song(&song);
let content = String::from_utf8_lossy(&pdf);
assert!(
!content.contains("/Subtype /Image"),
"oversized image must be rejected"
);
let _ = std::fs::remove_dir_all(subdir);
}
#[test]
fn test_negative_scale_falls_back_to_native() {
let attrs = ImageAttributes {
scale: Some("-1".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 400.0).abs() < 0.01);
assert!((h - 300.0).abs() < 0.01);
}
#[test]
fn test_negative_width_falls_back_to_native() {
let attrs = ImageAttributes {
width: Some("-200".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 400.0).abs() < 0.01);
assert!((h - 300.0).abs() < 0.01);
}
#[test]
fn test_negative_height_falls_back_to_native() {
let attrs = ImageAttributes {
height: Some("-150".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 400.0).abs() < 0.01);
assert!((h - 300.0).abs() < 0.01);
}
#[test]
fn test_zero_scale_falls_back_to_native() {
let attrs = ImageAttributes {
scale: Some("0".to_string()),
..Default::default()
};
let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
assert!((w - 400.0).abs() < 0.01);
assert!((h - 300.0).abs() < 0.01);
}
#[test]
fn test_clamp_to_printable_no_clamping_needed() {
let (w, h) = clamp_to_printable_area(200.0, 150.0, 500.0, 700.0, 200.0 / 150.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 150.0).abs() < 0.01);
}
#[test]
fn test_clamp_to_printable_width_exceeds() {
let (w, h) = clamp_to_printable_area(800.0, 200.0, 500.0, 700.0, 4.0);
assert!((w - 500.0).abs() < 0.01);
assert!((h - 125.0).abs() < 0.01); }
#[test]
fn test_clamp_to_printable_height_exceeds() {
let (w, h) = clamp_to_printable_area(200.0, 800.0, 500.0, 700.0, 0.25);
assert!((w - 175.0).abs() < 0.01); assert!((h - 700.0).abs() < 0.01); }
#[test]
fn test_clamp_to_printable_height_exceeds_extreme_aspect_reclamps_width() {
let (w, h) = clamp_to_printable_area(2800.0, 700.0, 500.0, 700.0, 4.0);
assert!((w - 500.0).abs() < 0.01);
assert!((h - 125.0).abs() < 0.01); }
#[test]
fn test_clamp_to_printable_height_clamp_triggers_width_reclamp() {
let (w, h) = clamp_to_printable_area(400.0, 800.0, 500.0, 700.0, 4.0);
assert!(w <= 500.0, "width {} must not exceed max_w 500", w);
assert!((w - 500.0).abs() < 0.01);
assert!((h - 125.0).abs() < 0.01); }
#[test]
fn test_clamp_to_printable_width_exceeds_then_height_reclamps() {
let (w, h) = clamp_to_printable_area(2000.0, 2000.0, 500.0, 50.0, 1.0);
assert!((w - 50.0).abs() < 0.01, "width {} should be 50.0", w);
assert!((h - 50.0).abs() < 0.01, "height {} should be 50.0", h);
}
#[test]
fn test_safe_image_path_relative() {
assert!(is_safe_image_path("photo.jpg"));
assert!(is_safe_image_path("images/photo.jpg"));
assert!(is_safe_image_path("sub/dir/photo.jpg"));
}
#[test]
fn test_safe_image_path_rejects_empty() {
assert!(!is_safe_image_path(""));
}
#[test]
fn test_safe_image_path_rejects_null_bytes() {
assert!(!is_safe_image_path("photo\0.jpg"));
assert!(!is_safe_image_path("images/photo.jpg\0../../etc/shadow"));
}
#[test]
fn test_safe_image_path_rejects_absolute() {
assert!(!is_safe_image_path("/etc/shadow.jpeg"));
assert!(!is_safe_image_path("/home/user/photo.jpg"));
}
#[test]
fn test_safe_image_path_rejects_traversal() {
assert!(!is_safe_image_path("../photo.jpg"));
assert!(!is_safe_image_path("images/../../etc/shadow.jpeg"));
assert!(!is_safe_image_path("sub/../../../photo.jpg"));
}
#[test]
fn test_safe_image_path_windows_style_strings() {
assert!(is_safe_image_path(r"images\photo.jpg"));
assert!(!is_safe_image_path("/images/photo.jpg"));
}
#[test]
fn test_safe_image_path_windows_absolute_rejected() {
assert!(!is_safe_image_path(r"C:\photo.jpg"));
assert!(!is_safe_image_path(r"D:\Users\photo.jpg"));
assert!(!is_safe_image_path(r"\\server\share\photo.jpg"));
assert!(!is_safe_image_path("C:/photo.jpg"));
}
#[test]
fn test_safe_image_path_backslash_traversal_rejected() {
assert!(!is_safe_image_path(r"..\photo.jpg"));
assert!(!is_safe_image_path(r"images\..\..\photo.jpg"));
}
#[cfg(unix)]
#[test]
fn test_symlink_image_is_rejected() {
use std::os::unix::fs::symlink;
let subdir = format!(
"_test_symlink_img_{}_{}",
std::process::id(),
std::thread::current().name().unwrap_or("main")
);
let _ = std::fs::remove_dir_all(&subdir);
std::fs::create_dir_all(&subdir).expect("create test dir");
let target = format!("{subdir}/real.jpg");
std::fs::write(&target, b"\xFF\xD8\xFF").expect("write target");
let link = format!("{subdir}/link.jpg");
symlink(&target, &link).expect("create symlink");
let input = format!("{{title: T}}\n{{image: src={link}}}");
let song = chordsketch_chordpro::parse(&input).expect("parse");
let pdf = render_song(&song);
let content = String::from_utf8_lossy(&pdf);
assert!(
!content.contains("/Subtype /Image"),
"symlink images must be rejected"
);
let _ = std::fs::remove_dir_all(&subdir);
}
#[test]
fn test_custom_margins_from_config() {
let config = Config::defaults()
.with_define("pdf.margins.top=100")
.unwrap();
let doc = PdfDocument::from_config(&config);
assert!((doc.margin_top - 100.0).abs() < 0.01);
assert!((doc.margin_bottom - MARGIN_BOTTOM).abs() < 0.01);
assert!((doc.margin_left - MARGIN_LEFT).abs() < 0.01);
assert!((doc.margin_right - MARGIN_RIGHT).abs() < 0.01);
}
#[test]
fn test_negative_margin_falls_back_to_default() {
let config = Config::defaults()
.with_define("pdf.margins.top=-100")
.unwrap();
let doc = PdfDocument::from_config(&config);
assert!((doc.margin_top - MARGIN_TOP).abs() < 0.01);
}
#[test]
fn test_zero_margin_is_valid() {
let config = Config::defaults().with_define("pdf.margins.top=0").unwrap();
let doc = PdfDocument::from_config(&config);
assert!(doc.margin_top.abs() < 0.01);
}
#[test]
fn test_excessive_margin_falls_back_to_default() {
let config = Config::defaults()
.with_define("pdf.margins.left=1000")
.unwrap();
let doc = PdfDocument::from_config(&config);
assert!((doc.margin_left - MARGIN_LEFT).abs() < 0.01);
}
#[test]
fn test_custom_margins_affect_output() {
let song = chordsketch_chordpro::parse("{title: Test}\nHello").unwrap();
let default_pdf = render_song(&song);
let config = Config::defaults()
.with_define("pdf.margins.top=200")
.unwrap();
let custom_pdf = render_song_with_transpose(&song, 0, &config);
assert_ne!(default_pdf, custom_pdf);
}
#[test]
fn test_fmt_f32_nan_produces_zero() {
assert_eq!(fmt_f32(f32::NAN), "0");
}
#[test]
fn test_fmt_f32_infinity_produces_zero() {
assert_eq!(fmt_f32(f32::INFINITY), "0");
assert_eq!(fmt_f32(f32::NEG_INFINITY), "0");
}
#[test]
fn test_fmt_f32_normal_values() {
assert_eq!(fmt_f32(1.0), "1");
assert_eq!(fmt_f32(3.25), "3.25");
assert_eq!(fmt_f32(0.0), "0");
assert_eq!(fmt_f32(-5.5), "-5.5");
}
}
#[cfg(test)]
mod png_tests {
use super::*;
fn build_png(width: u32, height: u32, bit_depth: u8, color_type: u8, pixels: &[u8]) -> Vec<u8> {
let channels: usize = match color_type {
0 => 1,
2 => 3,
4 => 2,
6 => 4,
_ => panic!("unsupported color type"),
};
let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 };
let row_bytes = width as usize * channels * bytes_per_sample;
let mut raw = Vec::new();
for row in 0..height as usize {
raw.push(0); let start = row * row_bytes;
raw.extend_from_slice(&pixels[start..start + row_bytes]);
}
let idat_payload = zlib_compress(&raw).expect("compression should succeed");
let mut png = Vec::new();
png.extend_from_slice(&PNG_SIGNATURE);
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(bit_depth);
ihdr.push(color_type);
ihdr.push(0); ihdr.push(0); ihdr.push(0); write_png_chunk(&mut png, b"IHDR", &ihdr);
write_png_chunk(&mut png, b"IDAT", &idat_payload);
write_png_chunk(&mut png, b"IEND", &[]);
png
}
fn build_indexed_png(width: u32, height: u32, palette: &[u8], indices: &[u8]) -> Vec<u8> {
let row_bytes = width as usize;
let mut raw = Vec::new();
for row in 0..height as usize {
raw.push(0); let start = row * row_bytes;
raw.extend_from_slice(&indices[start..start + row_bytes]);
}
let idat_payload = zlib_compress(&raw).expect("compression should succeed");
let mut png = Vec::new();
png.extend_from_slice(&PNG_SIGNATURE);
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(8); ihdr.push(3); ihdr.push(0);
ihdr.push(0);
ihdr.push(0);
write_png_chunk(&mut png, b"IHDR", &ihdr);
write_png_chunk(&mut png, b"PLTE", palette);
write_png_chunk(&mut png, b"IDAT", &idat_payload);
write_png_chunk(&mut png, b"IEND", &[]);
png
}
fn write_png_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(chunk_type);
out.extend_from_slice(data);
let mut crc_data = Vec::new();
crc_data.extend_from_slice(chunk_type);
crc_data.extend_from_slice(data);
let crc = crc32(&crc_data);
out.extend_from_slice(&crc.to_be_bytes());
}
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
!crc
}
#[test]
fn test_parse_png_rgb() {
let pixels = vec![
255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, ];
let png = build_png(2, 2, 8, 2, &pixels);
let info = parse_png(&png).expect("should parse");
assert_eq!(info.width, 2);
assert_eq!(info.height, 2);
assert_eq!(info.bit_depth, 8);
assert_eq!(info.colors, 3);
assert!(info.palette.is_none());
assert!(info.smask.is_none());
}
#[test]
fn test_parse_png_grayscale() {
let pixels = vec![0, 128, 255];
let png = build_png(3, 1, 8, 0, &pixels);
let info = parse_png(&png).expect("should parse");
assert_eq!(info.width, 3);
assert_eq!(info.height, 1);
assert_eq!(info.colors, 1);
assert!(info.smask.is_none());
}
#[test]
fn test_parse_png_rgba_separates_alpha() {
let pixels = vec![
255, 0, 0, 128, 0, 255, 0, 255, ];
let png = build_png(2, 1, 8, 6, &pixels);
let info = parse_png(&png).expect("should parse RGBA");
assert_eq!(info.width, 2);
assert_eq!(info.height, 1);
assert_eq!(info.colors, 3); assert!(info.smask.is_some());
let mut decoder = ZlibDecoder::new(info.idat_data.as_slice());
let mut color = Vec::new();
decoder.read_to_end(&mut color).unwrap();
assert_eq!(color, vec![0, 255, 0, 0, 0, 255, 0]);
let mut decoder = ZlibDecoder::new(info.smask.as_ref().unwrap().as_slice());
let mut alpha = Vec::new();
decoder.read_to_end(&mut alpha).unwrap();
assert_eq!(alpha, vec![0, 128, 255]);
}
#[test]
fn test_parse_png_gray_alpha() {
let pixels = vec![
100, 200, 50, 100, ];
let png = build_png(2, 1, 8, 4, &pixels);
let info = parse_png(&png).expect("should parse gray+alpha");
assert_eq!(info.colors, 1); assert!(info.smask.is_some());
let mut decoder = ZlibDecoder::new(info.idat_data.as_slice());
let mut color = Vec::new();
decoder.read_to_end(&mut color).unwrap();
assert_eq!(color, vec![0, 100, 50]);
let mut decoder = ZlibDecoder::new(info.smask.as_ref().unwrap().as_slice());
let mut alpha = Vec::new();
decoder.read_to_end(&mut alpha).unwrap();
assert_eq!(alpha, vec![0, 200, 100]);
}
#[test]
fn test_parse_png_indexed() {
let palette = vec![255, 0, 0, 0, 0, 255]; let indices = vec![0, 1]; let png = build_indexed_png(2, 1, &palette, &indices);
let info = parse_png(&png).expect("should parse indexed");
assert_eq!(info.colors, 3); assert!(info.palette.is_some());
assert_eq!(info.palette.as_ref().unwrap(), &palette);
}
#[test]
fn test_parse_png_invalid_signature() {
assert!(parse_png(b"not a png").is_none());
assert!(parse_png(&[]).is_none());
}
#[test]
fn test_parse_png_no_idat() {
let mut png = Vec::new();
png.extend_from_slice(&PNG_SIGNATURE);
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&2u32.to_be_bytes());
ihdr.extend_from_slice(&2u32.to_be_bytes());
ihdr.push(8);
ihdr.push(2); ihdr.extend_from_slice(&[0, 0, 0]);
write_png_chunk(&mut png, b"IHDR", &ihdr);
write_png_chunk(&mut png, b"IEND", &[]);
assert!(parse_png(&png).is_none());
}
#[test]
fn test_embedded_image_num_objects_jpeg() {
let img = EmbeddedImage {
width: 10,
height: 10,
format: ImageFormat::Jpeg {
data: vec![],
components: 3,
},
};
assert_eq!(img.num_pdf_objects(), 1);
}
#[test]
fn test_embedded_image_num_objects_png_no_alpha() {
let img = EmbeddedImage {
width: 10,
height: 10,
format: ImageFormat::Png {
idat_data: vec![],
bit_depth: 8,
colors: 3,
palette: None,
smask: None,
},
};
assert_eq!(img.num_pdf_objects(), 1);
}
#[test]
fn test_embedded_image_num_objects_png_with_alpha() {
let img = EmbeddedImage {
width: 10,
height: 10,
format: ImageFormat::Png {
idat_data: vec![],
bit_depth: 8,
colors: 3,
palette: None,
smask: Some(vec![1, 2, 3]),
},
};
assert_eq!(img.num_pdf_objects(), 2);
}
#[test]
fn test_paeth_predictor() {
assert_eq!(paeth_predictor(0, 0, 0), 0);
assert_eq!(paeth_predictor(10, 20, 15), 15);
assert_eq!(paeth_predictor(10, 10, 10), 10);
}
#[test]
fn test_render_songs_with_warnings_empty_slice() {
let songs: Vec<chordsketch_chordpro::ast::Song> = Vec::new();
let result = render_songs_with_warnings(&songs, 0, &Config::defaults());
assert!(result.output.is_empty());
}
}
#[cfg(test)]
mod info_title_tests {
use super::*;
#[test]
fn pdf_title_hex_string_encodes_ascii_with_bom() {
assert_eq!(pdf_title_hex_string("AB"), "<FEFF00410042>");
}
#[test]
fn pdf_title_hex_string_encodes_bmp_codepoints() {
assert_eq!(pdf_title_hex_string("日本"), "<FEFF65E5672C>");
}
#[test]
fn pdf_title_hex_string_encodes_supplementary_plane_with_surrogate_pair() {
assert_eq!(pdf_title_hex_string("🎵"), "<FEFFD83CDFB5>");
}
#[test]
fn render_song_emits_info_title_for_titled_song() {
let input = "{title: Hello}\n\nHello world\n";
let song = chordsketch_chordpro::parse(input).expect("parse");
let pdf = render_song(&song);
let s = String::from_utf8_lossy(&pdf);
assert!(
s.contains("/Info "),
"trailer should reference /Info when {{title}} is set; got: {s}"
);
assert!(
s.contains("<FEFF00480065006C006C006F>"),
"PDF should contain UTF-16BE-encoded title bytes; got: {s}"
);
}
#[test]
fn set_doc_title_caps_oversized_input() {
let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
let big = "A".repeat(PdfDocument::MAX_TITLE_CHARS * 100);
doc.set_doc_title(Some(&big));
let stored = doc.doc_title.expect("title should be stored");
assert_eq!(
stored.chars().count(),
PdfDocument::MAX_TITLE_CHARS,
"oversized title must be truncated to MAX_TITLE_CHARS"
);
let hex = pdf_title_hex_string(&stored);
let expected_max = 5 + 4 * PdfDocument::MAX_TITLE_CHARS + 1;
assert!(
hex.len() <= expected_max,
"hex literal must be bounded; got {} bytes, max {expected_max}",
hex.len()
);
}
#[test]
fn set_doc_title_passes_through_at_cap_and_truncates_one_over() {
let mut at_cap = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
at_cap.set_doc_title(Some(&"A".repeat(PdfDocument::MAX_TITLE_CHARS)));
assert_eq!(
at_cap.doc_title.as_deref().map(|s| s.chars().count()),
Some(PdfDocument::MAX_TITLE_CHARS),
"input exactly at cap must pass through unchanged"
);
let mut over_cap = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
over_cap.set_doc_title(Some(&"A".repeat(PdfDocument::MAX_TITLE_CHARS + 1)));
assert_eq!(
over_cap.doc_title.as_deref().map(|s| s.chars().count()),
Some(PdfDocument::MAX_TITLE_CHARS),
"input one char over cap must truncate"
);
}
#[test]
fn set_doc_title_truncates_at_char_boundary_for_multibyte_input() {
let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
let big: String = "日".repeat(PdfDocument::MAX_TITLE_CHARS + 50);
doc.set_doc_title(Some(&big));
let stored = doc.doc_title.expect("title should be stored");
assert_eq!(stored.chars().count(), PdfDocument::MAX_TITLE_CHARS);
assert!(
stored.is_char_boundary(stored.len()),
"truncation produced an invalid UTF-8 boundary"
);
}
#[test]
fn set_doc_title_trims_leading_and_trailing_whitespace() {
let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
doc.set_doc_title(Some(" Hello World "));
assert_eq!(doc.doc_title.as_deref(), Some("Hello World"));
}
#[test]
fn render_song_omits_info_when_title_missing() {
let input = "Just a lyric line\n";
let song = chordsketch_chordpro::parse(input).expect("parse");
let pdf = render_song(&song);
let s = String::from_utf8_lossy(&pdf);
assert!(
!s.contains("/Info "),
"no /Info should be emitted when {{title}} is absent"
);
assert!(
!s.contains("/Title "),
"no /Title should be emitted when {{title}} is absent"
);
}
#[test]
fn render_song_omits_info_for_whitespace_only_title() {
let input = "{title: }\n\nbody\n";
let song = chordsketch_chordpro::parse(input).expect("parse");
let pdf = render_song(&song);
let s = String::from_utf8_lossy(&pdf);
assert!(
!s.contains("/Info "),
"whitespace-only title must normalise to no /Info"
);
}
#[test]
fn render_songs_omits_info_for_multi_song_output() {
let input = "{title: One}\n\nfirst body\n\n{new_song}\n{title: Two}\n\nsecond body\n";
let songs = chordsketch_chordpro::parse_multi(input).expect("parse_multi");
assert!(songs.len() >= 2, "expected multi-song parse");
let pdf = render_songs(&songs);
let s = String::from_utf8_lossy(&pdf);
assert!(
!s.contains("/Info "),
"multi-song render must not emit /Info; got trailer fragment: {s}"
);
}
}