From 93287b0b10f84dda018f07f7334d58da291432a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 22:45:32 -0400 Subject: [PATCH 1/7] Text: vertical scale doesn't move the Ascent first baseline With First Baseline Offset set to Ascent, the first baseline sits the font's typographic ascender times the nominal point size below the top of the text area: vertically scaled characters no longer push it down. The line's ascent and descent, its leading, the Cap Height and x Height offsets and drop caps still follow the vertical scale. --- crates/compose/src/shape.rs | 7 ++++--- crates/compose/src/tests.rs | 35 +++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 6448ab4bb..47e1b1ec8 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -28,7 +28,8 @@ pub struct Glyph { /// Baseline shift (positive = up), including super/subscript. pub shift: f64, pub ascent: f64, - /// The font's typographic ascender, scaled like `ascent`: the Ascent first baseline offset. + /// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent + /// first baseline offset. pub typo_ascent: f64, pub descent: f64, /// The leading this character asks for (absolute, or auto = size × auto %). @@ -780,7 +781,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty sy: k * p.v_scale, shift, ascent, - typo_ascent: face.typo_ascent * k * p.v_scale, + typo_ascent: face.typo_ascent * k, descent, leading, cap, @@ -899,7 +900,7 @@ fn shape_segment( sy: k * p.v_scale, shift, ascent, - typo_ascent: face.typo_ascent * k * p.v_scale, + typo_ascent: face.typo_ascent * k, descent, leading, cap, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b9da78381..2779769bd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1395,18 +1395,6 @@ fn ascent_first_baseline_uses_the_typographic_ascender() { assert!(close(at(FirstBaseline::XHeight, 0.0), top + 0.486 * 20.0)); assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0)); assert!(close(at(FirstBaseline::Fixed, 0.0), top)); - // Vertical scale scales it like the ascent. - let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); - d.story_mut(sid).unwrap().format_chars(0..3, |f| { - f.over.font_family = Some("TypoAscent 07".into()); - f.over.size = Some(20.0); - f.over.v_scale = Some(1.5); - }); - d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0]; - let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; - assert!(close(l.baseline, top + 21.0), "{}", l.baseline); - // The line box keeps the font's ascent. - assert!(close(l.ascent, 30.0), "{}", l.ascent); // A mixed first line takes its tallest typographic ascender: 0.7 × 20 pt beats 1 em × 10 pt … let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); let st = d.story_mut(sid).unwrap(); @@ -1451,6 +1439,29 @@ fn vertical_frames_keep_the_ascent_first_baseline() { assert!((b - 20.0).abs() < 0.01, "{b}"); } +#[test] +fn vertical_scale_does_not_move_the_ascent_first_baseline() { + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscentVSc", Some(700))); + let line = |v_scale: f64| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..3, |f| { + f.over.font_family = Some("TypoAscentVSc".into()); + f.over.size = Some(20.0); + f.over.v_scale = Some(v_scale); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.ascent) + }; + let ((b100, a100), (b200, a200)) = (line(1.0), line(2.0)); + // The typo ascender of the nominal size (0.7 × 20 pt) at 100 % and at 200 %. + assert!((b100 - (36.0 + 4.0 + 14.0)).abs() < 0.01, "{b100}"); + assert!((b200 - b100).abs() < 0.01, "{b200} vs {b100}"); + // The line box keeps the scaled ascent. + assert!((a100 - 20.0).abs() < 0.01 && (a200 - 40.0).abs() < 0.01, "{a100} {a200}"); +} + #[test] fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() { use designcraft_doc::FirstBaseline; From bb608dc03fa512f19a444860c05f465ae86eea2e Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 22:48:03 -0400 Subject: [PATCH 2/7] Text: a frame's visible stroke insets its text A text frame's text area starts at the inner edge of its stroke: the frame edge, then the part of the stroke inside the frame, then the inset, on all four edges. A centred stroke takes half its weight, an inside stroke all of it and an outside stroke none. A stroke without a colour (or with no weight) doesn't count. Composition, hit testing and vertical frames all take the text area from the frame, so they agree. Graphics frames, wraps and the frame's bounds are unchanged, and type on a path, which has no inset, ignores the stroke as well. --- crates/compose/src/tests.rs | 29 +++++++++++++++++++++++ crates/doc/src/item.rs | 46 +++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 2779769bd..68fb5670b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1439,6 +1439,35 @@ fn vertical_frames_keep_the_ascent_first_baseline() { assert!((b - 20.0).abs() < 0.01, "{b}"); } +#[test] +fn a_visible_frame_stroke_insets_the_text() { + use designcraft_doc::{Stroke, StrokeAlign}; + let line = |stroke: Option<(StrokeAlign, &str)>| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + if let Some((align, swatch)) = stroke { + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + } + it.text_frame_mut().unwrap().options.inset = [4.0; 4]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.x0, l.x1) + }; + let black = designcraft_color::swatch::BLACK; + let (b, x0, x1) = line(None); + // The part of the 4 pt stroke inside the frame: half of it centred, all of it inside, none + // outside or without a colour; on the left and right as on the top. + for (stroke, d) in [ + ((StrokeAlign::Center, black), 2.0), + ((StrokeAlign::Inside, black), 4.0), + ((StrokeAlign::Outside, black), 0.0), + ((StrokeAlign::Inside, designcraft_color::swatch::NONE), 0.0), + ] { + let (sb, sx0, sx1) = line(Some(stroke)); + assert!((sb - (b + d)).abs() < 1e-6, "{stroke:?}: baseline {sb} vs {b}"); + assert!((sx0 - (x0 + d)).abs() < 1e-6 && (sx1 - (x1 - d)).abs() < 1e-6, "{stroke:?}: {sx0}..{sx1} vs {x0}..{x1}"); + } +} + #[test] fn vertical_scale_does_not_move_the_ascent_first_baseline() { let db = designcraft_fonts::FontDb::global(); diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index b06f4fe6f..dd690b093 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1141,13 +1141,29 @@ impl Item { b.inflate(w, w) } - /// The frame's text area (inner space) after inset. + /// The text frame's text area (inner space): inside the part of a visible stroke that lies + /// within the frame, then the inset, on each edge. pub fn text_area(&self) -> Rect { let r = self.inner_bounds(); - let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); + let s = self.stroke_inside(); + let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s); Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0])) } + /// How far a visible stroke reaches inside the path: half its weight centred, all of it inside, + /// none outside. + fn stroke_inside(&self) -> f64 { + let w = self.stroke.weight; + if self.stroke.is_none() || !w.is_finite() { + return 0.0; + } + match self.stroke.align { + StrokeAlign::Center => w / 2.0, + StrokeAlign::Inside => w, + StrokeAlign::Outside => 0.0, + } + } + /// Spread-space centre. pub fn center(&self) -> Point { self.bounds().center() @@ -1192,6 +1208,32 @@ mod tests { assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0)); } + #[test] + fn text_area_starts_inside_the_stroke() { + let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); + it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } }); + let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0); + let stroked = |align: StrokeAlign, swatch: &str| { + let mut it = it.clone(); + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + it.text_area() + }; + let black = designcraft_color::swatch::BLACK; + // The part of the stroke inside the frame, on every edge, then the inset. + assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0)); + assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0)); + assert_eq!(stroked(StrokeAlign::Outside, black), unstroked); + // A stroke without a colour doesn't count. + assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked); + // Nor does a weight that isn't a number. + it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() }; + assert_eq!(it.text_area(), unstroked); + // A stroke wider than the frame leaves no room for text. + it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() }; + let a = it.text_area(); + assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}"); + } + #[test] fn group_bounds_union_children() { let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); From 8ce8ed5fbe9fb5e352971ac39335cfe334f6a0d7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 22:51:34 -0400 Subject: [PATCH 3/7] Text: list labels don't raise the Ascent first baseline The bullet or number a paragraph's list adds no longer counts toward the first line's tallest typographic ascender: with First Baseline Offset set to Ascent, only the line's own text places the first baseline. The label still counts toward the line's ascent, descent and leading, and toward the Cap Height and x Height offsets. An empty list paragraph places its first baseline as an empty paragraph does. Characters that don't draw, such as spaces, keep counting toward the Ascent offset; a test pins that. --- crates/compose/src/lib.rs | 42 +++++++++++++++++++++++++---------- crates/compose/src/shape.rs | 5 +++++ crates/compose/src/tests.rs | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 156f55a90..8c4d087c6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -704,23 +704,27 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align); // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; - match pp.list_type { + let list_label = match pp.list_type { designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). let n = named_numbers.get(pi).copied().flatten().unwrap_or(1); - let label = format!("{}.{}", pp.number_style.format(n), pp.list_separator); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(format!("{}.{}", pp.number_style.format(n), pp.list_separator)) } designcraft_doc::ListType::Numbers => { list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1)); - let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator)) } - designcraft_doc::ListType::Bullets => { - let label = format!("{}{}", pp.bullet_char, pp.list_separator); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + designcraft_doc::ListType::Bullets => Some(format!("{}{}", pp.bullet_char, pp.list_separator)), + designcraft_doc::ListType::None => { + list_counter = 0; + None + } + }; + if let Some(label) = list_label { + let n = prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + for g in sp.glyphs.iter_mut().take(n) { + g.list_label = true; } - designcraft_doc::ListType::None => list_counter = 0, } if pi == 0 && let Some(label) = &opts.label @@ -1555,8 +1559,9 @@ fn line_metrics( base: &designcraft_doc::CharProps, ) -> (f64, Tops, f64, f64) { let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; + let face = || db.face(&base.font_family, &base.font_style); if src.is_empty() { - let face = db.face(&base.font_family, &base.font_style); + let face = face(); let k = base_size / face.upem; let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k }; return (face.ascent * k, tops, face.descent * k, base_leading); @@ -1566,16 +1571,26 @@ fn line_metrics( let mut desc: f64 = 0.0; let mut lead: f64 = 0.0; let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size)); + let mut own_text = false; for g in src { let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r)); let up = shift.max(0.0); asc = asc.max(g.ascent + up); - tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up); + // A list's bullet or number doesn't raise the Ascent offset: only the line's own text does. + if !g.list_label { + own_text = true; + tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up); + } tops.cap = tops.cap.max(g.cap + up); tops.xh = tops.xh.max(g.xh + up); desc = desc.max(g.descent - shift.min(0.0)); lead = lead.max(g.leading); } + if !own_text { + // Only a label (an empty list paragraph): as an empty paragraph. + let face = face(); + tops.typo_ascent = face.typo_ascent * base_size / face.upem; + } (asc, tops, desc, lead) } @@ -2298,6 +2313,7 @@ fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec { out } +/// Put generated `label` text before the paragraph's glyphs; returns how many glyphs it added. fn prepend_label( db: &ScopedFonts<'_>, glyphs: &mut Vec, @@ -2306,7 +2322,7 @@ fn prepend_label( base: &designcraft_doc::CharProps, env: shape::TypeEnv, table: &mut StyleTable<'_>, -) { +) -> usize { // Shape the label as a tiny standalone story so it uses the paragraph's base character style. let mut tmp = Story::new(StoryId(0)); tmp.insert(0, label); @@ -2321,8 +2337,10 @@ fn prepend_label( g }) .collect(); + let n = pre.len(); pre.append(glyphs); *glyphs = pre; + n } type LimitsKey = (usize, usize, usize, bool); diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 47e1b1ec8..5e43560af 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -69,6 +69,9 @@ pub struct Glyph { /// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung /// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]). pub upright: bool, + /// Part of the bullet or number the paragraph's list adds: not the line's own text, so it + /// doesn't raise the Ascent first baseline. + pub list_label: bool, } impl Glyph { @@ -807,6 +810,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty shaping_rtl: false, lang: designcraft_doc::language_tag(&p.language), upright: false, + list_label: false, } } @@ -930,6 +934,7 @@ fn shape_segment( shaping_rtl: sg.rtl, lang, upright: up, + list_label: false, }); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 68fb5670b..cb753f4b0 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1468,6 +1468,50 @@ fn a_visible_frame_stroke_insets_the_text() { } } +#[test] +fn list_labels_do_not_raise_the_ascent_first_baseline() { + use designcraft_doc::{CharAttrs, ListType}; + // The paragraph's own character size (the label's) is 40 pt; its text is 10 pt (Source Sans 3: + // typo ascender 1 em). + let line = |text: &str, list: ListType| { + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs { list_type: Some(list), ..Default::default() }); + let st = d.story_mut(sid).unwrap(); + for p in &mut st.paras { + p.chars = CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(40.0), ..Default::default() }; + } + st.format_chars(0..text.len(), |f| f.over.size = Some(10.0)); + let l = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + (l.baseline, l.ascent, l.leading) + }; + let (b, asc, lead) = line("Hxg Hxg", ListType::None); + // 1 em of the 10 pt text below the frame top. + assert!((b - 46.0).abs() < 0.01, "{b}"); + for list in [ListType::Bullets, ListType::Numbers] { + let (lb, lasc, llead) = line("Hxg Hxg", list); + assert!((lb - b).abs() < 1e-6, "{list:?}: {lb} vs {b}"); + // The 40 pt label still sets the line's ascent and leading. + assert!(lasc > asc + 20.0 && (llead - 48.0).abs() < 1e-6 && (lead - 12.0).abs() < 1e-6, "{list:?}: {lasc} {asc} {llead} {lead}"); + } + // An empty bulleted paragraph sits like an empty paragraph. + let (eb, _, _) = line("", ListType::None); + let (ebl, _, _) = line("", ListType::Bullets); + assert!((ebl - eb).abs() < 1e-6, "{ebl} vs {eb}"); +} + +#[test] +fn spaces_count_toward_the_ascent_first_baseline() { + // 10 pt text with a 40 pt space between its words (Source Sans 3: typo ascender 1 em). + let (mut d, sid, _) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.size = Some(10.0); + }); + st.format_chars(3..4, |f| f.over.size = Some(40.0)); + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + assert!((l.baseline - (36.0 + 40.0)).abs() < 0.01, "{}", l.baseline); +} + #[test] fn vertical_scale_does_not_move_the_ascent_first_baseline() { let db = designcraft_fonts::FontDb::global(); From 6fe91b0dfbb8ba8274825259cc3c68067d62b7bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 22:57:03 -0400 Subject: [PATCH 4/7] Text: a drop cap in a document font aligns its left edge A drop cap set in one of the document's own fonts measures its side bearing in that font, through the document's font scope, so Align Left Edge puts its ink at the indent. --- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index cb753f4b0..02ec152fd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2728,3 +2728,29 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +/// A drop cap set in one of the document's own fonts measures its side bearing in that font. +#[test] +fn drop_cap_in_a_document_font_aligns_its_left_edge() { + use designcraft_geom::Shape as _; + const FAMILY: &str = "DocFont Drop Cap"; + let text = format!("Once {LOREM}"); + let mut chars: Vec = text.chars().collect(); + chars.sort_unstable(); + chars.dedup(); + let folder = std::env::temp_dir().join(format!("dc-compose-dropcap-docfont-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("dropcap.ttf"), designcraft_fonts::testing::font_with_glyph(FAMILY, &chars, 'O').unwrap()).unwrap(); + let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope; + let para = ParaAttrs { left_indent: Some(10.0), ..drop_cap(2, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.font_scope = scope; + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let dc = &all_lines(&cs)[0].glyphs[0]; + assert_eq!(dc.face.family, FAMILY); + let ink_left = dc.x + designcraft_fonts::FontDb::global().outline(dc.face.get(), dc.gid).bounding_box().x0 * dc.sx; + assert!(ink_left - dc.x > 0.5, "the O has a visible side bearing"); + assert!((ink_left - 10.0).abs() < 1e-6, "ink at {ink_left}"); + let _ = std::fs::remove_dir_all(&folder); +} From 5e515725c62a55eac47ba35c348cd02c3a4dec77 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 22:57:58 -0400 Subject: [PATCH 5/7] IDML: one reader and writer for the paragraph Kashidas attribute The paragraph Kashidas attribute is imported and exported by the Arabic attribute code; the second copy in the paragraph defaults, which read and wrote the same values, is gone. --- crates/idml/src/export.rs | 3 --- crates/idml/src/import.rs | 1 - crates/idml/src/names.rs | 7 ------- 3 files changed, 11 deletions(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c62674409..fcd688284 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1360,9 +1360,6 @@ impl<'a> Ex<'a> { if let Some(v) = a.single_word_justify { el.set("SingleWordJustification", names::align_out(v)); } - if let Some(v) = a.kashidas { - el.set("Kashidas", names::kashidas_out(v)); - } n!(keep_with_next, "KeepWithNext"); b!(keep_lines_together, "KeepLinesTogether"); b!(keep_all_lines, "KeepAllLinesTogether"); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index e51f1d81b..22fb03420 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1286,7 +1286,6 @@ impl<'r> Importer<'r> { a.glyph_scale_max = frac("MaximumGlyphScaling"); a.auto_leading = frac("AutoLeading"); a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim())); - a.kashidas = e.prop("Kashidas").and_then(|v| names::kashidas_in(v.trim())); a.keep_with_next = u("KeepWithNext"); a.keep_lines_together = e.boolean("KeepLinesTogether"); a.keep_all_lines = e.boolean("KeepAllLinesTogether"); diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 9a1c4ca1b..daa57d5fb 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -447,13 +447,6 @@ pub fn auto_size_in(s: &str) -> AutoSize { .find(|m| auto_size_out(*m) == s) .unwrap_or(AutoSize::Off) } -/// Insert Kashidas (`Kashidas`): InDesign's default, or off. -pub fn kashidas_out(on: bool) -> &'static str { - if on { "DefaultKashidas" } else { "KashidasOff" } -} -pub fn kashidas_in(s: &str) -> Option { - [true, false].into_iter().find(|on| kashidas_out(*on) == s) -} /// Auto-size reference points in our 0..9 order (row-major, top-left first). pub const REF_POINTS: [&str; 9] = [ From b40e221d58be6b5dc47c7343c723a1d34ace8f66 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 23:02:03 -0400 Subject: [PATCH 6/7] Text: a frame removed from a thread keeps the story's direction Tests that a frame taken out of a vertical or right-to-left thread gets a new story with the thread's vertical setting and column direction. --- crates/doc/src/tests.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index fca520ed8..8af281d3b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -134,6 +134,34 @@ fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() { assert!(d.remove_from_thread(b).is_err()); } +#[test] +fn a_frame_removed_from_a_vertical_thread_stays_vertical() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().vertical = true; + d.thread(a, b).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + assert!(d.frame_vertical(d.item(a).unwrap())); + assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction"); +} + +#[test] +fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft; + d.thread(a, b).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + let own = d.item(b).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft); +} + #[test] fn frames_keep_their_story_direction_through_threading() { let mut d = doc(); From e13dee07285e961e26c1052ef25289c7949cc2a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 23:04:29 -0400 Subject: [PATCH 7/7] Text: Bold and Italic link the document's own fonts Bold and Italic find the linked style through the document's font scope, so a family from the document's fonts folder links its own styles; the Character panel's B and I state reads the same scope. --- crates/engine/src/cmd/file.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 3 +-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6e572a98a..9b9f73ce8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -887,4 +887,24 @@ mod document_fonts_tests { assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn bold_and_italic_link_the_document_fonts_styles() { + const FAMILY: &str = "DocFont Linked"; + let dir = temp_dir("linked"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Linked.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + // The family is the document's alone: its Regular is the style without bold or italic. + let r = s.execute("type.bold", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let r = s.execute("type.italic", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3d5cd0223..062d74b20 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1289,8 +1289,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); ui.horizontal(|ui| { // Bold and Italic pick the family's own styles (style linking). - let (weight, italic) = - designcraft_fonts::FontDb::global().traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default()); + let (weight, italic) = super::fonts(app).traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default()); let tip = |app: &DesignApp, name: &str, id: &str| { let name = crate::i18n::tr(&app.ui.language, name); match crate::menus::shortcut_of(app, id) {