From 40cc78d8f8a917dacfd61328caa5125d38bbd367 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Sat, 14 Nov 2026 03:08:26 +0600 Subject: [PATCH 1/2] Text Frame Options: insets and gutter in the ruler units (#2739) (#2754) The Text Frame Options dialog filled its fields from the document's ruler unit but never set the dialog's own unit, so it stayed at picas. The spinner fields (Top, Bottom, Left, Right, a non-rectangular frame's Inset, the column width and gutter, Paragraph Spacing Limit) showed their values in picas whatever Preferences > Units & Increments said, and a bare number typed in any measure field, Min under First Baseline included, was read as picas. The dialog now takes the document's horizontal ruler unit when it opens and keeps it while open, as the Rectangle dialog does (#1964). --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 5 ++- crates/ui-egui/src/dialogs/tests.rs | 56 +++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 961d69281..b22b0e407 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -394,6 +394,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-14** · Object › Text Frame Options shows the inset spacing (one Inset for a non-rectangular frame), the column width and gutter and the paragraph spacing limit in the document's ruler units, as the Properties panel does, instead of always in picas, and a bare number typed in any of its measure fields is in those units (#2739). No readiness percentage change. - **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change. - **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change. - **2026-11-12** · Composing a story with endnotes, anchored objects or cross-references counts their marks in one pass over the story, instead of counting again from the story start for every paragraph. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9ea164597..b4e0fa310 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1165,7 +1165,10 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { return; } let o = selected_frame_options(app).unwrap_or_default(); - let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas); + let units = doc_unit(app); + // The spinner fields show their measures in it and a bare number typed is in it (#2739): + // kept from when the dialog opened, as the Rectangle dialog keeps its units. + d.unit = units; let mut shown = text_frame_fields(&o, units, selected_frame_width(app), &document_grid(app)); // A non-rectangular frame shows one Inset (its top inset, the one kept from the outline). let shaped = selected_frame_is_shaped(app); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0309be7e8..f5c6403e8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1796,6 +1796,62 @@ fn text_frame_options_dims_the_sides_for_a_non_rectangular_frame() { } } +/// Text Frame Options shows the insets (or a non-rectangular frame's one Inset), the gutter and +/// the other measures in the document's ruler unit, not always in picas, and reads a bare number +/// typed there in that unit (#2739). +#[test] +fn text_frame_options_follow_the_ruler_units() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + let mm = |pt: f64| format_measure(pt, Unit::Millimeters); + for shape in ["rectangle", "ellipse"] { + 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(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": shape, "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + app.run("object.textFrameOptions", json!({"ids": [id.0], "columns": 2, "gutter": 18, "inset": [9, 9, 9, 9]})).unwrap(); + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + app.ui.dialog = Some(Dialog::new("textFrameOptions", json!({}))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("text_frame_units_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let value = |h: &Harness<'_, DesignApp>, name: &str| h.get_by_role_and_label(Role::TextInput, name).value().unwrap_or_default(); + let sides: &[&str] = if shape == "ellipse" { &["Inset"] } else { &["Top", "Left", "Bottom", "Right"] }; + for side in sides { + assert_eq!(value(&h, side), mm(9.0), "{shape}: {side}"); + } + assert_eq!(value(&h, "Gutter"), mm(18.0), "{shape}: the gutter"); + assert_eq!(value(&h, "Paragraph Spacing Limit"), mm(0.0), "{shape}: the paragraph spacing limit"); + let width = value(&h, "Width"); + assert!(width.ends_with(" mm"), "{shape}: the column width {width:?} in millimetres"); + // Bare numbers typed in the fields are millimetres. + let mut app = h.into_state(); + let d = app.ui.dialog.as_mut().expect("the dialog stays open"); + assert_eq!(d.unit, Unit::Millimeters, "{shape}"); + let inset_key = if shape == "ellipse" { "inset" } else { "insetTop" }; + for (k, v) in [(inset_key, "5"), ("gutter", "4"), ("firstBaselineMin", "2")] { + d.fields.insert(k.into(), json!(v)); + } + confirm(&mut app).unwrap(); + let o = app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let pt = |n: f64| Unit::Millimeters.to_pt(n); + assert!((o.inset[0] - pt(5.0)).abs() < 1e-6, "{shape}: the inset {:?}", o.inset); + assert!((o.gutter - pt(4.0)).abs() < 1e-6, "{shape}: the gutter {}", o.gutter); + assert!((o.first_baseline_min - pt(2.0)).abs() < 1e-6, "{shape}: the first baseline minimum {}", o.first_baseline_min); + } +} + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From c509445ed0e811bd74402b05b69af725379aaca4 Mon Sep 17 00:00:00 2001 From: jayzed80 <121670721+jayzed80@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:34:31 +0000 Subject: [PATCH 2/2] IDML export: keep endnote reference numbers (#2753) (#2764) The IDML story writer skipped the endnote reference character, so an exported body story lost every endnote's place: "an endnote here. End." with nothing between "here" and the full stop. The endnote frame's story was written with its numbers as text, so nothing linked the listed endnote back to where it was referenced. Each reference is now written as its own superscript character range holding the endnote's number, formatted by Endnote Options (start, style, prefix and suffix) as the endnote frame lists it. IDML's Endnote / EndnoteRange elements are not written yet. --- ROADMAP.md | 1 + crates/idml/src/export/story.rs | 20 ++++++++++++++++++-- crates/idml/src/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b22b0e407..2972324df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -394,6 +394,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-14** · IDML export keeps each endnote reference as its number in superscript text, as the endnote frame lists it, instead of dropping it; the endnotes are still written as plain text, without IDML's endnote elements (#2753). No readiness percentage change. - **2026-11-14** · Object › Text Frame Options shows the inset spacing (one Inset for a non-rectangular frame), the column width and gutter and the paragraph spacing limit in the document's ruler units, as the Properties panel does, instead of always in picas, and a bare number typed in any of its measure fields is in those units (#2739). No readiness percentage change. - **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change. - **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change. diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 7ca41e231..5e520587b 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -507,8 +507,24 @@ impl<'a> Ex<'a> { } else if ch == designcraft_doc::NOTE_MARK { // Editorial notes aren't written to IDML yet. } else if ch == designcraft_doc::ENDNOTE_REF { - // Endnotes aren't written to IDML yet: the endnote frame keeps their text - // (numbers included) and the references are left out. + // Endnote structures aren't written to IDML yet: the endnote frame keeps their + // text (numbers included), and the reference keeps its number as superscript + // text, so the endnote's place isn't lost (#2753). + flush_text(&mut cur, &mut pending); + flush_content(&mut pending, &mut out); + if !out.is_empty() { + let mut c = self.csr_el(&f); + c.children.append(&mut out); + csrs.push(c); + } + let k = u32::try_from(s.endnotes_before(seg_start + ci)).unwrap_or(u32::MAX); + let n = self.d.endnote_start(s.id).saturating_add(k); + let mut c = self.csr_el(&f); + c.set("Position", names::position_out(designcraft_doc::Position::Superscript)); + let mut content = El::new("Content"); + content.children.push(Node::Text(self.d.endnote_options.label(n))); + c.push(content); + csrs.push(c); } else if ch == designcraft_doc::FOOTNOTE_REF { // The reference: its own range carrying the reference position. flush_text(&mut cur, &mut pending); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 1b09e6ec1..a6f771a8e 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1276,6 +1276,37 @@ fn round_trips_footnotes_and_options() { assert_eq!(back.footnote_options, d.footnote_options); } +/// IDML has no endnote structure written yet, so each endnote reference keeps its number as +/// superscript text: the place of the endnote isn't lost (#2753). +#[test] +fn exports_endnote_reference_numbers() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "One here, two there.", ParaFormat::default()).unwrap(); + { + let s = d.story_mut(sid).unwrap(); + s.insert_endnote(8, "First endnote.", ParaFormat::default()).unwrap(); + let at = s.text.find(" there").unwrap() + " there".len(); + s.insert_endnote(at, "Second endnote.", ParaFormat::default()).unwrap(); + } + d.endnote_options.start_at = 4; + d.endnote_options.prefix = "[".into(); + d.endnote_options.suffix = "]".into(); + d.check().unwrap(); + let bytes = export_idml(&d); + let xml = zip_text(&bytes, "Stories/"); + assert!(xml.contains("[4]"), "first reference number: {xml}"); + assert!(xml.contains("[5]"), "second reference number: {xml}"); + let back = import_idml(&bytes).unwrap(); + back.check().unwrap(); + let s = back.stories.values().find(|s| s.text.contains("One here")).unwrap(); + assert_eq!(s.text, "One here[4], two there[5]."); + let at = s.text.find("[4]").unwrap(); + assert_eq!(s.char_format_at(at + 1).over.position, Some(designcraft_doc::Position::Superscript)); + assert_eq!(s.char_format_at(0).over.position, None); +} + /// The structure InDesign writes: the footnote in its own superscript range, its text starting /// with the number marker and the separator. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 52bd45ddc..679628856 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -324,7 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. -- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. +- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text and each reference's number as superscript text, not IDML endnote elements (#2753). - Editorial notes (`note.new/edit/delete/convertToText/convertFromText/split/list`, Notes panel with Split Note, Type › Notes › Convert to Note): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. - Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent); RGB exports give pages with transparency a DeviceRGB page group. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working color settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens colored areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open.