M6: add bounded offline Penpot import and export

This commit is contained in:
Guillermo Bueno
2026-10-25 14:36:23 +01:00
parent 22c15e3206
commit 1cd9c0d875
23 changed files with 10420 additions and 36 deletions
+410 -13
View File
@@ -1,5 +1,7 @@
//! File menu: documents, save/open, import/export, versions.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use base64::Engine as _;
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .penpot, .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
false,
open
),
@@ -53,6 +55,13 @@ pub fn specs() -> Vec<CommandSpec> {
false,
open_recent
),
spec!(
"file.fontReport",
"Inspect document fonts",
"{} list fonts used by text layers, which families are available, and sample layers; does not substitute or change the original family names",
false,
|s, _| Ok(font_report(s.doc()?))
),
spec!(
"file.save",
"Save",
@@ -70,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x .penpot archive, with `warnings` for what Penpot cannot represent), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
false,
export
),
@@ -98,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.import",
"Import",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, .penpot, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
true,
import
),
@@ -536,6 +545,19 @@ fn load_with(
.notes;
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
}
"penpot" => {
let (mut doc, mut notes) = figcraft_format::penpot::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
expand_penpot_raw_svgs(&mut doc, &mut notes);
// Penpot imports stay offline: referenced fonts must be installed locally.
// The format reader stays platform-agnostic. Font availability is checked here,
// where FigCraft's system/embedded font registry is accessible. Never replace
// the original family in the document: it can resolve after installation.
figcraft_text::scan_system_fonts();
let families = figcraft_text::family_names();
let mut font_notes = missing_penpot_font_warnings(&doc, &families);
font_notes.append(&mut notes); // Show missing-font details first in the UI toast.
Ok((figcraft_format::Loaded { issues: font_notes, ..doc.into() }, true))
}
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => {
let mut doc = Document::new(file_stem(name_hint));
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
@@ -572,6 +594,249 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
}))
}
/// Turn Penpot raw SVG placeholders into editable FigCraft SVG content, using the
/// normal SVG importer. The parser (figcraft-format) only stores bounded XML so it
/// does not need to depend on the SVG crate; the engine does this second pass.
/// Any failure leaves the original placeholder and XML data intact for later use.
fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
let layers = doc
.nodes()
.filter_map(|node| {
let xml = node.plugin_data.get("penpot.svgRawXml")?;
(node.plugin_data.get("penpot.svgRawStatus").map(String::as_str) == Some("pending"))
.then(|| (node.id, node.name.clone(), node.width, node.height, xml.clone(), node.children.is_empty()))
})
.collect::<Vec<_>>();
let mut ok_count = 0usize;
for (id, name, width, height, xml, empty) in layers {
if !empty {
notes.push(format!("Penpot SVG '{name}': has separate child layers; the original tree was preserved to prevent duplicate rendering"));
continue;
}
match figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name) {
Ok(imported) => {
// An SVG can be syntactically valid yet contain no supported drawable
// elements. Do not claim success or leave an empty extra frame.
if doc.node(imported.frame).is_none_or(|n| n.children.is_empty()) {
let _ = doc.remove(imported.frame);
if let Some(n) = doc.node_mut(id) {
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
}
if notes.len() < 100 {
notes.push(format!("Penpot SVG '{name}' has no supported drawable elements; the original markup is preserved"));
}
continue;
}
let size = doc.node(imported.frame).map(|n| (n.width, n.height));
if let Some((svg_w, svg_h)) = size
&& svg_w.is_finite()
&& svg_h.is_finite()
&& svg_w > 0.0
&& svg_h > 0.0
{
let sx = (width / svg_w).clamp(0.0001, 10_000.0);
let sy = (height / svg_h).clamp(0.0001, 10_000.0);
if let Some(n) = doc.node_mut(imported.frame) {
n.transform = figcraft_geom::Affine::scale_non_uniform(sx, sy);
}
}
if let Some(n) = doc.node_mut(id) {
n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into());
}
for issue in imported.notes {
if notes.len() < 100 {
notes.push(format!("Penpot SVG '{name}': {issue}"));
}
}
ok_count += 1;
}
Err(err) => {
if let Some(n) = doc.node_mut(id) {
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
}
if notes.len() < 100 {
notes.push(format!("Penpot SVG '{name}' could not be imported: {err}; its original markup is preserved"));
}
}
}
}
if ok_count > 0 && notes.len() < 100 {
notes.push(format!("Penpot: {ok_count} raw SVG layer(s) converted to editable SVG content"));
}
}
/// Non-destructive font diagnostic for the action palette, CLI and control API.
/// Includes styled runs, not just the default typeface of each text layer.
fn font_report(doc: &Document) -> Value {
figcraft_text::scan_system_fonts();
font_report_with_families(doc, &figcraft_text::family_names())
}
fn font_report_with_families(doc: &Document, installed: &[String]) -> Value {
let known: BTreeSet<String> = installed.iter().map(|s| s.trim().to_lowercase()).collect();
let mut families: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for node in doc.nodes() {
let NodeKind::Text(text) = &node.kind else { continue };
if text.characters.is_empty() {
continue;
}
let mut covered = vec![false; text.characters.chars().count().min(100_000)];
let mut used = BTreeSet::new();
for run in &text.runs {
let start = run.start.min(covered.len());
let end = run.end.min(covered.len());
if start < end {
if !run.style.font_family.trim().is_empty() {
used.insert(run.style.font_family.clone());
}
for entry in covered.iter_mut().take(end).skip(start) {
*entry = true;
}
}
}
if covered.iter().any(|entry| !entry) {
used.insert(text.style.font_family.clone());
}
for family in used {
let family = family.trim();
if family.is_empty() {
continue;
}
if families.len() >= 256 && !families.contains_key(family) {
continue;
}
let layer_name = node.name.chars().filter(|c| !c.is_control()).take(120).collect::<String>();
let layers = families.entry(family.chars().filter(|c| !c.is_control()).take(160).collect()).or_default();
if layers.len() < 25 {
layers.insert(layer_name);
}
}
}
// Explain WHERE a Penpot family comes from. `custom-*` references are
// stored in Penpot's font service, not necessarily in its design archive.
// Keep the id for diagnostics; never treat it as a download URL.
let mut external: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
for node in doc.nodes() {
for key in ["penpot.googleFontRefs", "penpot.customFontRefs", "penpot.customLibraryFontRefs"] {
let Some(raw) = node.plugin_data.get(key) else {
continue;
};
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) else {
continue;
};
for item in items.iter().take(128) {
let Some(id) = item.get("id").and_then(Value::as_str) else {
continue;
};
let Some(family) = item.get("family").and_then(Value::as_str) else {
continue;
};
if id.len() > 128 || family.len() > 128 || external.len() >= 256 {
continue;
}
let key = (id.to_owned(), family.to_owned());
external.entry(key).or_default().insert(node.name.chars().take(80).collect());
}
}
}
let external_fonts: Vec<Value> = external
.into_iter()
.map(|((id, family), layers)| {
json!({
"source": if id.starts_with("custom-") { "penpot-custom" } else { "google-fonts" },
"referenceKind": if id.starts_with("custom-") { "custom-original-unverified" } else { "google-fonts-reference" },
"originNote": if id.starts_with("custom-") {
"Penpot-managed font: a matching installed/downloaded family is not proof of the original font file"
} else {
"Google Fonts provider reference; availability is reported separately"
},
"id": id, "family": family, "layers": layers.into_iter().collect::<Vec<_>>()
})
})
.collect();
let entries = families
.into_iter()
.map(|(family, layers)| {
let lower = family.to_lowercase();
let generic = matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui");
// No registered families can mean an uninitialised/no-font runtime; it is
// not evidence that all names are absent from the user's computer.
let available = if generic {
Some(true)
} else if known.is_empty() {
None
} else {
Some(known.contains(&lower))
};
json!({"family":family,"available":available,"layers":layers.into_iter().collect::<Vec<_>>()})
})
.collect::<Vec<_>>();
let missing = entries.iter().filter(|v| v.get("available") == Some(&Value::Bool(false))).count();
json!({"total": entries.len(), "missing": missing, "embeddedFontFiles": 0,
"fontLicenseNotices": 0, "fonts": entries,
"externalPenpotFontRefs": external_fonts})
}
/// Describe fonts unavailable on this machine, while keeping their original family names
/// editable in the document. The detected families are provided separately for testability
/// and to avoid a dependency from the format parser to the font renderer.
fn missing_penpot_font_warnings(doc: &Document, families: &[String]) -> Vec<String> {
// An uninitialised font registry cannot prove a family is unavailable.
if families.is_empty() {
return Vec::new();
}
let available: BTreeSet<String> = families.iter().map(|s| s.trim().to_lowercase()).collect();
let mut missing: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for node in doc.nodes() {
let NodeKind::Text(text) = &node.kind else { continue };
let text_len = text.characters.chars().count();
if text_len == 0 {
continue;
}
let mut covered = vec![false; text_len.min(100_000)];
let mut used = BTreeSet::new();
for run in &text.runs {
let start = run.start.min(covered.len());
let end = run.end.min(covered.len());
if start < end {
used.insert(run.style.font_family.clone());
for flag in covered.iter_mut().take(end).skip(start) {
*flag = true;
}
}
}
if covered.iter().any(|present| !present) {
used.insert(text.style.font_family.clone());
}
for family in used {
let family = family.trim();
let lower = family.to_lowercase();
if family.is_empty() || matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui") || available.contains(&lower) {
continue;
}
let display_family: String = family.chars().filter(|c| !c.is_control()).take(160).collect();
let display_layer: String = node.name.chars().filter(|c| !c.is_control()).take(80).collect();
missing.entry(display_family).or_default().insert(display_layer);
}
}
// A single warning remains readable even for documents with hundreds of
// type layers and does not push out more urgent Penpot import diagnostics.
if missing.is_empty() {
return Vec::new();
}
let count = missing.len();
let names = missing
.into_iter()
.take(8)
.map(|(family, layers)| format!("'{family}' (e.g. '{}')", layers.iter().next().map(String::as_str).unwrap_or("text")))
.collect::<Vec<_>>()
.join(", ");
let extra = if count > 8 { format!(" and {} more", count - 8) } else { String::new() };
vec![format!(
"Penpot: {count} font family/families are not installed or embedded: {names}{extra}. FigCraft uses fallback fonts for display, but preserves the original family names; install the licensed fonts and reopen the file to restore text metrics."
)]
}
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
@@ -722,9 +987,9 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || ext(&path) == "svg";
let warnings = rest || matches!(ext(&path).as_str(), "svg" | "penpot");
// A native file that lost something (a damaged image, say): the app says so, not only the result.
if native && !loaded.issues.is_empty() {
if (native || warnings) && !loaded.issues.is_empty() {
s.message = Some(match loaded.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&path)),
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
@@ -745,7 +1010,7 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>)
let data = data.split_once(',').map_or(data, |(_, b)| b);
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
let warnings = rest || ext(&name) == "svg";
let warnings = rest || matches!(ext(&name).as_str(), "svg" | "penpot");
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
@@ -1028,12 +1293,14 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixma
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export";
let fig = match string(p, "format") {
Some(f) => f.eq_ignore_ascii_case("fig"),
None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"),
let whole_file = match string(p, "format") {
Some(f) => f.to_ascii_lowercase(),
None => string(p, "path").map(|pp| ext(&pp)).unwrap_or_default(),
};
if fig {
return export_fig(s, p);
match whole_file.as_str() {
"fig" => return export_fig(s, p),
"penpot" => return export_penpot(s, p),
_ => {}
}
let doc = s.doc()?;
let mut ids = targets(s, p);
@@ -1075,6 +1342,21 @@ fn export_fig(s: &mut Session, p: &Value) -> Result<Value> {
}
}
fn export_penpot(s: &mut Session, p: &Value) -> Result<Value> {
let (bytes, warnings) = penpot_bytes(s.doc()?)?;
match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": "penpot", "warnings": warnings}))
}
None => Ok(json!({"format": "penpot", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})),
}
}
pub fn penpot_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
figcraft_format::penpot::export(doc).map_err(|e| EngineError::Other(e.to_string()))
}
/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out.
pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string()))
@@ -1331,7 +1613,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
}
"figcraft" | "json" | "fig" => {
"figcraft" | "json" | "fig" | "penpot" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
@@ -1341,8 +1623,14 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
let summary = import_summary(&other.doc);
let pages = append_pages(s.doc_mut()?, &other.doc)?;
if !other.issues.is_empty() {
s.message = Some(match other.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&name)),
more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]),
});
}
let mut out = json!({"pages": pages, "summary": summary});
if rest {
if rest || !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
}
Ok(out)
@@ -1768,3 +2056,112 @@ mod staging_write_tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod penpot_font_warning_tests {
use super::*;
#[test]
fn font_report_explains_custom_and_google_provenance() {
let mut doc = Document::new("External font refs");
let page = doc.first_page().unwrap();
let text = TextData::new("Playball");
let id = doc.create(page, NodeKind::Text(text), "Custom caption", 0., 0., 120., 30.).unwrap();
doc.node_mut(id).unwrap().plugin_data.insert(
"penpot.customFontRefs".into(),
r#"[{"id":"custom-123abc","family":"Playball","variants":[{"weight":400,"italic":false}]}]"#.into(),
);
let report = font_report_with_families(&doc, &[]);
assert_eq!(report["externalPenpotFontRefs"][0]["source"], "penpot-custom");
assert_eq!(report["externalPenpotFontRefs"][0]["family"], "Playball");
assert_eq!(report["externalPenpotFontRefs"][0]["referenceKind"], "custom-original-unverified");
assert!(report["externalPenpotFontRefs"][0]["originNote"].as_str().unwrap().contains("not proof"));
}
#[test]
fn reports_only_missing_families_without_modifying_original_text() {
let mut doc = Document::new("Penpot fonts");
let page = doc.first_page().unwrap();
let mut text = TextData::new("hello world");
text.style.font_family = "Rare Licensed Family".into();
doc.create(page, NodeKind::Text(text), "Headline", 0.0, 0.0, 200.0, 30.0).unwrap();
let warnings = missing_penpot_font_warnings(&doc, &["Inter".into(), "Source Sans Pro".into()]);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Rare Licensed Family"));
assert!(warnings[0].contains("Headline"));
assert_eq!(
doc.nodes().find(|n| n.name == "Headline").and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.style.font_family.as_str()),
_ => None,
}),
Some("Rare Licensed Family")
);
assert!(missing_penpot_font_warnings(&doc, &["RARE LICENSED FAMILY".into()]).is_empty());
assert!(missing_penpot_font_warnings(&doc, &[]).is_empty());
}
#[test]
fn completely_styled_text_does_not_report_an_unused_base_font() {
let mut doc = Document::new("Run fonts");
let page = doc.first_page().unwrap();
let mut t = TextData::new("AB");
t.style.font_family = "Unused Base".into();
let mut run = t.style.clone();
run.font_family = "Available Font".into();
t.runs.push(StyleRun { start: 0, end: 2, style: run });
doc.create(page, NodeKind::Text(t), "Full run", 0.0, 0.0, 100.0, 30.0).unwrap();
assert!(missing_penpot_font_warnings(&doc, &["Available Font".into()]).is_empty());
}
}
#[cfg(test)]
mod penpot_svg_v10_tests {
use super::*;
#[test]
fn expands_inline_svg_into_editable_shapes_without_discarding_source() {
let mut doc = Document::new("SVG fixture");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "SVG ornament", 10.0, 20.0, 100.0, 50.0).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="20" height="20" fill="red"/></svg>"#;
let layer = doc.node_mut(id).unwrap();
layer.plugin_data.insert("penpot.svgRawXml".into(), svg.into());
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
let mut warnings = Vec::new();
expand_penpot_raw_svgs(&mut doc, &mut warnings);
let layer = doc.node(id).unwrap();
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("expanded"));
assert!(!layer.children.is_empty());
assert!(doc.nodes().any(|node| matches!(&node.kind, NodeKind::Rectangle | NodeKind::Vector { .. })));
assert_eq!(layer.plugin_data.get("penpot.svgRawXml").map(String::as_str), Some(svg));
}
#[test]
fn invalid_raw_svg_keeps_placeholder_and_markup() {
let mut doc = Document::new("Invalid SVG fixture");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "Broken", 0.0, 0.0, 80.0, 30.0).unwrap();
let layer = doc.node_mut(id).unwrap();
layer.plugin_data.insert("penpot.svgRawXml".into(), "<svg><broken".into());
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
let mut warnings = Vec::new();
expand_penpot_raw_svgs(&mut doc, &mut warnings);
let layer = doc.node(id).unwrap();
assert!(layer.children.is_empty());
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("not-expanded"));
assert!(!warnings.is_empty());
}
#[test]
fn font_inventory_reports_missing_families_without_rewriting_text() {
let mut doc = Document::new("Font inventory");
let page = doc.first_page().unwrap();
let mut text = TextData::new("Example");
text.style.font_family = "Definitely Never Installed V10 Font Q7".into();
doc.create(page, NodeKind::Text(text), "Caption", 0.0, 0.0, 100.0, 24.0).unwrap();
let report = font_report_with_families(&doc, &["Inter".into()]);
assert_eq!(report["missing"], 1);
assert_eq!(report["fonts"][0]["family"], "Definitely Never Installed V10 Font Q7");
assert_eq!(report["fonts"][0]["layers"][0], "Caption");
}
}