From f5daa27a2fccc80d7223276a6e47a89e533692c6 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:56:19 +0530 Subject: [PATCH 1/2] =?UTF-8?q?#5820:=20files=20that=20record=20no=20units?= =?UTF-8?q?=20open=20in=20Preferences=20=E2=80=BA=20Units=20=E2=80=BA=20Ge?= =?UTF-8?q?neral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF, an EPS or .ai without ruler units, a raster image, a metafile, an Affinity file or an importer plug-in's document opened (and reverted) in points whatever the General unit was. They now open in the General unit, as the reference app opens files that record no unit. Native, SVG and DXF files and a .ai or EPS with %AI5_RulerUnits keep their own units. Only the displayed units change, not the art. --- crates/doc/src/document.rs | 2 + crates/doc/src/lib.rs | 5 ++ crates/engine/src/cmd/fileio/load.rs | 19 +++++- crates/engine/src/cmd/fileio/save.rs | 2 +- crates/engine/src/tests/units.rs | 75 +++++++++++++++++++++++ crates/engine/src/units.rs | 2 +- crates/eps/src/import/ai/read/document.rs | 1 + docs/ui-parity.md | 5 +- 8 files changed, 106 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2a2f8a8d7..0d5163bac 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 @@ -256,6 +257,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 63e9f3df7..3cf057abf 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1253,6 +1253,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/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. | From 52918add2d1d94adb43ad1b662b426ad85b4f254 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:12:00 +0530 Subject: [PATCH 2/2] #5820: keep ruler units the EPS program header gives when the editing data has none --- crates/eps/src/import/ai/read/document.rs | 4 ++++ crates/eps/src/import/tests_native.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 3cf057abf..e9bca3646 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) { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 867d36d1a..4adb04e8b 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));