diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index f3f159e53..fecdc9bac 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -75,6 +75,7 @@ impl Document { dimensions, retired_artboard_id: _, opened_artboard: _, + units_from_file: _, index: _, } = self; *version == other.version @@ -257,6 +258,7 @@ impl Document { dimensions: false, retired_artboard_id: Default::default(), opened_artboard: None, + units_from_file: false, index: Default::default(), }; let id = d.alloc_id(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5735d9b48..1f555b74e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -339,6 +339,11 @@ pub struct Document { /// names it as the file's active artboard. `None`: the first. Native files do not keep it. #[serde(skip)] pub opened_artboard: Option, + /// The file this document was read from recorded its units (a `.ai` file's + /// `%AI5_RulerUnits`): it opens in them. A file that records none opens in Preferences › + /// Units › General (#5820). Native files do not keep it: they always keep their units. + #[serde(skip)] + pub units_from_file: bool, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this /// is set, so documents without any pay nothing for them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fd40139cf..1c1989bb9 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -43,6 +43,22 @@ impl Loaded { } self } + + /// The file in the units of Preferences › Units › General when it records none of its own: a + /// PDF, an EPS or `.ai` without ruler units, a raster image, a metafile, an Affinity file or + /// what an importer plug-in reads, as the reference app opens such files (#5820). A native + /// document (also one that an SVG, PDF, `.ai` or EPS file carries, `restored`), an SVG (the + /// units of its root's size, pixels when it gives none), a DXF drawing (the unit it was read + /// in) and a `.ai` or EPS with ruler units keep theirs. Only the units the document shows + /// change, not its art. + pub(crate) fn with_unit_prefs(mut self, prefs: &Prefs) -> Self { + let records_units = + self.restored || self.doc.units_from_file || matches!(self.format.id, "vectorcraft" | "template" | "svg" | "svgz" | "dxf"); + if !records_units { + self.doc.units = crate::units::pref_unit(&prefs.units_general); + } + self + } } /// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG @@ -610,7 +626,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = + loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6cc93ede1..9f4e5b057 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1051,7 +1051,7 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; diff --git a/crates/engine/src/tests/units.rs b/crates/engine/src/tests/units.rs index e29365ec9..6c859b727 100644 --- a/crates/engine/src/tests/units.rs +++ b/crates/engine/src/tests/units.rs @@ -199,3 +199,78 @@ fn east_asian_type_unit_leaves_the_document_in_points() { assert!((lead.unwrap() - Unit::Ha.to_pt(20.0)).abs() < 1e-9, "{lead:?}"); assert!((sz - 17.0 * 72.0 / 101.6).abs() < 1e-9); } + +/// A session whose Preferences › Units › General is `unit`, with no document open. +fn general_in(unit: &str) -> Session { + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": unit})).unwrap(); + s +} + +/// `bytes` opened as `name` in `s` → the opened document's units. +fn opened_units(s: &mut Session, name: &str, bytes: &[u8]) -> Unit { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + units(s) +} + +/// The active document of a session in `unit`, exported as `format`. +fn exported(unit: &str, format: &str) -> Vec { + let mut s = session(); + s.execute("document.setUnits", &json!({"units": unit})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 30})).unwrap(); + let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone(); + vectorcraft_format::base64_decode(data.as_str().unwrap()).unwrap() +} + +/// A PostScript `.ai` with one empty layer and `header` among its comments. +fn postscript_ai(header: &str) -> Vec { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n{header}%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 A\n0 Xw\n\nLB\n%AI5_EndLayer--\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n" + ) + .into_bytes() +} + +#[test] +fn files_that_record_no_units_open_in_the_general_unit() { + let pdf = vectorcraft_testkit::pdf::pdf(&[vectorcraft_testkit::pdf::PdfPage::new(120.0, 60.0, "0 0 0 rg 10 10 50 30 re f")], None); + let png = exported("Inches", "png"); + let ai = postscript_ai(""); + let mut s = general_in("Millimeters"); + assert_eq!(opened_units(&mut s, "plain.pdf", &pdf), Unit::Millimeters, "a PDF records no units (#5820)"); + assert_eq!(opened_units(&mut s, "image.png", &png), Unit::Millimeters, "nor does an image"); + assert_eq!(opened_units(&mut s, "old.ai", &ai), Unit::Millimeters, "nor a .ai without ruler units"); + // Only the units change: the page keeps its size in points. + s.execute("document.open", &json!({"name": "plain.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + let board = s.doc().unwrap().doc.artboards[0].rect; + assert!((board.width() - 120.0).abs() < 1e-6 && (board.height() - 60.0).abs() < 1e-6, "{board:?}"); + // With the preference left at points, they open in points as before. + let mut p = general_in("Points"); + assert_eq!(opened_units(&mut p, "plain.pdf", &pdf), Unit::Points); +} + +#[test] +fn files_that_record_units_keep_them_whatever_the_general_unit() { + let mut s = general_in("Millimeters"); + assert_eq!(opened_units(&mut s, "inches.ai", &postscript_ai("%AI5_RulerUnits: 0\n")), Unit::Inches, "a .ai's ruler units"); + assert_eq!(opened_units(&mut s, "points.ai", &postscript_ai("%AI5_RulerUnits: 2\n")), Unit::Points, "ruler units in points are kept too"); + assert_eq!(opened_units(&mut s, "saved.vectorcraft", &exported("Inches", "vectorcraft")), Unit::Inches, "a native file"); + let svg = r#""#; + assert_eq!(opened_units(&mut s, "inches.svg", svg.as_bytes()), Unit::Inches, "an SVG's root size"); +} + +#[test] +fn revert_reopens_a_file_without_units_in_the_general_unit() { + let dir = std::env::temp_dir().join(format!("vc-5820-revert-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plain.pdf"); + std::fs::write(&path, vectorcraft_testkit::pdf::pdf(&[vectorcraft_testkit::pdf::PdfPage::new(120.0, 60.0, "0 0 0 rg 10 10 50 30 re f")], None)) + .unwrap(); + let mut s = general_in("Millimeters"); + s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(units(&s), Unit::Millimeters); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + s.execute("document.setUnits", &json!({"units": "Inches"})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(units(&s), Unit::Millimeters, "as the file opens"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index edad5c532..26fd9ba14 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -44,7 +44,7 @@ impl Measure { } /// The unit a Units preference value names (points when it names none). -fn pref_unit(value: &str) -> Unit { +pub(crate) fn pref_unit(value: &str) -> Unit { Unit::named(value).unwrap_or_default() } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 0e1ca6767..eb3d73c69 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -648,6 +648,10 @@ impl<'a> Reader<'a> { if self.author.is_none() { self.author = outer.author; } + // Ruler units the editing data doesn't give: the file still records them (#5820). + if self.ruler_units.is_none() { + self.ruler_units = outer.ruler_units; + } self.names.absorb(outer.names); for n in outer.custom_colors { if self.custom_colors.len() < super::directives::MAX_CUSTOM_COLORS && !self.custom_colors.iter().any(|c| c.name == n.name) { @@ -1255,6 +1259,7 @@ impl<'a> Reader<'a> { } if let Some(u) = self.ruler_units.and_then(ruler_unit) { doc.units = u; + doc.units_from_file = true; } // The file's own swatches in place of the defaults, in its order and colour groups. let own_palette = match self.palette_swatches(&doc.patterns) { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 35ccdb5bd..2b7b421bb 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -77,6 +77,26 @@ fn names(d: &Document) -> Vec { d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() } +#[test] +fn ruler_units_in_the_program_header_alone_are_kept() { + let art = layer("Art", true, &square(10, 10)); + let native = |units: &str| { + format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{units}%AI5_NumLayers: 1\n{art}%%Trailer\n" + ) + }; + let with_header = |data: Vec| { + let at = data.windows(13).position(|w| w == b"%%EndComments").unwrap(); + [&data[..at], &b"%AI5_RulerUnits: 0\n"[..], &data[at..]].concat() + }; + let r = import(&with_header(eps(&page_square(10, 10), &native("")))).unwrap(); + assert_eq!((r.document.units, r.document.units_from_file), (Unit::Inches, true), "the program's header gives them"); + let r = import(&with_header(eps(&page_square(10, 10), &native("%AI5_RulerUnits: 1\n")))).unwrap(); + assert_eq!(r.document.units, Unit::Millimeters, "the editing data's own come first"); + let r = import(&eps(&page_square(10, 10), &native(""))).unwrap(); + assert_eq!((r.document.units, r.document.units_from_file), (Unit::Points, false), "none recorded"); +} + #[test] fn native_import_keeps_ruler_units_from_editing_data() { let art = layer("Art", true, &square(10, 10)); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d1b54ef4d..38297994f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -282,7 +282,7 @@ tools, never checked side by side) isn't captured row by row. | Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | -| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) | +| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864). A file that records no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files) opens and reverts in General units; native, SVG and DXF files and a `.ai` or EPS with ruler units keep theirs, as a [Community Expert's answer](https://community.adobe.com/questions-652/change-units-from-points-to-inches-won-t-stick-765372) describes (#5820) | | Units › East Asian Type (Q and Ha) | done | #394: `unitsAsianType` offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide's Measurement Units](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`); only it lists Q and Ha. The public pages do not say which fields use it, so no field uses it yet and that waits for a source. Tests `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | Field precision: 3 decimals for steps; values kept as f64 | done | | | Scrubby labels (beyond Illustrator) | done | #400; the labels that are links (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke) scrub too, and a click on them still opens their panel (#4088) | @@ -507,6 +507,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Files that record no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files, importer plug-ins) open and revert in Preferences › Units › General instead of points; native, SVG and DXF files and `.ai`/EPS ruler units are kept (#5820). Scores and estimates unchanged. | | 2026-11-10 | minor | Held numeric spinner arrows keep stepping past the click timeout and stop on release (#6307). Scores and estimates unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |