Skip to main content

acorde_render_svg/
lib.rs

1//! Pure-Rust/WASM SVG score renderer for [`acorde_core::Score`], driven by
2//! [`acorde_layout`](https://docs.rs/acorde-layout).
3//!
4//! ```text
5//! Score model (acorde-core) → logical layout (acorde-layout) → renderer (acorde-render-svg)
6//! ```
7//!
8//! This crate has no browser/DOM dependency — [`render_svg`] produces a plain `String` that
9//! renders identically whether you're on native Rust, in a WASM host, or on a server. It
10//! consumes layout decisions ([`LayoutResult`]) rather than re-deriving them: beam grouping,
11//! tuplet grouping, courtesy accidentals, and row breaks all stay in `acorde-layout`. This
12//! crate places glyphs at pixel coordinates and nothing more.
13//!
14//! Glyphs (clefs, noteheads, accidentals, rests) are original hand-authored SVG paths — no
15//! vendored font, no system-font dependency. See the crate README for the rationale.
16//!
17//! # Example
18//!
19//! ```
20//! use acorde_core::Score;
21//! use acorde_render_svg::{render_svg, SvgRenderOptions};
22//!
23//! let score = Score::default();
24//! let svg = render_svg(&score, &SvgRenderOptions::default()).unwrap();
25//! assert!(svg.starts_with("<svg"));
26//! ```
27
28mod annotations;
29mod beams;
30mod collision;
31mod geometry;
32mod glyphs;
33mod render;
34mod spans;
35mod tab_technique;
36mod tuplets;
37
38use acorde_core::{
39    HarpPedalDiagram, NoteAddr, NoteHead, ObjectStyleOverride, Score, ScoreView, StaffPresentation,
40    TextStyle, ValidationError, ViewStyle, ViewStyleOverride,
41};
42use acorde_layout::{
43    GlyphCollisionClass, GlyphCollisionDirection, LayoutConfig, LayoutResult, compute_layout,
44};
45use serde::{Deserialize, Serialize};
46use std::fmt;
47
48/// Version of the browser-facing [`RenderMetadata`] contract.
49pub const SVG_CONTRACT_VERSION: u32 = 22;
50/// Version of the built-in glyph coverage contract.
51pub const GLYPH_COVERAGE_CONTRACT_VERSION: u32 = 3;
52/// Stable identifier for the renderer's font-independent vector glyph set.
53pub const BUILTIN_GLYPH_RESOURCE_ID: &str = "acorde-vector-glyphs-v1";
54/// Version of the deterministic tablature metric contract.
55pub const TAB_METRICS_CONTRACT_VERSION: u32 = 1;
56/// Fixed separation used by the deterministic SVG annotation collision pass.
57pub const SVG_ANNOTATION_COLLISION_GAP_PX: f32 = 2.0;
58
59/// Font-independent metrics for one rendered tablature fret label.
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
61pub struct TabFretMetrics {
62    pub fret: u8,
63    pub digit_count: u8,
64    /// Horizontal advance in staff-space units, before multiplying by staff size.
65    pub advance_units: f32,
66    /// Fixed gap between adjacent fret labels in staff-space units.
67    pub side_gap_units: f32,
68}
69
70/// Return the stable metrics used for tablature fret labels.
71pub fn tab_fret_metrics(fret: u8) -> TabFretMetrics {
72    let digit_count = fret.to_string().len() as u8;
73    TabFretMetrics {
74        fret,
75        digit_count,
76        advance_units: f32::from(digit_count) * 0.48,
77        side_gap_units: 0.22,
78    }
79}
80
81/// Return the stable built-in resource name for a bounded unpitched notehead shape.
82///
83/// This maps only visual notehead intent; it does not resolve a percussion instrument or
84/// invent a MIDI sound identity.
85pub fn percussion_notehead_resource_id(note_head: &NoteHead) -> &'static str {
86    match note_head {
87        NoteHead::Normal => "acorde-percussion-notehead-normal",
88        NoteHead::Diamond => "acorde-percussion-notehead-diamond",
89        NoteHead::X => "acorde-percussion-notehead-x",
90        NoteHead::Slash => "acorde-percussion-notehead-slash",
91        NoteHead::Cross => "acorde-percussion-notehead-cross",
92        NoteHead::Triangle => "acorde-percussion-notehead-triangle",
93    }
94}
95
96const MAX_RENDER_ANNOTATIONS: usize = 10_000;
97const MAX_ANNOTATION_TEXT_BYTES: usize = 16 * 1024;
98
99pub(crate) fn is_valid_xml_char(character: char) -> bool {
100    matches!(character, '\u{9}' | '\u{A}' | '\u{D}')
101        || ('\u{20}'..='\u{D7FF}').contains(&character)
102        || ('\u{E000}'..='\u{FFFD}').contains(&character)
103        || ('\u{10000}'..='\u{10FFFF}').contains(&character)
104}
105
106/// Options controlling SVG output. All fields have defaults — safe to deserialize from
107/// partial JSON (e.g. `"{}"` from a WASM caller that only wants defaults).
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(default)]
110pub struct SvgRenderOptions {
111    /// Total SVG width in pixels. Content is distributed across this width per row.
112    pub width: f32,
113    /// Distance between two adjacent staff lines, in pixels. Drives every other glyph
114    /// dimension (noteheads, stems, clefs, accidentals are all proportional to this).
115    pub staff_size: f32,
116    /// How many measures to place per system/row.
117    pub measures_per_system: usize,
118    /// When `true`, emit stable `data-*` hooks (`data-acorde-kind`, `data-part`, `data-staff`,
119    /// `data-measure`, `data-voice`, `data-note`, `data-note-addr`) for click-to-position
120    /// interaction. When `false`, those attributes are omitted.
121    pub interactive: bool,
122}
123
124/// Lightweight browser-facing metadata for a rendered score.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct RenderMetadata {
127    /// Version of the metadata contract, for forward-compatible browser hosts.
128    pub contract_version: u32,
129    pub width: f32,
130    pub height: f32,
131    pub part_count: usize,
132    pub staff_count: usize,
133    pub measure_count: usize,
134    pub note_count: usize,
135    /// Resolved linked-view context when the caller used [`render_svg_view_metadata`].
136    /// `None` means the source score was rendered directly.
137    #[serde(default)]
138    pub view: Option<RenderedViewMetadata>,
139    /// Human-readable fallback text for hosts that cannot expose the SVG semantics.
140    pub accessible_text: String,
141    pub address_bounds: Vec<AddressBounds>,
142    /// Measure-level text with its stable score location and typed presentation role.
143    #[serde(default)]
144    pub text_annotations: Vec<TextAnnotation>,
145    /// Score-level title-page text imported from formats such as MuseScore VBox.
146    #[serde(default)]
147    pub score_texts: Vec<ScoreTextMetadata>,
148    /// String/fret positions exposed without requiring hosts to parse SVG elements.
149    #[serde(default)]
150    pub tablature_positions: Vec<TablaturePositionMetadata>,
151    /// Tuning and capo metadata for each rendered tablature staff.
152    #[serde(default)]
153    pub tablature_staves: Vec<TablatureStaffMetadata>,
154    /// Measure-local tuning or capo changes for tablature hosts.
155    #[serde(default)]
156    pub tablature_changes: Vec<TablatureChangeMetadata>,
157    /// Chord labels whose continuation range is exposed with typed note addresses.
158    #[serde(default)]
159    pub harmony_ranges: Vec<HarmonyRangeMetadata>,
160    /// Tablature technique connections with stable start/end note addresses.
161    #[serde(default)]
162    pub tablature_technique_connections: Vec<TablatureTechniqueConnectionMetadata>,
163    /// Note-level semantic fields needed by browser playback and editing hosts.
164    #[serde(default)]
165    pub note_semantics: Vec<NoteSemanticMetadata>,
166    /// Typed object-level presentation overrides, preserved for browser hosts without SVG parsing.
167    #[serde(default)]
168    pub object_style_overrides: Vec<ObjectStyleOverride>,
169    /// Harp pedal diagrams in the conventional D-C-B / E-F-G-A order.
170    #[serde(default)]
171    pub harp_pedal_diagrams: Vec<HarpPedalDiagramMetadata>,
172    /// Semantic section boundaries by physical measure; unrelated to page or system breaks.
173    #[serde(default)]
174    pub section_breaks: Vec<usize>,
175    /// Renderer-independent presentation selected for every rendered staff.
176    #[serde(default)]
177    pub staff_presentations: Vec<StaffPresentationMetadata>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub struct StaffPresentationMetadata {
182    pub part: usize,
183    pub staff: usize,
184    pub presentation: StaffPresentation,
185}
186
187/// Browser-facing identity and effective style for a resolved linked view.
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189pub struct RenderedViewMetadata {
190    pub id: String,
191    pub name: String,
192    pub measures_per_system: usize,
193    pub style: ViewStyle,
194}
195
196/// Stable note-level semantic metadata that avoids forcing browser hosts to parse SVG elements.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct NoteSemanticMetadata {
199    pub part: usize,
200    pub staff: usize,
201    pub measure: usize,
202    pub voice: usize,
203    pub note: usize,
204    pub is_unpitched: bool,
205    /// Whether the note starts or ends an authored tie.
206    #[serde(default)]
207    pub tie_start: bool,
208    #[serde(default)]
209    pub tie_end: bool,
210    /// Per-note performed duration in canonical beats, including dots and tuplets.
211    #[serde(default)]
212    pub duration_beats: f64,
213    /// Exact sounding pitch for each source pitch in hundredths of a MIDI semitone.
214    #[serde(default)]
215    pub pitch_midi_cents: Vec<i32>,
216    /// MusicXML note placement offsets in tenths, when authored.
217    #[serde(default)]
218    pub offset_x: Option<f64>,
219    #[serde(default)]
220    pub offset_y: Option<f64>,
221    #[serde(default)]
222    pub relative_x: Option<f64>,
223    #[serde(default)]
224    pub relative_y: Option<f64>,
225    #[serde(default)]
226    pub dynamic: Option<String>,
227    #[serde(default)]
228    pub lyric: Option<String>,
229    #[serde(default)]
230    pub chord_label: Option<String>,
231    #[serde(default)]
232    pub technique_text: Option<String>,
233    /// Ordered note articulations using stable kebab-case names; tremolo includes its level.
234    #[serde(default)]
235    pub articulations: Vec<String>,
236    /// Guitar technique used by tablature playback/rendering hosts.
237    #[serde(default)]
238    pub guitar_technique: Option<acorde_core::GuitarTechnique>,
239    /// Authored bend amount in cents, when present.
240    #[serde(default)]
241    pub guitar_bend_alter_cents: Option<i16>,
242    /// Authored multi-point bend/hold/release curve, when present.
243    #[serde(default)]
244    pub guitar_bend_curve: Vec<acorde_core::GuitarBendPoint>,
245    #[serde(default)]
246    pub fingerings: Vec<u8>,
247    #[serde(default)]
248    pub instrument_id: Option<String>,
249    /// One entry per pitch in source order; zero means no microtonal offset.
250    #[serde(default)]
251    pub microtone_cents: Vec<i16>,
252}
253
254/// A ranged harmony annotation with stable score addresses for browser editors.
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
256pub struct HarmonyRangeMetadata {
257    pub start: NoteAddr,
258    pub end: NoteAddr,
259    pub label: String,
260}
261
262/// A tablature position with a stable source address for browser editing hosts.
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264pub struct TablaturePositionMetadata {
265    pub part: usize,
266    pub staff: usize,
267    pub measure: usize,
268    pub voice: usize,
269    pub note: usize,
270    pub position: usize,
271    pub string: u8,
272    pub fret: u8,
273}
274
275/// Typed tablature configuration for a rendered staff.
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
277pub struct TablatureStaffMetadata {
278    pub part: usize,
279    pub staff: usize,
280    pub lines: u8,
281    #[serde(default)]
282    pub tuning_midi: Vec<i16>,
283    pub capo: u8,
284    #[serde(default)]
285    pub rhythm_display: acorde_core::TablatureRhythmDisplay,
286    #[serde(default)]
287    pub fret_mark_style: acorde_core::TablatureFretMarkStyle,
288}
289
290/// A tablature tuning or capo configuration that takes effect at a physical measure.
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292pub struct TablatureChangeMetadata {
293    pub part: usize,
294    pub staff: usize,
295    pub measure: usize,
296    pub lines: u8,
297    #[serde(default)]
298    pub tuning_midi: Vec<i16>,
299    pub capo: u8,
300}
301
302/// A typed tablature connection for browser editing and playback hosts.
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
304pub struct TablatureTechniqueConnectionMetadata {
305    pub start: NoteAddr,
306    pub end: NoteAddr,
307    pub technique: acorde_core::GuitarTechnique,
308    pub string: u8,
309    /// True when the connection joins adjacent physical measures.
310    #[serde(default)]
311    pub cross_measure: bool,
312}
313
314/// A measure-level styled text entry exposed to browser hosts without SVG parsing.
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub struct TextAnnotation {
317    pub part: usize,
318    pub staff: usize,
319    pub measure: usize,
320    pub style: TextStyle,
321    pub text: String,
322    #[serde(default)]
323    pub placement: Option<String>,
324    #[serde(default)]
325    pub offset_x: Option<f64>,
326    #[serde(default)]
327    pub offset_y: Option<f64>,
328    #[serde(default)]
329    pub relative_x: Option<f64>,
330    #[serde(default)]
331    pub relative_y: Option<f64>,
332}
333
334/// A harp pedal diagram with its stable staff-local measure location.
335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
336pub struct HarpPedalDiagramMetadata {
337    pub part: usize,
338    pub staff: usize,
339    pub measure: usize,
340    pub diagram: HarpPedalDiagram,
341}
342
343/// A score-level styled text entry exposed without requiring hosts to parse SVG or interchange XML.
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct ScoreTextMetadata {
346    pub style: TextStyle,
347    pub text: String,
348    #[serde(default)]
349    pub placement: Option<String>,
350    #[serde(default)]
351    pub offset_x: Option<f64>,
352    #[serde(default)]
353    pub offset_y: Option<f64>,
354    #[serde(default)]
355    pub relative_x: Option<f64>,
356    #[serde(default)]
357    pub relative_y: Option<f64>,
358}
359
360/// Host-neutral capability information for the renderer's explicit microtone marker.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362pub struct MicrotoneMarkerCoverage {
363    /// Whether non-zero cents are emitted as a visible SVG marker.
364    pub supported: bool,
365    /// Stable representation name; this is intentionally not a font glyph claim.
366    pub representation: String,
367    /// Whether the marker preserves the exact signed cents value from the score model.
368    pub exact_cents: bool,
369}
370
371impl Default for MicrotoneMarkerCoverage {
372    fn default() -> Self {
373        Self {
374            supported: true,
375            representation: "svg-text-cents".to_owned(),
376            exact_cents: true,
377        }
378    }
379}
380
381/// Explicit coverage information for the renderer's built-in glyph resource.
382///
383/// Hosts can use this before rendering or selecting a print resource. Unsupported
384/// notation still returns a typed [`RenderError`] during rendering.
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
386pub struct GlyphCoverage {
387    pub contract_version: u32,
388    pub resource_id: String,
389    pub vector_glyphs: bool,
390    pub supported_clefs: Vec<String>,
391    pub accidental_min: i8,
392    pub accidental_max: i8,
393    /// Explicit, font-independent microtone representation available to hosts.
394    #[serde(default)]
395    pub microtone_marker: MicrotoneMarkerCoverage,
396    /// Stable visual resources available for explicitly unpitched notehead shapes.
397    #[serde(default)]
398    pub percussion_noteheads: Vec<String>,
399}
400
401/// Describe the deterministic, font-independent glyphs shipped by this renderer.
402pub fn glyph_coverage() -> GlyphCoverage {
403    GlyphCoverage {
404        contract_version: GLYPH_COVERAGE_CONTRACT_VERSION,
405        resource_id: BUILTIN_GLYPH_RESOURCE_ID.to_owned(),
406        vector_glyphs: true,
407        supported_clefs: vec![
408            "treble".to_owned(),
409            "bass".to_owned(),
410            "alto".to_owned(),
411            "tenor".to_owned(),
412        ],
413        accidental_min: -2,
414        accidental_max: 2,
415        microtone_marker: MicrotoneMarkerCoverage::default(),
416        percussion_noteheads: [
417            NoteHead::Normal,
418            NoteHead::Diamond,
419            NoteHead::X,
420            NoteHead::Slash,
421            NoteHead::Cross,
422            NoteHead::Triangle,
423        ]
424        .iter()
425        .map(|head| percussion_notehead_resource_id(head).to_owned())
426        .collect(),
427    }
428}
429
430/// The bounded notation capabilities checked by [`render_preflight`].
431#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
432pub enum RenderPreflightKind {
433    UnsupportedClef,
434    UnsupportedAccidental,
435    InvalidTabPosition,
436    MeasureTextTooLarge,
437    InvalidMeasureTextOffset,
438    InvalidNotePlacement,
439    InvalidXmlCharacter,
440}
441
442/// A source-located renderer capability warning discovered before SVG emission.
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
444pub struct RenderPreflightIssue {
445    pub kind: RenderPreflightKind,
446    pub source_location: String,
447    pub preserved_value: String,
448}
449
450/// Inspect renderer capability boundaries without producing SVG or partially rendering a score.
451pub fn render_preflight(score: &Score) -> Vec<RenderPreflightIssue> {
452    let mut issues = Vec::new();
453    push_text_preflight_issue(&mut issues, "/score/title", &score.metadata.title);
454    for (part_index, part) in score.parts.iter().enumerate() {
455        push_text_preflight_issue(
456            &mut issues,
457            &format!("/score/part/{}/name", part_index + 1),
458            &part.name,
459        );
460        push_text_preflight_issue(
461            &mut issues,
462            &format!("/score/part/{}/short-name", part_index + 1),
463            &part.short_name,
464        );
465        for (staff_index, staff) in part.staves.iter().enumerate() {
466            let staff_path = format!("/score/part/{}/staff/{}", part_index + 1, staff_index + 1);
467            for (measure_index, measure) in staff.measures.iter().enumerate() {
468                for (text_index, styled) in measure.texts.iter().enumerate() {
469                    let text_path = format!(
470                        "{staff_path}/measure/{}/text/{}",
471                        measure_index + 1,
472                        text_index + 1
473                    );
474                    push_text_preflight_issue(&mut issues, &text_path, &styled.text);
475                    for (field, value) in [
476                        ("offset_x", styled.offset_x),
477                        ("offset_y", styled.offset_y),
478                        ("relative_x", styled.relative_x),
479                        ("relative_y", styled.relative_y),
480                    ] {
481                        if let Some(value) = value {
482                            if !value.is_finite() || !(value as f32).is_finite() {
483                                issues.push(RenderPreflightIssue {
484                                    kind: RenderPreflightKind::InvalidMeasureTextOffset,
485                                    source_location: format!("{text_path}/{field}"),
486                                    preserved_value: value.to_string(),
487                                });
488                            }
489                        }
490                    }
491                }
492                for (field, value) in [
493                    ("tempo-text", measure.tempo_text.as_deref()),
494                    ("rehearsal", measure.rehearsal.as_deref()),
495                    ("navigation", measure.navigation.as_deref()),
496                    ("expression-text", measure.expression_text.as_deref()),
497                ] {
498                    if let Some(value) = value {
499                        push_text_preflight_issue(
500                            &mut issues,
501                            &format!("{staff_path}/measure/{}/{}", measure_index + 1, field),
502                            value,
503                        );
504                    }
505                }
506                for (voice_index, voice) in measure.voices.iter().enumerate() {
507                    for (note_index, note) in voice.iter().enumerate() {
508                        let note_path = format!(
509                            "{staff_path}/measure/{}/voice/{}/note/{}",
510                            measure_index + 1,
511                            voice_index + 1,
512                            note_index + 1
513                        );
514                        if let Some(lyric) = &note.lyric {
515                            push_text_preflight_issue(
516                                &mut issues,
517                                &format!("{note_path}/lyric"),
518                                &lyric.text,
519                            );
520                        }
521                        for entry in &note.additional_lyrics {
522                            push_text_preflight_issue(
523                                &mut issues,
524                                &format!("{note_path}/lyric/{}", entry.verse),
525                                &entry.lyric.text,
526                            );
527                        }
528                        if let Some(technique) = &note.technique_text {
529                            push_text_preflight_issue(
530                                &mut issues,
531                                &format!("{note_path}/technique-text"),
532                                technique,
533                            );
534                        }
535                        if let Some(chord) = &note.chord_symbol {
536                            let chord_text = chord.display_text();
537                            push_text_preflight_issue(
538                                &mut issues,
539                                &format!("{note_path}/chord-symbol"),
540                                &chord_text,
541                            );
542                        }
543                        for (field, value) in [
544                            ("default-x", note.offset_x),
545                            ("default-y", note.offset_y),
546                            ("relative-x", note.relative_x),
547                            ("relative-y", note.relative_y),
548                        ] {
549                            if let Some(value) = value {
550                                if !value.is_finite() || !(value as f32).is_finite() {
551                                    issues.push(RenderPreflightIssue {
552                                        kind: RenderPreflightKind::InvalidNotePlacement,
553                                        source_location: format!("{note_path}/{field}"),
554                                        preserved_value: value.to_string(),
555                                    });
556                                }
557                            }
558                        }
559                        for (pitch_index, pitch) in note.pitches.iter().enumerate() {
560                            if !(-2..=2).contains(&pitch.alter) {
561                                issues.push(RenderPreflightIssue {
562                                    kind: RenderPreflightKind::UnsupportedAccidental,
563                                    source_location: format!(
564                                        "{note_path}/pitch/{}",
565                                        pitch_index + 1
566                                    ),
567                                    preserved_value: pitch.alter.to_string(),
568                                });
569                            }
570                        }
571                        if let Some(tab) = &staff.tablature {
572                            let positions = if note.tab_positions.is_empty() {
573                                note.tab_position.iter().collect::<Vec<_>>()
574                            } else {
575                                note.tab_positions.iter().collect::<Vec<_>>()
576                            };
577                            for position in positions {
578                                if position.string == 0 || position.string > tab.lines {
579                                    issues.push(RenderPreflightIssue {
580                                        kind: RenderPreflightKind::InvalidTabPosition,
581                                        source_location: format!("{note_path}/tab-position"),
582                                        preserved_value: format!(
583                                            "string={},lines={}",
584                                            position.string, tab.lines
585                                        ),
586                                    });
587                                }
588                            }
589                        }
590                    }
591                }
592            }
593        }
594    }
595    issues
596}
597
598fn push_text_preflight_issue(
599    issues: &mut Vec<RenderPreflightIssue>,
600    source_location: &str,
601    text: &str,
602) {
603    if text.len() > MAX_ANNOTATION_TEXT_BYTES {
604        issues.push(RenderPreflightIssue {
605            kind: RenderPreflightKind::MeasureTextTooLarge,
606            source_location: source_location.to_owned(),
607            preserved_value: format!("bytes={}", text.len()),
608        });
609    }
610    if let Some(character) = text
611        .chars()
612        .find(|&character| !is_valid_xml_char(character))
613    {
614        issues.push(RenderPreflightIssue {
615            kind: RenderPreflightKind::InvalidXmlCharacter,
616            source_location: source_location.to_owned(),
617            preserved_value: format!("U+{:04X}", character as u32),
618        });
619    }
620}
621
622/// Approximate interactive bounds for one stable [`acorde_core::NoteAddr`]. The box is
623/// centered on the note's anchor and is intended for hit testing/highlighting, not engraving.
624#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct AddressBounds {
626    pub part: usize,
627    pub staff: usize,
628    pub measure: usize,
629    pub voice: usize,
630    pub note: usize,
631    pub x: f32,
632    pub y: f32,
633    pub width: f32,
634    pub height: f32,
635}
636
637/// A host-provided, non-semantic annotation to place on top of rendered SVG.
638///
639/// Coordinates are in the SVG viewport's pixel coordinate system. The renderer does not
640/// interpret `id` or `text`; this keeps domain-specific analysis outside `acorde-render-svg`.
641#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
642pub struct SvgAnnotation {
643    /// Stable identifier within one rendered score.
644    pub id: String,
645    pub x: f32,
646    pub y: f32,
647    pub text: String,
648}
649
650/// Font-independent SVG-pixel bounds relative to an annotation's `(x, y)` anchor.
651///
652/// A provider supplies these when it wants the renderer to route its annotation through the
653/// shared collision pass. The renderer does not measure fonts or shape text on its own.
654#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
655pub struct SvgAnnotationMetrics {
656    pub left_px: f32,
657    pub top_px: f32,
658    pub width_px: f32,
659    pub height_px: f32,
660}
661
662/// Collision ownership and permitted escape lane for one host annotation.
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
664pub struct SvgAnnotationCollisionPolicy {
665    pub class: GlyphCollisionClass,
666    pub direction: GlyphCollisionDirection,
667    /// Higher-priority annotations retain their requested position where possible.
668    pub priority: u8,
669}
670
671/// Extension point for deterministic, host-defined SVG annotations.
672pub trait RenderAnnotation {
673    /// Stable provider identifier. Providers are executed in lexicographic ID order.
674    fn id(&self) -> &str;
675
676    /// Return annotations using the already-computed score, layout, and render metadata.
677    fn annotate(
678        &self,
679        score: &Score,
680        layout: &LayoutResult,
681        metadata: &RenderMetadata,
682    ) -> Vec<SvgAnnotation>;
683
684    /// Optionally place one annotation in the shared deterministic collision pass.
685    ///
686    /// Returning `None` preserves the annotation's requested coordinates. A provider must supply
687    /// its own font-independent bounds through [`SvgAnnotationMetrics`]; this renderer never
688    /// infers text metrics from a host font.
689    fn collision_policy(
690        &self,
691        _annotation: &SvgAnnotation,
692    ) -> Option<(SvgAnnotationMetrics, SvgAnnotationCollisionPolicy)> {
693        None
694    }
695}
696
697/// Errors returned while validating host-provided render annotations.
698#[derive(Debug, Clone, PartialEq, Eq)]
699pub enum RenderAnnotationError {
700    EmptyProviderId,
701    DuplicateProviderId(String),
702    EmptyAnnotationId,
703    DuplicateAnnotationId(String),
704    NonFiniteCoordinate { id: String },
705    TooManyAnnotations { count: usize },
706    AnnotationTextTooLarge { id: String, size: usize },
707    InvalidXmlCharacter { id: String, codepoint: u32 },
708    InvalidCollisionMetrics { id: String },
709    CollisionResolution { id: String },
710}
711
712impl fmt::Display for RenderAnnotationError {
713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714        match self {
715            Self::EmptyProviderId => write!(f, "render annotation provider id is empty"),
716            Self::DuplicateProviderId(id) => {
717                write!(f, "duplicate render annotation provider: {id}")
718            }
719            Self::EmptyAnnotationId => write!(f, "render annotation id is empty"),
720            Self::DuplicateAnnotationId(id) => write!(f, "duplicate render annotation: {id}"),
721            Self::NonFiniteCoordinate { id } => {
722                write!(f, "render annotation {id} has a non-finite coordinate")
723            }
724            Self::TooManyAnnotations { count } => {
725                write!(f, "render annotation count exceeds limit: {count}")
726            }
727            Self::AnnotationTextTooLarge { id, size } => {
728                write!(f, "render annotation {id} text exceeds limit: {size} bytes")
729            }
730            Self::InvalidXmlCharacter { id, codepoint } => write!(
731                f,
732                "render annotation {id} contains invalid XML character U+{codepoint:04X}"
733            ),
734            Self::InvalidCollisionMetrics { id } => {
735                write!(f, "render annotation {id} has invalid collision metrics")
736            }
737            Self::CollisionResolution { id } => {
738                write!(f, "render annotation {id} could not be collision-resolved")
739            }
740        }
741    }
742}
743
744impl std::error::Error for RenderAnnotationError {}
745
746impl Default for SvgRenderOptions {
747    fn default() -> Self {
748        Self {
749            width: 900.0,
750            staff_size: 24.0,
751            measures_per_system: 4,
752            interactive: true,
753        }
754    }
755}
756
757/// Errors returned by [`render_svg`] / [`render_svg_with_layout`].
758///
759/// These are all system-boundary validation failures — an arbitrary [`Score`] can reference
760/// notation this renderer does not (yet) support. Never fails silently: unsupported notation or
761/// an accidental beyond double-sharp/double-flat is reported, not dropped or approximated.
762#[derive(Debug, Clone, PartialEq, Eq)]
763pub enum RenderError {
764    /// The score has no staves to render (no parts, or no parts with staves).
765    EmptyScore,
766    /// A requested system row does not exist in the supplied layout.
767    InvalidRow { row: usize },
768    /// A linked score view could not be resolved to a renderable score snapshot.
769    ViewResolution { view_id: String, reason: String },
770    /// The score and precomputed layout do not have compatible indices.
771    InvalidLayout { reason: String },
772    /// Rendering dimensions or system settings are not finite and positive.
773    InvalidOptions { reason: String },
774    /// A staff uses a clef this renderer has no staff-position mapping for.
775    UnsupportedClef,
776    /// A pitch's `alter` is outside the supported range (`-2..=2`: double-flat..double-sharp).
777    UnsupportedAccidental { alter: i8 },
778    /// A tablature position cannot be represented by the owning staff.
779    InvalidTabPosition { string: u8, lines: u8 },
780    /// Tablature glyph metrics overflowed before SVG emission.
781    TabMetricsOverflow,
782    /// A measure-level styled text entry exceeds the bounded renderer input size.
783    MeasureTextTooLarge { size: usize },
784    /// A measure-level styled text offset is not finite or cannot fit SVG coordinates.
785    InvalidMeasureTextOffset { field: &'static str },
786    /// A note-level MusicXML placement offset is not finite or cannot fit SVG coordinates.
787    InvalidNotePlacement { field: &'static str },
788    /// A score text contains a character that XML 1.0 cannot represent.
789    InvalidXmlCharacter { codepoint: u32 },
790    /// Host-provided annotation validation failed.
791    Annotation(RenderAnnotationError),
792}
793
794impl std::fmt::Display for RenderError {
795    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796        match self {
797            RenderError::EmptyScore => write!(f, "score has no staves to render"),
798            RenderError::InvalidRow { row } => write!(f, "layout row {row} does not exist"),
799            RenderError::ViewResolution { view_id, reason } => {
800                write!(f, "cannot resolve score view '{view_id}': {reason}")
801            }
802            RenderError::InvalidLayout { reason } => write!(f, "invalid layout: {reason}"),
803            RenderError::InvalidOptions { reason } => write!(f, "invalid render options: {reason}"),
804            RenderError::UnsupportedClef => write!(
805                f,
806                "unsupported clef (no staff-position mapping is available)"
807            ),
808            RenderError::UnsupportedAccidental { alter } => {
809                write!(
810                    f,
811                    "unsupported accidental alter={alter} (supported range is -2..=2)"
812                )
813            }
814            RenderError::InvalidTabPosition { string, lines } => write!(
815                f,
816                "tablature string {string} is outside the owning staff line range 1..={lines}"
817            ),
818            RenderError::TabMetricsOverflow => write!(f, "tablature glyph metrics overflowed"),
819            RenderError::MeasureTextTooLarge { size } => write!(
820                f,
821                "measure-level text is too large ({size} bytes; maximum is {MAX_ANNOTATION_TEXT_BYTES})"
822            ),
823            RenderError::InvalidMeasureTextOffset { field } => {
824                write!(f, "measure-level text offset {field} is not finite")
825            }
826            RenderError::InvalidNotePlacement { field } => {
827                write!(f, "note placement offset {field} is not finite")
828            }
829            RenderError::InvalidXmlCharacter { codepoint } => write!(
830                f,
831                "score text contains invalid XML character U+{codepoint:04X}"
832            ),
833            RenderError::Annotation(error) => write!(f, "invalid render annotation: {error}"),
834        }
835    }
836}
837
838impl std::error::Error for RenderError {}
839
840/// Compute layout with `options` and render `score` to an SVG string.
841///
842/// Equivalent to calling [`acorde_layout::compute_layout`] with a [`LayoutConfig`] built from
843/// `options.measures_per_system`, then [`render_svg_with_layout`]. Use `render_svg_with_layout`
844/// directly when you already have a [`LayoutResult`] (e.g. computed once and reused, or built
845/// with non-default `LayoutConfig` fields such as `concert_pitch`).
846pub fn render_svg(score: &Score, options: &SvgRenderOptions) -> Result<String, RenderError> {
847    let config = LayoutConfig {
848        measures_per_row: options.measures_per_system.max(1),
849        ..Default::default()
850    };
851    let layout = compute_layout(score, &config);
852    render_svg_with_layout(score, &layout, options)
853}
854
855/// Render one linked [`ScoreView`] without mutating the source [`Score`].
856///
857/// The view's selected parts, written/concert-pitch projection, and staff-kind overrides are
858/// resolved by `acorde-core`. Its `measures_per_row` override takes precedence over
859/// [`SvgRenderOptions::measures_per_system`]. `ViewStyleProperty::StaffSpace` scales the
860/// renderer's base staff size; the remaining typed style values stay available to hosts through
861/// [`ScoreView::layout`] because they describe text and page-system policy rather than SVG glyph
862/// geometry.
863pub fn render_svg_view(
864    score: &Score,
865    view_id: &str,
866    options: &SvgRenderOptions,
867) -> Result<String, RenderError> {
868    let view = score
869        .views
870        .iter()
871        .find(|view| view.id == view_id)
872        .ok_or_else(|| RenderError::ViewResolution {
873            view_id: view_id.to_owned(),
874            reason: "view does not exist".into(),
875        })?;
876    let projected = score
877        .resolve_view(view_id)
878        .map_err(|error| RenderError::ViewResolution {
879            view_id: view_id.to_owned(),
880            reason: error.to_string(),
881        })?;
882    let options = options_for_view(options, view)?;
883    render_svg(&projected, &options)
884}
885
886/// Return browser metadata for a resolved linked view without requiring a host to duplicate its
887/// layout/style resolution rules.
888pub fn render_svg_view_metadata(
889    score: &Score,
890    view_id: &str,
891    options: &SvgRenderOptions,
892) -> Result<RenderMetadata, RenderError> {
893    let view = score
894        .views
895        .iter()
896        .find(|view| view.id == view_id)
897        .ok_or_else(|| RenderError::ViewResolution {
898            view_id: view_id.to_owned(),
899            reason: "view does not exist".into(),
900        })?;
901    let projected = score
902        .resolve_view(view_id)
903        .map_err(|error| RenderError::ViewResolution {
904            view_id: view_id.to_owned(),
905            reason: error.to_string(),
906        })?;
907    let options = options_for_view(options, view)?;
908    let layout = compute_layout(
909        &projected,
910        &LayoutConfig {
911            measures_per_row: options.measures_per_system,
912            ..Default::default()
913        },
914    );
915    let mut metadata = render_svg_metadata(&projected, &layout, &options)?;
916    metadata.view = Some(RenderedViewMetadata {
917        id: view.id.clone(),
918        name: view.name.clone(),
919        measures_per_system: options.measures_per_system,
920        style: score.resolved_view_style(&view.layout),
921    });
922    Ok(metadata)
923}
924
925fn options_for_view(
926    options: &SvgRenderOptions,
927    view: &ScoreView,
928) -> Result<SvgRenderOptions, RenderError> {
929    validate_style_overrides(&view.layout.typed_style_overrides)?;
930    let mut resolved = options.clone();
931    if let Some(measures_per_row) = view.layout.measures_per_row {
932        resolved.measures_per_system = measures_per_row;
933    }
934    resolved.staff_size *= view.layout.resolved_style().staff_space;
935    Ok(resolved)
936}
937
938fn options_for_score(
939    options: &SvgRenderOptions,
940    score: &Score,
941) -> Result<SvgRenderOptions, RenderError> {
942    validate_style_overrides(&score.style_overrides)?;
943    if acorde_core::validate(score)
944        .errors
945        .iter()
946        .any(|error| matches!(error, ValidationError::InvalidObjectStyleOverride { .. }))
947    {
948        return Err(RenderError::InvalidOptions {
949            reason: "object style overrides must target an existing object and use valid values and provenance".into(),
950        });
951    }
952    let mut resolved = options.clone();
953    resolved.staff_size *= score.resolved_view_style(&Default::default()).staff_space;
954    Ok(resolved)
955}
956
957fn validate_style_overrides(overrides: &[ViewStyleOverride]) -> Result<(), RenderError> {
958    if overrides
959        .iter()
960        .any(|override_| !override_.value.is_finite() || !(0.05..=64.0).contains(&override_.value))
961    {
962        return Err(RenderError::InvalidOptions {
963            reason: "typed style override values must be finite and within 0.05..=64".into(),
964        });
965    }
966    Ok(())
967}
968
969/// Render `score` to an SVG string using an already-computed [`LayoutResult`].
970///
971/// `layout` must have been computed from `score` (or a structurally identical score) —
972/// mismatched inputs produce undefined visual output, not a panic.
973pub fn render_svg_with_layout(
974    score: &Score,
975    layout: &LayoutResult,
976    options: &SvgRenderOptions,
977) -> Result<String, RenderError> {
978    let options = options_for_score(options, score)?;
979    render::build_svg(score, layout, &options)
980}
981
982/// Render a score and append deterministic, host-provided annotations.
983///
984/// Annotation providers are sorted by [`RenderAnnotation::id`]. The returned marks are sorted by
985/// their stable annotation IDs and serialized with XML escaping; arbitrary SVG fragments are not
986/// accepted. An empty provider list has the same output as [`render_svg_with_layout`].
987pub fn render_svg_with_annotations(
988    score: &Score,
989    layout: &LayoutResult,
990    options: &SvgRenderOptions,
991    providers: &[&dyn RenderAnnotation],
992) -> Result<String, RenderError> {
993    let options = options_for_score(options, score)?;
994    let (mut svg, metadata) = render::build_svg_with_metadata(score, layout, &options)?;
995    let annotations = render::collect_annotations(score, layout, &metadata, providers)
996        .map_err(RenderError::Annotation)?;
997    if annotations.is_empty() {
998        return Ok(svg);
999    }
1000    let body = annotations
1001        .iter()
1002        .map(|annotation| {
1003            format!(
1004                r#"<text class="acorde-render-annotation" data-acorde-kind="render-annotation" data-acorde-annotation-id="{}" x="{}" y="{}">{}</text>"#,
1005                render::escape_xml(&annotation.id),
1006                annotation.x,
1007                annotation.y,
1008                render::escape_xml(&annotation.text),
1009            )
1010        })
1011        .collect::<String>();
1012    let marker = "</g></svg>";
1013    let insertion = svg
1014        .rfind(marker)
1015        .ok_or_else(|| RenderError::InvalidLayout {
1016            reason: "renderer output has no score root".into(),
1017        })?;
1018    svg.insert_str(insertion, &body);
1019    Ok(svg)
1020}
1021
1022/// Render one system row from a precomputed layout. The returned SVG contains only that row
1023/// and uses the same deterministic renderer as full-score output.
1024pub fn render_svg_row(
1025    score: &Score,
1026    layout: &LayoutResult,
1027    row: usize,
1028    options: &SvgRenderOptions,
1029) -> Result<String, RenderError> {
1030    let mut subset = layout.clone();
1031    subset.rows = vec![
1032        layout
1033            .rows
1034            .get(row)
1035            .cloned()
1036            .ok_or(RenderError::InvalidRow { row })?,
1037    ];
1038    render_svg_with_layout(score, &subset, options)
1039}
1040
1041/// Return SVG dimensions and approximate hit-test bounds keyed by stable score addresses.
1042pub fn render_svg_metadata(
1043    score: &Score,
1044    layout: &LayoutResult,
1045    options: &SvgRenderOptions,
1046) -> Result<RenderMetadata, RenderError> {
1047    let options = options_for_score(options, score)?;
1048    render::build_svg_with_metadata(score, layout, &options).map(|(_, metadata)| metadata)
1049}