diff --git a/ROADMAP.md b/ROADMAP.md index ea46b5c87..0ee1849f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change. - **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). - **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). @@ -524,6 +525,7 @@ Newest first. - **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. - **2026-10-17** · Cross-references keep text typed over their generated text (IDML import and export), with the page number still live, as InDesign shows them until updated; a cross-reference whose destination is gone shows its saved text and page instead of "??"; and a cross-reference's text breaks at its spaces instead of inside words. No readiness percentage change. +- **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. - **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b19833ff1..227dad654 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -581,8 +581,10 @@ pub struct FrameSpec { pub page_name: Option, /// Marker text of the section the frame's page is in (None on parent pages). pub section_marker: Option, - /// Absolute document page the frame is on (None on parent pages). + /// Absolute document page the frame is on (None on parent pages and the pasteboard). pub page: Option, + /// The frame is on a parent spread. + pub parent: bool, /// Baseline grid in inner space: (first grid line y, increment). pub grid: Option<(f64, f64)>, /// The frame is on a left page (for towards/away-from-spine alignment). @@ -817,6 +819,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId page_name, section_marker, page, + parent: matches!(spread_ref, Some(designcraft_doc::SpreadRef::Parent(_))), grid, left_page, right_page, @@ -862,7 +865,7 @@ struct SubstitutionInput<'a> { frame: Option<&'a FrameSpec>, opts: &'a ComposeOptions, var_story: bool, - var_cache: &'a mut HashMap, Arc>>, + var_cache: &'a mut HashMap<(Option, bool), Arc>>, notes: &'a mut Notes, } @@ -870,7 +873,9 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input; let var_values = if var_story { let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); - var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone() + // A parent page itself (not composed for one of the pages it applies to). + let parent = page.is_none() && cur_frame.is_some_and(|f| f.parent); + var_cache.entry((page, parent)).or_insert_with(|| std::sync::Arc::new(vars::values_shown(doc, page, parent, opts.running.as_deref()))).clone() } else { Default::default() }; @@ -2681,7 +2686,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let var_story = vars::has_vars(story); let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); - let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + let mut var_cache: std::collections::HashMap<(Option, bool), std::sync::Arc>> = Default::default(); // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search // in progress, and a budget for the re-lays. let mut limits: Rc> = Rc::default(); @@ -5977,6 +5982,7 @@ mod column_allocation_safety_tests { page_name: None, section_marker: None, page: None, + parent: false, grid: None, left_page: false, right_page: false, diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a385a9093..1e1c852a5 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -176,6 +176,7 @@ impl Notes { page_name: f.page_name.clone(), section_marker: f.section_marker.clone(), page: f.page, + parent: f.parent, grid: None, left_page: f.left_page, right_page: f.right_page, diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index dc579d8a7..a124b1fce 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -132,6 +132,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef page_name: opts.page_name.clone(), section_marker: opts.section_marker.clone(), page: opts.page, + parent: false, grid: None, left_page, right_page, diff --git a/crates/compose/src/vars.rs b/crates/compose/src/vars.rs index 508ac4a3e..bd9e32ccd 100644 --- a/crates/compose/src/vars.rs +++ b/crates/compose/src/vars.rs @@ -160,10 +160,18 @@ impl RunningIndex { /// Values of every variable on `page`. pub fn values(doc: &Document, page: Option, running: Option<&RunningIndex>) -> Vec { + values_shown(doc, page, false, running) +} + +/// [`values`] as text shows them: on a parent page itself (`parent`, with no `page`), a running +/// header has no entries of its own and shows which variable sits there, ``, as InDesign +/// does. Elsewhere without a page (the pasteboard), it is its text before and after. +pub fn values_shown(doc: &Document, page: Option, parent: bool, running: Option<&RunningIndex>) -> Vec { doc.text_variables .iter() .enumerate() .map(|(i, v)| match &v.kind { + VarKind::RunningHeader { .. } if parent && page.is_none() => format!("<{}>", v.name), VarKind::RunningHeader { style, use_, character } => { let body = match (running, page) { (Some(r), Some(p)) => r.value(style, *character, *use_, p), diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 75c4bcaed..9093f31dd 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -1,5 +1,10 @@ use super::*; +fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize { + let cs = s.cache.get(d, sid, None); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() +} + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's /// start number renames its pages without changing their index or the number of sections, and /// the marker composed for a page shows the new name. @@ -350,6 +355,14 @@ fn running_header_on_parent_page_follows_headings() { assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); let list = s.execute("variables.list", &json!({})).unwrap(); assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // On the parent page itself the header shows which variable it holds, not a blank. Text with + // no page that isn't on a parent shows the variable's text before and after, as before. + let head = d.variable("Chapter Head").unwrap(); + assert_eq!(crate::compose::vars::values_shown(&d, None, true, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values_shown(&d, None, false, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], "", "values() is the no-parent case"); + assert_eq!(crate::compose::vars::values_shown(&d, Some(2), true, Some(&idx))[head], "Epsilon", "composed for a page: its entry"); + assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows , longer than Alpha"); // Deleting a variable flattens its instances to text. s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 44e35ef15..bb75c102e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -83,6 +83,7 @@ impl DesignApp { let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } if id == "colorTheme" => dialogs::open_color_theme(self, params), + UiRequest::Dialog { id, params } if id == "frameSize" => self.ui.dialog = Some(dialogs::frame_size_dialog(self, params)), UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), UiRequest::Pick { purpose, params } => { diff --git a/crates/ui-egui/src/canvas/frames.rs b/crates/ui-egui/src/canvas/frames.rs index a6a03bf6a..718904d7f 100644 --- a/crates/ui-egui/src/canvas/frames.rs +++ b/crates/ui-egui/src/canvas/frames.rs @@ -10,7 +10,10 @@ use super::{ /// Spread-space → canvas transform for an item at `loc`. pub(super) fn item_canvas_xf(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) -> Option<(Affine, &'static str)> { let loc = doc.find(id)?; - Some((layout.xf(loc.spread) * doc.parent_xf(&loc), "")) + // Items on spreads that aren't laid out (document pages while parents are being edited, or + // the reverse) have no place on the canvas. + let slot = layout.slot(loc.spread)?; + Some((slot.xf * doc.parent_xf(&loc), "")) } pub(super) fn path_screen(it: &Item, xf: &Xf, a: Affine) -> Vec> { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index e137a8a03..2b1082b7a 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1004,6 +1004,31 @@ fn overset_frames_are_found_inside_groups() { assert!(out.is_empty()); } +/// While parents are edited, a document page's frames have no place on the canvas (they fell back +/// to the identity transform and their ¶ marks and other overlays piled onto the right parent +/// page), and the reverse. +#[test] +fn document_pages_have_no_place_on_the_parent_canvas() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2})).unwrap(); + let s = &mut app.session; + let body = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "one\ntwo"})).unwrap()["id"].as_u64().unwrap(); + let head = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [36, 10, 300, 30], "content": "text", "text": "head"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (body, head) = (designcraft_doc::ItemId(body), designcraft_doc::ItemId(head)); + let pages = CanvasLayout::new(&doc, false); + assert!(item_canvas_xf(&doc, &pages, body).is_some()); + assert!(item_canvas_xf(&doc, &pages, head).is_none()); + let parents = CanvasLayout::new(&doc, true); + assert!(item_canvas_xf(&doc, &parents, body).is_none(), "a page frame is not drawn over the parent"); + assert!(item_canvas_xf(&doc, &parents, head).is_some()); +} + /// The always-on overset mark sits at the frame's out port, a step along its own right edge /// (so a rotated frame gets it inside its edge, not straight up from the corner), and a /// selected overset frame gets only the mark its ports draw. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e4c6fe723..97a99f925 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -389,7 +389,8 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { app.session.reset_find_scope(); } - let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); + let fields = p.get("fields").cloned().unwrap_or(Value::Null); + let d = if id == "frameSize" { crate::dialogs::frame_size_dialog(app, fields) } else { crate::dialogs::Dialog::new(id, fields) }; let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); ok(v) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f8532d9cc..b8e149eb3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,6 +22,10 @@ pub struct Dialog { /// Set from the active document when the dialog is shown or confirmed (picas without one). #[serde(skip)] pub unit: Unit, + /// The vertical ruler unit, for a dialog with a height field (the Rectangle dialog): kept + /// from when it opened, so a bare `50` means what it did then. `None` falls back to `unit`. + #[serde(skip)] + pub vertical_unit: Option, } impl Dialog { @@ -81,14 +85,7 @@ impl Dialog { if id == "ruby" { fields.entry("section".to_string()).or_insert(json!("cjk.rubyPlacement")); } - if id == "frameSize" { - for k in ["width", "height"] { - if let Some(v) = fields.get(k).and_then(Value::as_f64) { - fields.insert(k.into(), json!(format_measure(v, Unit::Picas))); - } - } - } - Dialog { id: id.into(), fields, unit: Unit::Picas } + Dialog { id: id.into(), fields, unit: Unit::Picas, vertical_unit: None } } /// Set a field and report a genuine Find domain/mode transition to the caller. pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { @@ -108,9 +105,13 @@ impl Dialog { } } fn m(&self, k: &str) -> Option { + self.m_in(k, self.unit) + } + /// A measure where a bare number is in `unit`. + fn m_in(&self, k: &str, unit: Unit) -> Option { match self.fields.get(k) { Some(Value::Number(n)) => n.as_f64(), - Some(Value::String(s)) => parse_measure(s, self.unit).ok(), + Some(Value::String(s)) => parse_measure(s, unit).ok(), _ => None, } } @@ -1503,6 +1504,26 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } +/// The document's vertical ruler unit: what a bare number means in a height field. +fn doc_vertical_unit(app: &DesignApp) -> Unit { + app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) +} + +/// The Rectangle dialog a click with a frame tool opens: the width (points in `params`) shown in +/// the horizontal ruler unit, the height in the vertical one, as Preferences › Units & +/// Increments and the rulers' menus set them (#1964). +pub fn frame_size_dialog(app: &DesignApp, params: Value) -> Dialog { + let mut d = Dialog::new("frameSize", params); + for (k, unit) in [("width", doc_unit(app)), ("height", doc_vertical_unit(app))] { + if let Some(v) = d.fields.get(k).and_then(Value::as_f64) { + d.fields.insert(k.into(), json!(format_measure(v, unit))); + } + } + d.unit = doc_unit(app); + d.vertical_unit = Some(doc_vertical_unit(app)); + d +} + /// Layout › Create Guides…: guides go on every page of the spread in view (or of the parent /// spread being edited), as InDesign's do; "This page" narrows them to /// the page in view. The gutters are typed in the document's ruler unit. @@ -4056,8 +4077,12 @@ fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { d.fields.remove(FORMAT_EDIT); return; }; - let mut fd = - Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; + let mut fd = Dialog { + id: "changeFormat".into(), + fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), + unit: d.unit, + vertical_unit: None, + }; let base = json!({}); let cf = CharFields { base: &base, sparse: true }; let find = fd.s("for") == "find"; @@ -4220,7 +4245,8 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0); let w = d.m("width").unwrap_or(72.0); - let h = d.m("height").unwrap_or(72.0); + // In the units the dialog showed, not ones changed while it was open. + let h = d.m_in("height", d.vertical_unit.unwrap_or(d.unit)).unwrap_or(72.0); app.run( "frame.create", json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}), diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index b253b1bc9..b77774570 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -4814,3 +4814,86 @@ fn convert_dialogs_use_their_separators() { // (The empty paragraph after a table that ends its story stays after the text.) assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:"); } + +/// The Rectangle dialog a frame-tool click opens shows the width in the horizontal ruler unit and +/// the height in the vertical one, not always picas, and reads bare numbers the same way (#1964). +#[test] +fn frame_size_dialog_follows_the_ruler_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap(); + // As the frame tool asks: a click at (36, 36), 72 pt by 72 pt. + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_ref().expect("the Rectangle dialog"); + assert_eq!((d.id.as_str(), d.unit), ("frameSize", Unit::Inches)); + assert_eq!(d.s("width"), format_measure(72.0, Unit::Inches)); + assert_eq!(d.s("height"), format_measure(72.0, Unit::Millimeters)); + assert!(!d.s("width").contains('p') && !d.s("height").contains('p'), "no picas: {} x {}", d.s("width"), d.s("height")); + // Bare numbers typed in each field are in that field's unit: 2 in by 50 mm. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("2")); + d.fields.insert("height".into(), json!("50")); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 50.0 / 25.4 * 72.0).abs() < 0.01, "{b:?}"); + // The properties panel's link opens it in the same units. + let d = frame_size_dialog(&app, json!({"width": 36.0, "height": 36.0})); + assert_eq!((d.s("width"), d.s("height")), (format_measure(36.0, Unit::Inches), format_measure(36.0, Unit::Millimeters))); + // Every ruler unit: the shown size reads back as the size, so OK leaves a 72 pt frame 72 pt. + let units = serde_json::to_value(Unit::ALL).unwrap(); + for (unit, name) in Unit::ALL.iter().zip(units.as_array().unwrap()) { + app.run("document.preferences", json!({"horizontalUnits": name, "verticalUnits": name})).unwrap(); + let d = frame_size_dialog(&app, json!({"width": 72.0, "height": 145.0})); + assert_eq!(d.unit, *unit); + for (k, pt) in [("width", 72.0), ("height", 145.0)] { + let back = d.m_in(k, *unit).unwrap_or(f64::NAN); + assert!((back - pt).abs() < 0.05, "{name}: {k} shows {:?}, reads back {back}", d.s(k)); + } + } +} + +/// #2055 review: the Rectangle dialog reads the height in the vertical unit it showed when it +/// opened. Rulers switched while it is open (the control channel) leave a typed 50 at 50 mm. +#[test] +fn frame_size_dialog_keeps_its_units_while_open() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "millimeters", "verticalUnits": "millimeters"})).unwrap(); + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_mut().expect("the Rectangle dialog"); + d.fields.insert("width".into(), json!("50")); + d.fields.insert("height".into(), json!("50")); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "inches"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + let mm50 = 50.0 / 25.4 * 72.0; + assert!((b.width() - mm50).abs() < 0.01 && (b.height() - mm50).abs() < 0.01, "50 mm by 50 mm (about 141.73 pt): {b:?}"); + // A dialog made without the vertical unit (`Dialog::new`) reads the height in its own unit. + let mut d = Dialog::new("frameSize", json!({"width": "2", "height": "3"})); + d.unit = Unit::Inches; + assert_eq!(d.vertical_unit, None); + app.ui.dialog = Some(d); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 216.0).abs() < 0.01, "{b:?}"); +}