mod beams;
mod geometry;
mod glyphs;
mod render;
mod tuplets;
use acorde_core::{NoteAddr, NoteHead, Score, TextStyle};
use acorde_layout::{LayoutConfig, LayoutResult, compute_layout};
use serde::{Deserialize, Serialize};
use std::fmt;
pub const SVG_CONTRACT_VERSION: u32 = 15;
pub const GLYPH_COVERAGE_CONTRACT_VERSION: u32 = 3;
pub const BUILTIN_GLYPH_RESOURCE_ID: &str = "acorde-vector-glyphs-v1";
pub const TAB_METRICS_CONTRACT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct TabFretMetrics {
pub fret: u8,
pub digit_count: u8,
pub advance_units: f32,
pub side_gap_units: f32,
}
pub fn tab_fret_metrics(fret: u8) -> TabFretMetrics {
let digit_count = fret.to_string().len() as u8;
TabFretMetrics {
fret,
digit_count,
advance_units: f32::from(digit_count) * 0.48,
side_gap_units: 0.22,
}
}
pub fn percussion_notehead_resource_id(note_head: &NoteHead) -> &'static str {
match note_head {
NoteHead::Normal => "acorde-percussion-notehead-normal",
NoteHead::Diamond => "acorde-percussion-notehead-diamond",
NoteHead::X => "acorde-percussion-notehead-x",
NoteHead::Slash => "acorde-percussion-notehead-slash",
NoteHead::Cross => "acorde-percussion-notehead-cross",
NoteHead::Triangle => "acorde-percussion-notehead-triangle",
}
}
const MAX_RENDER_ANNOTATIONS: usize = 10_000;
const MAX_ANNOTATION_TEXT_BYTES: usize = 16 * 1024;
pub(crate) fn is_valid_xml_char(character: char) -> bool {
matches!(character, '\u{9}' | '\u{A}' | '\u{D}')
|| ('\u{20}'..='\u{D7FF}').contains(&character)
|| ('\u{E000}'..='\u{FFFD}').contains(&character)
|| ('\u{10000}'..='\u{10FFFF}').contains(&character)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SvgRenderOptions {
pub width: f32,
pub staff_size: f32,
pub measures_per_system: usize,
pub interactive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderMetadata {
pub contract_version: u32,
pub width: f32,
pub height: f32,
pub part_count: usize,
pub staff_count: usize,
pub measure_count: usize,
pub note_count: usize,
pub accessible_text: String,
pub address_bounds: Vec<AddressBounds>,
#[serde(default)]
pub text_annotations: Vec<TextAnnotation>,
#[serde(default)]
pub score_texts: Vec<ScoreTextMetadata>,
#[serde(default)]
pub tablature_positions: Vec<TablaturePositionMetadata>,
#[serde(default)]
pub tablature_staves: Vec<TablatureStaffMetadata>,
#[serde(default)]
pub harmony_ranges: Vec<HarmonyRangeMetadata>,
#[serde(default)]
pub tablature_technique_connections: Vec<TablatureTechniqueConnectionMetadata>,
#[serde(default)]
pub note_semantics: Vec<NoteSemanticMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NoteSemanticMetadata {
pub part: usize,
pub staff: usize,
pub measure: usize,
pub voice: usize,
pub note: usize,
pub is_unpitched: bool,
#[serde(default)]
pub tie_start: bool,
#[serde(default)]
pub tie_end: bool,
#[serde(default)]
pub duration_beats: f64,
#[serde(default)]
pub pitch_midi_cents: Vec<i32>,
#[serde(default)]
pub offset_x: Option<f64>,
#[serde(default)]
pub offset_y: Option<f64>,
#[serde(default)]
pub relative_x: Option<f64>,
#[serde(default)]
pub relative_y: Option<f64>,
#[serde(default)]
pub dynamic: Option<String>,
#[serde(default)]
pub lyric: Option<String>,
#[serde(default)]
pub chord_label: Option<String>,
#[serde(default)]
pub technique_text: Option<String>,
#[serde(default)]
pub articulations: Vec<String>,
#[serde(default)]
pub guitar_technique: Option<acorde_core::GuitarTechnique>,
#[serde(default)]
pub guitar_bend_alter_cents: Option<i16>,
#[serde(default)]
pub fingerings: Vec<u8>,
#[serde(default)]
pub instrument_id: Option<String>,
#[serde(default)]
pub microtone_cents: Vec<i16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HarmonyRangeMetadata {
pub start: NoteAddr,
pub end: NoteAddr,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TablaturePositionMetadata {
pub part: usize,
pub staff: usize,
pub measure: usize,
pub voice: usize,
pub note: usize,
pub position: usize,
pub string: u8,
pub fret: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TablatureStaffMetadata {
pub part: usize,
pub staff: usize,
pub lines: u8,
#[serde(default)]
pub tuning_midi: Vec<i16>,
pub capo: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TablatureTechniqueConnectionMetadata {
pub start: NoteAddr,
pub end: NoteAddr,
pub technique: acorde_core::GuitarTechnique,
pub string: u8,
#[serde(default)]
pub cross_measure: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TextAnnotation {
pub part: usize,
pub staff: usize,
pub measure: usize,
pub style: TextStyle,
pub text: String,
#[serde(default)]
pub placement: Option<String>,
#[serde(default)]
pub offset_x: Option<f64>,
#[serde(default)]
pub offset_y: Option<f64>,
#[serde(default)]
pub relative_x: Option<f64>,
#[serde(default)]
pub relative_y: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScoreTextMetadata {
pub style: TextStyle,
pub text: String,
#[serde(default)]
pub placement: Option<String>,
#[serde(default)]
pub offset_x: Option<f64>,
#[serde(default)]
pub offset_y: Option<f64>,
#[serde(default)]
pub relative_x: Option<f64>,
#[serde(default)]
pub relative_y: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MicrotoneMarkerCoverage {
pub supported: bool,
pub representation: String,
pub exact_cents: bool,
}
impl Default for MicrotoneMarkerCoverage {
fn default() -> Self {
Self {
supported: true,
representation: "svg-text-cents".to_owned(),
exact_cents: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GlyphCoverage {
pub contract_version: u32,
pub resource_id: String,
pub vector_glyphs: bool,
pub supported_clefs: Vec<String>,
pub accidental_min: i8,
pub accidental_max: i8,
#[serde(default)]
pub microtone_marker: MicrotoneMarkerCoverage,
#[serde(default)]
pub percussion_noteheads: Vec<String>,
}
pub fn glyph_coverage() -> GlyphCoverage {
GlyphCoverage {
contract_version: GLYPH_COVERAGE_CONTRACT_VERSION,
resource_id: BUILTIN_GLYPH_RESOURCE_ID.to_owned(),
vector_glyphs: true,
supported_clefs: vec![
"treble".to_owned(),
"bass".to_owned(),
"alto".to_owned(),
"tenor".to_owned(),
],
accidental_min: -2,
accidental_max: 2,
microtone_marker: MicrotoneMarkerCoverage::default(),
percussion_noteheads: [
NoteHead::Normal,
NoteHead::Diamond,
NoteHead::X,
NoteHead::Slash,
NoteHead::Cross,
NoteHead::Triangle,
]
.iter()
.map(|head| percussion_notehead_resource_id(head).to_owned())
.collect(),
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum RenderPreflightKind {
UnsupportedClef,
UnsupportedAccidental,
InvalidTabPosition,
MeasureTextTooLarge,
InvalidMeasureTextOffset,
InvalidNotePlacement,
InvalidXmlCharacter,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RenderPreflightIssue {
pub kind: RenderPreflightKind,
pub source_location: String,
pub preserved_value: String,
}
pub fn render_preflight(score: &Score) -> Vec<RenderPreflightIssue> {
let mut issues = Vec::new();
push_text_preflight_issue(&mut issues, "/score/title", &score.metadata.title);
for (part_index, part) in score.parts.iter().enumerate() {
push_text_preflight_issue(
&mut issues,
&format!("/score/part/{}/name", part_index + 1),
&part.name,
);
push_text_preflight_issue(
&mut issues,
&format!("/score/part/{}/short-name", part_index + 1),
&part.short_name,
);
for (staff_index, staff) in part.staves.iter().enumerate() {
let staff_path = format!("/score/part/{}/staff/{}", part_index + 1, staff_index + 1);
for (measure_index, measure) in staff.measures.iter().enumerate() {
for (text_index, styled) in measure.texts.iter().enumerate() {
let text_path = format!(
"{staff_path}/measure/{}/text/{}",
measure_index + 1,
text_index + 1
);
push_text_preflight_issue(&mut issues, &text_path, &styled.text);
for (field, value) in [
("offset_x", styled.offset_x),
("offset_y", styled.offset_y),
("relative_x", styled.relative_x),
("relative_y", styled.relative_y),
] {
if let Some(value) = value {
if !value.is_finite() || !(value as f32).is_finite() {
issues.push(RenderPreflightIssue {
kind: RenderPreflightKind::InvalidMeasureTextOffset,
source_location: format!("{text_path}/{field}"),
preserved_value: value.to_string(),
});
}
}
}
}
for (field, value) in [
("tempo-text", measure.tempo_text.as_deref()),
("rehearsal", measure.rehearsal.as_deref()),
("navigation", measure.navigation.as_deref()),
("expression-text", measure.expression_text.as_deref()),
] {
if let Some(value) = value {
push_text_preflight_issue(
&mut issues,
&format!("{staff_path}/measure/{}/{}", measure_index + 1, field),
value,
);
}
}
for (voice_index, voice) in measure.voices.iter().enumerate() {
for (note_index, note) in voice.iter().enumerate() {
let note_path = format!(
"{staff_path}/measure/{}/voice/{}/note/{}",
measure_index + 1,
voice_index + 1,
note_index + 1
);
if let Some(lyric) = ¬e.lyric {
push_text_preflight_issue(
&mut issues,
&format!("{note_path}/lyric"),
&lyric.text,
);
}
if let Some(technique) = ¬e.technique_text {
push_text_preflight_issue(
&mut issues,
&format!("{note_path}/technique-text"),
technique,
);
}
if let Some(chord) = ¬e.chord_symbol {
let chord_text = chord.display_text();
push_text_preflight_issue(
&mut issues,
&format!("{note_path}/chord-symbol"),
&chord_text,
);
}
for (field, value) in [
("default-x", note.offset_x),
("default-y", note.offset_y),
("relative-x", note.relative_x),
("relative-y", note.relative_y),
] {
if let Some(value) = value {
if !value.is_finite() || !(value as f32).is_finite() {
issues.push(RenderPreflightIssue {
kind: RenderPreflightKind::InvalidNotePlacement,
source_location: format!("{note_path}/{field}"),
preserved_value: value.to_string(),
});
}
}
}
for (pitch_index, pitch) in note.pitches.iter().enumerate() {
if !(-2..=2).contains(&pitch.alter) {
issues.push(RenderPreflightIssue {
kind: RenderPreflightKind::UnsupportedAccidental,
source_location: format!(
"{note_path}/pitch/{}",
pitch_index + 1
),
preserved_value: pitch.alter.to_string(),
});
}
}
if let Some(tab) = &staff.tablature {
let positions = if note.tab_positions.is_empty() {
note.tab_position.iter().collect::<Vec<_>>()
} else {
note.tab_positions.iter().collect::<Vec<_>>()
};
for position in positions {
if position.string == 0 || position.string > tab.lines {
issues.push(RenderPreflightIssue {
kind: RenderPreflightKind::InvalidTabPosition,
source_location: format!("{note_path}/tab-position"),
preserved_value: format!(
"string={},lines={}",
position.string, tab.lines
),
});
}
}
}
}
}
}
}
}
issues
}
fn push_text_preflight_issue(
issues: &mut Vec<RenderPreflightIssue>,
source_location: &str,
text: &str,
) {
if text.len() > MAX_ANNOTATION_TEXT_BYTES {
issues.push(RenderPreflightIssue {
kind: RenderPreflightKind::MeasureTextTooLarge,
source_location: source_location.to_owned(),
preserved_value: format!("bytes={}", text.len()),
});
}
if let Some(character) = text
.chars()
.find(|&character| !is_valid_xml_char(character))
{
issues.push(RenderPreflightIssue {
kind: RenderPreflightKind::InvalidXmlCharacter,
source_location: source_location.to_owned(),
preserved_value: format!("U+{:04X}", character as u32),
});
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddressBounds {
pub part: usize,
pub staff: usize,
pub measure: usize,
pub voice: usize,
pub note: usize,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SvgAnnotation {
pub id: String,
pub x: f32,
pub y: f32,
pub text: String,
}
pub trait RenderAnnotation {
fn id(&self) -> &str;
fn annotate(
&self,
score: &Score,
layout: &LayoutResult,
metadata: &RenderMetadata,
) -> Vec<SvgAnnotation>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderAnnotationError {
EmptyProviderId,
DuplicateProviderId(String),
EmptyAnnotationId,
DuplicateAnnotationId(String),
NonFiniteCoordinate { id: String },
TooManyAnnotations { count: usize },
AnnotationTextTooLarge { id: String, size: usize },
InvalidXmlCharacter { id: String, codepoint: u32 },
}
impl fmt::Display for RenderAnnotationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyProviderId => write!(f, "render annotation provider id is empty"),
Self::DuplicateProviderId(id) => {
write!(f, "duplicate render annotation provider: {id}")
}
Self::EmptyAnnotationId => write!(f, "render annotation id is empty"),
Self::DuplicateAnnotationId(id) => write!(f, "duplicate render annotation: {id}"),
Self::NonFiniteCoordinate { id } => {
write!(f, "render annotation {id} has a non-finite coordinate")
}
Self::TooManyAnnotations { count } => {
write!(f, "render annotation count exceeds limit: {count}")
}
Self::AnnotationTextTooLarge { id, size } => {
write!(f, "render annotation {id} text exceeds limit: {size} bytes")
}
Self::InvalidXmlCharacter { id, codepoint } => write!(
f,
"render annotation {id} contains invalid XML character U+{codepoint:04X}"
),
}
}
}
impl std::error::Error for RenderAnnotationError {}
impl Default for SvgRenderOptions {
fn default() -> Self {
Self {
width: 900.0,
staff_size: 24.0,
measures_per_system: 4,
interactive: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderError {
EmptyScore,
InvalidRow { row: usize },
InvalidLayout { reason: String },
InvalidOptions { reason: String },
UnsupportedClef,
UnsupportedAccidental { alter: i8 },
InvalidTabPosition { string: u8, lines: u8 },
TabMetricsOverflow,
MeasureTextTooLarge { size: usize },
InvalidMeasureTextOffset { field: &'static str },
InvalidNotePlacement { field: &'static str },
InvalidXmlCharacter { codepoint: u32 },
Annotation(RenderAnnotationError),
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RenderError::EmptyScore => write!(f, "score has no staves to render"),
RenderError::InvalidRow { row } => write!(f, "layout row {row} does not exist"),
RenderError::InvalidLayout { reason } => write!(f, "invalid layout: {reason}"),
RenderError::InvalidOptions { reason } => write!(f, "invalid render options: {reason}"),
RenderError::UnsupportedClef => write!(
f,
"unsupported clef (no staff-position mapping is available)"
),
RenderError::UnsupportedAccidental { alter } => {
write!(
f,
"unsupported accidental alter={alter} (supported range is -2..=2)"
)
}
RenderError::InvalidTabPosition { string, lines } => write!(
f,
"tablature string {string} is outside the owning staff line range 1..={lines}"
),
RenderError::TabMetricsOverflow => write!(f, "tablature glyph metrics overflowed"),
RenderError::MeasureTextTooLarge { size } => write!(
f,
"measure-level text is too large ({size} bytes; maximum is {MAX_ANNOTATION_TEXT_BYTES})"
),
RenderError::InvalidMeasureTextOffset { field } => {
write!(f, "measure-level text offset {field} is not finite")
}
RenderError::InvalidNotePlacement { field } => {
write!(f, "note placement offset {field} is not finite")
}
RenderError::InvalidXmlCharacter { codepoint } => write!(
f,
"score text contains invalid XML character U+{codepoint:04X}"
),
RenderError::Annotation(error) => write!(f, "invalid render annotation: {error}"),
}
}
}
impl std::error::Error for RenderError {}
pub fn render_svg(score: &Score, options: &SvgRenderOptions) -> Result<String, RenderError> {
let config = LayoutConfig {
measures_per_row: options.measures_per_system.max(1),
..Default::default()
};
let layout = compute_layout(score, &config);
render_svg_with_layout(score, &layout, options)
}
pub fn render_svg_with_layout(
score: &Score,
layout: &LayoutResult,
options: &SvgRenderOptions,
) -> Result<String, RenderError> {
render::build_svg(score, layout, options)
}
pub fn render_svg_with_annotations(
score: &Score,
layout: &LayoutResult,
options: &SvgRenderOptions,
providers: &[&dyn RenderAnnotation],
) -> Result<String, RenderError> {
let (mut svg, metadata) = render::build_svg_with_metadata(score, layout, options)?;
let annotations = render::collect_annotations(score, layout, &metadata, providers)
.map_err(RenderError::Annotation)?;
if annotations.is_empty() {
return Ok(svg);
}
let body = annotations
.iter()
.map(|annotation| {
format!(
r#"<text class="acorde-render-annotation" data-acorde-kind="render-annotation" data-acorde-annotation-id="{}" x="{}" y="{}">{}</text>"#,
render::escape_xml(&annotation.id),
annotation.x,
annotation.y,
render::escape_xml(&annotation.text),
)
})
.collect::<String>();
let marker = "</g></svg>";
let insertion = svg
.rfind(marker)
.ok_or_else(|| RenderError::InvalidLayout {
reason: "renderer output has no score root".into(),
})?;
svg.insert_str(insertion, &body);
Ok(svg)
}
pub fn render_svg_row(
score: &Score,
layout: &LayoutResult,
row: usize,
options: &SvgRenderOptions,
) -> Result<String, RenderError> {
let mut subset = layout.clone();
subset.rows = vec![
layout
.rows
.get(row)
.cloned()
.ok_or(RenderError::InvalidRow { row })?,
];
render_svg_with_layout(score, &subset, options)
}
pub fn render_svg_metadata(
score: &Score,
layout: &LayoutResult,
options: &SvgRenderOptions,
) -> Result<RenderMetadata, RenderError> {
render::build_svg_with_metadata(score, layout, options).map(|(_, metadata)| metadata)
}