Merge #84 into fix/29-kerning-across-runs

This commit is contained in:
aiongg
2026-10-07 23:14:02 -04:00
10 changed files with 254 additions and 42 deletions
+30 -12
View File
@@ -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
@@ -1564,8 +1568,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);
@@ -1575,16 +1580,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)
}
@@ -2307,6 +2322,7 @@ fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec<PlacedGlyph> {
out
}
/// Put generated `label` text before the paragraph's glyphs; returns how many glyphs it added.
fn prepend_label(
db: &ScopedFonts<'_>,
glyphs: &mut Vec<Glyph>,
@@ -2315,7 +2331,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);
@@ -2330,8 +2346,10 @@ fn prepend_label(
g
})
.collect();
let n = pre.len();
pre.append(glyphs);
*glyphs = pre;
n
}
type LimitsKey = (usize, usize, usize, bool);
+9 -3
View File
@@ -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 %).
@@ -68,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 {
@@ -872,7 +876,7 @@ fn control_glyph(face: &Arc<FontFace>, 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,
@@ -898,6 +902,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
shaping_rtl: false,
lang: designcraft_doc::language_tag(&p.language),
upright: false,
list_label: false,
}
}
@@ -991,7 +996,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,
@@ -1021,6 +1026,7 @@ fn shape_segment(
shaping_rtl: sg.rtl,
lang,
upright: up,
list_label: false,
});
}
}
+122 -12
View File
@@ -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,102 @@ 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 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();
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;
@@ -2758,3 +2842,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<char> = 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);
}
+44 -2
View File
@@ -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)));
+28
View File
@@ -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();
+20
View File
@@ -900,4 +900,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);
}
}
-3
View File
@@ -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");
-1
View File
@@ -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");
-7
View File
@@ -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<bool> {
[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] = [
+1 -2
View File
@@ -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) {