From 265f10b91dc59fa8715d1f0dcdac730a228ff19a Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 10 Oct 2026 03:58:08 -0400 Subject: [PATCH] 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 Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 7 ++- apps/photocraft/src/services.rs | 23 +++++++--- crates/engine/src/file_cmds.rs | 9 +++- crates/engine/src/jobs.rs | 10 +++-- crates/engine/src/prefs.rs | 4 ++ crates/engine/src/prefs/tests.rs | 13 ++++++ crates/io/src/lib.rs | 52 ++++++++++++++++++++--- crates/io/src/svg.rs | 12 +++++- crates/io/tests/svg.rs | 18 ++++++++ crates/ui-egui/examples/snapshot.rs | 4 +- crates/ui-egui/src/camera_raw_ui.rs | 4 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/file_open/tests.rs | 12 +++++- crates/ui-egui/src/i18n/cs.tsv | 3 ++ crates/ui-egui/src/i18n/de.tsv | 3 ++ crates/ui-egui/src/i18n/el.tsv | 3 ++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/id.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/ko.tsv | 3 ++ crates/ui-egui/src/i18n/nl.tsv | 3 ++ crates/ui-egui/src/i18n/pl.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/lib.rs | 8 ++-- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/prefs_ui.rs | 8 ++++ crates/ui-egui/src/save_identity_tests.rs | 4 +- docs/scorecard.md | 4 +- 34 files changed, 216 insertions(+), 30 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index fa787224..af0ea95d 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -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 { diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 77df5944..373be0b4 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -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 { 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)> { + 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::>())) } 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) -> }) 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] diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index f4ccae1b..d28efc04 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -397,7 +397,14 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option (k / 100.0).max(1e-4), diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index bc39fa4f..5838b4eb 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -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); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 25561562..9e1ece11 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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, @@ -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), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 4ad258ff..b4a02ea7 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -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(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 7d108c08..66072f3b 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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 { - 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 { + 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 { + 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 { 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 { +fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, max_group_depth: usize) -> Result { // 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, 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"")); + assert!(is_svg_input("drawing.svgz", &[0x1f, 0x8b, 0, 0])); + assert!(!is_svg_input("drawing.svgz", webp_header)); + } +} diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 693b725a..4d73b99e 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -184,13 +184,20 @@ fn canvas(tree: &usvg::Tree, warnings: &mut Vec) -> 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 { + 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 { 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, warnings: Vec, /// 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 { diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index 1784b8a5..adbdf08a 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -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!( + "" + ); + 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!( diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 52e5df76..c9ab5177 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -39,7 +39,9 @@ fn main() { arg(&args, "--script").map(|s| serde_json::from_str::>(&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 { diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 627a4df5..798b5ffe 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -1263,7 +1263,9 @@ mod tests { fn raw_app() -> (PhotocraftApp, Vec) { 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); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5f01971f..b58705db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1645,7 +1645,7 @@ mod tests { let written: Rc>> = 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()])))), diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 8fb2771b..ad3651d6 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -18,7 +18,7 @@ fn app_with(answers: Vec>) -> (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"") + .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); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0e417060..3f8c01ff 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abe4320f..9e3e905c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 321a666f..c1fcef29 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Χρυσή σπείρα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 036aee1e..6b8727a6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a0ff7541..4358225b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9fc08d22..ee411314 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e18a697f..b995e93a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a907fa8c..2841b189 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 黄金螺旋 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1ce9ee1f..62973741 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 황금 나선 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 483dae64..f2d430bc 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 29bca50e..4599d382 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c22fc8fb..6e15c30f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f302989..5eea2a9f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Золотое сечение diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8b638e78..b4eb1420 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Золота спіраль diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 322e36b3..f6b06c8f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 黄金比例 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9b17de49..7668470a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 黃金比例 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af78328a..440da485 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 Result<(Document, Vec), String>>; +pub type ImportFn = Box Result<(Document, Vec), 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, 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() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3f3945c1..569e0e46 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 3e3e7a3b..58d69ada 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1017,6 +1017,14 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option 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()) })), diff --git a/docs/scorecard.md b/docs/scorecard.md index 9dd0fc76..173cdc78 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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`.