1use chordsketch_chordpro::ast::{
14 CommentStyle, DirectiveKind, ImageAttributes, Line, LyricsLine, Song,
15};
16use chordsketch_chordpro::canonical_chord_name;
17use chordsketch_chordpro::config::Config;
18use chordsketch_chordpro::inline_markup::TextSpan;
19use chordsketch_chordpro::notation::NotationKind;
20use chordsketch_chordpro::render_result::{
21 RenderResult, push_warning, validate_capo, validate_multiple_capo, validate_strict_key,
22};
23use chordsketch_chordpro::resolve_diagrams_instrument;
24use chordsketch_chordpro::transpose::{transpose_chord_with_style, transposed_key_prefers_flat};
25use chordsketch_chordpro::typography::{tempo_marking_for, unicode_accidentals};
26
27use flate2::Compression;
28use flate2::read::ZlibDecoder;
29use flate2::write::ZlibEncoder;
30use std::collections::BTreeMap;
31use std::io::{Read as IoRead, Write as IoWrite};
32
33static UNICODE_FONT_BYTES: &[u8] = include_bytes!("../assets/NotoSansCJK-subset.otf");
45
46fn unicode_face() -> &'static ttf_parser::Face<'static> {
52 use std::sync::OnceLock;
53 static FACE: OnceLock<ttf_parser::Face<'static>> = OnceLock::new();
54 FACE.get_or_init(|| {
55 ttf_parser::Face::parse(UNICODE_FONT_BYTES, 0)
56 .expect("bundled NotoSansCJK-subset.otf must be a valid font face")
57 })
58}
59
60fn extract_cff_table(otf_bytes: &[u8]) -> Option<&[u8]> {
65 if otf_bytes.len() < 12 {
66 return None;
67 }
68 let num_tables = u16::from_be_bytes([otf_bytes[4], otf_bytes[5]]) as usize;
69 for i in 0..num_tables {
70 let rec = 12 + i * 16;
71 if rec + 16 > otf_bytes.len() {
72 return None;
73 }
74 if &otf_bytes[rec..rec + 4] == b"CFF " {
75 let offset = u32::from_be_bytes(otf_bytes[rec + 8..rec + 12].try_into().ok()?) as usize;
76 let length =
77 u32::from_be_bytes(otf_bytes[rec + 12..rec + 16].try_into().ok()?) as usize;
78 let end = offset.checked_add(length)?;
79 if end <= otf_bytes.len() {
80 return Some(&otf_bytes[offset..end]);
81 }
82 }
83 }
84 None
85}
86
87fn unicode_cff_bytes() -> &'static [u8] {
93 use std::sync::OnceLock;
94 static CFF: OnceLock<&'static [u8]> = OnceLock::new();
95 CFF.get_or_init(|| {
96 extract_cff_table(UNICODE_FONT_BYTES)
97 .expect("bundled NotoSansCJK-subset.otf must contain a CFF table")
98 })
99}
100
101#[must_use]
108fn needs_cid_font(c: char) -> bool {
109 let code = c as u32;
110 if code <= 0x7F {
111 return false; }
113 if (0xA0..=0xFF).contains(&code) {
114 return false; }
116 winansi_byte(c).is_none() }
118
119fn text_segments(text: &str) -> Vec<(bool, String)> {
125 let mut result: Vec<(bool, String)> = Vec::new();
126 let mut current_cid = false;
127 let mut current = String::new();
128 for c in text.chars() {
129 let cid = needs_cid_font(c);
130 if !current.is_empty() && cid != current_cid {
131 result.push((current_cid, std::mem::take(&mut current)));
132 }
133 current_cid = cid;
134 current.push(c);
135 }
136 if !current.is_empty() {
137 result.push((current_cid, current));
138 }
139 result
140}
141
142fn encode_cid_text(text: &str) -> (String, Vec<(u16, char)>) {
151 let face = unicode_face();
152 let mut hex = String::with_capacity(text.chars().count() * 4);
153 let mut mappings: Vec<(u16, char)> = Vec::with_capacity(text.chars().count());
154 for c in text.chars() {
155 let gid = face.glyph_index(c).map(|g| g.0).unwrap_or(0);
156 hex.push_str(&format!("{:04X}", gid));
157 mappings.push((gid, c));
158 }
159 (hex, mappings)
160}
161
162fn cid_text_width(text: &str, font_size: f32) -> f32 {
164 let face = unicode_face();
165 let units = face.units_per_em() as f32;
166 text.chars()
167 .map(|c| {
168 let gid = face.glyph_index(c).unwrap_or(ttf_parser::GlyphId(0));
169 face.glyph_hor_advance(gid).unwrap_or(1000) as f32 / units * font_size
170 })
171 .sum()
172}
173
174fn build_to_unicode_cmap(cid_glyphs: &BTreeMap<u16, char>) -> String {
180 let mut cmap = String::new();
181 cmap.push_str("/CIDInit /ProcSet findresource begin\n");
182 cmap.push_str("12 dict begin\n");
183 cmap.push_str("begincmap\n");
184 cmap.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
185 cmap.push_str("/CMapName /Adobe-Identity-UCS def\n");
186 cmap.push_str("/CMapType 2 def\n");
187
188 let entries: Vec<_> = cid_glyphs.iter().filter(|&(&gid, _)| gid != 0).collect();
192 for chunk in entries.chunks(100) {
193 cmap.push_str(&format!("{} beginbfchar\n", chunk.len()));
194 for &(gid, ch) in chunk {
195 let cp = *ch as u32;
196 if cp <= 0xFFFF {
197 cmap.push_str(&format!("<{:04X}> <{:04X}>\n", gid, cp));
198 } else {
199 let offset = cp - 0x10000;
201 let hi = 0xD800u32 + (offset >> 10);
202 let lo = 0xDC00u32 + (offset & 0x3FF);
203 cmap.push_str(&format!("<{:04X}> <{:04X}{:04X}>\n", gid, hi, lo));
204 }
205 }
206 cmap.push_str("endbfchar\n");
207 }
208
209 cmap.push_str("endcmap\n");
210 cmap.push_str("CMapName currentdict /CMap defineresource pop\n");
211 cmap.push_str("end\n"); cmap.push_str("end\n"); cmap
214}
215
216fn pdf_title_hex_string(s: &str) -> String {
227 use std::fmt::Write as _;
228 let mut out = String::with_capacity(5 + 4 * s.len() + 1);
229 out.push_str("<FEFF");
230 for ch in s.chars() {
231 let cp = ch as u32;
232 if cp <= 0xFFFF {
233 let _ = write!(out, "{cp:04X}");
235 } else {
236 let offset = cp - 0x10000;
238 let hi = 0xD800u32 + (offset >> 10);
239 let lo = 0xDC00u32 + (offset & 0x3FF);
240 let _ = write!(out, "{hi:04X}{lo:04X}");
241 }
242 }
243 out.push('>');
244 out
245}
246
247#[derive(Default, Clone)]
256struct PdfElementStyle {
257 size: Option<f32>,
258}
259
260#[derive(Default, Clone)]
262struct PdfFormattingState {
263 text: PdfElementStyle,
264 chord: PdfElementStyle,
265}
266
267impl PdfFormattingState {
268 fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
270 let size_val = value
271 .as_deref()
272 .and_then(|v| v.parse::<f32>().ok())
273 .map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE));
274 match kind {
275 DirectiveKind::TextSize => self.text.size = size_val,
276 DirectiveKind::ChordSize => self.chord.size = size_val,
277 _ => {}
278 }
279 }
280
281 fn lyrics_size(&self) -> f32 {
283 self.text.size.unwrap_or(LYRICS_SIZE)
284 }
285
286 fn chord_size(&self) -> f32 {
288 self.chord.size.unwrap_or(CHORD_SIZE)
289 }
290}
291
292const PAGE_W: f32 = 595.0;
298const PAGE_H: f32 = 842.0;
300const MARGIN_LEFT: f32 = 56.0;
302const MARGIN_TOP: f32 = 56.0;
304const MARGIN_BOTTOM: f32 = 56.0;
306const TITLE_SIZE: f32 = 18.0;
308const SUBTITLE_SIZE: f32 = 13.0;
310const CHORD_SIZE: f32 = 9.0;
312const LYRICS_SIZE: f32 = 11.0;
314const SECTION_SIZE: f32 = 10.0;
316const COMMENT_SIZE: f32 = 9.0;
318const LINE_GAP: f32 = 4.0;
320#[must_use]
326fn char_width(c: char) -> f32 {
327 #[rustfmt::skip]
329 const WIDTHS: [f32; 95] = [
330 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, ];
426 let code = c as u32;
427 if (32..=126).contains(&code) {
428 return WIDTHS[(code - 32) as usize];
429 }
430 if needs_cid_font(c) {
433 let face = unicode_face();
434 let gid = face.glyph_index(c).unwrap_or(ttf_parser::GlyphId(0));
435 return face.glyph_hor_advance(gid).unwrap_or(1000) as f32 / face.units_per_em() as f32;
436 }
437 0.52 }
439
440#[must_use]
442fn text_width(s: &str, font_size: f32) -> f32 {
443 s.chars().map(|c| char_width(c) * font_size).sum()
444}
445
446const TOC_ENTRY_SIZE: f32 = 11.0;
448const MAX_PAGES: usize = 10_000;
451const MAX_COLUMNS: u32 = 32;
454const MIN_FONT_SIZE: f32 = 0.5;
456const MAX_FONT_SIZE: f32 = 200.0;
458const MAX_IMAGE_FILE_SIZE: u64 = 50 * 1024 * 1024;
460const MAX_IMAGE_PIXELS: u32 = 10_000;
465const MAX_IMAGES: usize = 1_000;
468const MAX_CHORUS_RECALLS: usize = 1000;
471
472pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
477
478#[must_use]
493pub fn render_song(song: &Song) -> Vec<u8> {
494 render_song_with_transpose(song, 0, &Config::defaults())
495}
496
497#[must_use]
505pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> Vec<u8> {
506 let result = render_song_with_warnings(song, cli_transpose, config);
507 for w in &result.warnings {
508 eprintln!("warning: {w}");
509 }
510 result.output
511}
512
513#[must_use = "caller must check warnings in the returned RenderResult"]
519pub fn render_song_with_warnings(
520 song: &Song,
521 cli_transpose: i8,
522 config: &Config,
523) -> RenderResult<Vec<u8>> {
524 let mut warnings = Vec::new();
525 let song_overrides = song.config_overrides();
527 let song_config;
528 let effective_config = if song_overrides.is_empty() {
529 config
530 } else {
531 song_config = config
532 .clone()
533 .with_song_overrides(&song_overrides, &mut warnings);
534 &song_config
535 };
536 let mut doc = PdfDocument::from_config_with_warnings(effective_config, &mut warnings);
537 doc.set_doc_title(song.metadata.title.as_deref());
541 render_song_into_doc(
542 song,
543 cli_transpose,
544 effective_config,
545 &mut doc,
546 &mut warnings,
547 );
548 RenderResult::with_warnings(doc.build_pdf(), warnings)
549}
550
551#[must_use]
555pub fn render_songs(songs: &[Song]) -> Vec<u8> {
556 render_songs_with_transpose(songs, 0, &Config::defaults())
557}
558
559#[must_use]
568pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> Vec<u8> {
569 let result = render_songs_with_warnings(songs, cli_transpose, config);
570 for w in &result.warnings {
571 eprintln!("warning: {w}");
572 }
573 result.output
574}
575
576fn push_toc_entry(entries: &mut Vec<(String, usize)>, title: String, page: usize) {
586 let candidate = (title, page);
587 if entries.last() != Some(&candidate) {
588 entries.push(candidate);
589 }
590}
591
592#[must_use = "caller must check warnings in the returned RenderResult"]
612pub fn render_songs_with_warnings(
613 songs: &[Song],
614 cli_transpose: i8,
615 config: &Config,
616) -> RenderResult<Vec<u8>> {
617 let mut warnings = Vec::new();
618
619 if songs.len() <= 1 {
620 return songs
621 .first()
622 .map(|s| render_song_with_warnings(s, cli_transpose, config))
623 .unwrap_or_else(|| RenderResult::with_warnings(Vec::new(), warnings));
624 }
625
626 let mut body_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
628 let mut toc_entries: Vec<(String, usize)> = Vec::new(); for (i, song) in songs.iter().enumerate() {
631 if i > 0 {
632 body_doc.new_page();
633 }
634 let song_overrides = song.config_overrides();
638 let song_config;
639 let effective_config = if song_overrides.is_empty() {
640 config
641 } else {
642 song_config = config
643 .clone()
644 .with_song_overrides(&song_overrides, &mut warnings);
645 &song_config
646 };
647 body_doc.reset_margins_from_config(effective_config, &mut warnings);
648 let start_page = body_doc.page_count();
649 let title = song
650 .metadata
651 .title
652 .as_deref()
653 .unwrap_or("Untitled")
654 .to_string();
655 push_toc_entry(&mut toc_entries, title, start_page);
656 render_song_into_doc(
657 song,
658 cli_transpose,
659 effective_config,
660 &mut body_doc,
661 &mut warnings,
662 );
663 }
664
665 let mut toc_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
667 toc_doc.text("Table of Contents", Font::HelveticaBold, TITLE_SIZE);
668 toc_doc.newline(TITLE_SIZE + LINE_GAP * 2.0);
669
670 let toc_page_count = {
671 for (title, body_page_idx) in &toc_entries {
672 toc_doc.ensure_space(TOC_ENTRY_SIZE + LINE_GAP);
673 let page_num_placeholder = body_page_idx + 1; let entry_text = format!("{title} ...... {page_num_placeholder}");
677 toc_doc.text(&entry_text, Font::Helvetica, TOC_ENTRY_SIZE);
678 toc_doc.newline(TOC_ENTRY_SIZE + LINE_GAP);
679 }
680 toc_doc.page_count()
681 };
682
683 let mut toc_doc = PdfDocument::from_config_with_warnings(config, &mut warnings);
685 toc_doc.text("Table of Contents", Font::HelveticaBold, TITLE_SIZE);
686 toc_doc.newline(TITLE_SIZE + LINE_GAP * 2.0);
687
688 for (title, body_page_idx) in &toc_entries {
689 toc_doc.ensure_space(TOC_ENTRY_SIZE + LINE_GAP);
690 let page_num = body_page_idx + toc_page_count; let x = toc_doc.margin_left();
692 let y = toc_doc.y();
693
694 toc_doc.text_at(title, Font::Helvetica, TOC_ENTRY_SIZE, x, y);
696
697 let num_str = page_num.to_string();
699 let num_width = text_width(&num_str, TOC_ENTRY_SIZE);
700 let right_x = PAGE_W - toc_doc.margin_right - num_width;
701 toc_doc.text_at(&num_str, Font::Helvetica, TOC_ENTRY_SIZE, right_x, y);
702
703 toc_doc.newline(TOC_ENTRY_SIZE + LINE_GAP);
704 }
705
706 let mut combined = toc_doc;
708 for (gid, ch) in body_doc.cid_glyphs.iter() {
711 combined.cid_glyphs.entry(*gid).or_insert(*ch);
712 }
713 for page_ops in body_doc.take_pages() {
714 combined.push_page(page_ops);
715 }
716
717 RenderResult::with_warnings(combined.build_pdf(), warnings)
718}
719
720fn render_song_into_doc(
726 song: &Song,
727 cli_transpose: i8,
728 config: &Config,
729 doc: &mut PdfDocument,
730 warnings: &mut Vec<String>,
731) {
732 let song_overrides = song.config_overrides();
735 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
736 let (combined_transpose, _) =
737 chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
738 let mut transpose_offset: i8 = combined_transpose;
739 let mut fmt_state = PdfFormattingState::default();
740
741 let diagram_frets = config.get_path("diagrams.frets").as_f64().map_or(
743 chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
744 |n| (n as usize).max(1),
745 );
746
747 validate_capo(&song.metadata, warnings);
748 validate_multiple_capo(song, warnings);
749 validate_strict_key(&song.metadata, config, warnings);
750
751 if let Some(title) = &song.metadata.title {
753 doc.text(title, Font::HelveticaBold, TITLE_SIZE);
754 doc.newline(TITLE_SIZE + LINE_GAP);
755 }
756 for subtitle in &song.metadata.subtitles {
758 doc.text(subtitle, Font::Helvetica, SUBTITLE_SIZE);
759 doc.newline(SUBTITLE_SIZE + LINE_GAP);
760 }
761
762 let mut show_diagrams = true;
764
765 let default_instrument = config
767 .get_path("diagrams.instrument")
768 .as_str()
769 .map(str::to_ascii_lowercase)
770 .unwrap_or_else(|| "guitar".to_string());
771 let mut auto_diagrams_instrument: Option<String> = None;
772 let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
776
777 let mut chorus_body: Vec<Line> = Vec::new();
779 let mut chorus_buf: Option<Vec<Line>> = None;
781 let mut saved_fmt_state: Option<PdfFormattingState> = None;
782 let mut chorus_recall_count: usize = 0;
783
784 let mut in_notation_block: Option<NotationKind> = None;
799
800 let mut in_verbatim_section = false;
807
808 for line in &song.lines {
809 if let Some(kind) = in_notation_block {
815 match line {
816 Line::Directive(d) if kind.is_end_directive(&d.kind) => {
817 in_notation_block = None;
818 }
819 _ => {}
820 }
821 continue;
822 }
823 match line {
824 Line::Lyrics(lyrics) => {
825 if let Some(buf) = chorus_buf.as_mut() {
826 buf.push(line.clone());
827 }
828 let prefer_flat = transposed_key_prefers_flat(&song.metadata, transpose_offset);
829 render_lyrics(
830 lyrics,
831 transpose_offset,
832 prefer_flat,
833 &fmt_state,
834 doc,
835 in_verbatim_section,
836 );
837 }
838 Line::Directive(d)
845 if d.kind.is_metadata()
846 && matches!(
847 d.kind,
848 DirectiveKind::Key | DirectiveKind::Tempo | DirectiveKind::Time
849 ) =>
850 {
851 if let Some(value) = d.value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
852 let line = match d.kind {
859 DirectiveKind::Key => format!("Key: {}", unicode_accidentals(value)),
860 DirectiveKind::Tempo => {
861 let marking = value
864 .parse::<f32>()
865 .ok()
866 .and_then(tempo_marking_for)
867 .map(|m| format!(" ({m})"))
868 .unwrap_or_default();
869 format!("Tempo: {value} BPM{marking}")
870 }
871 DirectiveKind::Time => format!("Time: {value}"),
872 _ => unreachable!(),
873 };
874 doc.text(&line, Font::HelveticaOblique, COMMENT_SIZE);
875 doc.newline(COMMENT_SIZE + LINE_GAP);
876 }
877 }
878 Line::Directive(d) if !d.kind.is_metadata() => {
879 if d.kind == DirectiveKind::Diagrams {
880 auto_diagrams_instrument =
881 resolve_diagrams_instrument(d.value.as_deref(), &default_instrument);
882 show_diagrams = auto_diagrams_instrument.is_some();
883 continue;
884 }
885 if d.kind == DirectiveKind::NoDiagrams {
886 show_diagrams = false;
887 auto_diagrams_instrument = None;
888 continue;
889 }
890 if d.kind == DirectiveKind::Transpose {
891 let file_offset: i8 = match d.value.as_deref() {
894 None | Some("") => 0,
895 Some(raw) => match raw.parse() {
896 Ok(v) => v,
897 Err(_) => {
898 push_warning(
899 warnings,
900 format!(
901 "{{transpose}} value {raw:?} cannot be \
902 parsed as i8, ignored (using 0)"
903 ),
904 );
905 0
906 }
907 },
908 };
909 let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
910 file_offset,
911 cli_transpose,
912 );
913 if saturated {
914 push_warning(
915 warnings,
916 format!(
917 "transpose offset {file_offset} + {cli_transpose} \
918 exceeds i8 range, clamped to {combined}"
919 ),
920 );
921 }
922 transpose_offset = combined;
923 continue;
924 }
925 if d.kind.is_font_size_color() {
926 fmt_state.apply(&d.kind, &d.value);
927 continue;
928 }
929 if let Some(kind) = NotationKind::from_start_directive(&d.kind) {
936 render_section_label(d, doc);
937 let label = kind.label();
938 let tag = kind.tag();
939 push_warning(
940 warnings,
941 format!(
942 "PDF renderer does not support {label} blocks; body of the \
943 `{{start_of_{tag}}} … {{end_of_{tag}}}` section has been \
944 omitted. Use the HTML renderer for full {label} support.",
945 ),
946 );
947 let placeholder = format!(
948 "[{} block omitted — use the HTML renderer to view it]",
949 label
950 );
951 doc.ensure_space(LYRICS_SIZE + LINE_GAP);
952 doc.text(&placeholder, Font::HelveticaOblique, LYRICS_SIZE);
953 doc.newline(LYRICS_SIZE + LINE_GAP);
954 in_notation_block = Some(kind);
955 continue;
956 }
957 match &d.kind {
958 DirectiveKind::StartOfChorus => {
959 render_section_label(d, doc);
960 chorus_buf = Some(Vec::new());
961 saved_fmt_state = Some(fmt_state.clone());
962 }
963 DirectiveKind::EndOfChorus => {
964 if let Some(buf) = chorus_buf.take() {
965 chorus_body = buf;
966 }
967 if let Some(saved) = saved_fmt_state.take() {
968 fmt_state = saved;
969 }
970 }
971 DirectiveKind::Chorus => {
972 if chorus_recall_count < MAX_CHORUS_RECALLS {
973 let prefer_flat =
974 transposed_key_prefers_flat(&song.metadata, transpose_offset);
975 render_chorus_recall(
976 &d.value,
977 &ChorusRecallCtx {
978 chorus_body: &chorus_body,
979 transpose_offset,
980 prefer_flat,
981 fmt_state: &fmt_state,
982 show_diagrams,
983 diagram_frets,
984 },
985 doc,
986 );
987 chorus_recall_count += 1;
988 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
989 push_warning(
990 warnings,
991 format!(
992 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
993 further recalls suppressed"
994 ),
995 );
996 chorus_recall_count += 1;
997 }
998 }
999 DirectiveKind::NewPage => {
1005 doc.new_page();
1006 }
1007 DirectiveKind::NewPhysicalPage => {
1008 doc.new_page();
1009 if doc.page_count() % 2 == 0 {
1014 doc.new_page();
1015 }
1016 }
1017 DirectiveKind::Columns => {
1018 let n: u32 = d
1023 .value
1024 .as_deref()
1025 .and_then(|v| v.trim().parse().ok())
1026 .unwrap_or(1)
1027 .clamp(1, MAX_COLUMNS);
1028 doc.set_columns(n);
1029 }
1030 DirectiveKind::ColumnBreak => {
1031 doc.column_break();
1032 }
1033 DirectiveKind::StartOfTab
1038 | DirectiveKind::StartOfGrid
1039 | DirectiveKind::StartOfTextblock => {
1040 if let Some(buf) = chorus_buf.as_mut() {
1041 buf.push(line.clone());
1042 }
1043 in_verbatim_section = true;
1044 render_directive(d, show_diagrams, diagram_frets, doc);
1045 }
1046 DirectiveKind::EndOfTab
1047 | DirectiveKind::EndOfGrid
1048 | DirectiveKind::EndOfTextblock => {
1049 if let Some(buf) = chorus_buf.as_mut() {
1050 buf.push(line.clone());
1051 }
1052 in_verbatim_section = false;
1053 }
1054 _ => {
1055 if let Some(buf) = chorus_buf.as_mut() {
1056 buf.push(line.clone());
1057 }
1058 if d.kind == DirectiveKind::Define && show_diagrams {
1061 if let Some(ref val) = d.value {
1062 let name =
1063 chordsketch_chordpro::ast::ChordDefinition::parse_value(val)
1064 .name;
1065 if !name.is_empty() {
1066 inline_defined.insert(canonical_chord_name(&name));
1067 }
1068 }
1069 }
1070 render_directive(d, show_diagrams, diagram_frets, doc);
1071 }
1072 }
1073 }
1074 Line::Comment(style, text) => {
1075 if let Some(buf) = chorus_buf.as_mut() {
1076 buf.push(line.clone());
1077 }
1078 render_comment(*style, text, doc);
1079 }
1080 Line::Empty => {
1081 if let Some(buf) = chorus_buf.as_mut() {
1082 buf.push(line.clone());
1083 }
1084 doc.newline(LINE_GAP * 2.0);
1085 }
1086 _ => {}
1087 }
1088 }
1089
1090 if let Some(ref instrument) = auto_diagrams_instrument {
1092 let chord_names: Vec<String> = song
1094 .used_chord_names()
1095 .into_iter()
1096 .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
1097 .collect();
1098
1099 if instrument == "piano" {
1100 let kbd_defines = song.keyboard_defines();
1101 for name in chord_names {
1102 if let Some(voicing) =
1103 chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
1104 {
1105 render_keyboard_diagram_pdf(&voicing, doc);
1106 }
1107 }
1108 } else {
1109 let defines = song.fretted_defines();
1110 for name in chord_names {
1111 if let Some(diagram) =
1112 chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, diagram_frets)
1113 {
1114 render_chord_diagram_pdf(&diagram, doc);
1115 }
1116 }
1117 }
1118 }
1119}
1120
1121#[must_use = "parse errors should be handled"]
1123pub fn try_render(input: &str) -> Result<Vec<u8>, chordsketch_chordpro::ParseError> {
1124 let song = chordsketch_chordpro::parse(input)?;
1125 Ok(render_song(&song))
1126}
1127
1128fn render_lyrics(
1133 lyrics: &LyricsLine,
1134 transpose_offset: i8,
1135 prefer_flat: bool,
1136 fmt_state: &PdfFormattingState,
1137 doc: &mut PdfDocument,
1138 verbatim: bool,
1139) {
1140 let has_markup = lyrics.segments.iter().any(|s| s.has_markup());
1141 let lyrics_size = fmt_state.lyrics_size();
1142 let chord_size = fmt_state.chord_size();
1143 let body_font = if verbatim {
1148 Font::Courier
1149 } else {
1150 Font::Helvetica
1151 };
1152
1153 if !lyrics.has_chords() {
1154 doc.ensure_space(lyrics_size + LINE_GAP);
1155 if has_markup {
1156 render_lyrics_spans(lyrics, lyrics_size, doc);
1157 } else {
1158 doc.text(&lyrics.text(), body_font, lyrics_size);
1159 }
1160 doc.newline(lyrics_size + LINE_GAP);
1161 return;
1162 }
1163
1164 doc.ensure_space(chord_size + 2.0 + lyrics_size + LINE_GAP);
1166
1167 let mut x = doc.margin_left();
1169 let start_y = doc.y();
1170 for seg in &lyrics.segments {
1171 let chord_display: Option<String> = seg.chord.as_ref().map(|c| {
1174 if transpose_offset != 0 {
1175 transpose_chord_with_style(c, transpose_offset, prefer_flat)
1176 .display_name()
1177 .to_string()
1178 } else {
1179 c.display_name().to_string()
1180 }
1181 });
1182 if let Some(ref name) = chord_display {
1183 doc.text_at(name, Font::HelveticaBold, chord_size, x, start_y);
1184 }
1185 let text_w = text_width(&seg.text, lyrics_size);
1186 let chord_w = chord_display
1187 .as_ref()
1188 .map_or(0.0, |name| text_width(name, chord_size) + 2.0);
1189 x += text_w.max(chord_w);
1190 }
1191
1192 doc.advance_y(chord_size + 2.0);
1194 if has_markup {
1195 render_lyrics_spans(lyrics, lyrics_size, doc);
1196 } else {
1197 doc.text(&lyrics.text(), Font::Helvetica, lyrics_size);
1198 }
1199 doc.newline(lyrics_size + LINE_GAP);
1200}
1201
1202fn render_lyrics_spans(lyrics: &LyricsLine, font_size: f32, doc: &mut PdfDocument) {
1207 let clip = doc.num_columns > 1;
1208 let col_right = if clip {
1209 doc.margin_left() + doc.column_width()
1210 } else {
1211 0.0
1212 };
1213 let mut x = doc.margin_left();
1214 let y = doc.y();
1215 if clip {
1216 let clip_w = (col_right - x).max(0.0);
1217 let ops = doc.current_page_mut();
1218 ops.push("q".to_string());
1219 ops.push(format!(
1220 "{} {} {} {} re W n",
1221 fmt_f32(x),
1222 fmt_f32(0.0),
1223 fmt_f32(clip_w),
1224 fmt_f32(PAGE_H)
1225 ));
1226 }
1227 for seg in &lyrics.segments {
1228 if seg.has_markup() {
1229 x = render_span_list(&seg.spans, doc, x, y, font_size, false, false);
1230 } else {
1231 doc.text_at_raw(&seg.text, Font::Helvetica, font_size, x, y);
1232 x += text_width(&seg.text, font_size);
1233 }
1234 }
1235 if clip {
1236 let ops = doc.current_page_mut();
1237 ops.push("Q".to_string());
1238 }
1239}
1240
1241fn render_span_list(
1245 spans: &[TextSpan],
1246 doc: &mut PdfDocument,
1247 mut x: f32,
1248 y: f32,
1249 font_size: f32,
1250 bold: bool,
1251 italic: bool,
1252) -> f32 {
1253 for span in spans {
1254 match span {
1255 TextSpan::Plain(text) => {
1256 let font = match (bold, italic) {
1257 (true, true) => Font::HelveticaBoldOblique,
1258 (true, false) => Font::HelveticaBold,
1259 (false, true) => Font::HelveticaOblique,
1260 (false, false) => Font::Helvetica,
1261 };
1262 doc.text_at_raw(text, font, font_size, x, y);
1263 x += text_width(text, font_size);
1264 }
1265 TextSpan::Bold(children) => {
1266 x = render_span_list(children, doc, x, y, font_size, true, italic);
1267 }
1268 TextSpan::Italic(children) => {
1269 x = render_span_list(children, doc, x, y, font_size, bold, true);
1270 }
1271 TextSpan::Highlight(children) | TextSpan::Comment(children) => {
1272 x = render_span_list(children, doc, x, y, font_size, bold, italic);
1275 }
1276 TextSpan::Span(attrs, children) => {
1277 let span_bold = bold
1279 || attrs
1280 .weight
1281 .as_deref()
1282 .is_some_and(|w| w.eq_ignore_ascii_case("bold"));
1283 let span_italic = italic
1284 || attrs
1285 .style
1286 .as_deref()
1287 .is_some_and(|s| s.eq_ignore_ascii_case("italic"));
1288 x = render_span_list(children, doc, x, y, font_size, span_bold, span_italic);
1289 }
1290 }
1291 }
1292 x
1293}
1294
1295fn render_section_label(directive: &chordsketch_chordpro::ast::Directive, doc: &mut PdfDocument) {
1297 let label: Option<String> = match &directive.kind {
1298 DirectiveKind::StartOfChorus => Some("Chorus".to_string()),
1299 DirectiveKind::StartOfVerse => Some("Verse".to_string()),
1300 DirectiveKind::StartOfBridge => Some("Bridge".to_string()),
1301 DirectiveKind::StartOfTab => Some("Tab".to_string()),
1302 DirectiveKind::StartOfGrid => Some("Grid".to_string()),
1303 DirectiveKind::StartOfAbc => Some("ABC".to_string()),
1304 DirectiveKind::StartOfLy => Some("Lilypond".to_string()),
1305 DirectiveKind::StartOfSvg => Some("SVG".to_string()),
1306 DirectiveKind::StartOfTextblock => Some("Textblock".to_string()),
1307 DirectiveKind::StartOfMusicxml => Some("MusicXML".to_string()),
1308 DirectiveKind::StartOfSection(section_name) => {
1309 Some(chordsketch_chordpro::capitalize(section_name))
1310 }
1311 _ => None,
1312 };
1313 if let Some(label) = label {
1314 let resolved_value: Option<String> = if matches!(directive.kind, DirectiveKind::StartOfGrid)
1321 {
1322 directive.value.as_ref().and_then(|v| {
1323 if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
1324 Some(label)
1325 } else if !v.contains('=') {
1326 Some(v.clone())
1327 } else {
1328 None
1329 }
1330 })
1331 } else {
1332 directive.value.clone()
1333 };
1334 let text = match resolved_value {
1335 Some(v) if !v.is_empty() => format!("{label}: {v}"),
1336 _ => label,
1337 };
1338 doc.ensure_space(SECTION_SIZE + LINE_GAP);
1339 doc.text(&text, Font::HelveticaBoldOblique, SECTION_SIZE);
1340 doc.newline(SECTION_SIZE + LINE_GAP);
1341 }
1342}
1343
1344fn render_directive(
1345 directive: &chordsketch_chordpro::ast::Directive,
1346 show_diagrams: bool,
1347 diagram_frets: usize,
1348 doc: &mut PdfDocument,
1349) {
1350 if directive.kind == DirectiveKind::Define && show_diagrams {
1351 if let Some(ref value) = directive.value {
1352 let def = chordsketch_chordpro::ast::ChordDefinition::parse_value(value);
1353 if let Some(ref keys_raw) = def.keys {
1355 let keys_u8: Vec<u8> = keys_raw
1356 .iter()
1357 .filter_map(|&k| {
1358 if (0i32..=127).contains(&k) {
1359 Some(k as u8)
1360 } else {
1361 None
1362 }
1363 })
1364 .collect();
1365 if !keys_u8.is_empty() {
1366 let root = keys_u8[0];
1367 let voicing = chordsketch_chordpro::chord_diagram::KeyboardVoicing {
1368 name: def.name.clone(),
1369 display_name: def.display.clone(),
1370 keys: keys_u8,
1371 root_key: root,
1372 };
1373 render_keyboard_diagram_pdf(&voicing, doc);
1374 return;
1375 }
1376 }
1377 if let Some(ref raw) = def.raw {
1379 if let Some(mut diagram) =
1380 chordsketch_chordpro::chord_diagram::DiagramData::from_raw_infer_frets(
1381 &def.name,
1382 raw,
1383 diagram_frets,
1384 )
1385 {
1386 diagram.display_name = def.display.clone();
1387 render_chord_diagram_pdf(&diagram, doc);
1388 return;
1389 }
1390 }
1391 }
1392 }
1393
1394 if let DirectiveKind::Image(ref attrs) = directive.kind {
1395 render_image(attrs, doc);
1396 return;
1397 }
1398
1399 render_section_label(directive, doc);
1400}
1401
1402fn is_safe_image_path(path: &str) -> bool {
1409 if path.is_empty() || path.contains('\0') {
1411 return false;
1412 }
1413
1414 if chordsketch_chordpro::image_path::is_windows_absolute(path) {
1418 return false;
1419 }
1420
1421 let p = std::path::Path::new(path);
1422
1423 if p.is_absolute() || path.starts_with('/') {
1427 return false;
1428 }
1429
1430 if chordsketch_chordpro::image_path::has_traversal(path) {
1436 return false;
1437 }
1438
1439 true
1440}
1441
1442fn read_image_file(path: &str) -> Option<Vec<u8>> {
1454 #[cfg(unix)]
1455 {
1456 use std::io::Read;
1457 use std::os::unix::fs::OpenOptionsExt;
1458
1459 let mut file = std::fs::OpenOptions::new()
1461 .read(true)
1462 .custom_flags(libc::O_NOFOLLOW)
1463 .open(path)
1464 .ok()?;
1465
1466 let meta = file.metadata().ok()?;
1468 if meta.len() > MAX_IMAGE_FILE_SIZE {
1469 return None;
1470 }
1471
1472 let mut buf = Vec::with_capacity(meta.len() as usize);
1473 file.read_to_end(&mut buf).ok()?;
1474
1475 if buf.len() as u64 > MAX_IMAGE_FILE_SIZE {
1477 return None;
1478 }
1479 Some(buf)
1480 }
1481
1482 #[cfg(not(unix))]
1483 {
1484 let meta = std::fs::symlink_metadata(path).ok()?;
1486 if meta.file_type().is_symlink() {
1487 return None;
1488 }
1489 if meta.len() > MAX_IMAGE_FILE_SIZE {
1490 return None;
1491 }
1492 let data = std::fs::read(path).ok()?;
1493 if data.len() as u64 > MAX_IMAGE_FILE_SIZE {
1494 return None;
1495 }
1496 Some(data)
1497 }
1498}
1499
1500fn render_image(attrs: &ImageAttributes, doc: &mut PdfDocument) {
1512 if !attrs.has_src() {
1513 return;
1514 }
1515
1516 if doc.images.len() >= MAX_IMAGES {
1518 return;
1519 }
1520
1521 if !chordsketch_chordpro::image_path::is_safe_image_src(&attrs.src) {
1527 return;
1528 }
1529
1530 if !is_safe_image_path(&attrs.src) {
1536 return;
1537 }
1538
1539 let src_lower = attrs.src.to_ascii_lowercase();
1540 let is_jpeg = src_lower.ends_with(".jpg") || src_lower.ends_with(".jpeg");
1541 let is_png = src_lower.ends_with(".png");
1542 if !is_jpeg && !is_png {
1543 return;
1544 }
1545
1546 let data = match read_image_file(&attrs.src) {
1548 Some(d) => d,
1549 None => return,
1550 };
1551
1552 let (pixel_w, pixel_h, img_idx) = if is_jpeg {
1554 let (w, h, components) = match parse_jpeg_dimensions(&data) {
1555 Some(dims) => dims,
1556 None => return,
1557 };
1558 if w == 0 || h == 0 {
1559 return;
1560 }
1561 let idx = doc.embed_jpeg(data, w, h, components);
1562 (w, h, idx)
1563 } else {
1564 let info = match parse_png(&data) {
1565 Some(info) => info,
1566 None => return,
1567 };
1568 if info.width == 0 || info.height == 0 {
1569 return;
1570 }
1571 let w = info.width;
1572 let h = info.height;
1573 let idx = doc.embed_png(info);
1574 (w, h, idx)
1575 };
1576
1577 let clamped_w = pixel_w.min(MAX_IMAGE_PIXELS);
1580 let clamped_h = pixel_h.min(MAX_IMAGE_PIXELS);
1581
1582 let native_w = clamped_w as f32;
1584 let native_h = clamped_h as f32;
1585 let aspect = native_w / native_h;
1586
1587 let (render_w, render_h) = compute_image_dimensions(attrs, native_w, native_h, aspect);
1588
1589 let max_w = doc.column_width();
1591 let max_h = PAGE_H - doc.margin_top - doc.margin_bottom;
1592 let (render_w, render_h) = clamp_to_printable_area(render_w, render_h, max_w, max_h, aspect);
1593
1594 doc.ensure_space(render_h + LINE_GAP);
1595
1596 let x = match attrs.anchor.as_deref() {
1598 Some("column") => {
1599 let col_left = doc.margin_left();
1600 let col_w = doc.column_width();
1601 col_left + (col_w - render_w) / 2.0
1602 }
1603 Some("paper") => (PAGE_W - render_w) / 2.0,
1604 _ => doc.margin_left(),
1605 };
1606
1607 let y = doc.y() - render_h;
1609 doc.draw_image(img_idx, x, y, render_w, render_h);
1610 doc.advance_y(render_h);
1611 doc.newline(LINE_GAP);
1612}
1613
1614fn clamp_to_printable_area(w: f32, h: f32, max_w: f32, max_h: f32, aspect: f32) -> (f32, f32) {
1622 if w > max_w {
1623 let clamped_h = max_w / aspect;
1624 if clamped_h > max_h {
1625 let clamped_w = (max_h * aspect).min(max_w);
1626 (clamped_w, max_h)
1627 } else {
1628 (max_w, clamped_h)
1629 }
1630 } else if h > max_h {
1631 let clamped_w = (max_h * aspect).min(max_w);
1632 (clamped_w, clamped_w / aspect)
1633 } else {
1634 (w, h)
1635 }
1636}
1637
1638fn parse_dimension(value: &str, reference: f32) -> Option<f32> {
1643 let trimmed = value.trim();
1644 if let Some(pct_str) = trimmed.strip_suffix('%') {
1645 let pct: f32 = pct_str.trim().parse().ok()?;
1646 let result = reference * pct / 100.0;
1647 if result > 0.0 && result.is_finite() {
1648 Some(result)
1649 } else {
1650 None
1651 }
1652 } else {
1653 let v: f32 = trimmed.parse().ok()?;
1654 if v > 0.0 && v.is_finite() {
1655 Some(v)
1656 } else {
1657 None
1658 }
1659 }
1660}
1661
1662fn compute_image_dimensions(
1669 attrs: &ImageAttributes,
1670 native_w: f32,
1671 native_h: f32,
1672 aspect: f32,
1673) -> (f32, f32) {
1674 let parsed_w = attrs
1675 .width
1676 .as_deref()
1677 .and_then(|v| parse_dimension(v, native_w));
1678 let parsed_h = attrs
1679 .height
1680 .as_deref()
1681 .and_then(|v| parse_dimension(v, native_h));
1682 let parsed_scale = attrs
1683 .scale
1684 .as_deref()
1685 .and_then(|v| v.trim().parse::<f32>().ok())
1686 .filter(|&v| v > 0.0 && v.is_finite());
1687
1688 match (parsed_w, parsed_h) {
1689 (Some(w), Some(h)) => (w, h),
1690 (Some(w), None) => (w, w / aspect),
1691 (None, Some(h)) => (h * aspect, h),
1692 (None, None) => {
1693 if let Some(s) = parsed_scale {
1694 (native_w * s, native_h * s)
1695 } else {
1696 (native_w, native_h)
1697 }
1698 }
1699 }
1700}
1701
1702fn render_chord_diagram_pdf(
1707 data: &chordsketch_chordpro::chord_diagram::DiagramData,
1708 doc: &mut PdfDocument,
1709) {
1710 if data.strings < chordsketch_chordpro::chord_diagram::MIN_STRINGS
1712 || data.strings > chordsketch_chordpro::chord_diagram::MAX_STRINGS
1713 || data.frets_shown < chordsketch_chordpro::chord_diagram::MIN_FRETS_SHOWN
1714 || data.frets_shown > chordsketch_chordpro::chord_diagram::MAX_FRETS_SHOWN
1715 {
1716 return;
1717 }
1718
1719 let cell_w: f32 = 10.0;
1723 let cell_h: f32 = 12.0;
1724 let num_strings = data.strings;
1725 let num_frets = data.frets_shown;
1726 let grid_w = (num_strings - 1) as f32 * cell_w;
1727 let grid_h = num_frets as f32 * cell_h;
1728 let total_h = grid_h + 25.0; doc.ensure_space(total_h);
1731
1732 let base_x = doc.margin_left();
1733 let top_y = doc.y();
1735
1736 doc.text_at(data.title(), Font::HelveticaBold, 9.0, base_x, top_y);
1738
1739 let grid_top = top_y - 15.0; if data.base_fret == 1 {
1743 doc.line_at(base_x, grid_top, base_x + grid_w, grid_top, 2.0);
1744 } else {
1745 let fret_label = format!("{}fr", data.base_fret);
1747 let fret_label_x = (base_x - 16.0).max(0.0);
1748 doc.text_at(
1749 &fret_label,
1750 Font::Helvetica,
1751 6.0,
1752 fret_label_x,
1753 grid_top - cell_h / 2.0,
1754 );
1755 }
1756
1757 for i in 0..num_strings {
1759 let x = base_x + i as f32 * cell_w;
1760 doc.line_at(x, grid_top, x, grid_top - grid_h, 0.5);
1761 }
1762
1763 for j in 0..=num_frets {
1765 let y = grid_top - j as f32 * cell_h;
1766 doc.line_at(base_x, y, base_x + grid_w, y, 0.5);
1767 }
1768
1769 for (i, &fret) in data.frets.iter().enumerate() {
1771 if i >= num_strings {
1772 break;
1773 }
1774 let x = base_x + i as f32 * cell_w;
1775 if fret == -1 {
1776 doc.text_at("X", Font::Helvetica, 7.0, x - 2.5, grid_top + 4.0);
1778 } else if fret == 0 {
1779 doc.stroked_circle_at(x, grid_top + 6.0, 2.5);
1781 } else {
1782 let y = grid_top - (fret as f32 - 0.5) * cell_h;
1784 doc.filled_circle_at(x, y, 3.0);
1785 if let Some(&finger) = data.fingers.get(i) {
1787 if finger > 0 {
1788 let label = finger.to_string();
1789 doc.white_text_at(&label, Font::Helvetica, 5.0, x - 1.5, y - 1.5);
1790 }
1791 }
1792 }
1793 }
1794
1795 doc.advance_y(total_h);
1796 doc.newline(LINE_GAP);
1797}
1798
1799fn render_keyboard_diagram_pdf(
1804 voicing: &chordsketch_chordpro::chord_diagram::KeyboardVoicing,
1805 doc: &mut PdfDocument,
1806) {
1807 if voicing.keys.is_empty() {
1808 return;
1809 }
1810
1811 let (keys, root) = chordsketch_chordpro::chord_diagram::normalise_keyboard_keys(
1813 &voicing.keys,
1814 voicing.root_key,
1815 );
1816
1817 let min_key = *keys.iter().min().unwrap_or(&60);
1818 let max_key = *keys.iter().max().unwrap_or(&71);
1819 let start_octave = u32::from(min_key / 12);
1820 let end_octave = u32::from(max_key / 12);
1821 let num_octaves = ((end_octave - start_octave) + 1).clamp(2, 3) as usize;
1822 let start_midi = (start_octave * 12) as u8;
1823
1824 let white_w: f32 = 8.0;
1826 let white_h: f32 = 30.0;
1827 let black_w: f32 = 5.0;
1828 let black_h: f32 = 18.0;
1829 let name_h: f32 = 10.0;
1830 let total_h = name_h + white_h + 6.0;
1831
1832 doc.ensure_space(total_h);
1833
1834 let base_x = doc.margin_left();
1835 let top_y = doc.y();
1836
1837 doc.text_at(voicing.title(), Font::HelveticaBold, 7.0, base_x, top_y);
1839
1840 let kbd_top_y = top_y - name_h;
1842
1843 const WHITE_KEYS_PDF: [(u8, f32); 7] = [
1845 (0, 0.0), (2, 1.0), (4, 2.0), (5, 3.0), (7, 4.0), (9, 5.0), (11, 6.0), ];
1853 const BLACK_KEYS_PDF: [(u8, f32); 5] = [
1855 (1, 0.6), (3, 1.6), (6, 3.6), (8, 4.6), (10, 5.6), ];
1861
1862 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 {
1870 let oct_midi = start_midi.saturating_add((oct * 12) as u8);
1871 let oct_x = base_x + oct as f32 * 7.0 * white_w;
1872 for (semitone, x_idx) in WHITE_KEYS_PDF {
1873 let midi = oct_midi.saturating_add(semitone);
1874 let x = oct_x + x_idx * white_w;
1875 let highlighted = keys.contains(&midi);
1876 let is_root = highlighted && midi == root;
1877 let y_bottom = kbd_top_y - white_h;
1879 let color = if is_root {
1880 ROOT_BLUE
1881 } else if highlighted {
1882 CHORD_BLUE
1883 } else {
1884 WHITE_KEY
1885 };
1886 doc.filled_rect_color(x, y_bottom, white_w - 0.5, white_h, color);
1887 doc.rect_stroke(x, y_bottom, white_w - 0.5, white_h, 0.3);
1888 }
1889 }
1890
1891 for oct in 0..num_octaves {
1893 let oct_midi = start_midi.saturating_add((oct * 12) as u8);
1894 let oct_x = base_x + oct as f32 * 7.0 * white_w;
1895 for (semitone, x_idx) in BLACK_KEYS_PDF {
1896 let midi = oct_midi.saturating_add(semitone);
1897 let x = oct_x + x_idx * white_w;
1898 let highlighted = keys.contains(&midi);
1899 let is_root = highlighted && midi == root;
1900 let y_bottom = kbd_top_y - black_h;
1901 let color = if is_root {
1902 ROOT_BLUE
1903 } else if highlighted {
1904 CHORD_BLUE
1905 } else {
1906 DARK_KEY
1907 };
1908 doc.filled_rect_color(x, y_bottom, black_w, black_h, color);
1909 }
1910 }
1911
1912 doc.advance_y(total_h);
1913 doc.newline(LINE_GAP);
1914}
1915
1916struct ChorusRecallCtx<'a> {
1927 chorus_body: &'a [Line],
1928 transpose_offset: i8,
1929 prefer_flat: bool,
1930 fmt_state: &'a PdfFormattingState,
1931 show_diagrams: bool,
1932 diagram_frets: usize,
1933}
1934
1935fn render_chorus_recall(value: &Option<String>, ctx: &ChorusRecallCtx<'_>, doc: &mut PdfDocument) {
1936 let text = match value {
1937 Some(v) if !v.is_empty() => format!("Chorus: {v}"),
1938 _ => "Chorus".to_string(),
1939 };
1940 doc.ensure_space(SECTION_SIZE + LINE_GAP);
1941 doc.text(&text, Font::HelveticaBoldOblique, SECTION_SIZE);
1942 doc.newline(SECTION_SIZE + LINE_GAP);
1943
1944 for line in ctx.chorus_body {
1946 match line {
1947 Line::Lyrics(lyrics) => {
1948 render_lyrics(
1949 lyrics,
1950 ctx.transpose_offset,
1951 ctx.prefer_flat,
1952 ctx.fmt_state,
1953 doc,
1954 false,
1955 );
1956 }
1957 Line::Comment(style, text) => render_comment(*style, text, doc),
1958 Line::Empty => doc.newline(LINE_GAP * 2.0),
1959 Line::Directive(d) if !d.kind.is_metadata() => {
1960 render_directive(d, ctx.show_diagrams, ctx.diagram_frets, doc);
1961 }
1962 _ => {}
1963 }
1964 }
1965}
1966
1967fn render_comment(style: CommentStyle, text: &str, doc: &mut PdfDocument) {
1968 let font = match style {
1969 CommentStyle::Normal => Font::Helvetica,
1970 CommentStyle::Italic | CommentStyle::Boxed => Font::HelveticaOblique,
1971 CommentStyle::Highlight => Font::HelveticaBold,
1976 };
1977 if style == CommentStyle::Boxed {
1978 let padding = 3.0_f32;
1979 let cap_height = COMMENT_SIZE * 0.72;
1986 let descent = COMMENT_SIZE * 0.21;
1987 let box_h = cap_height + descent + padding * 2.0;
1988 doc.ensure_space(box_h + LINE_GAP);
1989 let x = doc.margin_left();
1990 let text_y = doc.y();
1991 let rect_y = text_y - descent - padding;
1993 let text_w = text_width(text, COMMENT_SIZE);
1994 let box_w = text_w + padding * 2.0;
1995 doc.rect_stroke(x, rect_y, box_w, box_h, 0.5);
1996 doc.text_at(text, font, COMMENT_SIZE, x + padding, text_y);
1997 doc.newline(box_h + LINE_GAP);
1998 } else {
1999 doc.ensure_space(COMMENT_SIZE + LINE_GAP);
2000 doc.text(text, font, COMMENT_SIZE);
2001 doc.newline(COMMENT_SIZE + LINE_GAP);
2002 }
2003}
2004
2005const MARGIN_RIGHT: f32 = 56.0;
2011const COLUMN_GAP: f32 = 20.0;
2013
2014fn parse_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32, u8)> {
2029 const MAX_SCAN_BYTES: usize = 64 * 1024;
2033
2034 if data.len() < 4 {
2036 return None;
2037 }
2038 if data[0] != 0xFF || data[1] != 0xD8 {
2040 return None;
2041 }
2042
2043 let scan_limit = data.len().min(MAX_SCAN_BYTES);
2044 let mut i = 2;
2045 while i + 1 < scan_limit {
2046 if data[i] != 0xFF {
2047 i += 1;
2049 continue;
2050 }
2051 let marker = data[i + 1];
2052
2053 if marker == 0xFF {
2055 i += 1;
2056 continue;
2057 }
2058
2059 if matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
2062 if i + 10 > data.len() {
2065 return None;
2066 }
2067 let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2068 let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2069 let components = data[i + 9];
2070 return Some((width, height, components));
2071 }
2072
2073 if marker == 0xDA {
2075 return None;
2076 }
2077
2078 if i + 3 >= data.len() {
2080 return None;
2081 }
2082 let length = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2083 i += 2 + length;
2084 }
2085
2086 None
2087}
2088
2089const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
2095
2096struct PngInfo {
2098 width: u32,
2100 height: u32,
2102 bit_depth: u8,
2104 colors: u8,
2107 idat_data: Vec<u8>,
2109 palette: Option<Vec<u8>>,
2111 smask: Option<Vec<u8>>,
2113}
2114
2115fn parse_png(data: &[u8]) -> Option<PngInfo> {
2122 if data.len() < 8 || data[..8] != PNG_SIGNATURE {
2124 return None;
2125 }
2126
2127 if data.len() < 8 + 4 + 4 + 13 {
2129 return None;
2130 }
2131 let ihdr_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2132 if ihdr_len < 13 {
2133 return None;
2134 }
2135 let chunk_type = &data[12..16];
2136 if chunk_type != b"IHDR" {
2137 return None;
2138 }
2139 let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2140 let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2141 let bit_depth = data[24];
2142 let color_type = data[25];
2143
2144 let mut idat_chunks: Vec<u8> = Vec::new();
2146 let mut palette: Option<Vec<u8>> = None;
2147 let mut pos = 8; while pos + 12 <= data.len() {
2150 let chunk_len =
2151 u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
2152 let ctype = &data[pos + 4..pos + 8];
2153
2154 if pos + 12 + chunk_len > data.len() + 4 {
2156 break;
2157 }
2158 let chunk_data_start = pos + 8;
2159 let chunk_data_end = chunk_data_start + chunk_len;
2160 if chunk_data_end > data.len() {
2161 break;
2162 }
2163
2164 if ctype == b"IDAT" {
2165 idat_chunks.extend_from_slice(&data[chunk_data_start..chunk_data_end]);
2166 } else if ctype == b"PLTE" {
2167 palette = Some(data[chunk_data_start..chunk_data_end].to_vec());
2168 } else if ctype == b"IEND" {
2169 break;
2170 }
2171
2172 pos += 12 + chunk_len;
2174 }
2175
2176 if idat_chunks.is_empty() {
2177 return None;
2178 }
2179
2180 let has_alpha = color_type == 4 || color_type == 6;
2181
2182 if has_alpha {
2183 separate_alpha(&idat_chunks, width, height, bit_depth, color_type)
2185 } else {
2186 let colors = match color_type {
2188 0 => 1, 3 => 3, _ => 3, };
2192 Some(PngInfo {
2193 width,
2194 height,
2195 bit_depth,
2196 colors,
2197 idat_data: idat_chunks,
2198 palette,
2199 smask: None,
2200 })
2201 }
2202}
2203
2204fn separate_alpha(
2212 idat_data: &[u8],
2213 width: u32,
2214 height: u32,
2215 bit_depth: u8,
2216 color_type: u8,
2217) -> Option<PngInfo> {
2218 let w = width as usize;
2219 let h = height as usize;
2220 let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 };
2221
2222 let channels: usize = match color_type {
2226 4 => 2, 6 => 4, _ => return None,
2229 };
2230 let expected_size = h.checked_mul(1 + w * channels * bytes_per_sample)?;
2231 const MAX_DECOMPRESSED_SIZE: u64 = 256 * 1024 * 1024;
2233 let limit = (expected_size as u64).min(MAX_DECOMPRESSED_SIZE);
2234
2235 let mut decoder = ZlibDecoder::new(idat_data).take(limit + 1);
2237 let mut raw = Vec::new();
2238 if decoder.read_to_end(&mut raw).is_err() || raw.len() as u64 > limit {
2239 return None;
2240 }
2241
2242 let (color_channels, alpha_channels) = match color_type {
2244 4 => (1, 1), 6 => (3, 1), _ => return None,
2247 };
2248 let total_channels = color_channels + alpha_channels;
2249 let src_stride = 1 + w * total_channels * bytes_per_sample; if raw.len() < h * src_stride {
2252 return None;
2253 }
2254
2255 let bpp = total_channels * bytes_per_sample;
2257 let row_bytes = w * total_channels * bytes_per_sample;
2258 let mut decoded = vec![0u8; h * row_bytes];
2259
2260 for row in 0..h {
2261 let src_start = row * src_stride;
2262 let filter = raw[src_start];
2263 let src_row = &raw[src_start + 1..src_start + src_stride];
2264 let dst_start = row * row_bytes;
2265
2266 decoded[dst_start..dst_start + row_bytes].copy_from_slice(src_row);
2267
2268 match filter {
2269 0 => {} 1 => {
2271 for i in bpp..row_bytes {
2273 decoded[dst_start + i] =
2274 decoded[dst_start + i].wrapping_add(decoded[dst_start + i - bpp]);
2275 }
2276 }
2277 2 => {
2278 if row > 0 {
2280 let prev_start = (row - 1) * row_bytes;
2281 for i in 0..row_bytes {
2282 decoded[dst_start + i] =
2283 decoded[dst_start + i].wrapping_add(decoded[prev_start + i]);
2284 }
2285 }
2286 }
2287 3 => {
2288 let prev_start = if row > 0 { (row - 1) * row_bytes } else { 0 };
2290 for i in 0..row_bytes {
2291 let left = if i >= bpp {
2292 decoded[dst_start + i - bpp]
2293 } else {
2294 0
2295 };
2296 let up = if row > 0 { decoded[prev_start + i] } else { 0 };
2297 decoded[dst_start + i] =
2298 decoded[dst_start + i].wrapping_add(((left as u16 + up as u16) / 2) as u8);
2299 }
2300 }
2301 4 => {
2302 let prev_start = if row > 0 { (row - 1) * row_bytes } else { 0 };
2304 for i in 0..row_bytes {
2305 let left = if i >= bpp {
2306 decoded[dst_start + i - bpp] as i16
2307 } else {
2308 0
2309 };
2310 let up = if row > 0 {
2311 decoded[prev_start + i] as i16
2312 } else {
2313 0
2314 };
2315 let up_left = if i >= bpp && row > 0 {
2316 decoded[prev_start + i - bpp] as i16
2317 } else {
2318 0
2319 };
2320 decoded[dst_start + i] =
2321 decoded[dst_start + i].wrapping_add(paeth_predictor(left, up, up_left));
2322 }
2323 }
2324 _ => return None,
2325 }
2326 }
2327
2328 let color_stride = 1 + w * color_channels * bytes_per_sample;
2330 let alpha_stride = 1 + w * bytes_per_sample;
2331 let mut color_raw = Vec::with_capacity(h * color_stride);
2332 let mut alpha_raw = Vec::with_capacity(h * alpha_stride);
2333
2334 for row in 0..h {
2335 color_raw.push(0); alpha_raw.push(0); let row_start = row * row_bytes;
2338 for x in 0..w {
2339 let pixel_start = row_start + x * total_channels * bytes_per_sample;
2340 for c in 0..color_channels {
2342 let offset = pixel_start + c * bytes_per_sample;
2343 color_raw.extend_from_slice(&decoded[offset..offset + bytes_per_sample]);
2344 }
2345 let alpha_offset = pixel_start + color_channels * bytes_per_sample;
2347 alpha_raw.extend_from_slice(&decoded[alpha_offset..alpha_offset + bytes_per_sample]);
2348 }
2349 }
2350
2351 let idat_data = zlib_compress(&color_raw)?;
2353 let smask = zlib_compress(&alpha_raw)?;
2354
2355 Some(PngInfo {
2356 width,
2357 height,
2358 bit_depth,
2359 colors: color_channels as u8,
2360 idat_data,
2361 palette: None,
2362 smask: Some(smask),
2363 })
2364}
2365
2366fn paeth_predictor(a: i16, b: i16, c: i16) -> u8 {
2368 let p = a + b - c;
2369 let pa = (p - a).unsigned_abs();
2370 let pb = (p - b).unsigned_abs();
2371 let pc = (p - c).unsigned_abs();
2372 if pa <= pb && pa <= pc {
2373 a as u8
2374 } else if pb <= pc {
2375 b as u8
2376 } else {
2377 c as u8
2378 }
2379}
2380
2381fn zlib_compress(data: &[u8]) -> Option<Vec<u8>> {
2383 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
2384 if encoder.write_all(data).is_err() {
2385 return None;
2386 }
2387 encoder.finish().ok()
2388}
2389
2390enum ImageFormat {
2396 Jpeg {
2398 data: Vec<u8>,
2400 components: u8,
2402 },
2403 Png {
2405 idat_data: Vec<u8>,
2407 bit_depth: u8,
2409 colors: u8,
2411 palette: Option<Vec<u8>>,
2413 smask: Option<Vec<u8>>,
2416 },
2417}
2418
2419struct EmbeddedImage {
2421 width: u32,
2423 height: u32,
2425 format: ImageFormat,
2427}
2428
2429impl EmbeddedImage {
2430 fn num_pdf_objects(&self) -> usize {
2435 match &self.format {
2436 ImageFormat::Jpeg { .. } => 1,
2437 ImageFormat::Png { smask, .. } => {
2438 if smask.is_some() {
2439 2
2440 } else {
2441 1
2442 }
2443 }
2444 }
2445 }
2446}
2447
2448struct PdfDocument {
2454 pages: Vec<Vec<String>>,
2456 y: f32,
2458 num_columns: u32,
2460 current_column: u32,
2462 images: Vec<EmbeddedImage>,
2464 margin_top: f32,
2466 margin_bottom: f32,
2467 margin_left: f32,
2468 margin_right: f32,
2469 cid_glyphs: BTreeMap<u16, char>,
2483 doc_title: Option<String>,
2492}
2493
2494impl PdfDocument {
2495 #[cfg(test)]
2497 fn new() -> Self {
2498 Self::with_margins(MARGIN_TOP, MARGIN_BOTTOM, MARGIN_LEFT, MARGIN_RIGHT)
2499 }
2500
2501 fn with_margins(top: f32, bottom: f32, left: f32, right: f32) -> Self {
2503 Self {
2504 pages: vec![Vec::new()],
2505 y: PAGE_H - top,
2506 num_columns: 1,
2507 current_column: 0,
2508 images: Vec::new(),
2509 margin_top: top,
2510 margin_bottom: bottom,
2511 margin_left: left,
2512 margin_right: right,
2513 cid_glyphs: BTreeMap::new(),
2514 doc_title: None,
2515 }
2516 }
2517
2518 const MAX_TITLE_CHARS: usize = 1024;
2529
2530 fn set_doc_title(&mut self, title: Option<&str>) {
2545 self.doc_title = title.and_then(|t| {
2546 let trimmed = t.trim();
2547 if trimmed.is_empty() {
2548 None
2549 } else if trimmed.chars().count() > Self::MAX_TITLE_CHARS {
2550 Some(trimmed.chars().take(Self::MAX_TITLE_CHARS).collect())
2551 } else {
2552 Some(trimmed.to_string())
2553 }
2554 });
2555 }
2556
2557 const MAX_MARGIN: f32 = 297.0;
2560
2561 fn validate_margin(value: f32, default: f32, name: &str, warnings: &mut Vec<String>) -> f32 {
2572 if !value.is_finite() || !(0.0..=Self::MAX_MARGIN).contains(&value) {
2573 push_warning(
2574 warnings,
2575 format!("invalid pdf.margins.{name} value {value}, using default {default}"),
2576 );
2577 default
2578 } else {
2579 value
2580 }
2581 }
2582
2583 #[cfg(test)]
2588 fn from_config(config: &Config) -> Self {
2589 let mut warnings = Vec::new();
2590 let doc = Self::from_config_with_warnings(config, &mut warnings);
2591 for w in &warnings {
2592 eprintln!("warning: {w}");
2593 }
2594 doc
2595 }
2596
2597 fn from_config_with_warnings(config: &Config, warnings: &mut Vec<String>) -> Self {
2599 let top = config
2600 .get_path("pdf.margins.top")
2601 .as_f64()
2602 .map(|v| Self::validate_margin(v as f32, MARGIN_TOP, "top", warnings))
2603 .unwrap_or(MARGIN_TOP);
2604 let bottom = config
2605 .get_path("pdf.margins.bottom")
2606 .as_f64()
2607 .map(|v| Self::validate_margin(v as f32, MARGIN_BOTTOM, "bottom", warnings))
2608 .unwrap_or(MARGIN_BOTTOM);
2609 let left = config
2610 .get_path("pdf.margins.left")
2611 .as_f64()
2612 .map(|v| Self::validate_margin(v as f32, MARGIN_LEFT, "left", warnings))
2613 .unwrap_or(MARGIN_LEFT);
2614 let right = config
2615 .get_path("pdf.margins.right")
2616 .as_f64()
2617 .map(|v| Self::validate_margin(v as f32, MARGIN_RIGHT, "right", warnings))
2618 .unwrap_or(MARGIN_RIGHT);
2619 Self::with_margins(top, bottom, left, right)
2620 }
2621
2622 fn reset_margins_from_config(&mut self, config: &Config, warnings: &mut Vec<String>) {
2626 self.margin_top = config
2627 .get_path("pdf.margins.top")
2628 .as_f64()
2629 .map(|v| Self::validate_margin(v as f32, MARGIN_TOP, "top", warnings))
2630 .unwrap_or(MARGIN_TOP);
2631 self.margin_bottom = config
2632 .get_path("pdf.margins.bottom")
2633 .as_f64()
2634 .map(|v| Self::validate_margin(v as f32, MARGIN_BOTTOM, "bottom", warnings))
2635 .unwrap_or(MARGIN_BOTTOM);
2636 self.margin_left = config
2637 .get_path("pdf.margins.left")
2638 .as_f64()
2639 .map(|v| Self::validate_margin(v as f32, MARGIN_LEFT, "left", warnings))
2640 .unwrap_or(MARGIN_LEFT);
2641 self.margin_right = config
2642 .get_path("pdf.margins.right")
2643 .as_f64()
2644 .map(|v| Self::validate_margin(v as f32, MARGIN_RIGHT, "right", warnings))
2645 .unwrap_or(MARGIN_RIGHT);
2646 }
2647
2648 fn y(&self) -> f32 {
2650 self.y
2651 }
2652
2653 fn page_count(&self) -> usize {
2655 self.pages.len()
2656 }
2657
2658 fn margin_left(&self) -> f32 {
2660 if self.num_columns <= 1 {
2661 return self.margin_left;
2662 }
2663 let col_width = self.column_width();
2664 let result = self.margin_left + self.current_column as f32 * (col_width + COLUMN_GAP);
2665 debug_assert!(
2666 result.is_finite(),
2667 "margin_left() produced non-finite value"
2668 );
2669 result
2670 }
2671
2672 fn column_width(&self) -> f32 {
2677 let usable_width = PAGE_W - self.margin_left - self.margin_right;
2678 if self.num_columns <= 1 {
2679 return usable_width;
2680 }
2681 let total_gaps = (self.num_columns - 1) as f32 * COLUMN_GAP;
2682 ((usable_width - total_gaps) / self.num_columns as f32).max(0.0)
2683 }
2684
2685 fn set_columns(&mut self, n: u32) {
2687 self.num_columns = n.clamp(1, MAX_COLUMNS);
2688 self.current_column = 0;
2689 }
2690
2691 fn column_break(&mut self) {
2694 if self.num_columns <= 1 {
2695 self.new_page();
2696 return;
2697 }
2698 if self.current_column + 1 < self.num_columns {
2699 self.current_column += 1;
2700 self.y = PAGE_H - self.margin_top;
2701 } else {
2702 self.new_page();
2703 }
2704 }
2705
2706 fn ensure_space(&mut self, needed: f32) {
2709 if self.y - needed < self.margin_bottom {
2710 self.next_column_or_page();
2711 }
2712 }
2713
2714 fn next_column_or_page(&mut self) {
2716 if self.num_columns > 1 && self.current_column + 1 < self.num_columns {
2717 self.current_column += 1;
2718 self.y = PAGE_H - self.margin_top;
2719 } else {
2720 self.new_page();
2721 }
2722 }
2723
2724 fn new_page(&mut self) {
2729 if self.pages.len() >= MAX_PAGES {
2730 return;
2731 }
2732 self.pages.push(Vec::new());
2733 self.y = PAGE_H - self.margin_top;
2734 self.current_column = 0;
2735 }
2736
2737 fn text(&mut self, text: &str, font: Font, size: f32) {
2739 let x = self.margin_left();
2740 self.text_at(text, font, size, x, self.y);
2741 }
2742
2743 fn text_at_raw(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
2753 let segments = text_segments(text);
2754 let mut cid_mappings: Vec<(u16, char)> = Vec::new();
2756 let mut ops_batch: Vec<String> = Vec::new();
2757 let mut cur_x = x;
2758 for (is_cid, seg) in &segments {
2759 ops_batch.push("BT".to_string());
2760 if *is_cid {
2761 let (hex, mappings) = encode_cid_text(seg);
2762 cid_mappings.extend_from_slice(&mappings);
2763 ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
2764 ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2765 ops_batch.push(format!("<{}> Tj", hex));
2766 cur_x += cid_text_width(seg, size);
2767 } else {
2768 ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
2769 ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2770 ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
2771 cur_x += text_width(seg, size);
2772 }
2773 ops_batch.push("ET".to_string());
2774 }
2775 self.current_page_mut().extend(ops_batch);
2776 for (gid, ch) in cid_mappings {
2777 self.cid_glyphs.entry(gid).or_insert(ch);
2781 }
2782 }
2783
2784 fn text_at(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
2792 let clip = self.num_columns > 1;
2793 let col_right = if clip {
2794 self.margin_left() + self.column_width()
2795 } else {
2796 0.0
2797 };
2798 let segments = text_segments(text);
2799 let mut cid_mappings: Vec<(u16, char)> = Vec::new();
2800 let mut ops_batch: Vec<String> = Vec::new();
2801 if clip {
2802 let clip_w = (col_right - x).max(0.0);
2803 ops_batch.push("q".to_string());
2804 ops_batch.push(format!(
2805 "{} {} {} {} re W n",
2806 fmt_f32(x),
2807 fmt_f32(0.0),
2808 fmt_f32(clip_w),
2809 fmt_f32(PAGE_H)
2810 ));
2811 }
2812 let mut cur_x = x;
2813 for (is_cid, seg) in &segments {
2814 ops_batch.push("BT".to_string());
2815 if *is_cid {
2816 let (hex, mappings) = encode_cid_text(seg);
2817 cid_mappings.extend_from_slice(&mappings);
2818 ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
2819 ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2820 ops_batch.push(format!("<{}> Tj", hex));
2821 cur_x += cid_text_width(seg, size);
2822 } else {
2823 ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
2824 ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2825 ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
2826 cur_x += text_width(seg, size);
2827 }
2828 ops_batch.push("ET".to_string());
2829 }
2830 if clip {
2831 ops_batch.push("Q".to_string());
2832 }
2833 self.current_page_mut().extend(ops_batch);
2834 for (gid, ch) in cid_mappings {
2835 self.cid_glyphs.entry(gid).or_insert(ch);
2839 }
2840 }
2841
2842 fn white_text_at(&mut self, text: &str, font: Font, size: f32, x: f32, y: f32) {
2850 let clip = self.num_columns > 1;
2851 let col_right = if clip {
2852 self.margin_left() + self.column_width()
2853 } else {
2854 0.0
2855 };
2856 let segments = text_segments(text);
2857 let mut cid_mappings: Vec<(u16, char)> = Vec::new();
2858 let mut ops_batch: Vec<String> = Vec::new();
2859 if clip {
2860 let clip_w = (col_right - x).max(0.0);
2861 ops_batch.push("q".to_string());
2862 ops_batch.push(format!(
2863 "{} {} {} {} re W n",
2864 fmt_f32(x),
2865 fmt_f32(0.0),
2866 fmt_f32(clip_w),
2867 fmt_f32(PAGE_H)
2868 ));
2869 }
2870 let mut cur_x = x;
2871 for (is_cid, seg) in &segments {
2872 ops_batch.push("BT".to_string());
2873 ops_batch.push("1 1 1 rg".to_string()); if *is_cid {
2875 let (hex, mappings) = encode_cid_text(seg);
2876 cid_mappings.extend_from_slice(&mappings);
2877 ops_batch.push(format!("/F5 {} Tf", fmt_f32(size)));
2878 ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2879 ops_batch.push(format!("<{}> Tj", hex));
2880 cur_x += cid_text_width(seg, size);
2881 } else {
2882 ops_batch.push(format!("{} {} Tf", font.pdf_name(), fmt_f32(size)));
2883 ops_batch.push(format!("{} {} Td", fmt_f32(cur_x), fmt_f32(y)));
2884 ops_batch.push(format!("({}) Tj", pdf_escape(seg)));
2885 cur_x += text_width(seg, size);
2886 }
2887 ops_batch.push("ET".to_string());
2888 ops_batch.push("0 0 0 rg".to_string()); }
2890 if clip {
2891 ops_batch.push("Q".to_string());
2892 }
2893 self.current_page_mut().extend(ops_batch);
2894 for (gid, ch) in cid_mappings {
2895 self.cid_glyphs.entry(gid).or_insert(ch);
2899 }
2900 }
2901
2902 fn newline(&mut self, amount: f32) {
2904 self.y -= amount;
2905 if self.y < self.margin_bottom {
2906 self.next_column_or_page();
2907 }
2908 }
2909
2910 fn advance_y(&mut self, amount: f32) {
2914 self.y -= amount;
2915 }
2916
2917 fn line_at(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, width: f32) {
2922 let ops = self.current_page_mut();
2923 ops.push("q".to_string());
2924 ops.push(format!("{} w", fmt_f32(width)));
2925 ops.push(format!(
2926 "{} {} m {} {} l S",
2927 fmt_f32(x1),
2928 fmt_f32(y1),
2929 fmt_f32(x2),
2930 fmt_f32(y2)
2931 ));
2932 ops.push("Q".to_string());
2933 }
2934
2935 fn filled_rect_color(&mut self, x: f32, y: f32, w: f32, h: f32, color: (f32, f32, f32)) {
2941 let (r, g, b) = color;
2942 let ops = self.current_page_mut();
2943 ops.push("q".to_string());
2944 ops.push(format!("{} {} {} rg", fmt_f32(r), fmt_f32(g), fmt_f32(b)));
2945 ops.push(format!(
2946 "{} {} {} {} re f",
2947 fmt_f32(x),
2948 fmt_f32(y),
2949 fmt_f32(w),
2950 fmt_f32(h)
2951 ));
2952 ops.push("Q".to_string());
2953 }
2954
2955 fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, line_width: f32) {
2960 let ops = self.current_page_mut();
2961 ops.push("q".to_string());
2962 ops.push(format!("{} w", fmt_f32(line_width)));
2963 ops.push(format!(
2964 "{} {} {} {} re S",
2965 fmt_f32(x),
2966 fmt_f32(y),
2967 fmt_f32(w),
2968 fmt_f32(h)
2969 ));
2970 ops.push("Q".to_string());
2971 }
2972
2973 fn filled_circle_at(&mut self, cx: f32, cy: f32, r: f32) {
2975 let k = r * 0.5523;
2977 let ops = self.current_page_mut();
2978 ops.push(format!(
2979 "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f",
2980 fmt_f32(cx + r), fmt_f32(cy),
2981 fmt_f32(cx + r), fmt_f32(cy + k), fmt_f32(cx + k), fmt_f32(cy + r), fmt_f32(cx), fmt_f32(cy + r),
2982 fmt_f32(cx - k), fmt_f32(cy + r), fmt_f32(cx - r), fmt_f32(cy + k), fmt_f32(cx - r), fmt_f32(cy),
2983 fmt_f32(cx - r), fmt_f32(cy - k), fmt_f32(cx - k), fmt_f32(cy - r), fmt_f32(cx), fmt_f32(cy - r),
2984 fmt_f32(cx + k), fmt_f32(cy - r), fmt_f32(cx + r), fmt_f32(cy - k), fmt_f32(cx + r), fmt_f32(cy),
2985 ));
2986 }
2987
2988 fn stroked_circle_at(&mut self, cx: f32, cy: f32, r: f32) {
2990 let k = r * 0.5523;
2991 let ops = self.current_page_mut();
2992 ops.push("0.5 w".to_string());
2993 ops.push(format!(
2994 "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S",
2995 fmt_f32(cx + r), fmt_f32(cy),
2996 fmt_f32(cx + r), fmt_f32(cy + k), fmt_f32(cx + k), fmt_f32(cy + r), fmt_f32(cx), fmt_f32(cy + r),
2997 fmt_f32(cx - k), fmt_f32(cy + r), fmt_f32(cx - r), fmt_f32(cy + k), fmt_f32(cx - r), fmt_f32(cy),
2998 fmt_f32(cx - r), fmt_f32(cy - k), fmt_f32(cx - k), fmt_f32(cy - r), fmt_f32(cx), fmt_f32(cy - r),
2999 fmt_f32(cx + k), fmt_f32(cy - r), fmt_f32(cx + r), fmt_f32(cy - k), fmt_f32(cx + r), fmt_f32(cy),
3000 ));
3001 }
3002
3003 fn embed_jpeg(&mut self, data: Vec<u8>, width: u32, height: u32, components: u8) -> usize {
3008 let idx = self.images.len();
3009 self.images.push(EmbeddedImage {
3010 width,
3011 height,
3012 format: ImageFormat::Jpeg { data, components },
3013 });
3014 idx
3015 }
3016
3017 fn embed_png(&mut self, info: PngInfo) -> usize {
3022 let idx = self.images.len();
3023 self.images.push(EmbeddedImage {
3024 width: info.width,
3025 height: info.height,
3026 format: ImageFormat::Png {
3027 idat_data: info.idat_data,
3028 bit_depth: info.bit_depth,
3029 colors: info.colors,
3030 palette: info.palette,
3031 smask: info.smask,
3032 },
3033 });
3034 idx
3035 }
3036
3037 fn draw_image(&mut self, img_idx: usize, x: f32, y: f32, w: f32, h: f32) {
3042 let name = format!("/Im{}", img_idx + 1);
3043 let ops = self.current_page_mut();
3044 ops.push("q".to_string());
3045 ops.push(format!(
3046 "{} 0 0 {} {} {} cm",
3047 fmt_f32(w),
3048 fmt_f32(h),
3049 fmt_f32(x),
3050 fmt_f32(y)
3051 ));
3052 ops.push(format!("{name} Do"));
3053 ops.push("Q".to_string());
3054 }
3055
3056 fn current_page_mut(&mut self) -> &mut Vec<String> {
3058 self.pages.last_mut().expect("pages is never empty")
3060 }
3061
3062 fn take_pages(&mut self) -> Vec<Vec<String>> {
3065 let pages = std::mem::take(&mut self.pages);
3066 self.pages.push(Vec::new());
3067 self.y = PAGE_H - self.margin_top;
3068 self.current_column = 0;
3069 pages
3070 }
3071
3072 fn push_page(&mut self, ops: Vec<String>) {
3077 if self.pages.len() >= MAX_PAGES {
3078 return;
3079 }
3080 self.pages.push(ops);
3081 }
3082
3083 fn build_pdf(&self) -> Vec<u8> {
3085 let num_pages = self.pages.len();
3086 let num_images = self.images.len();
3087 const CID_OBJ_COUNT: usize = 5;
3090 let cid_needed = !self.cid_glyphs.is_empty();
3091 let extra_objs = if cid_needed { CID_OBJ_COUNT } else { 0 };
3092 let mut offsets: Vec<usize> = Vec::new();
3093 let mut pdf = Vec::<u8>::new();
3094
3095 pdf.extend_from_slice(b"%PDF-1.4\n");
3097
3098 offsets.push(pdf.len());
3100 pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
3101
3102 offsets.push(pdf.len());
3104 let font_refs: String = FONTS
3105 .iter()
3106 .enumerate()
3107 .map(|(i, _)| format!("{} {} 0 R", FONTS[i].pdf_name(), i + 3))
3108 .collect::<Vec<_>>()
3109 .join(" ");
3110
3111 let cid_font_ref = if cid_needed {
3114 format!(" /F5 {} 0 R", 3 + FONTS.len())
3115 } else {
3116 String::new()
3117 };
3118
3119 let image_obj_base = 3 + FONTS.len() + extra_objs; let xobject_refs = if num_images > 0 {
3124 let mut refs = Vec::new();
3125 let mut obj_offset = 0;
3126 for (i, img) in self.images.iter().enumerate() {
3127 refs.push(format!("/Im{} {} 0 R", i + 1, image_obj_base + obj_offset));
3128 obj_offset += img.num_pdf_objects();
3129 }
3130 format!(" /XObject << {} >>", refs.join(" "))
3131 } else {
3132 String::new()
3133 };
3134
3135 let procset = if num_images > 0 {
3136 "/ProcSet [/PDF /Text /ImageB /ImageC]"
3137 } else {
3138 "/ProcSet [/PDF /Text]"
3139 };
3140
3141 let total_image_objects: usize = self.images.iter().map(|img| img.num_pdf_objects()).sum();
3143 let page_obj_start = 3 + FONTS.len() + extra_objs + total_image_objects;
3144 let kids: String = (0..num_pages)
3145 .map(|i| format!("{} 0 R", page_obj_start + i * 2))
3146 .collect::<Vec<_>>()
3147 .join(" ");
3148 let obj2 = format!(
3149 "2 0 obj\n<< /Type /Pages /MediaBox [0 0 {} {}] /Resources << /Font << {}{} >>{} {} >> /Kids [{}] /Count {} >>\nendobj\n",
3150 fmt_f32(PAGE_W),
3151 fmt_f32(PAGE_H),
3152 font_refs,
3153 cid_font_ref,
3154 xobject_refs,
3155 procset,
3156 kids,
3157 num_pages
3158 );
3159 pdf.extend_from_slice(obj2.as_bytes());
3160
3161 for font in &FONTS {
3163 offsets.push(pdf.len());
3164 let obj_num = offsets.len();
3165 let obj = format!(
3166 "{} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /{} /Encoding /WinAnsiEncoding >>\nendobj\n",
3167 obj_num,
3168 font.base_name()
3169 );
3170 pdf.extend_from_slice(obj.as_bytes());
3171 }
3172
3173 if cid_needed {
3181 let f5_obj = 3 + FONTS.len();
3182 let cid_dict_obj = f5_obj + 1;
3183 let desc_obj = f5_obj + 2;
3184 let font_file_obj = f5_obj + 3;
3185 let to_unicode_obj = f5_obj + 4;
3186
3187 let face = unicode_face();
3196 debug_assert_eq!(
3197 face.units_per_em(),
3198 1000,
3199 "CID font /W values assume UPM=1000; scale advances by 1000/upe if the font changes"
3200 );
3201 let upe = face.units_per_em() as i32;
3202 let scale = |v: i32| v * 1000 / upe;
3204 let ascender = scale(face.ascender() as i32);
3205 let descender = scale(face.descender() as i32);
3206 let cap_height = scale(
3207 face.capital_height()
3208 .map(|h| h as i32)
3209 .unwrap_or(face.ascender() as i32),
3210 );
3211 let bbox = face.global_bounding_box();
3212 let llx = scale(bbox.x_min as i32);
3213 let lly = scale(bbox.y_min as i32);
3214 let urx = scale(bbox.x_max as i32);
3215 let ury = scale(bbox.y_max as i32);
3216
3217 const DW: u16 = 1000;
3222 let width_array: String = {
3223 let entries: Vec<String> = self
3224 .cid_glyphs
3225 .keys()
3226 .filter_map(|&gid| {
3227 let advance = face
3228 .glyph_hor_advance(ttf_parser::GlyphId(gid))
3229 .unwrap_or(DW);
3230 if advance != DW {
3231 Some(format!("{} [{}]", gid, advance))
3232 } else {
3233 None
3234 }
3235 })
3236 .collect();
3237 if entries.is_empty() {
3238 String::new()
3239 } else {
3240 format!(" /W [{}]", entries.join(" "))
3241 }
3242 };
3243
3244 offsets.push(pdf.len());
3246 pdf.extend_from_slice(
3247 format!(
3248 "{f5_obj} 0 obj\n<< /Type /Font /Subtype /Type0 \
3249 /BaseFont /NotoSansCJK-Regular-Subset /Encoding /Identity-H \
3250 /DescendantFonts [{cid_dict_obj} 0 R] \
3251 /ToUnicode {to_unicode_obj} 0 R >>\nendobj\n"
3252 )
3253 .as_bytes(),
3254 );
3255
3256 offsets.push(pdf.len());
3258 pdf.extend_from_slice(
3259 format!(
3260 "{cid_dict_obj} 0 obj\n<< /Type /Font /Subtype /CIDFontType0 \
3261 /BaseFont /NotoSansCJK-Regular-Subset \
3262 /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
3263 /FontDescriptor {desc_obj} 0 R /DW {DW}{width_array} >>\nendobj\n"
3264 )
3265 .as_bytes(),
3266 );
3267
3268 offsets.push(pdf.len());
3270 pdf.extend_from_slice(
3271 format!(
3272 "{desc_obj} 0 obj\n<< /Type /FontDescriptor \
3273 /FontName /NotoSansCJK-Regular-Subset /Flags 6 \
3274 /FontBBox [{llx} {lly} {urx} {ury}] /ItalicAngle 0 \
3275 /Ascent {ascender} /Descent {descender} /CapHeight {cap_height} \
3276 /StemV 80 /FontFile3 {font_file_obj} 0 R >>\nendobj\n"
3277 )
3278 .as_bytes(),
3279 );
3280
3281 let cff_bytes = unicode_cff_bytes();
3285 offsets.push(pdf.len());
3286 pdf.extend_from_slice(
3287 format!(
3288 "{font_file_obj} 0 obj\n<< /Subtype /CIDFontType0C /Length {} >>\nstream\n",
3289 cff_bytes.len()
3290 )
3291 .as_bytes(),
3292 );
3293 pdf.extend_from_slice(cff_bytes);
3294 pdf.extend_from_slice(b"\nendstream\nendobj\n");
3295
3296 offsets.push(pdf.len());
3298 let cmap_body = build_to_unicode_cmap(&self.cid_glyphs);
3299 pdf.extend_from_slice(
3300 format!(
3301 "{to_unicode_obj} 0 obj\n<< /Length {} >>\nstream\n",
3302 cmap_body.len()
3303 )
3304 .as_bytes(),
3305 );
3306 pdf.extend_from_slice(cmap_body.as_bytes());
3307 pdf.extend_from_slice(b"\nendstream\nendobj\n");
3308 }
3309
3310 for img in &self.images {
3312 match &img.format {
3313 ImageFormat::Jpeg { data, components } => {
3314 offsets.push(pdf.len());
3315 let obj_num = offsets.len();
3316 let color_space = match components {
3317 1 => "/DeviceGray",
3318 4 => "/DeviceCMYK",
3319 _ => "/DeviceRGB",
3320 };
3321 let header = format!(
3322 "{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n",
3323 obj_num,
3324 img.width,
3325 img.height,
3326 color_space,
3327 data.len()
3328 );
3329 pdf.extend_from_slice(header.as_bytes());
3330 pdf.extend_from_slice(data);
3331 pdf.extend_from_slice(b"\nendstream\nendobj\n");
3332 }
3333 ImageFormat::Png {
3334 idat_data,
3335 bit_depth,
3336 colors,
3337 palette,
3338 smask,
3339 } => {
3340 let smask_obj_num = if smask.is_some() {
3343 offsets.push(pdf.len());
3344 let sobj = offsets.len();
3345 let smask_data = smask.as_ref().expect("checked above");
3346 let smask_header = format!(
3347 "{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent {} /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 1 /BitsPerComponent {} /Columns {} >> /Length {} >>\nstream\n",
3348 sobj,
3349 img.width,
3350 img.height,
3351 bit_depth,
3352 bit_depth,
3353 img.width,
3354 smask_data.len()
3355 );
3356 pdf.extend_from_slice(smask_header.as_bytes());
3357 pdf.extend_from_slice(smask_data);
3358 pdf.extend_from_slice(b"\nendstream\nendobj\n");
3359 Some(sobj)
3360 } else {
3361 None
3362 };
3363
3364 offsets.push(pdf.len());
3365 let obj_num = offsets.len();
3366
3367 let color_space = match (colors, palette) {
3368 (_, Some(pal)) => {
3369 let num_entries = pal.len() / 3;
3371 let max_idx = if num_entries > 0 { num_entries - 1 } else { 0 };
3372 let hex: String = pal.iter().map(|b| format!("{b:02x}")).collect();
3373 format!("[/Indexed /DeviceRGB {} <{}>]", max_idx, hex)
3374 }
3375 (1, None) => "/DeviceGray".to_string(),
3376 _ => "/DeviceRGB".to_string(),
3377 };
3378
3379 let smask_ref = smask_obj_num
3380 .map(|n| format!(" /SMask {} 0 R", n))
3381 .unwrap_or_default();
3382
3383 let header = format!(
3384 "{} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {} /BitsPerComponent {} /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors {} /BitsPerComponent {} /Columns {} >>{} /Length {} >>\nstream\n",
3385 obj_num,
3386 img.width,
3387 img.height,
3388 color_space,
3389 bit_depth,
3390 colors,
3391 bit_depth,
3392 img.width,
3393 smask_ref,
3394 idat_data.len()
3395 );
3396 pdf.extend_from_slice(header.as_bytes());
3397 pdf.extend_from_slice(idat_data);
3398 pdf.extend_from_slice(b"\nendstream\nendobj\n");
3399 }
3400 }
3401 }
3402
3403 for (i, page_ops) in self.pages.iter().enumerate() {
3405 let page_obj_num = page_obj_start + i * 2;
3406 let content_obj_num = page_obj_num + 1;
3407
3408 offsets.push(pdf.len());
3410 let page_obj = format!(
3411 "{} 0 obj\n<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>\nendobj\n",
3412 page_obj_num, content_obj_num
3413 );
3414 pdf.extend_from_slice(page_obj.as_bytes());
3415
3416 let content = page_ops.join("\n");
3418 offsets.push(pdf.len());
3419 let stream_obj = format!(
3423 "{} 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
3424 content_obj_num,
3425 content.len(),
3426 content
3427 );
3428 pdf.extend_from_slice(stream_obj.as_bytes());
3429 }
3430
3431 let info_obj_num = if let Some(title) = &self.doc_title {
3442 offsets.push(pdf.len());
3443 let n = offsets.len();
3444 let title_hex = pdf_title_hex_string(title);
3445 pdf.extend_from_slice(
3446 format!("{n} 0 obj\n<< /Title {title_hex} >>\nendobj\n").as_bytes(),
3447 );
3448 Some(n)
3449 } else {
3450 None
3451 };
3452
3453 let xref_offset = pdf.len();
3455 let num_objects = offsets.len() + 1; pdf.extend_from_slice(format!("xref\n0 {num_objects}\n").as_bytes());
3457 pdf.extend_from_slice(b"0000000000 65535 f \n");
3458 for offset in &offsets {
3459 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
3460 }
3461
3462 let info_ref = info_obj_num
3464 .map(|n| format!(" /Info {n} 0 R"))
3465 .unwrap_or_default();
3466 pdf.extend_from_slice(
3467 format!(
3468 "trailer\n<< /Size {num_objects} /Root 1 0 R{info_ref} >>\nstartxref\n{xref_offset}\n%%EOF\n"
3469 )
3470 .as_bytes(),
3471 );
3472
3473 pdf
3474 }
3475}
3476
3477#[derive(Clone, Copy)]
3483enum Font {
3484 Helvetica,
3485 HelveticaBold,
3486 HelveticaOblique,
3487 HelveticaBoldOblique,
3488 Courier,
3492}
3493
3494impl Font {
3495 fn pdf_name(self) -> &'static str {
3501 match self {
3502 Self::Helvetica => "/F1",
3503 Self::HelveticaBold => "/F2",
3504 Self::HelveticaOblique => "/F3",
3505 Self::HelveticaBoldOblique => "/F4",
3506 Self::Courier => "/F6",
3507 }
3508 }
3509
3510 fn base_name(self) -> &'static str {
3512 match self {
3513 Self::Helvetica => "Helvetica",
3514 Self::HelveticaBold => "Helvetica-Bold",
3515 Self::HelveticaOblique => "Helvetica-Oblique",
3516 Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
3517 Self::Courier => "Courier",
3518 }
3519 }
3520}
3521
3522const FONTS: [Font; 5] = [
3526 Font::Helvetica,
3527 Font::HelveticaBold,
3528 Font::HelveticaOblique,
3529 Font::HelveticaBoldOblique,
3530 Font::Courier,
3531];
3532
3533fn pdf_escape(s: &str) -> String {
3549 let mut out = String::with_capacity(s.len());
3550 for c in s.chars() {
3551 match c {
3552 '\\' => out.push_str("\\\\"),
3553 '(' => out.push_str("\\("),
3554 ')' => out.push_str("\\)"),
3555 _ if c.is_ascii() => out.push(c),
3556 '\u{00A0}'..='\u{00FF}' => {
3558 let byte = c as u32;
3559 out.push_str(&format!("\\{byte:03o}"));
3560 }
3561 _ => {
3564 if let Some(byte) = winansi_byte(c) {
3565 out.push_str(&format!("\\{byte:03o}"));
3566 } else {
3567 out.push('?');
3568 }
3569 }
3570 }
3571 }
3572 out
3573}
3574
3575fn winansi_byte(c: char) -> Option<u32> {
3581 match c {
3582 '\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,
3610 }
3611}
3612
3613fn fmt_f32(v: f32) -> String {
3618 if !v.is_finite() {
3619 return "0".to_string();
3620 }
3621 let s = format!("{v:.2}");
3622 if s.contains('.') {
3624 s.trim_end_matches('0').trim_end_matches('.').to_string()
3625 } else {
3626 s
3627 }
3628}
3629
3630#[cfg(test)]
3635mod tests {
3636 use super::*;
3637
3638 #[test]
3639 fn test_produces_valid_pdf() {
3640 let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello [G]world").unwrap();
3641 let bytes = render_song(&song);
3642 assert!(!bytes.is_empty());
3643 assert!(bytes.starts_with(b"%PDF-1.4"));
3644 assert!(bytes.ends_with(b"%%EOF\n"));
3645 }
3646
3647 #[test]
3648 fn test_empty_song() {
3649 let song = chordsketch_chordpro::parse("").unwrap();
3650 let bytes = render_song(&song);
3651 assert!(bytes.starts_with(b"%PDF"));
3652 }
3653
3654 #[test]
3655 fn test_try_render_success() {
3656 let result = try_render("{title: Test}\n[G]Hello");
3657 assert!(result.is_ok());
3658 assert!(result.unwrap().starts_with(b"%PDF"));
3659 }
3660
3661 #[test]
3662 fn test_try_render_error() {
3663 let result = try_render("{unclosed");
3664 assert!(result.is_err());
3665 }
3666
3667 #[test]
3668 fn test_full_song() {
3669 let input = "\
3670{title: Amazing Grace}
3671{subtitle: Traditional}
3672
3673{start_of_verse}
3674[G]Amazing [G7]grace
3675{end_of_verse}
3676
3677{comment: Repeat}";
3678 let song = chordsketch_chordpro::parse(input).unwrap();
3679 let bytes = render_song(&song);
3680 assert!(bytes.starts_with(b"%PDF"));
3681 let content = String::from_utf8_lossy(&bytes);
3683 assert!(content.contains("Amazing Grace"));
3684 }
3685
3686 #[test]
3687 fn test_stream_length_matches_content() {
3688 let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
3689 let bytes = render_song(&song);
3690 let content = String::from_utf8_lossy(&bytes);
3691
3692 for length_match in content.match_indices("/Length ") {
3695 let after = &content[length_match.0 + 8..];
3696 let end = after.find(' ').or_else(|| after.find('>')).unwrap();
3697 let declared_len: usize = after[..end].trim().parse().unwrap();
3698
3699 let stream_start_offset =
3701 length_match.0 + content[length_match.0..].find("stream\n").unwrap() + 7;
3702 let endstream_offset =
3703 length_match.0 + content[length_match.0..].find("\nendstream").unwrap();
3704 let actual_len = endstream_offset - stream_start_offset;
3705 assert_eq!(
3706 declared_len, actual_len,
3707 "/Length {declared_len} does not match actual stream size {actual_len}"
3708 );
3709 }
3710 }
3711
3712 #[test]
3713 fn test_pdf_escape() {
3714 assert_eq!(pdf_escape("hello"), "hello");
3715 assert_eq!(pdf_escape("a(b)c"), "a\\(b\\)c");
3716 assert_eq!(pdf_escape("back\\slash"), "back\\\\slash");
3717 }
3718
3719 #[test]
3720 fn test_pdf_escape_latin1_accented() {
3721 assert_eq!(pdf_escape("café"), "caf\\351");
3723 assert_eq!(pdf_escape("über"), "\\374ber");
3725 assert_eq!(pdf_escape("España"), "Espa\\361a");
3727 assert_eq!(pdf_escape("Straße"), "Stra\\337e");
3729 }
3730
3731 #[test]
3732 fn test_pdf_escape_non_latin1_replaced() {
3733 assert_eq!(pdf_escape("日本語"), "???");
3738 assert_eq!(pdf_escape("hello 世界"), "hello ??");
3739 }
3740
3741 #[test]
3744 fn test_cjk_renders_via_cid_font() {
3745 let song = chordsketch_chordpro::parse("{title: 桜}\n日本語の歌詞").unwrap();
3746 let bytes = render_song(&song);
3747 assert!(bytes.starts_with(b"%PDF"), "must produce a PDF");
3748
3749 let text = String::from_utf8_lossy(&bytes);
3750 assert!(
3752 text.contains("/Subtype /CIDFontType0"),
3753 "CIDFontType0 object must appear when CJK glyphs are used"
3754 );
3755 assert!(
3756 text.contains("/Subtype /Type0"),
3757 "Type0 composite font wrapper must be present"
3758 );
3759 assert!(
3760 text.contains("/Encoding /Identity-H"),
3761 "Identity-H encoding must be specified for the CID font"
3762 );
3763 assert!(
3767 bytes.windows(6).any(|w| {
3768 w[0] == b'<' && w[1..5].iter().all(|b| b.is_ascii_hexdigit()) && w[5] == b'>'
3769 }),
3770 "CID hex glyph sequence must appear in content stream"
3771 );
3772 }
3773
3774 #[test]
3777 fn test_mixed_ascii_and_cjk() {
3778 let song =
3779 chordsketch_chordpro::parse("{title: Sakura 桜}\n[Am]Hello [G]世界\nEnd of song")
3780 .unwrap();
3781 let bytes = render_song(&song);
3782 assert!(bytes.starts_with(b"%PDF"));
3783 let text = String::from_utf8_lossy(&bytes);
3784 assert!(
3786 text.contains("Helvetica"),
3787 "Helvetica Type1 font must be present"
3788 );
3789 assert!(
3790 text.contains("CIDFontType0"),
3791 "CID font must be present for kanji"
3792 );
3793 }
3794
3795 #[test]
3798 fn test_ascii_only_has_no_cid_font() {
3799 let song = chordsketch_chordpro::parse("{title: Test}\n[G]Hello world").unwrap();
3800 let bytes = render_song(&song);
3801 let text = String::from_utf8_lossy(&bytes);
3802 assert!(
3803 !text.contains("CIDFontType0"),
3804 "CID font must not appear in ASCII-only PDFs"
3805 );
3806 }
3807
3808 #[test]
3809 fn test_missing_glyph_gid0_not_in_to_unicode_cmap() {
3810 let song = chordsketch_chordpro::parse("{title: T}\n\u{1F600}\u{1F601}").unwrap();
3819 let bytes = render_song(&song);
3820 let text = String::from_utf8_lossy(&bytes);
3821 assert!(
3823 text.contains("<00000000>"),
3824 "both missing glyphs should emit GID 0"
3825 );
3826 assert!(
3828 !text.contains("<0000> <"),
3829 "GID 0 (.notdef) must not appear as a CMap source entry"
3830 );
3831 assert!(
3835 text.contains("CIDFontType0"),
3836 "CID font chain must be emitted even when all glyphs map to GID 0"
3837 );
3838 }
3839
3840 #[test]
3841 fn test_pdf_escape_mixed_ascii_latin1() {
3842 assert_eq!(pdf_escape("résumé"), "r\\351sum\\351");
3843 assert_eq!(pdf_escape("a\u{00A0}b"), "a\\240b");
3845 }
3846
3847 #[test]
3848 fn test_pdf_escape_winansi_0x80_range() {
3849 assert_eq!(pdf_escape("\u{20AC}"), "\\200");
3851 assert_eq!(pdf_escape("\u{2018}"), "\\221");
3853 assert_eq!(pdf_escape("\u{2019}"), "\\222");
3855 assert_eq!(pdf_escape("\u{201C}"), "\\223");
3857 assert_eq!(pdf_escape("\u{201D}"), "\\224");
3859 assert_eq!(pdf_escape("\u{2013}"), "\\226");
3861 assert_eq!(pdf_escape("\u{2014}"), "\\227");
3863 assert_eq!(pdf_escape("\u{2026}"), "\\205");
3865 assert_eq!(pdf_escape("\u{2122}"), "\\231");
3867 assert_eq!(pdf_escape("\u{2022}"), "\\225");
3869 }
3870
3871 #[test]
3872 fn test_pdf_escape_winansi_mixed() {
3873 assert_eq!(
3875 pdf_escape("\u{201C}Don\u{2019}t stop\u{201D}"),
3876 "\\223Don\\222t stop\\224"
3877 );
3878 assert_eq!(pdf_escape("\u{20AC}50"), "\\20050");
3880 }
3881
3882 #[test]
3883 fn test_render_grid_section() {
3884 let input = "{start_of_grid}\n| Am . | C . |\n{end_of_grid}";
3885 let song = chordsketch_chordpro::parse(input).unwrap();
3886 let bytes = render_song(&song);
3887 assert!(bytes.starts_with(b"%PDF"));
3888 let content = String::from_utf8_lossy(&bytes);
3889 assert!(content.contains("Grid"));
3890 }
3891
3892 #[test]
3895 fn test_custom_section_in_pdf() {
3896 let input = "\
3897{title: Test}
3898
3899{start_of_intro: Guitar}
3900[Am]Intro line
3901{end_of_intro}";
3902 let song = chordsketch_chordpro::parse(input).unwrap();
3903 let bytes = render_song(&song);
3904 let content = String::from_utf8_lossy(&bytes);
3905 assert!(content.contains("Intro: Guitar"));
3906 }
3907
3908 #[test]
3911 fn test_chorus_recall_produces_valid_pdf() {
3912 let input = "\
3913{start_of_chorus}
3914[G]La la la
3915{end_of_chorus}
3916
3917{chorus}";
3918 let song = chordsketch_chordpro::parse(input).unwrap();
3919 let bytes = render_song(&song);
3920 assert!(bytes.starts_with(b"%PDF-1.4"));
3921 assert!(bytes.ends_with(b"%%EOF\n"));
3922 let content = String::from_utf8_lossy(&bytes);
3924 assert!(content.matches("Chorus").count() >= 2);
3925 }
3926
3927 #[test]
3928 fn test_chorus_recall_with_label() {
3929 let input = "\
3930{start_of_chorus}
3931Sing along
3932{end_of_chorus}
3933
3934{chorus: Repeat}";
3935 let song = chordsketch_chordpro::parse(input).unwrap();
3936 let bytes = render_song(&song);
3937 let content = String::from_utf8_lossy(&bytes);
3938 assert!(content.contains("Chorus: Repeat"));
3939 }
3940
3941 #[test]
3942 fn test_chorus_recall_no_chorus_defined() {
3943 let input = "{chorus}";
3944 let song = chordsketch_chordpro::parse(input).unwrap();
3945 let bytes = render_song(&song);
3946 assert!(bytes.starts_with(b"%PDF"));
3947 let content = String::from_utf8_lossy(&bytes);
3948 assert!(content.contains("Chorus"));
3949 }
3950
3951 #[test]
3952 fn test_chorus_recall_limit_exceeded() {
3953 let mut input = String::from("{start_of_chorus}\nChorus line\n{end_of_chorus}\n");
3954 for _ in 0..1005 {
3955 input.push_str("{chorus}\n");
3956 }
3957 let song = chordsketch_chordpro::parse(&input).unwrap();
3958 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3959 assert!(result.output.starts_with(b"%PDF"));
3960 assert!(
3961 result
3962 .warnings
3963 .iter()
3964 .any(|w| w.contains("chorus recall limit")),
3965 "should warn when chorus recall limit is exceeded"
3966 );
3967 }
3968
3969 #[test]
3970 fn test_chorus_recall_respects_diagrams_off() {
3971 let input = "\
3974{diagrams: off}
3975{start_of_chorus}
3976{define: Am base-fret 1 frets x 0 2 2 1 0}
3977[Am]Chorus line
3978{end_of_chorus}
3979{chorus}";
3980 let song = chordsketch_chordpro::parse(input).unwrap();
3981 let bytes = render_song(&song);
3982 let content = String::from_utf8_lossy(&bytes);
3983 let input_on = "\
3987{start_of_chorus}
3988{define: Am base-fret 1 frets x 0 2 2 1 0}
3989[Am]Chorus line
3990{end_of_chorus}
3991{chorus}";
3992 let song_on = chordsketch_chordpro::parse(input_on).unwrap();
3993 let bytes_on = render_song(&song_on);
3994 let content_on = String::from_utf8_lossy(&bytes_on);
3995 let diagram_lines_off = content.matches("l S").count();
3998 let diagram_lines_on = content_on.matches("l S").count();
3999 assert!(
4000 diagram_lines_on > diagram_lines_off,
4001 "diagrams=on should produce more line ops than diagrams=off"
4002 );
4003 }
4004
4005 #[test]
4008 fn test_diagrams_off_case_insensitive_pdf() {
4009 let input = "{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}";
4010 let song = chordsketch_chordpro::parse(input).unwrap();
4011 let bytes = render_song(&song);
4012 let content = String::from_utf8_lossy(&bytes);
4013 assert!(
4017 !content.contains("Am"),
4018 "diagrams=Off should suppress diagrams in PDF (case-insensitive)"
4019 );
4020 }
4021
4022 #[test]
4023 fn test_diagrams_off_uppercase_pdf() {
4024 let input = "{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}";
4025 let song = chordsketch_chordpro::parse(input).unwrap();
4026 let bytes = render_song(&song);
4027 let content = String::from_utf8_lossy(&bytes);
4028 assert!(
4032 !content.contains("Am"),
4033 "diagrams=OFF should suppress diagrams in PDF (case-insensitive)"
4034 );
4035 }
4036
4037 #[test]
4038 fn test_custom_section_solo_in_pdf() {
4039 let input = "{start_of_solo}\n[Em]Solo\n{end_of_solo}";
4040 let song = chordsketch_chordpro::parse(input).unwrap();
4041 let bytes = render_song(&song);
4042 let content = String::from_utf8_lossy(&bytes);
4043 assert!(content.contains("Solo"));
4044 }
4045
4046 #[test]
4047 fn test_render_grid_section_with_label() {
4048 let input = "{start_of_grid: Intro}\n| Am |\n{end_of_grid}";
4049 let song = chordsketch_chordpro::parse(input).unwrap();
4050 let bytes = render_song(&song);
4051 let content = String::from_utf8_lossy(&bytes);
4052 assert!(content.contains("Grid: Intro"));
4053 }
4054
4055 #[test]
4056 fn test_define_display_name_in_pdf_output() {
4057 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}";
4058 let song = chordsketch_chordpro::parse(input).unwrap();
4059 let bytes = render_song(&song);
4060 let content = String::from_utf8_lossy(&bytes);
4061 assert!(
4062 content.contains("A minor"),
4063 "display name should appear in rendered PDF output"
4064 );
4065 }
4066
4067 #[test]
4068 fn test_define_with_fingers_in_pdf_output() {
4069 let input = "{define: C base-fret 1 frets x 3 2 0 1 0 fingers 0 3 2 0 1 0}";
4070 let song = chordsketch_chordpro::parse(input).unwrap();
4071 let bytes = render_song(&song);
4072 let content = String::from_utf8_lossy(&bytes);
4073 assert!(
4075 content.contains("(3)"),
4076 "finger numbers should appear in rendered PDF output"
4077 );
4078 }
4079}
4080
4081#[cfg(test)]
4082mod comment_style_tests {
4083 use super::*;
4084
4085 #[test]
4086 fn test_comment_normal_renders_text() {
4087 let input = "{comment: This is normal}";
4088 let song = chordsketch_chordpro::parse(input).unwrap();
4089 let bytes = render_song(&song);
4090 let content = String::from_utf8_lossy(&bytes);
4091 assert!(
4092 content.contains("This is normal"),
4093 "normal comment text should appear in PDF"
4094 );
4095 }
4096
4097 #[test]
4098 fn test_comment_italic_renders_text() {
4099 let input = "{comment_italic: Italic note}";
4100 let song = chordsketch_chordpro::parse(input).unwrap();
4101 let bytes = render_song(&song);
4102 let content = String::from_utf8_lossy(&bytes);
4103 assert!(
4104 content.contains("Italic note"),
4105 "italic comment text should appear in PDF"
4106 );
4107 }
4108
4109 #[test]
4110 fn test_comment_box_renders_with_rect() {
4111 let input = "{comment_box: Boxed note}";
4112 let song = chordsketch_chordpro::parse(input).unwrap();
4113 let bytes = render_song(&song);
4114 let content = String::from_utf8_lossy(&bytes);
4115 assert!(
4116 content.contains("Boxed note"),
4117 "boxed comment text should appear in PDF"
4118 );
4119 assert!(
4121 content.contains("re S"),
4122 "boxed comment should draw a rectangle border"
4123 );
4124 }
4125
4126 #[test]
4127 fn test_comment_normal_no_rect() {
4128 let input = "{comment: No box here}";
4129 let song = chordsketch_chordpro::parse(input).unwrap();
4130 let bytes = render_song(&song);
4131 let content = String::from_utf8_lossy(&bytes);
4132 assert!(
4133 !content.contains("re S"),
4134 "normal comment should not draw a rectangle"
4135 );
4136 }
4137}
4138
4139#[cfg(test)]
4140mod transpose_tests {
4141 use super::*;
4142
4143 #[test]
4144 fn test_transpose_directive_produces_pdf() {
4145 let input = "{transpose: 2}\n[G]Hello [C]world";
4146 let song = chordsketch_chordpro::parse(input).unwrap();
4147 let bytes = render_song(&song);
4148 assert!(bytes.starts_with(b"%PDF"));
4149 let content = String::from_utf8_lossy(&bytes);
4151 assert!(content.contains("(A)"));
4152 assert!(content.contains("(D)"));
4153 }
4154
4155 #[test]
4156 fn test_transpose_with_cli_offset() {
4157 let input = "{transpose: 2}\n[C]Hello";
4158 let song = chordsketch_chordpro::parse(input).unwrap();
4159 let bytes = render_song_with_transpose(&song, 3, &Config::defaults());
4160 let content = String::from_utf8_lossy(&bytes);
4162 assert!(content.contains("(F)"));
4163 }
4164
4165 #[test]
4166 fn test_transpose_out_of_i8_range_emits_warning() {
4167 let input = "{transpose: 999}\n[G]Hello";
4169 let song = chordsketch_chordpro::parse(input).unwrap();
4170 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4171 let content = String::from_utf8_lossy(&result.output);
4173 assert!(content.contains("(G)"), "chord should be untransposed");
4174 assert!(
4175 result.warnings.iter().any(|w| w.contains("\"999\"")),
4176 "expected warning about out-of-range value, got: {:?}",
4177 result.warnings
4178 );
4179 }
4180
4181 #[test]
4182 fn test_transpose_no_value_treated_as_zero() {
4183 let input = "{transpose}\n[G]Hello";
4185 let song = chordsketch_chordpro::parse(input).unwrap();
4186 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4187 let content = String::from_utf8_lossy(&result.output);
4188 assert!(content.contains("(G)"), "chord should be untransposed");
4189 assert!(
4190 result.warnings.is_empty(),
4191 "missing {{transpose}} value should not emit a warning; got: {:?}",
4192 result.warnings
4193 );
4194 }
4195
4196 #[test]
4197 fn test_transpose_whitespace_value_treated_as_zero() {
4198 let input = "{transpose: }\n[G]Hello";
4202 let song = chordsketch_chordpro::parse(input).unwrap();
4203 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4204 let content = String::from_utf8_lossy(&result.output);
4205 assert!(content.contains("(G)"), "chord should be untransposed");
4206 assert!(
4207 result.warnings.is_empty(),
4208 "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
4209 result.warnings
4210 );
4211 }
4212}
4213
4214#[cfg(test)]
4215mod delegate_tests {
4216 use super::*;
4217
4218 #[test]
4219 fn test_abc_section_in_pdf() {
4220 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
4221 let song = chordsketch_chordpro::parse(input).unwrap();
4222 let bytes = render_song(&song);
4223 assert!(bytes.starts_with(b"%PDF"));
4224 let content = String::from_utf8_lossy(&bytes);
4225 assert!(content.contains("ABC: Melody"));
4226 }
4227
4228 #[test]
4229 fn test_ly_section_in_pdf() {
4230 let input = "{start_of_ly}\nnotes\n{end_of_ly}";
4231 let song = chordsketch_chordpro::parse(input).unwrap();
4232 let bytes = render_song(&song);
4233 let content = String::from_utf8_lossy(&bytes);
4234 assert!(content.contains("Lilypond"));
4235 }
4236
4237 #[test]
4238 fn test_svg_section_in_pdf() {
4239 let input = "{start_of_svg}\n<svg/>\n{end_of_svg}";
4240 let song = chordsketch_chordpro::parse(input).unwrap();
4241 let bytes = render_song(&song);
4242 let content = String::from_utf8_lossy(&bytes);
4243 assert!(content.contains("SVG"));
4244 }
4245
4246 #[test]
4247 fn test_textblock_section_in_pdf() {
4248 let input = "{start_of_textblock}\nText\n{end_of_textblock}";
4249 let song = chordsketch_chordpro::parse(input).unwrap();
4250 let bytes = render_song(&song);
4251 let content = String::from_utf8_lossy(&bytes);
4252 assert!(content.contains("Textblock"));
4253 }
4254
4255 #[test]
4256 fn test_musicxml_section_in_pdf() {
4257 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
4258 let song = chordsketch_chordpro::parse(input).unwrap();
4259 let bytes = render_song(&song);
4260 let content = String::from_utf8_lossy(&bytes);
4261 assert!(content.contains("MusicXML"));
4262 }
4263
4264 #[test]
4268 fn test_abc_block_emits_warning_and_skips_body() {
4269 let input = "{start_of_abc: Melody}\nX:1\nK:C\nCDEF\n{end_of_abc}";
4270 let song = chordsketch_chordpro::parse(input).unwrap();
4271 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4272 assert!(
4273 result
4274 .warnings
4275 .iter()
4276 .any(|w| w.contains("ABC") && w.contains("omitted")),
4277 "expected at least one warning mentioning `ABC` and `omitted`; got {:?}",
4278 result.warnings,
4279 );
4280 let content = String::from_utf8_lossy(&result.output);
4281 assert!(content.contains("ABC: Melody"));
4284 assert!(
4285 !content.contains("CDEF"),
4286 "ABC body content must not leak into the PDF as plain text",
4287 );
4288 assert!(content.contains("[ABC block omitted"));
4290 }
4291
4292 #[test]
4293 fn test_ly_block_emits_warning_and_skips_body() {
4294 let input = "{start_of_ly}\n\\relative c' { c4 d }\n{end_of_ly}";
4295 let song = chordsketch_chordpro::parse(input).unwrap();
4296 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4297 assert!(result.warnings.iter().any(|w| w.contains("Lilypond")));
4298 let content = String::from_utf8_lossy(&result.output);
4299 assert!(content.contains("[Lilypond block omitted"));
4300 assert!(
4301 !content.contains("\\relative"),
4302 "Lilypond body content must not leak into the PDF"
4303 );
4304 }
4305
4306 #[test]
4307 fn test_svg_block_emits_warning_and_skips_body() {
4308 let input = "{start_of_svg}\n<svg><circle r=\"10\"/></svg>\n{end_of_svg}";
4309 let song = chordsketch_chordpro::parse(input).unwrap();
4310 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4311 assert!(result.warnings.iter().any(|w| w.contains("SVG")));
4312 let content = String::from_utf8_lossy(&result.output);
4313 assert!(content.contains("[SVG block omitted"));
4314 assert!(
4315 !content.contains("<circle"),
4316 "SVG body content must not leak into the PDF"
4317 );
4318 }
4319
4320 #[test]
4321 fn test_musicxml_block_emits_warning_and_skips_body() {
4322 let input =
4323 "{start_of_musicxml: Score}\n<score-partwise>notes</score-partwise>\n{end_of_musicxml}";
4324 let song = chordsketch_chordpro::parse(input).unwrap();
4325 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4326 assert!(result.warnings.iter().any(|w| w.contains("MusicXML")));
4327 let content = String::from_utf8_lossy(&result.output);
4328 assert!(content.contains("[MusicXML block omitted"));
4329 assert!(
4330 !content.contains("<score-partwise"),
4331 "MusicXML body content must not leak into the PDF",
4332 );
4333 }
4334
4335 #[test]
4336 fn test_content_after_notation_block_still_renders() {
4337 let input = "{title: T}\n{start_of_abc}\nbody\n{end_of_abc}\n[C]Hello world\n";
4341 let song = chordsketch_chordpro::parse(input).unwrap();
4342 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4343 assert!(result.warnings.iter().any(|w| w.contains("ABC")));
4344 let content = String::from_utf8_lossy(&result.output);
4345 assert!(content.contains("Hello world"));
4346 assert!(!content.contains("body"));
4347 }
4348
4349 #[test]
4352 fn test_notation_block_inside_chorus_is_excluded_from_recall() {
4353 let input = "{start_of_chorus}\n\
4363 [G]Sing along\n\
4364 {start_of_abc}\n\
4365 X:1\n\
4366 {end_of_abc}\n\
4367 [C]another line\n\
4368 {end_of_chorus}\n\
4369 {chorus}\n";
4370 let song = chordsketch_chordpro::parse(input).unwrap();
4371 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4372 let abc_warnings = result.warnings.iter().filter(|w| w.contains("ABC")).count();
4375 assert_eq!(
4376 abc_warnings, 1,
4377 "exactly one ABC warning expected (recall must not re-emit); got {:?}",
4378 result.warnings,
4379 );
4380 let content = String::from_utf8_lossy(&result.output);
4381 assert!(content.contains("Sing along"));
4383 assert!(content.contains("another line"));
4384 }
4385
4386 #[test]
4387 fn test_unterminated_notation_block_renders_without_panic() {
4388 let input = "{title: T}\n[C]Before\n{start_of_abc}\nX:1\nK:C\n";
4394 let song = chordsketch_chordpro::parse(input).unwrap();
4395 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4396 assert!(
4397 result.warnings.iter().any(|w| w.contains("ABC")),
4398 "unterminated ABC block should still emit the warning; got {:?}",
4399 result.warnings,
4400 );
4401 let content = String::from_utf8_lossy(&result.output);
4402 assert!(content.contains("Before"));
4403 assert!(content.contains("[ABC block omitted"));
4404 assert!(!content.contains("X:1"));
4406 assert!(!content.contains("K:C"));
4407 }
4408
4409 #[test]
4410 fn test_stray_end_of_notation_is_silently_ignored() {
4411 let input = "{title: T}\n[C]Hello\n{end_of_abc}\n[D]World\n";
4418 let song = chordsketch_chordpro::parse(input).unwrap();
4419 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4420 assert!(
4421 !result
4422 .warnings
4423 .iter()
4424 .any(|w| w.contains("ABC") && w.contains("omitted")),
4425 "stray `end_of_abc` must not trigger the notation-block warning; got {:?}",
4426 result.warnings,
4427 );
4428 let content = String::from_utf8_lossy(&result.output);
4429 assert!(content.contains("Hello"));
4430 assert!(content.contains("World"));
4431 }
4432}
4433
4434#[cfg(test)]
4435mod inline_markup_tests {
4436 use super::*;
4437
4438 #[test]
4439 fn test_bold_markup_uses_bold_font() {
4440 let input = "Hello <b>bold</b> world";
4441 let song = chordsketch_chordpro::parse(input).unwrap();
4442 let bytes = render_song(&song);
4443 let content = String::from_utf8_lossy(&bytes);
4444 assert!(content.contains("/F1"));
4446 assert!(content.contains("/F2"));
4447 assert!(content.contains("bold"));
4448 }
4449
4450 #[test]
4451 fn test_italic_markup_uses_oblique_font() {
4452 let input = "Hello <i>italic</i> text";
4453 let song = chordsketch_chordpro::parse(input).unwrap();
4454 let bytes = render_song(&song);
4455 let content = String::from_utf8_lossy(&bytes);
4456 assert!(content.contains("/F3")); assert!(content.contains("italic"));
4458 }
4459
4460 #[test]
4461 fn test_bold_italic_markup_uses_bold_oblique_font() {
4462 let input = "<b><i>bold italic</i></b>";
4463 let song = chordsketch_chordpro::parse(input).unwrap();
4464 let bytes = render_song(&song);
4465 let content = String::from_utf8_lossy(&bytes);
4466 assert!(content.contains("/F4")); assert!(content.contains("bold italic"));
4468 }
4469
4470 #[test]
4471 fn test_markup_with_chords_produces_valid_pdf() {
4472 let input = "[Am]Hello <b>bold</b> world";
4473 let song = chordsketch_chordpro::parse(input).unwrap();
4474 let bytes = render_song(&song);
4475 assert!(bytes.starts_with(b"%PDF"));
4476 let content = String::from_utf8_lossy(&bytes);
4477 assert!(content.contains("Am"));
4478 assert!(content.contains("bold"));
4479 }
4480
4481 #[test]
4482 fn test_span_weight_bold_uses_bold_font() {
4483 let input = r#"<span weight="bold">weighted</span>"#;
4484 let song = chordsketch_chordpro::parse(input).unwrap();
4485 let bytes = render_song(&song);
4486 let content = String::from_utf8_lossy(&bytes);
4487 assert!(content.contains("/F2")); assert!(content.contains("weighted"));
4489 }
4490}
4491
4492#[cfg(test)]
4493mod formatting_directive_tests {
4494 use super::*;
4495
4496 #[test]
4497 fn test_textsize_directive_changes_font_size() {
4498 let input = "{textsize: 14}\nHello world";
4499 let song = chordsketch_chordpro::parse(input).unwrap();
4500 let bytes = render_song(&song);
4501 let content = String::from_utf8_lossy(&bytes);
4502 assert!(content.contains("14"));
4504 assert!(content.contains("Hello world"));
4505 }
4506
4507 #[test]
4508 fn test_chordsize_directive_changes_chord_size() {
4509 let input = "{chordsize: 16}\n[Am]Hello";
4510 let song = chordsketch_chordpro::parse(input).unwrap();
4511 let bytes = render_song(&song);
4512 let content = String::from_utf8_lossy(&bytes);
4513 assert!(content.contains("Am"));
4514 }
4515
4516 #[test]
4517 fn test_formatting_directive_produces_valid_pdf() {
4518 let input = "{textsize: 14}\n{chordsize: 12}\n[Am]Hello <b>bold</b> world";
4519 let song = chordsketch_chordpro::parse(input).unwrap();
4520 let bytes = render_song(&song);
4521 assert!(bytes.starts_with(b"%PDF"));
4522 }
4523
4524 #[test]
4525 fn test_textsize_clamped_to_max() {
4526 let input = "{textsize: 99999}\nHello";
4527 let song = chordsketch_chordpro::parse(input).unwrap();
4528 let bytes = render_song(&song);
4529 let content = String::from_utf8_lossy(&bytes);
4530 assert!(!content.contains("99999"));
4532 assert!(content.contains("200"));
4533 }
4534
4535 #[test]
4536 fn test_textsize_clamped_to_min() {
4537 let input = "{textsize: -5}\nHello";
4538 let song = chordsketch_chordpro::parse(input).unwrap();
4539 let bytes = render_song(&song);
4540 let content = String::from_utf8_lossy(&bytes);
4541 assert!(content.contains("0.5"));
4543 }
4544
4545 #[test]
4546 fn test_chordsize_clamped_to_max() {
4547 let input = "{chordsize: 500}\n[Am]Hello";
4548 let song = chordsketch_chordpro::parse(input).unwrap();
4549 let bytes = render_song(&song);
4550 let content = String::from_utf8_lossy(&bytes);
4551 assert!(!content.contains("500"));
4552 assert!(content.contains("200"));
4553 }
4554}
4555
4556#[cfg(test)]
4557mod multipage_tests {
4558 use super::*;
4559
4560 #[test]
4561 fn test_new_page_directive_creates_two_pages() {
4562 let input = "{title: Test}\nPage one\n{new_page}\nPage two";
4563 let song = chordsketch_chordpro::parse(input).unwrap();
4564 let bytes = render_song(&song);
4565 assert!(bytes.starts_with(b"%PDF"));
4566 let content = String::from_utf8_lossy(&bytes);
4567 assert!(content.contains("/Count 2"));
4569 assert!(content.contains("Page one"));
4570 assert!(content.contains("Page two"));
4571 }
4572
4573 #[test]
4574 fn test_new_physical_page_from_recto_inserts_blank() {
4575 let input = "Page one\n{new_physical_page}\nPage two";
4577 let song = chordsketch_chordpro::parse(input).unwrap();
4578 let bytes = render_song(&song);
4579 let content = String::from_utf8_lossy(&bytes);
4580 assert!(
4581 content.contains("/Count 3"),
4582 "new_physical_page from recto should insert blank page to reach next recto"
4583 );
4584 }
4585
4586 #[test]
4587 fn test_new_physical_page_from_verso_no_extra_blank() {
4588 let input = "Page one\n{new_page}\nPage two\n{new_physical_page}\nPage three";
4590 let song = chordsketch_chordpro::parse(input).unwrap();
4591 let bytes = render_song(&song);
4592 let content = String::from_utf8_lossy(&bytes);
4593 assert!(
4594 content.contains("/Count 3"),
4595 "new_physical_page from verso should go directly to next recto (no extra blank)"
4596 );
4597 }
4598
4599 #[test]
4600 fn test_single_page_has_count_one() {
4601 let input = "{title: Short Song}\n[Am]Hello";
4602 let song = chordsketch_chordpro::parse(input).unwrap();
4603 let bytes = render_song(&song);
4604 let content = String::from_utf8_lossy(&bytes);
4605 assert!(content.contains("/Count 1"));
4606 }
4607
4608 #[test]
4609 fn test_automatic_page_break_for_long_content() {
4610 let mut lines = vec!["{title: Long Song}".to_string()];
4612 for i in 0..80 {
4613 lines.push(format!("[Am]Line number {i}"));
4614 }
4615 let input = lines.join("\n");
4616 let song = chordsketch_chordpro::parse(&input).unwrap();
4617 let bytes = render_song(&song);
4618 let content = String::from_utf8_lossy(&bytes);
4619 assert!(
4621 !content.contains("/Count 1"),
4622 "80 chord-lyrics lines should overflow one page"
4623 );
4624 }
4625
4626 #[test]
4627 fn test_multiple_new_page_directives() {
4628 let input = "Page 1\n{new_page}\nPage 2\n{new_page}\nPage 3";
4629 let song = chordsketch_chordpro::parse(input).unwrap();
4630 let bytes = render_song(&song);
4631 let content = String::from_utf8_lossy(&bytes);
4632 assert!(content.contains("/Count 3"));
4633 }
4634
4635 #[test]
4636 fn test_multipage_pdf_structure_valid() {
4637 let input = "First page\n{new_page}\nSecond page";
4638 let song = chordsketch_chordpro::parse(input).unwrap();
4639 let bytes = render_song(&song);
4640 assert!(bytes.starts_with(b"%PDF-1.4"));
4641 assert!(bytes.ends_with(b"%%EOF\n"));
4642 let content = String::from_utf8_lossy(&bytes);
4644 assert!(content.contains("First page"));
4645 assert!(content.contains("Second page"));
4646 }
4647
4648 #[test]
4649 fn test_page_count_method() {
4650 let mut doc = PdfDocument::new();
4651 assert_eq!(doc.page_count(), 1);
4652 doc.new_page();
4653 assert_eq!(doc.page_count(), 2);
4654 doc.new_page();
4655 assert_eq!(doc.page_count(), 3);
4656 }
4657
4658 #[test]
4659 fn test_new_page_respects_max_limit() {
4660 let mut doc = PdfDocument::new();
4661 for _ in 0..MAX_PAGES {
4663 doc.new_page();
4664 }
4665 assert_eq!(doc.page_count(), MAX_PAGES);
4666 }
4667
4668 #[test]
4669 fn test_take_pages_preserves_invariant() {
4670 let mut doc = PdfDocument::new();
4671 doc.new_page();
4672 assert_eq!(doc.page_count(), 2);
4673
4674 let taken = doc.take_pages();
4675 assert_eq!(taken.len(), 2);
4676 assert_eq!(doc.page_count(), 1);
4678 let _ = doc.current_page_mut();
4680 }
4681
4682 #[test]
4683 fn test_new_page_works_after_take_pages() {
4684 let mut doc = PdfDocument::new();
4685 let _ = doc.take_pages();
4686 doc.new_page();
4687 assert_eq!(doc.page_count(), 2);
4688 }
4689
4690 #[test]
4691 fn test_push_page_respects_max_limit() {
4692 let mut doc = PdfDocument::new();
4693 for _ in 1..MAX_PAGES {
4695 doc.new_page();
4696 }
4697 assert_eq!(doc.page_count(), MAX_PAGES);
4698
4699 doc.push_page(vec!["BT (overflow) Tj ET".to_string()]);
4701 assert_eq!(doc.page_count(), MAX_PAGES);
4702 }
4703
4704 #[test]
4705 fn test_combined_toc_and_body_respects_max_limit() {
4706 let mut toc_doc = PdfDocument::new();
4707 for _ in 1..5 {
4709 toc_doc.new_page();
4710 }
4711 assert_eq!(toc_doc.page_count(), 5);
4712
4713 let mut body_doc = PdfDocument::new();
4714 for _ in 1..MAX_PAGES {
4716 body_doc.new_page();
4717 }
4718 assert_eq!(body_doc.page_count(), MAX_PAGES);
4719
4720 for page_ops in body_doc.take_pages() {
4722 toc_doc.push_page(page_ops);
4723 }
4724 assert_eq!(toc_doc.page_count(), MAX_PAGES);
4726 }
4727
4728 #[test]
4729 fn test_page_control_not_replayed_in_chorus_recall() {
4730 let input = "\
4732{start_of_chorus}\n\
4733{new_page}\n\
4734[G]La la la\n\
4735{end_of_chorus}\n\
4736Verse text\n\
4737{chorus}";
4738 let song = chordsketch_chordpro::parse(input).unwrap();
4739 let bytes = render_song(&song);
4740 let content = String::from_utf8_lossy(&bytes);
4741 assert!(
4745 content.contains("/Count 2"),
4746 "chorus recall must not replay page breaks"
4747 );
4748 }
4749}
4750
4751#[cfg(test)]
4752mod column_tests {
4753 use super::*;
4754
4755 #[test]
4756 fn test_columns_directive_produces_valid_pdf() {
4757 let input = "{columns: 2}\nColumn one\n{column_break}\nColumn two";
4758 let song = chordsketch_chordpro::parse(input).unwrap();
4759 let bytes = render_song(&song);
4760 assert!(bytes.starts_with(b"%PDF"));
4761 let content = String::from_utf8_lossy(&bytes);
4762 assert!(content.contains("Column one"));
4763 assert!(content.contains("Column two"));
4764 }
4765
4766 #[test]
4767 fn test_column_break_in_single_column_creates_new_page() {
4768 let input = "Page one\n{column_break}\nPage two";
4769 let song = chordsketch_chordpro::parse(input).unwrap();
4770 let bytes = render_song(&song);
4771 let content = String::from_utf8_lossy(&bytes);
4772 assert!(content.contains("/Count 2"));
4773 }
4774
4775 #[test]
4776 fn test_columns_reset_to_one() {
4777 let input = "{columns: 2}\nTwo cols\n{columns: 1}\nOne col";
4778 let song = chordsketch_chordpro::parse(input).unwrap();
4779 let bytes = render_song(&song);
4780 assert!(bytes.starts_with(b"%PDF"));
4781 let content = String::from_utf8_lossy(&bytes);
4782 assert!(content.contains("Two cols"));
4783 assert!(content.contains("One col"));
4784 }
4785
4786 #[test]
4787 fn test_margin_left_single_column() {
4788 let doc = PdfDocument::new();
4789 assert!((doc.margin_left() - MARGIN_LEFT).abs() < 0.01);
4790 }
4791
4792 #[test]
4793 fn test_margin_left_multi_column() {
4794 let mut doc = PdfDocument::new();
4795 doc.set_columns(2);
4796 assert!((doc.margin_left() - MARGIN_LEFT).abs() < 0.01);
4798 doc.current_column = 1;
4800 assert!(doc.margin_left() > MARGIN_LEFT);
4801 }
4802
4803 #[test]
4804 fn test_margin_left_all_column_counts_positive() {
4805 for n in 1..=MAX_COLUMNS {
4806 let mut doc = PdfDocument::new();
4807 doc.set_columns(n);
4808 for col in 0..n {
4809 doc.current_column = col;
4810 let m = doc.margin_left();
4811 assert!(
4812 m >= 0.0 && m.is_finite(),
4813 "margin_left() must be non-negative and finite for columns={n}, col={col}, got {m}"
4814 );
4815 }
4816 }
4817 }
4818
4819 #[test]
4820 fn test_column_break_advances_column() {
4821 let mut doc = PdfDocument::new();
4822 doc.set_columns(2);
4823 assert_eq!(doc.current_column, 0);
4824 doc.column_break();
4825 assert_eq!(doc.current_column, 1);
4826 }
4827
4828 #[test]
4829 fn test_set_columns_clamps_to_max() {
4830 let mut doc = PdfDocument::new();
4831 doc.set_columns(999);
4832 assert_eq!(doc.num_columns, MAX_COLUMNS);
4833 }
4834
4835 #[test]
4836 fn test_set_columns_clamps_zero_to_one() {
4837 let mut doc = PdfDocument::new();
4838 doc.set_columns(0);
4839 assert_eq!(doc.num_columns, 1);
4840 }
4841
4842 #[test]
4843 fn test_margin_left_at_max_columns_no_overflow() {
4844 let mut doc = PdfDocument::new();
4845 doc.set_columns(MAX_COLUMNS);
4846 for col in 0..MAX_COLUMNS {
4848 doc.current_column = col;
4849 let m = doc.margin_left();
4850 assert!(m.is_finite(), "margin_left must be finite for column {col}");
4851 assert!(
4852 m >= 0.0,
4853 "margin_left must be non-negative for column {col}"
4854 );
4855 }
4856 }
4857
4858 #[test]
4859 fn test_column_break_last_column_new_page() {
4860 let mut doc = PdfDocument::new();
4861 doc.set_columns(2);
4862 doc.column_break(); assert_eq!(doc.page_count(), 1);
4864 doc.column_break(); assert_eq!(doc.page_count(), 2);
4866 assert_eq!(doc.current_column, 0);
4867 }
4868
4869 #[test]
4870 fn test_columns_non_numeric_defaults_to_one() {
4871 let input = "{columns: abc}\n[Am]Hello";
4872 let song = chordsketch_chordpro::parse(input).unwrap();
4873 let bytes = render_song(&song);
4874 let content = String::from_utf8_lossy(&bytes);
4875 assert!(content.contains("Am"));
4877 assert!(content.contains("Hello"));
4878 }
4879
4880 #[test]
4881 fn test_columns_out_of_range_clamped() {
4882 let input = "{columns: 4294967295}\n[Am]Hello";
4885 let song = chordsketch_chordpro::parse(input).unwrap();
4886 let bytes = render_song(&song);
4887 let content = String::from_utf8_lossy(&bytes);
4888 assert!(content.contains("Am"));
4889 assert!(content.contains("Hello"));
4890 }
4891
4892 #[test]
4893 fn test_multi_column_text_clipped() {
4894 let input = "{columns: 2}\n[Am]Hello world this is a very long line of lyrics";
4897 let song = chordsketch_chordpro::parse(input).unwrap();
4898 let bytes = render_song(&song);
4899 let content = String::from_utf8_lossy(&bytes);
4900 assert!(
4902 content.contains("re W n"),
4903 "multi-column PDF should contain clipping rectangle operator"
4904 );
4905 let clip_line = content
4909 .lines()
4910 .find(|l| l.contains("re W n"))
4911 .expect("should find clip rect line");
4912 let parts: Vec<&str> = clip_line.split_whitespace().collect();
4913 assert!(parts.len() >= 6, "clip rect should have x y w h re W n");
4915 let w: f32 = parts[2].parse().expect("width should be a number");
4916 assert!(
4917 w > 100.0 && w < 300.0,
4918 "clip width {w} should be a reasonable column width"
4919 );
4920 }
4921
4922 #[test]
4923 fn test_single_column_no_clipping() {
4924 let input = "[Am]Hello world";
4926 let song = chordsketch_chordpro::parse(input).unwrap();
4927 let bytes = render_song(&song);
4928 let content = String::from_utf8_lossy(&bytes);
4929 assert!(
4930 !content.contains("re W n"),
4931 "single-column PDF should not contain clipping operator"
4932 );
4933 }
4934
4935 #[test]
4936 fn test_multi_column_inline_markup_single_clip_per_line() {
4937 let input = "{columns: 2}\nHello <b>bold</b> and <i>italic</i> text";
4941 let song = chordsketch_chordpro::parse(input).unwrap();
4942 let bytes = render_song(&song);
4943 let content = String::from_utf8_lossy(&bytes);
4944 let clip_count = content.matches("re W n").count();
4945 assert_eq!(
4946 clip_count, 1,
4947 "inline markup line should produce exactly 1 clip rect (got {clip_count})"
4948 );
4949 }
4950
4951 #[test]
4954 fn test_render_songs_single() {
4955 let songs = chordsketch_chordpro::parse_multi("{title: Only}\n[Am]Hello").unwrap();
4956 let bytes = render_songs(&songs);
4957 assert!(bytes.starts_with(b"%PDF-1.4"));
4958 assert!(bytes.ends_with(b"%%EOF\n"));
4959 assert_eq!(bytes, render_song(&songs[0]));
4961 }
4962
4963 #[test]
4964 fn test_render_songs_two_songs_multi_page() {
4965 let songs = chordsketch_chordpro::parse_multi(
4966 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
4967 )
4968 .unwrap();
4969 let bytes = render_songs(&songs);
4970 assert!(bytes.starts_with(b"%PDF-1.4"));
4971 assert!(bytes.ends_with(b"%%EOF\n"));
4972 let content = String::from_utf8_lossy(&bytes);
4973 assert!(content.contains("Song A"));
4975 assert!(content.contains("Song B"));
4976 assert!(content.contains("/Count 3"));
4978 assert!(content.contains("Table of Contents"));
4980 }
4981
4982 #[test]
4983 fn test_render_songs_with_transpose() {
4984 let songs =
4985 chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
4986 .unwrap();
4987 let bytes = render_songs_with_transpose(&songs, 2, &Config::defaults());
4988 let content = String::from_utf8_lossy(&bytes);
4989 assert!(content.contains("(D)"));
4991 assert!(content.contains("(A)"));
4992 }
4993
4994 #[test]
4995 fn test_render_song_into_doc_helper() {
4996 let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
4997 let mut doc = PdfDocument::new();
4998 let mut warnings = Vec::new();
4999 render_song_into_doc(&song, 0, &Config::defaults(), &mut doc, &mut warnings);
5000 assert_eq!(doc.page_count(), 1);
5002 let pdf = doc.build_pdf();
5003 assert!(pdf.starts_with(b"%PDF-1.4"));
5004 let content = String::from_utf8_lossy(&pdf);
5005 assert!(content.contains("Test"));
5006 }
5007}
5008
5009#[cfg(test)]
5010mod toc_tests {
5011 use super::*;
5012
5013 #[test]
5014 fn test_toc_generated_for_multi_song() {
5015 let songs = chordsketch_chordpro::parse_multi(
5016 "{title: First}\nLyrics 1\n{new_song}\n{title: Second}\nLyrics 2",
5017 )
5018 .unwrap();
5019 let bytes = render_songs(&songs);
5020 let content = String::from_utf8_lossy(&bytes);
5021 assert!(content.contains("Table of Contents"));
5022 assert!(content.contains("First"));
5023 assert!(content.contains("Second"));
5024 }
5025
5026 #[test]
5027 fn test_toc_not_generated_for_single_song() {
5028 let song = chordsketch_chordpro::parse("{title: Only Song}\nLyrics").unwrap();
5029 let bytes = render_song(&song);
5030 let content = String::from_utf8_lossy(&bytes);
5031 assert!(!content.contains("Table of Contents"));
5032 }
5033
5034 #[test]
5035 fn test_toc_page_numbers_present() {
5036 let songs = chordsketch_chordpro::parse_multi(
5037 "{title: Song A}\nA\n{new_song}\n{title: Song B}\nB\n{new_song}\n{title: Song C}\nC",
5038 )
5039 .unwrap();
5040 let bytes = render_songs(&songs);
5041 let content = String::from_utf8_lossy(&bytes);
5042 assert!(content.contains("/Count 4"));
5044 assert!(content.contains("Table of Contents"));
5045 }
5046
5047 #[test]
5048 fn test_toc_valid_pdf_structure() {
5049 let songs =
5050 chordsketch_chordpro::parse_multi("{title: A}\nText\n{new_song}\n{title: B}\nText")
5051 .unwrap();
5052 let bytes = render_songs(&songs);
5053 assert!(bytes.starts_with(b"%PDF-1.4"));
5054 assert!(bytes.ends_with(b"%%EOF\n"));
5055 }
5056
5057 #[test]
5058 fn test_toc_with_custom_margins_produces_valid_pdf() {
5059 use chordsketch_chordpro::config::Config;
5060 let songs =
5061 chordsketch_chordpro::parse_multi("{title: Song A}\nA\n{new_song}\n{title: Song B}\nB")
5062 .unwrap();
5063 let config = Config::parse(
5065 r#"{ "pdf": { "margintop": 100, "marginbottom": 100, "marginleft": 100, "marginright": 100 } }"#,
5066 )
5067 .unwrap();
5068 let bytes = render_songs_with_transpose(&songs, 0, &config);
5069 assert!(bytes.starts_with(b"%PDF-1.4"));
5070 assert!(bytes.ends_with(b"%%EOF\n"));
5071 let content = String::from_utf8_lossy(&bytes);
5072 assert!(content.contains("Table of Contents"));
5073 }
5074
5075 #[test]
5078 fn test_push_toc_entry_skips_adjacent_duplicate() {
5079 let mut entries = vec![("Song A".to_string(), 1)];
5080 push_toc_entry(&mut entries, "Song A".to_string(), 1);
5081 assert_eq!(entries, vec![("Song A".to_string(), 1)]);
5082 }
5083
5084 #[test]
5085 fn test_push_toc_entry_keeps_different_page() {
5086 let mut entries = vec![("Song A".to_string(), 1)];
5087 push_toc_entry(&mut entries, "Song A".to_string(), 2);
5088 assert_eq!(
5089 entries,
5090 vec![("Song A".to_string(), 1), ("Song A".to_string(), 2)]
5091 );
5092 }
5093
5094 #[test]
5095 fn test_push_toc_entry_keeps_different_title() {
5096 let mut entries = vec![("Song A".to_string(), 1)];
5097 push_toc_entry(&mut entries, "Song B".to_string(), 1);
5098 assert_eq!(
5099 entries,
5100 vec![("Song A".to_string(), 1), ("Song B".to_string(), 1)]
5101 );
5102 }
5103
5104 #[test]
5105 fn test_push_toc_entry_dedup_is_adjacent_only() {
5106 let mut entries = vec![("Song A".to_string(), 1), ("Song B".to_string(), 2)];
5110 push_toc_entry(&mut entries, "Song A".to_string(), 1);
5111 assert_eq!(
5112 entries,
5113 vec![
5114 ("Song A".to_string(), 1),
5115 ("Song B".to_string(), 2),
5116 ("Song A".to_string(), 1),
5117 ],
5118 "adjacent-only dedup must keep non-adjacent repeats"
5119 );
5120 }
5121
5122 #[test]
5123 fn test_push_toc_entry_into_empty() {
5124 let mut entries: Vec<(String, usize)> = Vec::new();
5125 push_toc_entry(&mut entries, "Song A".to_string(), 1);
5126 assert_eq!(entries, vec![("Song A".to_string(), 1)]);
5127 }
5128
5129 #[test]
5130 fn test_toc_multi_song_cjk_includes_cid_font() {
5131 let songs = chordsketch_chordpro::parse_multi(
5137 "{title: Song A}\nこんにちは\n{new_song}\n{title: Song B}\n日本語",
5138 )
5139 .unwrap();
5140 let bytes = render_songs(&songs);
5141 let content = String::from_utf8_lossy(&bytes);
5142 assert!(
5144 content.contains("/Type /Font") && content.contains("/Subtype /Type0"),
5145 "multi-song CJK PDF must contain a Type0 CID font"
5146 );
5147 assert!(
5148 content.contains("Identity-H"),
5149 "multi-song CJK PDF must use Identity-H encoding"
5150 );
5151 assert!(
5152 content.contains("/ToUnicode"),
5153 "multi-song CJK PDF must contain a ToUnicode CMap"
5154 );
5155 assert!(bytes.starts_with(b"%PDF-1.4"));
5156 assert!(bytes.ends_with(b"%%EOF\n"));
5157 }
5158}
5159
5160#[cfg(test)]
5161mod chord_diagram_pdf_tests {
5162 use super::*;
5163
5164 #[test]
5165 fn test_define_renders_diagram_in_pdf() {
5166 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello";
5167 let song = chordsketch_chordpro::parse(input).unwrap();
5168 let bytes = render_song(&song);
5169 assert!(bytes.starts_with(b"%PDF"));
5170 let content = String::from_utf8_lossy(&bytes);
5171 assert!(content.contains("Am"));
5173 assert!(content.contains(" c "));
5175 }
5176
5177 #[test]
5178 fn test_define_keyboard_renders_in_pdf() {
5179 let input = "{define: Am keys 0 3 7}\n[Am]Hello";
5181 let song = chordsketch_chordpro::parse(input).unwrap();
5182 let bytes = render_song(&song);
5183 assert!(bytes.starts_with(b"%PDF"));
5184 assert!(bytes.ends_with(b"%%EOF\n"));
5185 }
5186
5187 #[test]
5188 fn test_define_keyboard_absolute_midi_pdf() {
5189 let input = "{define: Cmaj7 keys 60 64 67 71}\n[Cmaj7]Hello";
5190 let song = chordsketch_chordpro::parse(input).unwrap();
5191 let bytes = render_song(&song);
5192 assert!(bytes.starts_with(b"%PDF-1.4"));
5193 assert!(bytes.ends_with(b"%%EOF\n"));
5194 }
5195
5196 #[test]
5197 fn test_diagrams_piano_auto_inject_pdf() {
5198 let input = "{diagrams: piano}\n[Am]Hello [C]world";
5199 let song = chordsketch_chordpro::parse(input).unwrap();
5200 let bytes = render_song(&song);
5201 assert!(bytes.starts_with(b"%PDF-1.4"));
5202 assert!(bytes.ends_with(b"%%EOF\n"));
5203 }
5204
5205 #[test]
5206 fn test_define_diagram_valid_pdf() {
5207 let input = "{define: F base-fret 1 frets 1 1 2 3 3 1}\n[F]Lyrics";
5208 let song = chordsketch_chordpro::parse(input).unwrap();
5209 let bytes = render_song(&song);
5210 assert!(bytes.starts_with(b"%PDF-1.4"));
5211 assert!(bytes.ends_with(b"%%EOF\n"));
5212 }
5213
5214 #[test]
5215 fn test_define_ukulele_diagram_in_pdf() {
5216 let input = "{define: C frets 0 0 0 3}\n[C]Hello";
5217 let song = chordsketch_chordpro::parse(input).unwrap();
5218 let bytes = render_song(&song);
5219 assert!(bytes.starts_with(b"%PDF"));
5220 let content = String::from_utf8_lossy(&bytes);
5221 assert!(content.contains("C"));
5222 }
5223
5224 #[test]
5225 fn test_define_banjo_diagram_in_pdf() {
5226 let input = "{define: G frets 0 0 0 0 0}\n[G]Hello";
5227 let song = chordsketch_chordpro::parse(input).unwrap();
5228 let bytes = render_song(&song);
5229 assert!(bytes.starts_with(b"%PDF"));
5230 }
5231
5232 #[test]
5233 fn test_diagrams_frets_config_affects_pdf_output() {
5234 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello";
5235 let song = chordsketch_chordpro::parse(input).unwrap();
5236 let config_4 = chordsketch_chordpro::config::Config::defaults()
5237 .with_define("diagrams.frets=4")
5238 .unwrap();
5239 let config_7 = chordsketch_chordpro::config::Config::defaults()
5240 .with_define("diagrams.frets=7")
5241 .unwrap();
5242 let bytes_4 = render_song_with_transpose(&song, 0, &config_4);
5243 let bytes_7 = render_song_with_transpose(&song, 0, &config_7);
5244 let content_4 = String::from_utf8_lossy(&bytes_4);
5245 let content_7 = String::from_utf8_lossy(&bytes_7);
5246 let lines_4 = content_4.matches("l S").count();
5251 let lines_7 = content_7.matches("l S").count();
5252 assert!(
5253 lines_7 >= lines_4,
5254 "frets=7 ({lines_7}) should have at least as many line ops as frets=4 ({lines_4})"
5255 );
5256 assert_eq!(
5257 lines_7 - lines_4,
5258 3,
5259 "frets=7 should produce exactly 3 more line-drawing ops than frets=4 \
5260 (got {lines_7} vs {lines_4})"
5261 );
5262 }
5263
5264 #[test]
5265 fn test_render_chord_diagram_pdf_single_string_no_panic() {
5266 let data = chordsketch_chordpro::chord_diagram::DiagramData {
5269 name: "X".to_string(),
5270 display_name: None,
5271 strings: 1,
5272 frets_shown: 5,
5273 base_fret: 1,
5274 frets: vec![0],
5275 fingers: vec![],
5276 };
5277 let mut doc = PdfDocument::new();
5278 render_chord_diagram_pdf(&data, &mut doc);
5279 }
5281
5282 #[test]
5283 fn test_render_chord_diagram_pdf_zero_strings_no_panic() {
5284 let data = chordsketch_chordpro::chord_diagram::DiagramData {
5285 name: "X".to_string(),
5286 display_name: None,
5287 strings: 0,
5288 frets_shown: 5,
5289 base_fret: 1,
5290 frets: vec![],
5291 fingers: vec![],
5292 };
5293 let mut doc = PdfDocument::new();
5294 render_chord_diagram_pdf(&data, &mut doc);
5295 }
5297
5298 #[test]
5299 fn test_render_chord_diagram_pdf_exceeding_max_strings_no_panic() {
5300 let data = chordsketch_chordpro::chord_diagram::DiagramData {
5301 name: "X".to_string(),
5302 display_name: None,
5303 strings: chordsketch_chordpro::chord_diagram::MAX_STRINGS + 1,
5304 frets_shown: 5,
5305 base_fret: 1,
5306 frets: vec![0; chordsketch_chordpro::chord_diagram::MAX_STRINGS + 1],
5307 fingers: vec![],
5308 };
5309 let mut doc = PdfDocument::new();
5310 render_chord_diagram_pdf(&data, &mut doc);
5311 }
5313
5314 #[test]
5315 fn test_render_chord_diagram_pdf_zero_frets_shown_no_panic() {
5316 let data = chordsketch_chordpro::chord_diagram::DiagramData {
5317 name: "X".to_string(),
5318 display_name: None,
5319 strings: 6,
5320 frets_shown: 0,
5321 base_fret: 1,
5322 frets: vec![0; 6],
5323 fingers: vec![],
5324 };
5325 let mut doc = PdfDocument::new();
5326 render_chord_diagram_pdf(&data, &mut doc);
5327 }
5329
5330 #[test]
5331 fn test_render_chord_diagram_pdf_exceeding_max_frets_shown_no_panic() {
5332 let data = chordsketch_chordpro::chord_diagram::DiagramData {
5333 name: "X".to_string(),
5334 display_name: None,
5335 strings: 6,
5336 frets_shown: chordsketch_chordpro::chord_diagram::MAX_FRETS_SHOWN + 1,
5337 base_fret: 1,
5338 frets: vec![0; 6],
5339 fingers: vec![],
5340 };
5341 let mut doc = PdfDocument::new();
5342 render_chord_diagram_pdf(&data, &mut doc);
5343 }
5345
5346 #[test]
5347 fn test_define_chord_not_duplicated_in_auto_inject_grid() {
5348 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n";
5359 let song = chordsketch_chordpro::parse(input).unwrap();
5360 let bytes = render_song(&song);
5361 assert!(bytes.starts_with(b"%PDF-1.4"), "must produce a valid PDF");
5362 let content = String::from_utf8_lossy(&bytes);
5363 assert!(content.contains("G"), "G should appear (auto-inject grid)");
5365 let am_count = content.matches("Am").count();
5368 assert!(
5369 am_count <= 2,
5370 "Am should appear at most twice (chord label + inline diagram), got {am_count}"
5371 );
5372 }
5373
5374 #[test]
5375 fn test_define_after_nodiagrams_appears_in_grid() {
5376 let input =
5380 "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n";
5381 let song = chordsketch_chordpro::parse(input).unwrap();
5382 let bytes = render_song(&song);
5383 assert!(bytes.starts_with(b"%PDF-1.4"), "must produce a valid PDF");
5384 let content = String::from_utf8_lossy(&bytes);
5385 let am_count = content.matches("Am").count();
5390 assert!(
5391 am_count >= 2,
5392 "Am should appear in the auto-inject grid (found {am_count} occurrences, expected ≥ 2)"
5393 );
5394 }
5395}
5396
5397#[cfg(test)]
5398mod jpeg_tests {
5399 use super::*;
5400
5401 fn minimal_jpeg(width: u16, height: u16) -> Vec<u8> {
5407 minimal_jpeg_with_components(width, height, 3)
5408 }
5409
5410 fn minimal_jpeg_with_components(width: u16, height: u16, components: u8) -> Vec<u8> {
5412 let mut data = Vec::new();
5413 data.extend_from_slice(&[0xFF, 0xD8]);
5415 data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]);
5417 data.extend_from_slice(&[0xFF, 0xC0]);
5419 data.extend_from_slice(&[0x00, 0x08]);
5421 data.push(0x08);
5423 data.extend_from_slice(&height.to_be_bytes());
5425 data.extend_from_slice(&width.to_be_bytes());
5427 data.push(components);
5429 data
5430 }
5431
5432 #[test]
5433 fn test_parse_jpeg_dimensions_basic() {
5434 let jpeg = minimal_jpeg(640, 480);
5435 let dims = parse_jpeg_dimensions(&jpeg);
5436 assert_eq!(dims, Some((640, 480, 3)));
5437 }
5438
5439 #[test]
5440 fn test_parse_jpeg_dimensions_square() {
5441 let jpeg = minimal_jpeg(100, 100);
5442 let dims = parse_jpeg_dimensions(&jpeg);
5443 assert_eq!(dims, Some((100, 100, 3)));
5444 }
5445
5446 #[test]
5447 fn test_parse_jpeg_dimensions_too_short() {
5448 assert_eq!(parse_jpeg_dimensions(&[0xFF]), None);
5449 assert_eq!(parse_jpeg_dimensions(&[]), None);
5450 }
5451
5452 #[test]
5453 fn test_parse_jpeg_dimensions_not_jpeg() {
5454 assert_eq!(
5456 parse_jpeg_dimensions(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]),
5457 None
5458 );
5459 }
5460
5461 #[test]
5462 fn test_parse_jpeg_dimensions_exceeds_scan_limit() {
5463 let mut data = vec![0xFF, 0xD8]; data.resize(70_000, 0x00);
5468 data.extend_from_slice(&[0xFF, 0xC0]);
5470 data.extend_from_slice(&[0x00, 0x08]);
5471 data.push(0x08);
5472 data.extend_from_slice(&100_u16.to_be_bytes());
5473 data.extend_from_slice(&200_u16.to_be_bytes());
5474 data.push(3);
5475 assert_eq!(
5476 parse_jpeg_dimensions(&data),
5477 None,
5478 "SOF beyond scan limit should not be found"
5479 );
5480 }
5481
5482 #[test]
5483 fn test_parse_jpeg_dimensions_sof2_progressive() {
5484 let mut data = Vec::new();
5485 data.extend_from_slice(&[0xFF, 0xD8]);
5487 data.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x02]);
5489 data.extend_from_slice(&[0xFF, 0xC2]);
5491 data.extend_from_slice(&[0x00, 0x08]);
5492 data.push(0x08);
5493 data.extend_from_slice(&300_u16.to_be_bytes()); data.extend_from_slice(&400_u16.to_be_bytes()); data.push(0x00); let dims = parse_jpeg_dimensions(&data);
5497 assert_eq!(dims, Some((400, 300, 0)));
5498 }
5499
5500 fn minimal_jpeg_with_sof(sof_marker: u8, width: u16, height: u16) -> Vec<u8> {
5502 let mut data = Vec::new();
5503 data.extend_from_slice(&[0xFF, 0xD8]); 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());
5509 data.extend_from_slice(&width.to_be_bytes());
5510 data.push(0x00); data
5512 }
5513
5514 #[test]
5515 fn test_parse_jpeg_dimensions_sof1_extended_sequential() {
5516 let data = minimal_jpeg_with_sof(0xC1, 800, 600);
5517 assert_eq!(parse_jpeg_dimensions(&data), Some((800, 600, 0)));
5518 }
5519
5520 #[test]
5521 fn test_parse_jpeg_dimensions_sof3_lossless() {
5522 let data = minimal_jpeg_with_sof(0xC3, 1024, 768);
5523 assert_eq!(parse_jpeg_dimensions(&data), Some((1024, 768, 0)));
5524 }
5525
5526 #[test]
5527 fn test_parse_jpeg_dimensions_sof9_arithmetic_sequential() {
5528 let data = minimal_jpeg_with_sof(0xC9, 320, 240);
5529 assert_eq!(parse_jpeg_dimensions(&data), Some((320, 240, 0)));
5530 }
5531
5532 #[test]
5533 fn test_parse_jpeg_dimensions_sof10_arithmetic_progressive() {
5534 let data = minimal_jpeg_with_sof(0xCA, 1920, 1080);
5535 assert_eq!(parse_jpeg_dimensions(&data), Some((1920, 1080, 0)));
5536 }
5537
5538 #[test]
5539 fn test_parse_jpeg_dimensions_sof11_arithmetic_lossless() {
5540 let data = minimal_jpeg_with_sof(0xCB, 256, 256);
5541 assert_eq!(parse_jpeg_dimensions(&data), Some((256, 256, 0)));
5542 }
5543
5544 #[test]
5545 fn test_parse_jpeg_dimensions_all_sof_markers() {
5546 let sof_markers = [
5547 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF, ];
5552 for marker in sof_markers {
5553 let data = minimal_jpeg_with_sof(marker, 500, 400);
5554 assert_eq!(
5555 parse_jpeg_dimensions(&data),
5556 Some((500, 400, 0)),
5557 "SOF marker 0x{marker:02X} should be recognized"
5558 );
5559 }
5560 }
5561
5562 #[test]
5563 fn test_parse_jpeg_dimensions_non_sof_markers_not_matched() {
5564 for marker in [0xC4, 0xC8, 0xCC] {
5566 let data = minimal_jpeg_with_sof(marker, 500, 400);
5567 assert_eq!(
5568 parse_jpeg_dimensions(&data),
5569 None,
5570 "Marker 0x{marker:02X} should NOT be recognized as SOF"
5571 );
5572 }
5573 }
5574
5575 #[test]
5576 fn test_image_directive_nonexistent_file_no_crash() {
5577 let input = "{image: src=nonexistent_file_that_does_not_exist.jpg}";
5578 let song = chordsketch_chordpro::parse(input).unwrap();
5579 let bytes = render_song(&song);
5580 assert!(bytes.starts_with(b"%PDF-1.4"));
5582 assert!(bytes.ends_with(b"%%EOF\n"));
5583 }
5584
5585 #[test]
5586 fn test_image_directive_non_jpeg_skipped() {
5587 let input = "{image: src=photo.png}";
5588 let song = chordsketch_chordpro::parse(input).unwrap();
5589 let bytes = render_song(&song);
5590 assert!(bytes.starts_with(b"%PDF-1.4"));
5591 assert!(bytes.ends_with(b"%%EOF\n"));
5592 }
5593
5594 #[test]
5595 fn test_image_directive_dangerous_scheme_rejected() {
5596 let input = "{image: src=\"javascript:alert(1)\"}";
5599 let song = chordsketch_chordpro::parse(input).unwrap();
5600 let bytes = render_song(&song);
5601 assert!(bytes.starts_with(b"%PDF-1.4"));
5603 let as_str = String::from_utf8_lossy(&bytes);
5604 assert!(
5605 !as_str.contains("javascript:"),
5606 "javascript: URI must not appear in PDF output"
5607 );
5608 }
5609
5610 #[test]
5613 fn test_capo_out_of_range_emits_warning() {
5614 let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
5615 let result = render_song_with_warnings(&song, 0, &Config::defaults());
5616 assert!(
5617 result
5618 .warnings
5619 .iter()
5620 .any(|w| w.contains("capo") && w.contains("999")),
5621 "expected out-of-range {{capo}} warning; got {:?}",
5622 result.warnings
5623 );
5624 }
5625
5626 #[test]
5627 fn test_capo_non_numeric_emits_warning() {
5628 let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
5629 let result = render_song_with_warnings(&song, 0, &Config::defaults());
5630 assert!(
5631 result
5632 .warnings
5633 .iter()
5634 .any(|w| w.contains("capo") && w.contains("foo")),
5635 "expected non-integer {{capo}} warning; got {:?}",
5636 result.warnings
5637 );
5638 }
5639
5640 #[test]
5641 fn test_capo_in_range_is_silent() {
5642 let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
5643 let result = render_song_with_warnings(&song, 0, &Config::defaults());
5644 assert!(
5645 !result.warnings.iter().any(|w| w.contains("capo")),
5646 "valid {{capo: 5}} should not warn; got {:?}",
5647 result.warnings
5648 );
5649 }
5650
5651 #[test]
5654 fn test_strict_off_with_missing_key_is_silent() {
5655 let song = chordsketch_chordpro::parse("{title: T}").unwrap();
5656 let result = render_song_with_warnings(&song, 0, &Config::defaults());
5657 assert!(
5658 !result
5659 .warnings
5660 .iter()
5661 .any(|w| w.contains("settings.strict")),
5662 "default settings.strict=false must not warn on missing {{key}}; got {:?}",
5663 result.warnings
5664 );
5665 }
5666
5667 #[test]
5668 fn test_strict_on_with_missing_key_warns() {
5669 let song = chordsketch_chordpro::parse("{title: T}").unwrap();
5670 let cfg = Config::defaults()
5671 .with_define("settings.strict=true")
5672 .unwrap();
5673 let result = render_song_with_warnings(&song, 0, &cfg);
5674 assert!(
5675 result
5676 .warnings
5677 .iter()
5678 .any(|w| w.contains("{key}") && w.contains("settings.strict")),
5679 "expected missing-{{key}} warning under settings.strict; got {:?}",
5680 result.warnings
5681 );
5682 }
5683
5684 #[test]
5685 fn test_strict_on_with_present_key_is_silent() {
5686 let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
5687 let cfg = Config::defaults()
5688 .with_define("settings.strict=true")
5689 .unwrap();
5690 let result = render_song_with_warnings(&song, 0, &cfg);
5691 assert!(
5692 !result
5693 .warnings
5694 .iter()
5695 .any(|w| w.contains("settings.strict")),
5696 "settings.strict warning must not fire when {{key}} is present; got {:?}",
5697 result.warnings
5698 );
5699 }
5700
5701 #[test]
5704 fn test_max_warnings_truncates() {
5705 let mut input = String::from("{title: T}\n");
5706 for _ in 0..(MAX_WARNINGS + 50) {
5707 input.push_str("{transpose: not-a-number}\n");
5708 }
5709 let song = chordsketch_chordpro::parse(&input).unwrap();
5710 let result = render_song_with_warnings(&song, 0, &Config::defaults());
5711 assert_eq!(
5712 result.warnings.len(),
5713 MAX_WARNINGS + 1,
5714 "expected exactly MAX_WARNINGS warnings plus one truncation marker"
5715 );
5716 assert!(
5717 result.warnings.last().unwrap().contains("MAX_WARNINGS"),
5718 "last entry must be the truncation marker; got {:?}",
5719 result.warnings.last()
5720 );
5721 }
5722
5723 #[test]
5724 fn test_validate_margin_respects_max_warnings_cap() {
5725 let mut warnings: Vec<String> = Vec::new();
5733 for i in 0..MAX_WARNINGS {
5734 push_warning(&mut warnings, format!("filler warning {i}"));
5735 }
5736 push_warning(&mut warnings, "overflow warning".to_string());
5740 assert_eq!(
5741 warnings.len(),
5742 MAX_WARNINGS + 1,
5743 "precondition: vector is at MAX_WARNINGS + 1 after first overflow",
5744 );
5745
5746 let _ = PdfDocument::validate_margin(-100.0, MARGIN_TOP, "top", &mut warnings);
5750 assert_eq!(
5751 warnings.len(),
5752 MAX_WARNINGS + 1,
5753 "validate_margin must route through push_warning and respect the cap",
5754 );
5755 }
5756
5757 #[test]
5758 fn test_embed_jpeg_produces_xobject() {
5759 let jpeg = minimal_jpeg(320, 240);
5760 let mut doc = PdfDocument::new();
5761 let idx = doc.embed_jpeg(jpeg, 320, 240, 3);
5762 assert_eq!(idx, 0);
5763 doc.draw_image(idx, 56.0, 700.0, 320.0, 240.0);
5764 let pdf = doc.build_pdf();
5765 let content = String::from_utf8_lossy(&pdf);
5766 assert!(content.contains("/XObject"));
5768 assert!(content.contains("/Im1"));
5769 assert!(content.contains("/DCTDecode"));
5770 assert!(content.contains("/Subtype /Image"));
5771 }
5772
5773 #[test]
5774 fn test_xobject_uses_actual_pixel_dimensions() {
5775 let large_w: u32 = 20_000;
5778 let large_h: u32 = 15_000;
5779 let jpeg = minimal_jpeg_with_components(large_w as u16, large_h as u16, 3);
5780 let mut doc = PdfDocument::new();
5781 let idx = doc.embed_jpeg(jpeg, large_w, large_h, 3);
5782 doc.draw_image(idx, 56.0, 700.0, 100.0, 75.0);
5783 let pdf = doc.build_pdf();
5784 let content = String::from_utf8_lossy(&pdf);
5785 let width_str = format!("/Width {large_w}");
5786 let height_str = format!("/Height {large_h}");
5787 assert!(
5788 content.contains(&width_str),
5789 "XObject must contain actual width {large_w}"
5790 );
5791 assert!(
5792 content.contains(&height_str),
5793 "XObject must contain actual height {large_h}"
5794 );
5795 }
5796
5797 #[test]
5798 fn test_embed_multiple_jpegs() {
5799 let jpeg1 = minimal_jpeg(100, 50);
5800 let jpeg2 = minimal_jpeg(200, 150);
5801 let mut doc = PdfDocument::new();
5802 let idx1 = doc.embed_jpeg(jpeg1, 100, 50, 3);
5803 let idx2 = doc.embed_jpeg(jpeg2, 200, 150, 3);
5804 assert_eq!(idx1, 0);
5805 assert_eq!(idx2, 1);
5806 doc.draw_image(idx1, 56.0, 700.0, 100.0, 50.0);
5807 doc.draw_image(idx2, 56.0, 600.0, 200.0, 150.0);
5808 let pdf = doc.build_pdf();
5809 let content = String::from_utf8_lossy(&pdf);
5810 assert!(content.contains("/Im1"));
5811 assert!(content.contains("/Im2"));
5812 }
5813
5814 #[test]
5815 fn test_embed_jpeg_grayscale_uses_device_gray() {
5816 let jpeg = minimal_jpeg_with_components(100, 100, 1);
5817 let mut doc = PdfDocument::new();
5818 let idx = doc.embed_jpeg(jpeg, 100, 100, 1);
5819 doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
5820 let pdf = doc.build_pdf();
5821 let content = String::from_utf8_lossy(&pdf);
5822 assert!(
5823 content.contains("/ColorSpace /DeviceGray"),
5824 "grayscale JPEG should use /DeviceGray"
5825 );
5826 assert!(
5827 !content.contains("/ColorSpace /DeviceRGB"),
5828 "grayscale JPEG should not use /DeviceRGB"
5829 );
5830 }
5831
5832 #[test]
5833 fn test_embed_jpeg_rgb_uses_device_rgb() {
5834 let jpeg = minimal_jpeg_with_components(100, 100, 3);
5835 let mut doc = PdfDocument::new();
5836 let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
5837 doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
5838 let pdf = doc.build_pdf();
5839 let content = String::from_utf8_lossy(&pdf);
5840 assert!(
5841 content.contains("/ColorSpace /DeviceRGB"),
5842 "RGB JPEG should use /DeviceRGB"
5843 );
5844 }
5845
5846 #[test]
5847 fn test_embed_jpeg_cmyk_uses_device_cmyk() {
5848 let jpeg = minimal_jpeg_with_components(100, 100, 4);
5849 let mut doc = PdfDocument::new();
5850 let idx = doc.embed_jpeg(jpeg, 100, 100, 4);
5851 doc.draw_image(idx, 56.0, 700.0, 100.0, 100.0);
5852 let pdf = doc.build_pdf();
5853 let content = String::from_utf8_lossy(&pdf);
5854 assert!(
5855 content.contains("/ColorSpace /DeviceCMYK"),
5856 "CMYK JPEG should use /DeviceCMYK"
5857 );
5858 }
5859
5860 #[test]
5861 fn test_parse_jpeg_dimensions_grayscale_component_count() {
5862 let jpeg = minimal_jpeg_with_components(200, 150, 1);
5863 let dims = parse_jpeg_dimensions(&jpeg);
5864 assert_eq!(dims, Some((200, 150, 1)));
5865 }
5866
5867 #[test]
5868 fn test_no_images_no_xobject_dict() {
5869 let doc = PdfDocument::new();
5870 let pdf = doc.build_pdf();
5871 let content = String::from_utf8_lossy(&pdf);
5872 assert!(!content.contains("/XObject"));
5874 }
5875
5876 #[test]
5877 fn test_draw_image_emits_cm_do_operators() {
5878 let jpeg = minimal_jpeg(50, 50);
5879 let mut doc = PdfDocument::new();
5880 let idx = doc.embed_jpeg(jpeg, 50, 50, 3);
5881 doc.draw_image(idx, 100.0, 200.0, 50.0, 50.0);
5882 let ops = &doc.pages[0];
5883 assert!(ops.iter().any(|op| op == "q"));
5884 assert!(ops.iter().any(|op| op.contains("cm")));
5885 assert!(ops.iter().any(|op| op.contains("/Im1 Do")));
5886 assert!(ops.iter().any(|op| op == "Q"));
5887 }
5888
5889 #[test]
5890 fn test_anchor_line_uses_margin_left() {
5891 let mut doc = PdfDocument::new();
5893 let jpeg = minimal_jpeg(100, 100);
5894 let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
5895 let x = doc.margin_left();
5896 doc.draw_image(idx, x, 500.0, 100.0, 100.0);
5897 let cm_op = doc.pages[0]
5898 .iter()
5899 .find(|op| op.contains("cm"))
5900 .expect("cm operator");
5901 let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
5903 assert!(
5904 (tx - MARGIN_LEFT).abs() < 0.01,
5905 "expected tx ~{MARGIN_LEFT}, got {tx}"
5906 );
5907 }
5908
5909 #[test]
5910 fn test_anchor_paper_centers_on_page() {
5911 let render_w: f32 = 200.0;
5913 let expected_x = (PAGE_W - render_w) / 2.0;
5914 let mut doc = PdfDocument::new();
5915 let jpeg = minimal_jpeg(200, 100);
5916 let idx = doc.embed_jpeg(jpeg, 200, 100, 3);
5917 doc.draw_image(idx, expected_x, 500.0, render_w, 100.0);
5918 let cm_op = doc.pages[0]
5919 .iter()
5920 .find(|op| op.contains("cm"))
5921 .expect("cm operator");
5922 let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
5923 assert!(
5924 (tx - expected_x).abs() < 0.01,
5925 "expected tx ~{expected_x}, got {tx}"
5926 );
5927 }
5928
5929 #[test]
5930 fn test_anchor_column_centers_in_column_multicolumn() {
5931 let mut doc = PdfDocument::new();
5935 doc.set_columns(2);
5936 let col_w = doc.column_width();
5938 let col_left = doc.margin_left();
5939
5940 let render_w: f32 = 100.0;
5941 let expected_x = col_left + (col_w - render_w) / 2.0;
5942
5943 let jpeg = minimal_jpeg(100, 100);
5944 let idx = doc.embed_jpeg(jpeg, 100, 100, 3);
5945 doc.draw_image(idx, expected_x, 500.0, render_w, 100.0);
5946 let cm_op = doc.pages[0]
5947 .iter()
5948 .find(|op| op.contains("cm"))
5949 .expect("cm operator");
5950 let tx: f32 = cm_op.split_whitespace().nth(4).unwrap().parse().unwrap();
5951 assert!(
5952 (tx - expected_x).abs() < 0.01,
5953 "expected tx ~{expected_x}, got {tx}"
5954 );
5955 }
5956
5957 #[test]
5958 fn test_column_width_single_column() {
5959 let doc = PdfDocument::new();
5960 let expected = PAGE_W - doc.margin_left - doc.margin_right;
5961 assert!((doc.column_width() - expected).abs() < 0.01);
5962 }
5963
5964 #[test]
5965 fn test_column_width_multi_column() {
5966 let mut doc = PdfDocument::new();
5967 doc.set_columns(2);
5968 let usable = PAGE_W - doc.margin_left - doc.margin_right;
5969 let expected = (usable - COLUMN_GAP) / 2.0;
5970 assert!((doc.column_width() - expected).abs() < 0.01);
5971 }
5972
5973 #[test]
5974 fn test_compute_image_dimensions_explicit_width() {
5975 let attrs = ImageAttributes {
5976 src: "test.jpg".to_string(),
5977 width: Some("200".to_string()),
5978 height: None,
5979 scale: None,
5980 title: None,
5981 anchor: None,
5982 };
5983 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
5984 assert!((w - 200.0).abs() < 0.01);
5985 assert!((h - 150.0).abs() < 0.01); }
5987
5988 #[test]
5989 fn test_compute_image_dimensions_explicit_height() {
5990 let attrs = ImageAttributes {
5991 src: "test.jpg".to_string(),
5992 width: None,
5993 height: Some("100".to_string()),
5994 scale: None,
5995 title: None,
5996 anchor: None,
5997 };
5998 let (w, h) = compute_image_dimensions(&attrs, 400.0, 200.0, 2.0);
5999 assert!((w - 200.0).abs() < 0.01);
6000 assert!((h - 100.0).abs() < 0.01);
6001 }
6002
6003 #[test]
6004 fn test_compute_image_dimensions_scale() {
6005 let attrs = ImageAttributes {
6006 src: "test.jpg".to_string(),
6007 width: None,
6008 height: None,
6009 scale: Some("0.5".to_string()),
6010 title: None,
6011 anchor: None,
6012 };
6013 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6014 assert!((w - 200.0).abs() < 0.01);
6015 assert!((h - 150.0).abs() < 0.01);
6016 }
6017
6018 #[test]
6019 fn test_compute_image_dimensions_native() {
6020 let attrs = ImageAttributes::default();
6021 let (w, h) = compute_image_dimensions(&attrs, 800.0, 600.0, 800.0 / 600.0);
6022 assert!((w - 800.0).abs() < 0.01);
6023 assert!((h - 600.0).abs() < 0.01);
6024 }
6025
6026 #[test]
6027 fn test_compute_image_dimensions_percentage_width() {
6028 let attrs = ImageAttributes {
6029 width: Some("50%".to_string()),
6030 ..Default::default()
6031 };
6032 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6034 assert!((w - 200.0).abs() < 0.01);
6035 assert!((h - 150.0).abs() < 0.01);
6036 }
6037
6038 #[test]
6039 fn test_compute_image_dimensions_percentage_height() {
6040 let attrs = ImageAttributes {
6041 height: Some("50%".to_string()),
6042 ..Default::default()
6043 };
6044 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6046 assert!((w - 200.0).abs() < 0.01);
6047 assert!((h - 150.0).abs() < 0.01);
6048 }
6049
6050 #[test]
6051 fn test_compute_image_dimensions_percentage_both() {
6052 let attrs = ImageAttributes {
6053 width: Some("75%".to_string()),
6054 height: Some("50%".to_string()),
6055 ..Default::default()
6056 };
6057 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6059 assert!((w - 300.0).abs() < 0.01);
6060 assert!((h - 150.0).abs() < 0.01);
6061 }
6062
6063 #[test]
6064 fn test_parse_dimension_absolute() {
6065 assert!((parse_dimension("200", 400.0).unwrap() - 200.0).abs() < 0.01);
6066 }
6067
6068 #[test]
6069 fn test_parse_dimension_percentage() {
6070 assert!((parse_dimension("50%", 400.0).unwrap() - 200.0).abs() < 0.01);
6071 assert!((parse_dimension(" 25% ", 800.0).unwrap() - 200.0).abs() < 0.01);
6072 }
6073
6074 #[test]
6075 fn test_parse_dimension_invalid() {
6076 assert!(parse_dimension("", 400.0).is_none());
6077 assert!(parse_dimension("abc", 400.0).is_none());
6078 assert!(parse_dimension("-10", 400.0).is_none());
6079 assert!(parse_dimension("0%", 400.0).is_none());
6080 assert!(parse_dimension("-5%", 400.0).is_none());
6081 }
6082
6083 #[test]
6084 fn test_parse_dimension_rejects_non_finite() {
6085 assert!(parse_dimension("inf", 400.0).is_none());
6087 assert!(parse_dimension("infinity", 400.0).is_none());
6088 assert!(parse_dimension("Infinity", 400.0).is_none());
6089 assert!(parse_dimension("NaN", 400.0).is_none());
6091 assert!(parse_dimension("inf%", 400.0).is_none());
6093 }
6094
6095 #[test]
6096 fn test_compute_image_dimensions_infinite_scale_rejected() {
6097 let attrs = ImageAttributes {
6098 src: String::new(),
6099 width: None,
6100 height: None,
6101 scale: Some("inf".to_string()),
6102 title: None,
6103 anchor: None,
6104 };
6105 let (w, h) = compute_image_dimensions(&attrs, 100.0, 200.0, 0.5);
6107 assert!((w - 100.0).abs() < 0.01);
6108 assert!((h - 200.0).abs() < 0.01);
6109 }
6110
6111 #[test]
6112 fn test_compute_image_dimensions_nan_scale_rejected() {
6113 let attrs = ImageAttributes {
6114 src: String::new(),
6115 width: None,
6116 height: None,
6117 scale: Some("NaN".to_string()),
6118 title: None,
6119 anchor: None,
6120 };
6121 let (w, h) = compute_image_dimensions(&attrs, 100.0, 200.0, 0.5);
6122 assert!((w - 100.0).abs() < 0.01);
6123 assert!((h - 200.0).abs() < 0.01);
6124 }
6125
6126 #[test]
6127 fn test_oversized_image_file_is_skipped() {
6128 let thread_name = std::thread::current()
6135 .name()
6136 .unwrap_or("main")
6137 .replace("::", "_");
6138 let subdir = format!("_test_oversized_img_{}_{}", std::process::id(), thread_name);
6139 let _ = std::fs::remove_dir_all(&subdir);
6140 std::fs::create_dir_all(&subdir).expect("create test dir");
6141 let rel_path = format!("{subdir}/huge.jpg");
6142
6143 let f = std::fs::File::create(&rel_path).unwrap();
6145 f.set_len(MAX_IMAGE_FILE_SIZE + 1).unwrap();
6146 drop(f);
6147
6148 let input = format!("{{image: src={rel_path}}}");
6149 let song = chordsketch_chordpro::parse(&input).unwrap();
6150 let pdf = render_song(&song);
6152 let content = String::from_utf8_lossy(&pdf);
6153 assert!(
6155 !content.contains("/Subtype /Image"),
6156 "oversized image must be rejected"
6157 );
6158
6159 let _ = std::fs::remove_dir_all(subdir);
6160 }
6161
6162 #[test]
6163 fn test_negative_scale_falls_back_to_native() {
6164 let attrs = ImageAttributes {
6165 scale: Some("-1".to_string()),
6166 ..Default::default()
6167 };
6168 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6169 assert!((w - 400.0).abs() < 0.01);
6171 assert!((h - 300.0).abs() < 0.01);
6172 }
6173
6174 #[test]
6175 fn test_negative_width_falls_back_to_native() {
6176 let attrs = ImageAttributes {
6177 width: Some("-200".to_string()),
6178 ..Default::default()
6179 };
6180 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6181 assert!((w - 400.0).abs() < 0.01);
6182 assert!((h - 300.0).abs() < 0.01);
6183 }
6184
6185 #[test]
6186 fn test_negative_height_falls_back_to_native() {
6187 let attrs = ImageAttributes {
6188 height: Some("-150".to_string()),
6189 ..Default::default()
6190 };
6191 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6192 assert!((w - 400.0).abs() < 0.01);
6193 assert!((h - 300.0).abs() < 0.01);
6194 }
6195
6196 #[test]
6197 fn test_zero_scale_falls_back_to_native() {
6198 let attrs = ImageAttributes {
6199 scale: Some("0".to_string()),
6200 ..Default::default()
6201 };
6202 let (w, h) = compute_image_dimensions(&attrs, 400.0, 300.0, 400.0 / 300.0);
6203 assert!((w - 400.0).abs() < 0.01);
6204 assert!((h - 300.0).abs() < 0.01);
6205 }
6206
6207 #[test]
6208 fn test_clamp_to_printable_no_clamping_needed() {
6209 let (w, h) = clamp_to_printable_area(200.0, 150.0, 500.0, 700.0, 200.0 / 150.0);
6210 assert!((w - 200.0).abs() < 0.01);
6211 assert!((h - 150.0).abs() < 0.01);
6212 }
6213
6214 #[test]
6215 fn test_clamp_to_printable_width_exceeds() {
6216 let (w, h) = clamp_to_printable_area(800.0, 200.0, 500.0, 700.0, 4.0);
6218 assert!((w - 500.0).abs() < 0.01);
6219 assert!((h - 125.0).abs() < 0.01); }
6221
6222 #[test]
6223 fn test_clamp_to_printable_height_exceeds() {
6224 let (w, h) = clamp_to_printable_area(200.0, 800.0, 500.0, 700.0, 0.25);
6226 assert!((w - 175.0).abs() < 0.01); assert!((h - 700.0).abs() < 0.01); }
6229
6230 #[test]
6231 fn test_clamp_to_printable_height_exceeds_extreme_aspect_reclamps_width() {
6232 let (w, h) = clamp_to_printable_area(2800.0, 700.0, 500.0, 700.0, 4.0);
6235 assert!((w - 500.0).abs() < 0.01);
6237 assert!((h - 125.0).abs() < 0.01); }
6239
6240 #[test]
6241 fn test_clamp_to_printable_height_clamp_triggers_width_reclamp() {
6242 let (w, h) = clamp_to_printable_area(400.0, 800.0, 500.0, 700.0, 4.0);
6247 assert!(w <= 500.0, "width {} must not exceed max_w 500", w);
6248 assert!((w - 500.0).abs() < 0.01);
6249 assert!((h - 125.0).abs() < 0.01); }
6251
6252 #[test]
6253 fn test_clamp_to_printable_width_exceeds_then_height_reclamps() {
6254 let (w, h) = clamp_to_printable_area(2000.0, 2000.0, 500.0, 50.0, 1.0);
6259 assert!((w - 50.0).abs() < 0.01, "width {} should be 50.0", w);
6260 assert!((h - 50.0).abs() < 0.01, "height {} should be 50.0", h);
6261 }
6262
6263 #[test]
6264 fn test_safe_image_path_relative() {
6265 assert!(is_safe_image_path("photo.jpg"));
6266 assert!(is_safe_image_path("images/photo.jpg"));
6267 assert!(is_safe_image_path("sub/dir/photo.jpg"));
6268 }
6269
6270 #[test]
6271 fn test_safe_image_path_rejects_empty() {
6272 assert!(!is_safe_image_path(""));
6273 }
6274
6275 #[test]
6276 fn test_safe_image_path_rejects_null_bytes() {
6277 assert!(!is_safe_image_path("photo\0.jpg"));
6278 assert!(!is_safe_image_path("images/photo.jpg\0../../etc/shadow"));
6279 }
6280
6281 #[test]
6282 fn test_safe_image_path_rejects_absolute() {
6283 assert!(!is_safe_image_path("/etc/shadow.jpeg"));
6284 assert!(!is_safe_image_path("/home/user/photo.jpg"));
6285 }
6286
6287 #[test]
6288 fn test_safe_image_path_rejects_traversal() {
6289 assert!(!is_safe_image_path("../photo.jpg"));
6290 assert!(!is_safe_image_path("images/../../etc/shadow.jpeg"));
6291 assert!(!is_safe_image_path("sub/../../../photo.jpg"));
6292 }
6293
6294 #[test]
6295 fn test_safe_image_path_windows_style_strings() {
6296 assert!(is_safe_image_path(r"images\photo.jpg"));
6305
6306 assert!(!is_safe_image_path("/images/photo.jpg"));
6308 }
6309
6310 #[test]
6311 fn test_safe_image_path_windows_absolute_rejected() {
6312 assert!(!is_safe_image_path(r"C:\photo.jpg"));
6314 assert!(!is_safe_image_path(r"D:\Users\photo.jpg"));
6315 assert!(!is_safe_image_path(r"\\server\share\photo.jpg"));
6316 assert!(!is_safe_image_path("C:/photo.jpg"));
6317 }
6318
6319 #[test]
6320 fn test_safe_image_path_backslash_traversal_rejected() {
6321 assert!(!is_safe_image_path(r"..\photo.jpg"));
6324 assert!(!is_safe_image_path(r"images\..\..\photo.jpg"));
6325 }
6326
6327 #[cfg(unix)]
6328 #[test]
6329 fn test_symlink_image_is_rejected() {
6330 use std::os::unix::fs::symlink;
6331
6332 let subdir = format!(
6336 "_test_symlink_img_{}_{}",
6337 std::process::id(),
6338 std::thread::current().name().unwrap_or("main")
6339 );
6340 let _ = std::fs::remove_dir_all(&subdir);
6341 std::fs::create_dir_all(&subdir).expect("create test dir");
6342
6343 let target = format!("{subdir}/real.jpg");
6344 std::fs::write(&target, b"\xFF\xD8\xFF").expect("write target");
6345 let link = format!("{subdir}/link.jpg");
6346 symlink(&target, &link).expect("create symlink");
6347
6348 let input = format!("{{title: T}}\n{{image: src={link}}}");
6349 let song = chordsketch_chordpro::parse(&input).expect("parse");
6350 let pdf = render_song(&song);
6351 let content = String::from_utf8_lossy(&pdf);
6352 assert!(
6354 !content.contains("/Subtype /Image"),
6355 "symlink images must be rejected"
6356 );
6357
6358 let _ = std::fs::remove_dir_all(&subdir);
6359 }
6360
6361 #[test]
6362 fn test_custom_margins_from_config() {
6363 let config = Config::defaults()
6364 .with_define("pdf.margins.top=100")
6365 .unwrap();
6366 let doc = PdfDocument::from_config(&config);
6367 assert!((doc.margin_top - 100.0).abs() < 0.01);
6368 assert!((doc.margin_bottom - MARGIN_BOTTOM).abs() < 0.01);
6370 assert!((doc.margin_left - MARGIN_LEFT).abs() < 0.01);
6371 assert!((doc.margin_right - MARGIN_RIGHT).abs() < 0.01);
6372 }
6373
6374 #[test]
6375 fn test_negative_margin_falls_back_to_default() {
6376 let config = Config::defaults()
6377 .with_define("pdf.margins.top=-100")
6378 .unwrap();
6379 let doc = PdfDocument::from_config(&config);
6380 assert!((doc.margin_top - MARGIN_TOP).abs() < 0.01);
6381 }
6382
6383 #[test]
6384 fn test_zero_margin_is_valid() {
6385 let config = Config::defaults().with_define("pdf.margins.top=0").unwrap();
6386 let doc = PdfDocument::from_config(&config);
6387 assert!(doc.margin_top.abs() < 0.01);
6388 }
6389
6390 #[test]
6391 fn test_excessive_margin_falls_back_to_default() {
6392 let config = Config::defaults()
6393 .with_define("pdf.margins.left=1000")
6394 .unwrap();
6395 let doc = PdfDocument::from_config(&config);
6396 assert!((doc.margin_left - MARGIN_LEFT).abs() < 0.01);
6397 }
6398
6399 #[test]
6400 fn test_custom_margins_affect_output() {
6401 let song = chordsketch_chordpro::parse("{title: Test}\nHello").unwrap();
6402 let default_pdf = render_song(&song);
6403 let config = Config::defaults()
6404 .with_define("pdf.margins.top=200")
6405 .unwrap();
6406 let custom_pdf = render_song_with_transpose(&song, 0, &config);
6407 assert_ne!(default_pdf, custom_pdf);
6409 }
6410
6411 #[test]
6412 fn test_fmt_f32_nan_produces_zero() {
6413 assert_eq!(fmt_f32(f32::NAN), "0");
6414 }
6415
6416 #[test]
6417 fn test_fmt_f32_infinity_produces_zero() {
6418 assert_eq!(fmt_f32(f32::INFINITY), "0");
6419 assert_eq!(fmt_f32(f32::NEG_INFINITY), "0");
6420 }
6421
6422 #[test]
6423 fn test_fmt_f32_normal_values() {
6424 assert_eq!(fmt_f32(1.0), "1");
6425 assert_eq!(fmt_f32(3.25), "3.25");
6426 assert_eq!(fmt_f32(0.0), "0");
6427 assert_eq!(fmt_f32(-5.5), "-5.5");
6428 }
6429}
6430
6431#[cfg(test)]
6432mod png_tests {
6433 use super::*;
6434
6435 fn build_png(width: u32, height: u32, bit_depth: u8, color_type: u8, pixels: &[u8]) -> Vec<u8> {
6440 let channels: usize = match color_type {
6441 0 => 1,
6442 2 => 3,
6443 4 => 2,
6444 6 => 4,
6445 _ => panic!("unsupported color type"),
6446 };
6447 let bytes_per_sample = if bit_depth == 16 { 2 } else { 1 };
6448 let row_bytes = width as usize * channels * bytes_per_sample;
6449
6450 let mut raw = Vec::new();
6452 for row in 0..height as usize {
6453 raw.push(0); let start = row * row_bytes;
6455 raw.extend_from_slice(&pixels[start..start + row_bytes]);
6456 }
6457
6458 let idat_payload = zlib_compress(&raw).expect("compression should succeed");
6460
6461 let mut png = Vec::new();
6462 png.extend_from_slice(&PNG_SIGNATURE);
6463
6464 let mut ihdr = Vec::new();
6466 ihdr.extend_from_slice(&width.to_be_bytes());
6467 ihdr.extend_from_slice(&height.to_be_bytes());
6468 ihdr.push(bit_depth);
6469 ihdr.push(color_type);
6470 ihdr.push(0); ihdr.push(0); ihdr.push(0); write_png_chunk(&mut png, b"IHDR", &ihdr);
6474
6475 write_png_chunk(&mut png, b"IDAT", &idat_payload);
6477
6478 write_png_chunk(&mut png, b"IEND", &[]);
6480
6481 png
6482 }
6483
6484 fn build_indexed_png(width: u32, height: u32, palette: &[u8], indices: &[u8]) -> Vec<u8> {
6486 let row_bytes = width as usize;
6487
6488 let mut raw = Vec::new();
6489 for row in 0..height as usize {
6490 raw.push(0); let start = row * row_bytes;
6492 raw.extend_from_slice(&indices[start..start + row_bytes]);
6493 }
6494
6495 let idat_payload = zlib_compress(&raw).expect("compression should succeed");
6496
6497 let mut png = Vec::new();
6498 png.extend_from_slice(&PNG_SIGNATURE);
6499
6500 let mut ihdr = Vec::new();
6502 ihdr.extend_from_slice(&width.to_be_bytes());
6503 ihdr.extend_from_slice(&height.to_be_bytes());
6504 ihdr.push(8); ihdr.push(3); ihdr.push(0);
6507 ihdr.push(0);
6508 ihdr.push(0);
6509 write_png_chunk(&mut png, b"IHDR", &ihdr);
6510
6511 write_png_chunk(&mut png, b"PLTE", palette);
6513
6514 write_png_chunk(&mut png, b"IDAT", &idat_payload);
6516
6517 write_png_chunk(&mut png, b"IEND", &[]);
6519
6520 png
6521 }
6522
6523 fn write_png_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
6524 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
6525 out.extend_from_slice(chunk_type);
6526 out.extend_from_slice(data);
6527 let mut crc_data = Vec::new();
6529 crc_data.extend_from_slice(chunk_type);
6530 crc_data.extend_from_slice(data);
6531 let crc = crc32(&crc_data);
6532 out.extend_from_slice(&crc.to_be_bytes());
6533 }
6534
6535 fn crc32(data: &[u8]) -> u32 {
6537 let mut crc: u32 = 0xFFFF_FFFF;
6538 for &byte in data {
6539 crc ^= byte as u32;
6540 for _ in 0..8 {
6541 if crc & 1 != 0 {
6542 crc = (crc >> 1) ^ 0xEDB8_8320;
6543 } else {
6544 crc >>= 1;
6545 }
6546 }
6547 }
6548 !crc
6549 }
6550
6551 #[test]
6552 fn test_parse_png_rgb() {
6553 let pixels = vec![
6555 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, ];
6558 let png = build_png(2, 2, 8, 2, &pixels);
6559 let info = parse_png(&png).expect("should parse");
6560 assert_eq!(info.width, 2);
6561 assert_eq!(info.height, 2);
6562 assert_eq!(info.bit_depth, 8);
6563 assert_eq!(info.colors, 3);
6564 assert!(info.palette.is_none());
6565 assert!(info.smask.is_none());
6566 }
6567
6568 #[test]
6569 fn test_parse_png_grayscale() {
6570 let pixels = vec![0, 128, 255];
6572 let png = build_png(3, 1, 8, 0, &pixels);
6573 let info = parse_png(&png).expect("should parse");
6574 assert_eq!(info.width, 3);
6575 assert_eq!(info.height, 1);
6576 assert_eq!(info.colors, 1);
6577 assert!(info.smask.is_none());
6578 }
6579
6580 #[test]
6581 fn test_parse_png_rgba_separates_alpha() {
6582 let pixels = vec![
6584 255, 0, 0, 128, 0, 255, 0, 255, ];
6587 let png = build_png(2, 1, 8, 6, &pixels);
6588 let info = parse_png(&png).expect("should parse RGBA");
6589 assert_eq!(info.width, 2);
6590 assert_eq!(info.height, 1);
6591 assert_eq!(info.colors, 3); assert!(info.smask.is_some());
6593
6594 let mut decoder = ZlibDecoder::new(info.idat_data.as_slice());
6596 let mut color = Vec::new();
6597 decoder.read_to_end(&mut color).unwrap();
6598 assert_eq!(color, vec![0, 255, 0, 0, 0, 255, 0]);
6600
6601 let mut decoder = ZlibDecoder::new(info.smask.as_ref().unwrap().as_slice());
6603 let mut alpha = Vec::new();
6604 decoder.read_to_end(&mut alpha).unwrap();
6605 assert_eq!(alpha, vec![0, 128, 255]);
6607 }
6608
6609 #[test]
6610 fn test_parse_png_gray_alpha() {
6611 let pixels = vec![
6613 100, 200, 50, 100, ];
6616 let png = build_png(2, 1, 8, 4, &pixels);
6617 let info = parse_png(&png).expect("should parse gray+alpha");
6618 assert_eq!(info.colors, 1); assert!(info.smask.is_some());
6620
6621 let mut decoder = ZlibDecoder::new(info.idat_data.as_slice());
6622 let mut color = Vec::new();
6623 decoder.read_to_end(&mut color).unwrap();
6624 assert_eq!(color, vec![0, 100, 50]);
6625
6626 let mut decoder = ZlibDecoder::new(info.smask.as_ref().unwrap().as_slice());
6627 let mut alpha = Vec::new();
6628 decoder.read_to_end(&mut alpha).unwrap();
6629 assert_eq!(alpha, vec![0, 200, 100]);
6630 }
6631
6632 #[test]
6633 fn test_parse_png_indexed() {
6634 let palette = vec![255, 0, 0, 0, 0, 255]; let indices = vec![0, 1]; let png = build_indexed_png(2, 1, &palette, &indices);
6638 let info = parse_png(&png).expect("should parse indexed");
6639 assert_eq!(info.colors, 3); assert!(info.palette.is_some());
6641 assert_eq!(info.palette.as_ref().unwrap(), &palette);
6642 }
6643
6644 #[test]
6645 fn test_parse_png_invalid_signature() {
6646 assert!(parse_png(b"not a png").is_none());
6647 assert!(parse_png(&[]).is_none());
6648 }
6649
6650 #[test]
6651 fn test_parse_png_no_idat() {
6652 let mut png = Vec::new();
6653 png.extend_from_slice(&PNG_SIGNATURE);
6654 let mut ihdr = Vec::new();
6656 ihdr.extend_from_slice(&2u32.to_be_bytes());
6657 ihdr.extend_from_slice(&2u32.to_be_bytes());
6658 ihdr.push(8);
6659 ihdr.push(2); ihdr.extend_from_slice(&[0, 0, 0]);
6661 write_png_chunk(&mut png, b"IHDR", &ihdr);
6662 write_png_chunk(&mut png, b"IEND", &[]);
6663 assert!(parse_png(&png).is_none());
6664 }
6665
6666 #[test]
6667 fn test_embedded_image_num_objects_jpeg() {
6668 let img = EmbeddedImage {
6669 width: 10,
6670 height: 10,
6671 format: ImageFormat::Jpeg {
6672 data: vec![],
6673 components: 3,
6674 },
6675 };
6676 assert_eq!(img.num_pdf_objects(), 1);
6677 }
6678
6679 #[test]
6680 fn test_embedded_image_num_objects_png_no_alpha() {
6681 let img = EmbeddedImage {
6682 width: 10,
6683 height: 10,
6684 format: ImageFormat::Png {
6685 idat_data: vec![],
6686 bit_depth: 8,
6687 colors: 3,
6688 palette: None,
6689 smask: None,
6690 },
6691 };
6692 assert_eq!(img.num_pdf_objects(), 1);
6693 }
6694
6695 #[test]
6696 fn test_embedded_image_num_objects_png_with_alpha() {
6697 let img = EmbeddedImage {
6698 width: 10,
6699 height: 10,
6700 format: ImageFormat::Png {
6701 idat_data: vec![],
6702 bit_depth: 8,
6703 colors: 3,
6704 palette: None,
6705 smask: Some(vec![1, 2, 3]),
6706 },
6707 };
6708 assert_eq!(img.num_pdf_objects(), 2);
6709 }
6710
6711 #[test]
6712 fn test_paeth_predictor() {
6713 assert_eq!(paeth_predictor(0, 0, 0), 0);
6716 assert_eq!(paeth_predictor(10, 20, 15), 15);
6718 assert_eq!(paeth_predictor(10, 10, 10), 10);
6720 }
6721
6722 #[test]
6723 fn test_render_songs_with_warnings_empty_slice() {
6724 let songs: Vec<chordsketch_chordpro::ast::Song> = Vec::new();
6725 let result = render_songs_with_warnings(&songs, 0, &Config::defaults());
6726 assert!(result.output.is_empty());
6728 }
6729}
6730
6731#[cfg(test)]
6732mod info_title_tests {
6733 use super::*;
6734
6735 #[test]
6736 fn pdf_title_hex_string_encodes_ascii_with_bom() {
6737 assert_eq!(pdf_title_hex_string("AB"), "<FEFF00410042>");
6738 }
6739
6740 #[test]
6741 fn pdf_title_hex_string_encodes_bmp_codepoints() {
6742 assert_eq!(pdf_title_hex_string("日本"), "<FEFF65E5672C>");
6744 }
6745
6746 #[test]
6747 fn pdf_title_hex_string_encodes_supplementary_plane_with_surrogate_pair() {
6748 assert_eq!(pdf_title_hex_string("🎵"), "<FEFFD83CDFB5>");
6750 }
6751
6752 #[test]
6753 fn render_song_emits_info_title_for_titled_song() {
6754 let input = "{title: Hello}\n\nHello world\n";
6755 let song = chordsketch_chordpro::parse(input).expect("parse");
6756 let pdf = render_song(&song);
6757 let s = String::from_utf8_lossy(&pdf);
6758 assert!(
6760 s.contains("/Info "),
6761 "trailer should reference /Info when {{title}} is set; got: {s}"
6762 );
6763 assert!(
6768 s.contains("<FEFF00480065006C006C006F>"),
6769 "PDF should contain UTF-16BE-encoded title bytes; got: {s}"
6770 );
6771 }
6772
6773 #[test]
6774 fn set_doc_title_caps_oversized_input() {
6775 let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6776 let big = "A".repeat(PdfDocument::MAX_TITLE_CHARS * 100);
6778 doc.set_doc_title(Some(&big));
6779 let stored = doc.doc_title.expect("title should be stored");
6780 assert_eq!(
6781 stored.chars().count(),
6782 PdfDocument::MAX_TITLE_CHARS,
6783 "oversized title must be truncated to MAX_TITLE_CHARS"
6784 );
6785 let hex = pdf_title_hex_string(&stored);
6787 let expected_max = 5 + 4 * PdfDocument::MAX_TITLE_CHARS + 1;
6788 assert!(
6789 hex.len() <= expected_max,
6790 "hex literal must be bounded; got {} bytes, max {expected_max}",
6791 hex.len()
6792 );
6793 }
6794
6795 #[test]
6796 fn set_doc_title_passes_through_at_cap_and_truncates_one_over() {
6797 let mut at_cap = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6798 at_cap.set_doc_title(Some(&"A".repeat(PdfDocument::MAX_TITLE_CHARS)));
6799 assert_eq!(
6800 at_cap.doc_title.as_deref().map(|s| s.chars().count()),
6801 Some(PdfDocument::MAX_TITLE_CHARS),
6802 "input exactly at cap must pass through unchanged"
6803 );
6804
6805 let mut over_cap = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6806 over_cap.set_doc_title(Some(&"A".repeat(PdfDocument::MAX_TITLE_CHARS + 1)));
6807 assert_eq!(
6808 over_cap.doc_title.as_deref().map(|s| s.chars().count()),
6809 Some(PdfDocument::MAX_TITLE_CHARS),
6810 "input one char over cap must truncate"
6811 );
6812 }
6813
6814 #[test]
6815 fn set_doc_title_truncates_at_char_boundary_for_multibyte_input() {
6816 let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6817 let big: String = "日".repeat(PdfDocument::MAX_TITLE_CHARS + 50);
6819 doc.set_doc_title(Some(&big));
6820 let stored = doc.doc_title.expect("title should be stored");
6821 assert_eq!(stored.chars().count(), PdfDocument::MAX_TITLE_CHARS);
6822 assert!(
6824 stored.is_char_boundary(stored.len()),
6825 "truncation produced an invalid UTF-8 boundary"
6826 );
6827 }
6828
6829 #[test]
6830 fn set_doc_title_trims_leading_and_trailing_whitespace() {
6831 let mut doc = PdfDocument::with_margins(10.0, 10.0, 10.0, 10.0);
6832 doc.set_doc_title(Some(" Hello World "));
6833 assert_eq!(doc.doc_title.as_deref(), Some("Hello World"));
6834 }
6835
6836 #[test]
6837 fn render_song_omits_info_when_title_missing() {
6838 let input = "Just a lyric line\n";
6839 let song = chordsketch_chordpro::parse(input).expect("parse");
6840 let pdf = render_song(&song);
6841 let s = String::from_utf8_lossy(&pdf);
6842 assert!(
6843 !s.contains("/Info "),
6844 "no /Info should be emitted when {{title}} is absent"
6845 );
6846 assert!(
6847 !s.contains("/Title "),
6848 "no /Title should be emitted when {{title}} is absent"
6849 );
6850 }
6851
6852 #[test]
6853 fn render_song_omits_info_for_whitespace_only_title() {
6854 let input = "{title: }\n\nbody\n";
6855 let song = chordsketch_chordpro::parse(input).expect("parse");
6856 let pdf = render_song(&song);
6857 let s = String::from_utf8_lossy(&pdf);
6858 assert!(
6859 !s.contains("/Info "),
6860 "whitespace-only title must normalise to no /Info"
6861 );
6862 }
6863
6864 #[test]
6865 fn render_songs_omits_info_for_multi_song_output() {
6866 let input = "{title: One}\n\nfirst body\n\n{new_song}\n{title: Two}\n\nsecond body\n";
6869 let songs = chordsketch_chordpro::parse_multi(input).expect("parse_multi");
6870 assert!(songs.len() >= 2, "expected multi-song parse");
6871 let pdf = render_songs(&songs);
6872 let s = String::from_utf8_lossy(&pdf);
6873 assert!(
6874 !s.contains("/Info "),
6875 "multi-song render must not emit /Info; got trailer fragment: {s}"
6876 );
6877 }
6878}