Fix SVG selection and import detection (#2224)

* Fix SVG import detection and configurable group depth

* Fix malformed SVG error routing and formatting

* Refresh scorecard for SVG depth preference

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex 2026-10-10 03:58:08 -04:00 committed by GitHub
parent 1b6150e3fc
commit 265f10b91d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 216 additions and 30 deletions

View File

@ -21,6 +21,7 @@ const OPEN_EXTS: &[&str] = &[
"pam", "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
"afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
/// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest
@ -335,7 +336,9 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
@ -355,7 +358,7 @@ fn services(inbox: Inbox) -> Services {
let dialog = if let Some(exts) = extensions {
rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts)
} else {
rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS)
rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS)
};
let picked = dialog.pick_file().await;
let answer = match picked {

View File

@ -24,6 +24,7 @@ const OPEN_EXTS: &[&str] = &[
"pam", "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
/// Open dialog extensions that also match uppercase and mixed-case names (`IMG_0001.JPG`).
/// On Linux and the BSDs rfd turns each extension into a case-sensitive `*.ext` glob for the XDG
@ -35,6 +36,12 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
vec![("All Formats", open_filter_extensions(OPEN_EXTS)), ("PhotoCraft", open_filter_extensions(&["pcraft"])), ("SVG", open_filter_extensions(SVG_EXTS))]
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
/// keeps defaulting to PSD.
@ -88,9 +95,9 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
} else {
dialog
.add_filter("All Formats", &open_filter_extensions(OPEN_EXTS))
.add_filter("PhotoCraft", &open_filter_extensions(&["pcraft"]))
open_filters()
.into_iter()
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
};
if multiple {
@ -310,8 +317,10 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}) as photocraft_ui_egui::AutomationCommandFn
});
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| {
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
crate::crash_guard::guard("Open", || {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let opts = export_options(settings);
@ -475,6 +484,9 @@ mod tests {
assert!(!matches(glob, &format!("{ext}x")) && !matches(glob, &ext[..ext.len() - 1]), "{glob} matches only .{ext}");
}
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
@ -482,6 +494,7 @@ mod tests {
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
}
#[test]

View File

@ -397,7 +397,14 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec<u8>, linked: Option<S
let (cw, ch) = (d.doc.size.width as f64, d.doc.size.height as f64);
let fmt = d.doc.pixel_format();
let vector = photocraft_io::svg::is_svg(&bytes);
let src = import(name, &bytes)?;
let max_svg_group_depth = s.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
let imported = photocraft_io::import_with_svg_group_depth(name, &bytes, max_svg_group_depth).map_err(|e| EngineError::Other(format!("{name}: {e}")))?;
if imported.preview_only {
return Err(EngineError::Other(format!(
"{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
)));
}
let src = imported.document;
let (w, h) = (src.size.width as f64, src.size.height as f64);
let scale = match f64_param(p, "scale") {
Some(k) => (k / 100.0).max(1e-4),

View File

@ -417,6 +417,7 @@ impl Session {
let label = format!("Opening {name}");
let name_w = name.to_string();
let name_a = name.to_string();
let max_svg_group_depth = self.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
self.start_job(
OPEN_JOB,
json!({"name": name}),
@ -430,10 +431,11 @@ impl Session {
};
ctx.check()?;
ctx.progress(0.02, "Decoding");
ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with(&name_w, &bytes, ctl)).map_err(|e| match e {
photocraft_io::IoError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
})
ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl))
.map_err(|e| match e {
photocraft_io::IoError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
})
},
move |s, r: photocraft_io::ImportResult| {
let (index, color) = s.open_document(r.document, None);

View File

@ -365,6 +365,8 @@ pub struct FileHandling {
pub ignore_exif_profile_tag: bool,
pub ask_before_saving_layered_tiff: bool,
pub maximize_psd_compatibility: Ask,
/// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed.
pub rasterize_svg_groups_deeper_than: u32,
pub recent_file_count: u32,
/// Most recently opened files, newest first (File › Open Recent).
pub recent_files: Vec<String>,
@ -382,6 +384,7 @@ impl Default for FileHandling {
ignore_exif_profile_tag: false,
ask_before_saving_layered_tiff: true,
maximize_psd_compatibility: Ask::Always,
rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32,
recent_file_count: 20,
recent_files: Vec::new(),
}
@ -950,6 +953,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
Some(match path {
"fileHandling.autosaveMinutes" => (1.0, 240.0),
"fileHandling.recentFileCount" => (0.0, 100.0),
"fileHandling.rasterizeSvgGroupsDeeperThan" => (0.0, photocraft_doc::MAX_GROUP_DEPTH as f64),
"interface.notificationDurationSeconds" => (1.0, 120.0),
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
"performance.memoryUsageMb" => (256.0, 1_048_576.0),

View File

@ -361,6 +361,19 @@ fn choice_and_range_tables_cover_enum_fields() {
}
}
#[test]
fn svg_group_rasterization_threshold_is_bounded_by_document_depth() {
let mut s = session();
let pref = "fileHandling.rasterizeSvgGroupsDeeperThan";
assert_eq!(s.execute("prefs.get", json!({"path": pref})).unwrap(), json!(photocraft_doc::MAX_GROUP_DEPTH));
s.execute("prefs.set", json!({"path": pref, "value": 4})).unwrap();
assert_eq!(s.prefs().file_handling.rasterize_svg_groups_deeper_than, 4);
s.execute("prefs.set", json!({"path": pref, "value": 0})).unwrap();
assert_eq!(s.prefs().file_handling.rasterize_svg_groups_deeper_than, 0, "zero disables the preference threshold");
assert!(s.execute("prefs.set", json!({"path": pref, "value": photocraft_doc::MAX_GROUP_DEPTH + 1})).is_err());
assert_eq!(s.prefs().file_handling.rasterize_svg_groups_deeper_than, 0, "out-of-range preferences leave the valid value unchanged");
}
#[test]
fn right_click_with_painting_tools_pref() {
let mut s = session();

View File

@ -171,20 +171,36 @@ pub fn is_psd(bytes: &[u8]) -> bool {
/// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything
/// else is decoded with `photocraft-codecs`.
pub fn import(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
import_with(name, bytes, &photocraft_raster::Interrupt::NONE)
import_with_svg_group_depth(name, bytes, photocraft_doc::MAX_GROUP_DEPTH)
}
/// [`import`] for a background open: checks `ctl` between stages (and per layer for PSD/PSB) and
/// reports progress. A cancelled import fails with [`IoError::Cancelled`].
pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result<ImportResult, IoError> {
import_with_svg_group_depth_and_interrupt(name, bytes, photocraft_doc::MAX_GROUP_DEPTH, ctl)
}
/// Imports a file with a configurable SVG editability threshold. SVG groups deeper than this are
/// rasterised during conversion; the XML parser safety cap and document nesting limit stay fixed.
pub fn import_with_svg_group_depth(name: &str, bytes: &[u8], max_group_depth: usize) -> Result<ImportResult, IoError> {
import_with_svg_group_depth_and_interrupt(name, bytes, max_group_depth, &photocraft_raster::Interrupt::NONE)
}
/// [`import_with`] with the SVG editable-group threshold selected by the user.
pub fn import_with_svg_group_depth_and_interrupt(
name: &str,
bytes: &[u8],
max_group_depth: usize,
ctl: &photocraft_raster::Interrupt,
) -> Result<ImportResult, IoError> {
ctl.check().map_err(|_| IoError::Cancelled)?;
let r = import_stages(name, bytes, ctl)?;
let r = import_stages(name, bytes, ctl, max_group_depth)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(1.0);
Ok(r)
}
fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result<ImportResult, IoError> {
fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result<ImportResult, IoError> {
// A declared native extension must reach its loader so malformed bundles retain format errors.
if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) {
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false });
@ -212,12 +228,24 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -
if raw::is_raw(bytes) {
return raw::import_raw(name, bytes);
}
if has_extension(name, "svg") || has_extension(name, "svgz") || svg::is_svg(bytes) {
return svg::import_svg(name, bytes);
if is_svg_input(name, bytes) {
return svg::import_svg_with_group_depth(name, bytes, max_group_depth);
}
flat::import_flat(name, bytes)
}
/// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`).
/// Valid plain SVG is recognized by its XML content; an otherwise unknown `.svg` is passed to the
/// SVG parser for a useful malformed-file error, and `.svgz` disambiguates gzip streams.
fn is_svg_input(name: &str, bytes: &[u8]) -> bool {
// Keep the SVG extension as a fallback for malformed files so the SVG parser can return an
// actionable SVG error. A recognized raster signature still wins over a misleading `.svg`
// suffix (for example, a WebP downloaded as Firefox_logo.svg).
svg::is_svg(bytes)
|| (has_extension(name, "svg") && photocraft_codecs::detect(bytes).is_none())
|| (has_extension(name, "svgz") && bytes.starts_with(&[0x1f, 0x8b]))
}
fn extension(name_or_ext: &str) -> String {
name_or_ext.rsplit(['.', '/', '\\']).next().unwrap_or(name_or_ext).to_ascii_lowercase()
}
@ -298,3 +326,17 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
Ok(photocraft_compose::surface_to_buffer(s, doc.bounds()).px)
}
#[cfg(test)]
mod tests {
use super::is_svg_input;
#[test]
fn svg_extension_does_not_override_another_format_signature() {
let webp_header = b"RIFF\x04\0\0\0WEBP";
assert!(!is_svg_input("Firefox_logo,_2019.svg", webp_header));
assert!(is_svg_input("drawing.svg", b"<?xml version=\"1.0\"?><svg/>"));
assert!(is_svg_input("drawing.svgz", &[0x1f, 0x8b, 0, 0]));
assert!(!is_svg_input("drawing.svgz", webp_header));
}
}

View File

@ -184,13 +184,20 @@ fn canvas(tree: &usvg::Tree, warnings: &mut Vec<String>) -> Canvas {
/// Opens an SVG as a document of shape layers (see the module docs for what is rasterised).
pub fn import_svg(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
import_svg_with_group_depth(name, bytes, MAX_GROUP_DEPTH)
}
/// Opens an SVG while rasterising any subtrees deeper than `max_group_depth` into single pixel
/// layers. The document's hard nesting limit remains [`MAX_GROUP_DEPTH`].
pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: usize) -> Result<ImportResult, IoError> {
let tree = parse(bytes)?;
let mut warnings = Vec::new();
let cv = canvas(&tree, &mut warnings);
let mut doc = Document::new(name, cv.size, ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale);
let mut conv = Conv { fmt, canvas: doc.bounds(), global, warnings, noted: Vec::new(), counts: [0; 4] };
let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH));
let mut conv = Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4] };
if count_nodes(tree.root()) > MAX_NODES {
conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes"));
let (w, h) = (cv.size.width, cv.size.height);
@ -273,6 +280,7 @@ struct Conv {
canvas: Rect,
/// Drawing units → canvas pixels.
global: tiny_skia::Transform,
max_group_depth: Option<usize>,
warnings: Vec<String>,
/// Features already warned about (one warning each).
noted: Vec<&'static str>,
@ -317,7 +325,7 @@ impl Conv {
usvg::Node::Group(g) => {
let dropped: [(bool, &'static str); 3] =
[(!g.filters().is_empty(), "filter"), (g.clip_path().is_some(), "clip-path"), (g.mask().is_some(), "mask")];
let too_deep = depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION;
let too_deep = self.max_group_depth.is_some_and(|limit| depth >= limit) || depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION;
if dropped.iter().any(|d| d.0) || too_deep {
for (yes, what) in dropped {
if yes {

View File

@ -79,6 +79,24 @@ fn groups_keep_opacity_blend_and_nesting_while_plain_groups_vanish() {
assert!(p[1] > 0.9 && (p[3] - 0.5).abs() < 0.03, "{p:?}");
}
#[test]
fn svg_group_rasterization_threshold_is_configurable_and_clamped() {
let source = format!(
"<svg {NS} width=\"20\" height=\"20\"><g id=\"outer\"><rect width=\"2\" height=\"2\" fill=\"blue\"/><g id=\"inner\"><rect width=\"10\" height=\"10\" fill=\"red\"/><rect x=\"12\" width=\"5\" height=\"5\" fill=\"green\"/></g></g></svg>"
);
let shallow = import_with_svg_group_depth("nested.svg", source.as_bytes(), 1).unwrap();
let LayerContent::Group(outer) = &shallow.document.layers[0].content else { panic!("outer group should remain editable") };
assert!(outer.children.iter().any(|child| matches!(child.content, LayerContent::Raster(_))), "the subtree past the setting should be rasterized");
assert!(shallow.warnings.iter().any(|warning| warning.contains("nested too deeply")));
let unlimited = import_with_svg_group_depth("nested.svg", source.as_bytes(), 0).unwrap();
let LayerContent::Group(outer) = &unlimited.document.layers[0].content else { panic!("outer group should remain editable") };
assert!(outer.children.iter().any(|child| matches!(child.content, LayerContent::Group(_))), "zero disables the preference threshold");
let clamped = import_with_svg_group_depth("nested.svg", source.as_bytes(), usize::MAX).unwrap();
assert!(matches!(clamped.document.layers[0].content, LayerContent::Group(_)), "out-of-range settings clamp to the document's hard group limit");
}
#[test]
fn transforms_curves_and_fill_rules_are_in_the_knots() {
let r = open(&format!(

View File

@ -39,7 +39,9 @@ fn main() {
arg(&args, "--script").map(|s| serde_json::from_str::<Vec<(String, Value)>>(&s).expect("--script must be [[method, params], …]")).unwrap_or_default();
let services = Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
import: Some(Box::new(|name: &str, bytes: &[u8], depth: usize| {
photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &photocraft_doc::Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {

View File

@ -1263,7 +1263,9 @@ mod tests {
fn raw_app() -> (PhotocraftApp, Vec<u8>) {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
let services = crate::Services {
import: Some(Box::new(|name: &str, b: &[u8]| photocraft_io::import(name, b).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
import: Some(Box::new(|name: &str, b: &[u8], depth: usize| {
photocraft_io::import_with_svg_group_depth(name, b, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
..Default::default()
};
let (w, h) = (48, 32);

View File

@ -1645,7 +1645,7 @@ mod tests {
let written: Rc<RefCell<Vec<String>>> = Rc::default();
let w = written.clone();
let services = crate::Services {
import: Some(Box::new(|name: &str, _b: &[u8]| {
import: Some(Box::new(|name: &str, _b: &[u8], _depth: usize| {
Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), vec!["Adjustment layer flattened".to_string()]))
})),
export: Some(Box::new(|_d: &Document, _p: &str, _s: &crate::ExportSettings| Ok((b"out".to_vec(), vec!["Layers were flattened".to_string()])))),

View File

@ -18,7 +18,7 @@ fn app_with(answers: Vec<Option<FileDialogAnswer>>) -> (PhotocraftApp, Written)
let written: Written = Rc::default();
let w = written.clone();
let services = Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| {
import: Some(Box::new(|name: &str, bytes: &[u8], _depth: usize| {
if bytes == b"bad" {
return Err("not an image".into());
}
@ -398,6 +398,9 @@ fn files_dropped_onto_the_canvas_are_placed_in_free_transform_one_by_one() {
std::fs::create_dir_all(&dir).unwrap();
let abs = dir.join("photo.png");
std::fs::write(&abs, png_bytes(200, 100)).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(&svg, b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\"><rect width=\"20\" height=\"10\" fill=\"red\"/></svg>")
.unwrap();
let ctx = egui::Context::default();
let mut app = app_with_canvas();
let (layers, steps) = (layer_count(&app), history_steps(&app));
@ -439,6 +442,13 @@ fn files_dropped_onto_the_canvas_are_placed_in_free_transform_one_by_one() {
app.place_next_dropped(&ctx);
assert!(app.ui.transform.is_none() && app.drop_places.is_empty());
assert!(app.ui.recent_files.is_empty(), "placing isn't opening");
app.open_dropped(&ctx, vec![dropped(svg, Err("egui's reader must not be used".into()))], Some(egui::pos2(400.0, 300.0)));
app.place_next_dropped(&ctx);
let st = app.session.active().unwrap();
let placed_svg = st.doc.layer(st.active_layer.unwrap()).unwrap();
assert_eq!(placed_svg.name, "logo");
assert!(matches!(placed_svg.content, photocraft_doc::LayerContent::Smart(_)), "SVG drops use Place Embedded");
assert!(app.ui.transform.is_some(), "SVG drop should enter Free Transform");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -2317,6 +2317,9 @@ fontWeight Italic Kurzíva
Color to Alpha… Barva na alfu…
Transparency Threshold Práh průhlednosti
Opacity Threshold Práh krytí
Rasterize svg groups deeper than Rasterizovat skupiny SVG hlubší než tato úroveň
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 vypne tento práh; skupiny SVG vnořené hlouběji než 100 úrovní se rastrují vždy.
Rule of Thirds Pravidlo třetin
Triangle Trojúhelník
Golden Ratio Zlatý řez

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2356,6 +2356,9 @@ fontWeight Italic Kursiv
Color to Alpha… Farbe zu Alpha…
Transparency Threshold Transparenz-Schwellenwert
Opacity Threshold Deckkraft-Schwellenwert
Rasterize svg groups deeper than SVG-Gruppen ab dieser Tiefe rastern
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 schaltet diese Schwelle aus; SVG-Gruppen, die tiefer als 100 Ebenen verschachtelt sind, werden immer gerastert.
Rule of Thirds Drittel-Regel
Triangle Dreieck
Golden Ratio Goldener Schnitt

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3428,6 +3428,9 @@ fontWeight Thin Πολύ λεπτά
Color to Alpha… Χρώμα σε άλφα…
Transparency Threshold Κατώφλι διαφάνειας
Opacity Threshold Κατώφλι αδιαφάνειας
Rasterize svg groups deeper than Ραστεροποίηση ομάδων SVG πέρα από αυτό το βάθος
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. Το 0 απενεργοποιεί αυτό το όριο· ομάδες SVG με ένθεση βαθύτερη από 100 επίπεδα ραστεροποιούνται πάντα.
Rule of Thirds Κανόνας των τρίτων
Golden Ratio Χρυσή τομή
Golden Spiral Χρυσή σπείρα

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -2385,6 +2385,9 @@ fontWeight Italic Cursiva
Color to Alpha… Color a alfa…
Transparency Threshold Umbral de transparencia
Opacity Threshold Umbral de opacidad
Rasterize svg groups deeper than Rasterizar grupos SVG con mayor profundidad
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 desactiva este umbral; los grupos SVG anidados a más de 100 niveles siempre se rasterizan.
Rule of Thirds Regla de los tercios
Triangle Triángulo
Golden Ratio Proporción áurea

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2738,6 +2738,9 @@ fontWeight Italic Italique
Color to Alpha… Couleur vers alpha…
Transparency Threshold Seuil de transparence
Opacity Threshold Seuil d'opacité
Rasterize svg groups deeper than Pixelliser les groupes SVG au-delà de cette profondeur
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 désactive ce seuil ; les groupes SVG imbriqués sur plus de 100 niveaux sont toujours pixellisés.
Rule of Thirds Règle des tiers
Triangle Triangle
Golden Ratio Nombre d’or

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2725,6 +2725,9 @@ fontWeight Italic Miring
Color to Alpha… Warna ke Alpha…
Transparency Threshold Ambang transparansi
Opacity Threshold Ambang opasitas
Rasterize svg groups deeper than Rasterisasi grup SVG yang lebih dalam dari ini
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 menonaktifkan ambang ini; grup SVG yang bersarang lebih dari 100 tingkat selalu dirasterisasi.
Rule of Thirds Aturan Sepertiga
Triangle Segitiga
Golden Ratio Rasio Emas

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2387,6 +2387,9 @@ fontWeight Italic Corsivo
Color to Alpha… Colore ad alfa…
Transparency Threshold Soglia di trasparenza
Opacity Threshold Soglia di opacità
Rasterize svg groups deeper than Rasterizza i gruppi SVG oltre questa profondità
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 disattiva questa soglia; i gruppi SVG annidati oltre 100 livelli vengono sempre rasterizzati.
Rule of Thirds Regola dei terzi
Triangle Triangolo
Golden Ratio Sezione aurea

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -3342,6 +3342,9 @@ fontWeight Italic 斜体
Color to Alpha… カラーを透明に…
Transparency Threshold 透明のしきい値
Opacity Threshold 不透明度のしきい値
Rasterize svg groups deeper than この深さを超える SVG グループをラスタライズ
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 でこのしきい値は無効になります。100 階層を超えて入れ子になった SVG グループは常にラスタライズされます。
Rule of Thirds 三分割法
Golden Ratio 黄金比
Golden Spiral 黄金螺旋

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2897,6 +2897,9 @@ fontWeight Italic 기울임
Color to Alpha… 색상을 알파로…
Transparency Threshold 투명도 한계값
Opacity Threshold 불투명도 한계값
Rasterize svg groups deeper than 이 깊이보다 깊은 SVG 그룹 래스터화
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 임계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다.
Rule of Thirds 삼등분 법칙
Golden Ratio 황금 비율
Golden Spiral 황금 나선

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -3503,6 +3503,9 @@ cameraRaw Whites Witten
Dual Axis Dubbele as
Diagonal Diagonaal
Transform Symmetry Symmetrie transformeren
Rasterize svg groups deeper than SVG-groepen rasteriseren dieper dan deze grens
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 schakelt deze drempel uit; SVG-groepen die dieper dan 100 niveaus zijn genest, worden altijd gerasterd.
Rule of Thirds Regel van derden
Golden Ratio Gulden snede
Golden Spiral Gulden spiraal

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -3341,6 +3341,9 @@ cameraRaw Roughness Chropowatość
Color to Alpha… Kolor na alfę…
Transparency Threshold Próg przezroczystości
Opacity Threshold Próg krycia
Rasterize svg groups deeper than Rasteryzuj grupy SVG głębsze niż ten poziom
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 wyłącza ten próg; grupy SVG zagnieżdżone głębiej niż 100 poziomów są zawsze rasteryzowane.
Rule of Thirds Zasada trójpodziału
Golden Ratio Złoty podział
Golden Spiral Złota spirala

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2746,6 +2746,9 @@ fontWeight Italic Itálico
Color to Alpha… Cor para alfa…
Transparency Threshold Limiar de transparência
Opacity Threshold Limiar de opacidade
Rasterize svg groups deeper than Rasterizar grupos SVG além desta profundidade
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 desativa este limite; grupos SVG aninhados em mais de 100 níveis são sempre rasterizados.
Rule of Thirds Regra dos terços
Triangle Triângulo
Golden Ratio Proporção áurea

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2591,6 +2591,9 @@ fontWeight Italic Курсив
Color to Alpha… Цвет в альфа-канал…
Transparency Threshold Порог прозрачности
Opacity Threshold Порог непрозрачности
Rasterize svg groups deeper than Растеризовать группы SVG глубже этого уровня
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 отключает этот порог; группы SVG с вложенностью более 100 уровней всегда растрируются.
Rule of Thirds Правило третей
Triangle Треугольник
Golden Ratio Золотое сечение

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3428,6 +3428,9 @@ cameraRaw Masking Маскування
Dual Axis Подвійна вісь
Diagonal Діагональна
Transform Symmetry Трансформувати симетрію
Rasterize svg groups deeper than Растеризувати групи SVG глибше цього рівня
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 вимикає цей поріг; групи SVG із вкладеністю понад 100 рівнів завжди растеризуються.
Rule of Thirds Правило третин
Golden Ratio Золотий перетин
Golden Spiral Золота спіраль

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -2325,6 +2325,9 @@ fontWeight Italic 斜体
Color to Alpha… 颜色转为 Alpha…
Transparency Threshold 透明度阈值
Opacity Threshold 不透明度阈值
Rasterize svg groups deeper than 栅格化超过此深度的 SVG 组
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 设为 0 可关闭此阈值;嵌套超过 100 层的 SVG 组始终会被栅格化。
Rule of Thirds 三等分
Triangle 三角形
Golden Ratio 黄金比例

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2294,6 +2294,9 @@ fontWeight Italic 斜體
Color to Alpha… 顏色轉為 Alpha…
Transparency Threshold 透明度臨界值
Opacity Threshold 不透明度臨界值
Rasterize svg groups deeper than 點陣化超過此深度的 SVG 群組
0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 設為 0 可關閉此臨界值;巢狀超過 100 層的 SVG 群組一律會點陣化。
Rule of Thirds 三等分
Triangle 三角形
Golden Ratio 黃金比例

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -183,7 +183,7 @@ pub use file_open::OsEvent;
pub use state::{Tool, UiState};
/// Decode a file: (document, warnings about anything approximated or dropped).
pub type ImportFn = Box<dyn Fn(&str, &[u8]) -> Result<(Document, Vec<String>), String>>;
pub type ImportFn = Box<dyn Fn(&str, &[u8], usize) -> Result<(Document, Vec<String>), String>>;
/// Encoder settings chosen in Export As (the file format comes from the name's extension).
#[derive(Clone, Debug, PartialEq)]
pub struct ExportSettings {
@ -939,7 +939,8 @@ impl PhotocraftApp {
return Ok(Vec::new());
}
let import = self.services.import.as_ref().ok_or("no importer configured")?;
let (doc, warnings) = import(name, bytes)?;
let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
let (doc, warnings) = import(name, bytes, max_svg_group_depth)?;
// Edit › Color Settings policies apply on open; mismatches can ask what to do.
// No path yet: a bare name isn't a location to save back to (`open_file` sets the path).
let (_, color) = self.session.open_document(doc, None);
@ -988,7 +989,8 @@ impl PhotocraftApp {
/// events and no Color Settings policy (which may read user-configured profile paths).
fn import_automation_document(&mut self, name: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
let import = self.services.import.as_ref().ok_or("no importer configured")?;
let (doc, warnings) = import(name, bytes)?;
let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
let (doc, warnings) = import(name, bytes, max_svg_group_depth)?;
// The caller records the path it read from.
self.session.add_document(doc, None);
if let Some(st) = self.session.active_mut() {

View File

@ -1694,7 +1694,9 @@ mod open_recent_tests {
use photocraft_color::{ColorMode, SampleType};
use photocraft_geom::Size;
let services = crate::Services {
import: Some(Box::new(|_n: &str, _b: &[u8]| Ok((photocraft_doc::Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())))),
import: Some(Box::new(|_n: &str, _b: &[u8], _depth: usize| {
Ok((photocraft_doc::Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new()))
})),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);

View File

@ -1017,6 +1017,14 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map<String, Value>, system: Option<egui
ui.label(RichText::new(tl!("These settings aren't available in PhotoCraft yet.")).color(t.text_faint));
} else if let Some(obj) = values.get_mut(&section).and_then(Value::as_object_mut) {
section_fields(ui, &section, obj, &order, lang, system);
if section == "fileHandling" {
ui.label(
RichText::new(tl!(
"0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized."
))
.color(t.text_faint),
);
}
if section == "performance" {
gpu_status_rows(ui, f.get("__gpuInfo"), obj);
}

View File

@ -9,7 +9,9 @@ fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(
Session::new(),
Services {
import: Some(Box::new(|name, bytes| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
import: Some(Box::new(|name, bytes, max_svg_group_depth| {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc, path, _| {
photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),

View File

@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 19 | 13 | 7 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 146 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 147 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 0 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present |
@ -172,7 +172,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 46 of 146** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 46 of 147** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.