use chordsketch_core::ast::{CommentStyle, DirectiveKind, ImageAttributes, Line, LyricsLine, Song};
use chordsketch_core::canonical_chord_name;
use chordsketch_core::config::Config;
use chordsketch_core::inline_markup::TextSpan;
use chordsketch_core::render_result::RenderResult;
use chordsketch_core::resolve_diagrams_instrument;
use chordsketch_core::transpose::transpose_chord;
use flate2::Compression;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use std::io::{Read as IoRead, Write as IoWrite};
#[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) {
WIDTHS[(code - 32) as usize]
} else {
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;
#[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
}
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);
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
}
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();
toc_entries.push((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 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_core::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_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
(n as usize).max(1)
});
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;
for line in &song.lines {
match line {
Line::Lyrics(lyrics) => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_lyrics(lyrics, transpose_offset, &fmt_state, doc);
}
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 =
d.value.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
let (combined, saturated) =
chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
if saturated {
warnings.push(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;
}
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 {
render_chorus_recall(
&d.value,
&chorus_body,
transpose_offset,
&fmt_state,
show_diagrams,
diagram_frets,
doc,
);
chorus_recall_count += 1;
} else if chorus_recall_count == MAX_CHORUS_RECALLS {
warnings.push(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);
doc.set_columns(n);
}
DirectiveKind::ColumnBreak => {
doc.column_break();
}
_ => {
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_core::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_core::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_core::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_core::ParseError> {
let song = chordsketch_core::parse(input)?;
Ok(render_song(&song))
}
fn render_lyrics(
lyrics: &LyricsLine,
transpose_offset: i8,
fmt_state: &PdfFormattingState,
doc: &mut PdfDocument,
) {
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();
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(), Font::Helvetica, 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(c, transpose_offset)
.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_core::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_core::capitalize(section_name))
}
_ => None,
};
if let Some(label) = label {
let text = match &directive.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_core::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_core::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_core::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_core::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_core::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_core::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 !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_core::chord_diagram::DiagramData,
doc: &mut PdfDocument,
) {
if data.strings < chordsketch_core::chord_diagram::MIN_STRINGS
|| data.strings > chordsketch_core::chord_diagram::MAX_STRINGS
|| data.frets_shown < chordsketch_core::chord_diagram::MIN_FRETS_SHOWN
|| data.frets_shown > chordsketch_core::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_core::chord_diagram::KeyboardVoicing,
doc: &mut PdfDocument,
) {
if voicing.keys.is_empty() {
return;
}
let (keys, root) =
chordsketch_core::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);
}
fn render_chorus_recall(
value: &Option<String>,
chorus_body: &[Line],
transpose_offset: i8,
fmt_state: &PdfFormattingState,
show_diagrams: bool,
diagram_frets: usize,
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 chorus_body {
match line {
Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, fmt_state, doc),
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, show_diagrams, 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,
};
if style == CommentStyle::Boxed {
let padding = 3.0_f32;
let box_h = COMMENT_SIZE + 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 - COMMENT_SIZE - 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,
}
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,
}
}
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) {
warnings.push(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 ops = self.current_page_mut();
ops.push("BT".to_string());
ops.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
ops.push(format!("{} {} Td", fmt_f32(x), fmt_f32(y)));
ops.push(format!("({}) Tj", pdf_escape(text)));
ops.push("ET".to_string());
}
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 ops = self.current_page_mut();
if clip {
let clip_w = (col_right - x).max(0.0);
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)
));
}
ops.push("BT".to_string());
ops.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
ops.push(format!("{} {} Td", fmt_f32(x), fmt_f32(y)));
ops.push(format!("({}) Tj", pdf_escape(text)));
ops.push("ET".to_string());
if clip {
ops.push("Q".to_string());
}
}
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 ops = self.current_page_mut();
if clip {
let clip_w = (col_right - x).max(0.0);
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)
));
}
ops.push("BT".to_string());
ops.push("1 1 1 rg".to_string()); ops.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
ops.push(format!("{} {} Td", fmt_f32(x), fmt_f32(y)));
ops.push(format!("({}) Tj", pdf_escape(text)));
ops.push("ET".to_string());
ops.push("0 0 0 rg".to_string()); if clip {
ops.push("Q".to_string());
}
}
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();
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 image_obj_base = 3 + FONTS.len(); 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() + 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,
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());
}
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 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());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {num_objects} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n"
)
.as_bytes(),
);
pdf
}
}
#[derive(Clone, Copy)]
enum Font {
Helvetica,
HelveticaBold,
HelveticaOblique,
HelveticaBoldOblique,
}
impl Font {
fn pdf_name(self) -> &'static str {
match self {
Self::Helvetica => "/F1",
Self::HelveticaBold => "/F2",
Self::HelveticaOblique => "/F3",
Self::HelveticaBoldOblique => "/F4",
}
}
fn base_name(self) -> &'static str {
match self {
Self::Helvetica => "Helvetica",
Self::HelveticaBold => "Helvetica-Bold",
Self::HelveticaOblique => "Helvetica-Oblique",
Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
}
}
}
const FONTS: [Font; 4] = [
Font::Helvetica,
Font::HelveticaBold,
Font::HelveticaOblique,
Font::HelveticaBoldOblique,
];
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_core::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_core::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_core::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_core::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_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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::parse(input).unwrap();
let bytes = render_song_with_transpose(&song, 3, &Config::defaults());
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("(F)"));
}
}
#[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_core::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_core::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_core::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_core::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_core::parse(input).unwrap();
let bytes = render_song(&song);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("MusicXML"));
}
}
#[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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::config::Config;
let songs =
chordsketch_core::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"));
}
}
#[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_core::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_core::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_core::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_core::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_core::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_core::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_core::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_core::parse(input).unwrap();
let config_4 = chordsketch_core::config::Config::defaults()
.with_define("diagrams.frets=4")
.unwrap();
let config_7 = chordsketch_core::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_core::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_core::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_core::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: chordsketch_core::chord_diagram::MAX_STRINGS + 1,
frets_shown: 5,
base_fret: 1,
frets: vec![0; chordsketch_core::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_core::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_core::chord_diagram::DiagramData {
name: "X".to_string(),
display_name: None,
strings: 6,
frets_shown: chordsketch_core::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_core::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_core::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_core::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_core::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_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_core::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_core::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_core::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_core::ast::Song> = Vec::new();
let result = render_songs_with_warnings(&songs, 0, &Config::defaults());
assert!(result.output.is_empty());
}
}