use serde::Serialize;
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MediaKind {
Image,
Audio,
Video,
Subtitles,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Diagnostic {
MissingMedia { media: MediaKind, name: String },
MissingFont { message: String },
MissingGlyph { message: String },
RendererWarning { message: String },
TextClipped { text: String, bbox: [f32; 4] },
TextOffCanvas { text: String, bbox: [f32; 4] },
InvalidTransform { id: String },
EmptyFrame,
Panic { message: String },
}
impl Diagnostic {
pub fn group_key(&self) -> String {
match self {
Diagnostic::TextClipped { text, .. } => format!("text_clipped:{text}"),
Diagnostic::TextOffCanvas { text, .. } => format!("text_off_canvas:{text}"),
other => other.to_string(),
}
}
pub fn severity(&self) -> Severity {
match self {
Diagnostic::Panic { .. }
| Diagnostic::MissingMedia { .. }
| Diagnostic::MissingGlyph { .. }
| Diagnostic::InvalidTransform { .. } => Severity::Error,
Diagnostic::MissingFont { .. }
| Diagnostic::TextClipped { .. }
| Diagnostic::EmptyFrame
| Diagnostic::RendererWarning { .. } => Severity::Warning,
Diagnostic::TextOffCanvas { .. } => Severity::Info,
}
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let [x, y, w, h] = match self {
Diagnostic::TextClipped { bbox, .. } | Diagnostic::TextOffCanvas { bbox, .. } => *bbox,
_ => [0.; 4],
};
match self {
Diagnostic::MissingMedia { media, name } => {
write!(f, "{media:?} \"{name}\" is not in the media provider")
}
Diagnostic::MissingFont { message }
| Diagnostic::MissingGlyph { message }
| Diagnostic::RendererWarning { message } => write!(f, "{message}"),
Diagnostic::TextClipped { text, .. } => write!(
f,
"text \"{text}\" is cut off by the canvas edge (x={x:.0} y={y:.0} w={w:.0} h={h:.0})"
),
Diagnostic::TextOffCanvas { text, .. } => write!(
f,
"text \"{text}\" is outside the canvas (x={x:.0} y={y:.0} w={w:.0} h={h:.0})"
),
Diagnostic::InvalidTransform { id } => {
write!(f, "node \"{id}\" has a NaN or infinite transform")
}
Diagnostic::EmptyFrame => write!(f, "frame is empty"),
Diagnostic::Panic { message } => write!(f, "render_frame panicked: {message}"),
}
}
}
thread_local! {
static COLLECTOR: RefCell<Option<Vec<Diagnostic>>> = const { RefCell::new(None) };
}
static MISSING_MEDIA: Mutex<BTreeSet<(MediaKind, String)>> = Mutex::new(BTreeSet::new());
pub fn collect<R>(f: impl FnOnce() -> R) -> (R, Vec<Diagnostic>) {
install_log_capture();
let previous = COLLECTOR.with(|c| c.borrow_mut().replace(Vec::new()));
let result = f();
let collected = COLLECTOR.with(|c| std::mem::replace(&mut *c.borrow_mut(), previous));
(result, dedup(collected.unwrap_or_default()))
}
pub fn report(diagnostic: Diagnostic) {
COLLECTOR.with(|c| {
if let Some(collected) = c.borrow_mut().as_mut() {
collected.push(diagnostic);
}
});
}
pub(crate) fn report_missing_media(media: MediaKind, name: &str) {
if let Ok(mut missing) = MISSING_MEDIA.lock()
&& !missing.iter().any(|(k, n)| *k == media && n == name)
{
missing.insert((media, name.to_owned()));
}
report(Diagnostic::MissingMedia {
media,
name: name.to_owned(),
});
}
pub fn take_missing_media() -> Vec<(MediaKind, String)> {
MISSING_MEDIA
.lock()
.map(|mut missing| std::mem::take(&mut *missing).into_iter().collect())
.unwrap_or_default()
}
fn dedup(diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
let mut unique: Vec<Diagnostic> = Vec::with_capacity(diagnostics.len());
for diagnostic in diagnostics {
if !unique.contains(&diagnostic) {
unique.push(diagnostic);
}
}
unique
}
fn from_renderer_warning(message: String) -> Diagnostic {
if message.starts_with("No match for") {
Diagnostic::MissingFont { message }
} else if message.starts_with("No fonts with a") {
Diagnostic::MissingGlyph { message }
} else {
Diagnostic::RendererWarning { message }
}
}
struct CaptureLogger;
impl log::Log for CaptureLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::Level::Warn
}
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
report(from_renderer_warning(record.args().to_string()));
}
}
fn flush(&self) {}
}
static CAPTURE_LOGGER: CaptureLogger = CaptureLogger;
pub fn install_log_capture() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
if log::set_logger(&CAPTURE_LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Warn);
}
});
}
pub fn inspect_tree(tree: &usvgr::Tree, width: f32, height: f32) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let root = tree.root();
if !root.has_children() {
diagnostics.push(Diagnostic::EmptyFrame);
}
inspect_group(root, width, height, &mut diagnostics);
diagnostics
}
fn inspect_group(group: &usvgr::Group, width: f32, height: f32, out: &mut Vec<Diagnostic>) {
if !group.abs_transform().is_finite() {
out.push(Diagnostic::InvalidTransform {
id: group.id().to_owned(),
});
return;
}
for node in group.children() {
match node {
usvgr::Node::Group(group) => {
if group.clip_path().is_some()
|| group.mask().is_some()
|| group.opacity().get() == 0.
{
check_transforms(group, out);
} else {
inspect_group(group, width, height, out)
}
}
usvgr::Node::Text(text) => {
if !text.abs_transform().is_finite() {
out.push(Diagnostic::InvalidTransform {
id: text.id().to_owned(),
});
continue;
}
let bbox = text.abs_bounding_box();
let rect = [bbox.x(), bbox.y(), bbox.width(), bbox.height()];
let content = text
.chunks()
.iter()
.map(|chunk| chunk.text())
.collect::<Vec<_>>()
.join(" ");
let inside = bbox.left() >= -1.
&& bbox.top() >= -1.
&& bbox.right() <= width + 1.
&& bbox.bottom() <= height + 1.;
let disjoint = bbox.right() <= 0.
|| bbox.bottom() <= 0.
|| bbox.left() >= width
|| bbox.top() >= height;
if disjoint {
out.push(Diagnostic::TextOffCanvas {
text: content,
bbox: rect,
});
} else if !inside {
out.push(Diagnostic::TextClipped {
text: content,
bbox: rect,
});
}
}
usvgr::Node::Path(path) => {
if !path.abs_transform().is_finite() {
out.push(Diagnostic::InvalidTransform {
id: path.id().to_owned(),
});
}
}
usvgr::Node::Image(image) => {
if !image.abs_transform().is_finite() {
out.push(Diagnostic::InvalidTransform {
id: image.id().to_owned(),
});
}
}
}
}
}
fn check_transforms(group: &usvgr::Group, out: &mut Vec<Diagnostic>) {
if !group.abs_transform().is_finite() {
out.push(Diagnostic::InvalidTransform {
id: group.id().to_owned(),
});
return;
}
for node in group.children() {
if let usvgr::Node::Group(group) = node {
check_transforms(group, out);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collects_only_inside_the_scope() {
report(Diagnostic::EmptyFrame);
let ((), collected) = collect(|| {
report(Diagnostic::EmptyFrame);
report(Diagnostic::EmptyFrame);
});
assert_eq!(collected, vec![Diagnostic::EmptyFrame]);
let ((), nothing) = collect(|| ());
assert!(nothing.is_empty());
}
#[test]
fn classifies_converter_warnings() {
assert!(matches!(
from_renderer_warning("No match for '\"Foo\"' font-family.".into()),
Diagnostic::MissingFont { .. }
));
assert!(matches!(
from_renderer_warning("No fonts with a x/U+78 character were found.".into()),
Diagnostic::MissingGlyph { .. }
));
}
#[test]
fn finds_clipped_and_off_canvas_text() {
let mut fontdb = usvgr::fontdb::Database::new();
fontdb
.load_font_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../cargo-fframes/templates/DMSans-Medium.ttf"
))
.expect("the scaffolder's font is part of the repository");
let options = usvgr::Options {
font_family: "DM Sans".to_owned(),
..Default::default()
};
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
<text x="150" y="50" font-size="40">clipped</text>
<text x="500" y="50" font-size="40">gone</text>
<text x="10" y="50" font-size="10">ok</text>
<clipPath id="c"><rect width="200" height="100"/></clipPath>
<g clip-path="url(#c)"><text x="150" y="50" font-size="40">scrolling</text></g>
</svg>"#;
let tree = usvgr::Tree::from_str(svg, &options, &fontdb).unwrap();
let diagnostics = inspect_tree(&tree, 200., 100.);
assert!(
diagnostics
.iter()
.any(|d| matches!(d, Diagnostic::TextClipped { text, .. } if text == "clipped"))
);
assert!(
diagnostics
.iter()
.any(|d| matches!(d, Diagnostic::TextOffCanvas { text, .. } if text == "gone"))
);
assert!(
!diagnostics
.iter()
.any(|d| matches!(d, Diagnostic::TextClipped { text, .. } | Diagnostic::TextOffCanvas { text, .. } if text == "ok" || text == "scrolling"))
);
}
}