mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29e5ea920 |
No files matched your search
@@ -31,6 +31,7 @@ WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmp
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
|
||||
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
|
||||
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
|
||||
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
|
||||
web builds BIZ UDPGothic Regular only) under the SIL
|
||||
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
|
||||
@@ -142,6 +142,12 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Quick start
|
||||
|
||||
Simplified Chinese (`zh-hans`) is available in the language menu and Preferences.
|
||||
Legacy `locale: "zh-Hans"` preferences migrate without overriding an explicit language choice.
|
||||
Horizontal Chinese text supports punctuation compression and inter-character justification.
|
||||
Build desktop releases with `CRAFT_FONTS_DIR` set to include Noto Sans CJK SC from craft-fonts;
|
||||
without that optional input, Chinese falls back to installed system fonts.
|
||||
|
||||
```sh
|
||||
cargo run --release -p vectorcraft # desktop app
|
||||
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
|
||||
|
||||
@@ -84,6 +84,7 @@ When a task lands, update this section, the parity table and "Shipped so far" in
|
||||
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
|
||||
|
||||
## Shipped so far
|
||||
- **Simplified Chinese and horizontal CJK composition:** `zh-hans` catalog and legacy language preference migration; punctuation compression, inter-character justification, per-family UI font fallback and responsive dock/dialog fixes. Desktop CJK fonts use the optional craft-fonts input rather than font binaries in this repository. Ruby, configurable mojikumi and vertical font metrics remain open.
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
//! hyphenation points. The breaks minimise the sum of squared (badness + penalty) demerits over
|
||||
//! the whole paragraph, so loose lines are traded for evenly spaced ones.
|
||||
|
||||
use crate::shape::SGlyph;
|
||||
use crate::shape::{SGlyph, can_break_between, is_cjk};
|
||||
|
||||
/// Whether justification may widen the gap between `g[i]` and `g[i + 1]`: a word space, or a CJK
|
||||
/// character boundary (Chinese has no spaces, so 两端对齐 stretches the character gaps). Forbidden
|
||||
/// line starts and ends are left alone so punctuation keeps its 标点挤压 spacing.
|
||||
pub(crate) fn stretchable_gap(g: &[SGlyph], i: usize) -> bool {
|
||||
if g[i].is_space() {
|
||||
return true;
|
||||
}
|
||||
let Some(next) = g.get(i + 1) else { return false };
|
||||
!next.is_space() && !next.continues_tcy() && (is_cjk(g[i].ch) || is_cjk(next.ch)) && can_break_between(g[i].ch, Some(next.ch))
|
||||
}
|
||||
|
||||
const STRETCH: f64 = 0.5;
|
||||
const SHRINK: f64 = 0.2;
|
||||
@@ -60,6 +71,12 @@ pub(crate) fn compose(
|
||||
};
|
||||
let mut all: Vec<Breakpoint> = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect();
|
||||
all.push(Breakpoint { end: n, hyphen: 0.0 });
|
||||
// Prefix sums of the gaps that justification may stretch. Between words that is the space;
|
||||
// between CJK characters, which have no space to stretch, it is the gap itself (两端对齐).
|
||||
let mut gap = vec![0.0; n + 1];
|
||||
for i in 0..n {
|
||||
gap[i + 1] = gap[i] + if !g[i].is_space() && stretchable_gap(g, i) { g[i].adv * 0.5 } else { 0.0 };
|
||||
}
|
||||
let mut nodes = vec![Node { pos: 0, line: 0, demerits: 0.0, hyphenated: false, prev: None }];
|
||||
let mut active: Vec<usize> = vec![0];
|
||||
for bp in &all {
|
||||
@@ -71,12 +88,14 @@ pub(crate) fn compose(
|
||||
let t = trim(bp.end, a.pos);
|
||||
let natural = w[t] - w[a.pos] + bp.hyphen;
|
||||
let spaces = sp[t] - sp[a.pos];
|
||||
// A CJK line with no spaces stretches its character gaps instead.
|
||||
let stretch = if spaces > 0.0 { spaces } else { gap[t] - gap[a.pos] };
|
||||
let lw = width(a.line);
|
||||
let ratio = if natural < lw {
|
||||
if last && !justify_last {
|
||||
0.0
|
||||
} else if spaces > 0.0 {
|
||||
(lw - natural) / (spaces * STRETCH)
|
||||
} else if stretch > 0.0 {
|
||||
(lw - natural) / (stretch * STRETCH)
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
|
||||
@@ -932,6 +932,21 @@ impl FontDb {
|
||||
self.fallback_for(c, 0)
|
||||
}
|
||||
|
||||
/// UI fallback at the requested weight; keep the official family preference and loader.
|
||||
pub fn face_covering_weighted(&self, c: char, weight: f32) -> Option<Arc<FontFace>> {
|
||||
self.fallback_for_weighted(c, 0, weight)
|
||||
}
|
||||
|
||||
pub(crate) fn fallback_for_weighted(&self, c: char, exclude: u32, weight: f32) -> Option<Arc<FontFace>> {
|
||||
let fallback = self.fallback_for(c, exclude)?;
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.filter(|face| face.id() != exclude && face.family == fallback.family && face.covers(c))
|
||||
.min_by_key(|face| (face.italic, (face.weight - weight).abs() as i32))
|
||||
.cloned()
|
||||
.or(Some(fallback))
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
|
||||
+32
-10
@@ -5,7 +5,7 @@ use std::ops::Range;
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
|
||||
use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextObject};
|
||||
|
||||
use crate::composer::{Breakpoint, compose};
|
||||
use crate::composer::{Breakpoint, compose, stretchable_gap};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
|
||||
@@ -638,6 +638,20 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte {
|
||||
end -= 1;
|
||||
}
|
||||
if !hy {
|
||||
// Keep punctuation and shaped clusters together even on an emergency overset line.
|
||||
let allowed = |end: usize| end == g.len() || (g[end].byte != g[end - 1].byte && kinsoku_allows(g, end - 1));
|
||||
let fitted = end;
|
||||
while end > i && !allowed(end) {
|
||||
end -= 1;
|
||||
}
|
||||
if end == i {
|
||||
end = fitted.max(i + 1);
|
||||
while end < g.len() && !allowed(end) {
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(end, hy)
|
||||
}
|
||||
|
||||
@@ -828,18 +842,27 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
Justify::JustifyAll => (0, true),
|
||||
};
|
||||
let justify = justify && regions.is_some();
|
||||
let (mut per_space, mut per_gap) = (0.0, 0.0);
|
||||
let (mut per_space, mut per_gap_base) = (0.0, 0.0);
|
||||
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
|
||||
let vertical = cx.vertical;
|
||||
let can_stretch = |j: usize| {
|
||||
sg.get(j + 1).is_some_and(|next| !next.continues_tcy())
|
||||
&& (vertical || para.justify == Justify::JustifyAll || stretchable_gap(&sg, j))
|
||||
};
|
||||
let gaps = (i..trimmed.saturating_sub(1)).filter(|&j| can_stretch(j)).count();
|
||||
let gap_em = sg.get(i).map_or(0.0, |g| g.adv);
|
||||
if justify && (width - w).abs() > EPS {
|
||||
if spaces > 0 {
|
||||
// Composed lines may shrink word spaces (never below zero).
|
||||
per_space =
|
||||
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
|
||||
} else if para.justify == Justify::JustifyAll && width > w {
|
||||
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
|
||||
if gaps > 0 {
|
||||
per_gap = (width - w) / gaps as f64;
|
||||
}
|
||||
} else if gaps > 0 && width > w && (!cx.vertical || para.justify == Justify::JustifyAll) {
|
||||
// 两端对齐 for scripts without spaces: widen the CJK character gaps. Half an em
|
||||
// per gap is the most Chinese composition allows before the line looks tracked
|
||||
// out, so a line that cannot reach the edge is left ragged. Justify All is the
|
||||
// exception: there every line must reach the edge, so the gaps take the slack.
|
||||
let per = (width - w) / gaps as f64;
|
||||
per_gap_base = if para.justify == Justify::JustifyAll { per } else { per.min(gap_em * 0.5) };
|
||||
}
|
||||
}
|
||||
let start_x = if justify {
|
||||
@@ -870,9 +893,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
} else if j < trimmed {
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
|
||||
// Between glyphs, never inside a tate-chu-yoko block (one cell).
|
||||
adv += per_gap;
|
||||
} else if j + 1 < trimmed && can_stretch(j) {
|
||||
adv += per_gap_base;
|
||||
}
|
||||
}
|
||||
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);
|
||||
|
||||
@@ -375,6 +375,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_cjk;
|
||||
#[cfg(test)]
|
||||
mod tests_embed;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
|
||||
@@ -107,7 +107,8 @@ pub(crate) fn no_line_start(c: char) -> bool {
|
||||
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
|
||||
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
|
||||
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
|
||||
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
|
||||
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ'
|
||||
| '…' | '‥' | '—' | '%' | '%')
|
||||
}
|
||||
|
||||
/// Kinsoku: a character that can't end a line (opening brackets).
|
||||
@@ -137,10 +138,60 @@ pub(crate) fn no_line_end(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
pub(crate) fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||||
}
|
||||
|
||||
pub(crate) fn can_break_between(left: char, right: Option<char>) -> bool {
|
||||
if left == SOFT_HYPHEN {
|
||||
return true;
|
||||
}
|
||||
let Some(right) = right else { return true };
|
||||
!no_line_end(left) && !no_line_start(right)
|
||||
}
|
||||
|
||||
/// Half-width, in em, trimmed from each side of full-width punctuation (标点挤压).
|
||||
///
|
||||
/// Chinese composition trims an opening bracket on the left and a closing bracket on the right
|
||||
/// by up to half an em; comma/full-stop forms lose the blank on the right. Proportional Latin
|
||||
/// curly quotes keep their font's spacing. Actual side bearings limit the trim below.
|
||||
/// The trim is applied to the glyph's offset and advance, so line breaking and justification see
|
||||
/// the composed width.
|
||||
pub(crate) fn cjk_punctuation_trim(c: char) -> (f64, f64) {
|
||||
let (l, r) = cjk_punctuation_trim_impl(c);
|
||||
(l, r)
|
||||
}
|
||||
|
||||
fn cjk_punctuation_trim_impl(c: char) -> (f64, f64) {
|
||||
// Tight punctuation: a comma or full stop hugs the character before it and takes half an em.
|
||||
if matches!(c, '\u{3001}' | '\u{3002}' | '\u{FF0C}' | '\u{FF0E}' | '\u{FF1A}' | '\u{FF1B}' | '\u{FF01}' | '\u{FF1F}' | '\u{FF61}' | '\u{FF64}')
|
||||
|| matches!(c, '\u{FE10}'..='\u{FE13}')
|
||||
{
|
||||
return (0.0, 0.5);
|
||||
}
|
||||
// Closing forms carry the blank half on the right.
|
||||
if matches!(c, '\u{3009}' | '\u{300B}' | '\u{300D}' | '\u{300F}' | '\u{3011}' | '\u{3015}' | '\u{3017}' | '\u{3019}' | '\u{301B}')
|
||||
|| matches!(c, '\u{FE18}')
|
||||
|| matches!(c, '\u{FF09}' | '\u{FF3D}' | '\u{FF5D}' | '\u{FF60}' | '\u{FF63}')
|
||||
|| matches!(c, '\u{301E}' | '\u{301F}')
|
||||
{
|
||||
return (0.0, 0.5);
|
||||
}
|
||||
// Opening forms carry the blank half on the left.
|
||||
if matches!(c, '\u{3008}' | '\u{300A}' | '\u{300C}' | '\u{300E}' | '\u{3010}' | '\u{3014}' | '\u{3016}' | '\u{3018}' | '\u{301A}')
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{FE17}' | '\u{FE35}' | '\u{FE37}' | '\u{FE39}' | '\u{FE3B}' | '\u{FE3D}' | '\u{FE3F}' | '\u{FE41}' | '\u{FE43}' | '\u{FE47}'
|
||||
)
|
||||
|| matches!(c, '\u{FF08}' | '\u{FF3B}' | '\u{FF5B}' | '\u{FF5F}' | '\u{FF62}')
|
||||
|| matches!(c, '\u{301D}')
|
||||
{
|
||||
return (0.5, 0.0);
|
||||
}
|
||||
// The interpunct and the ellipsis keep their full width but centre in it.
|
||||
(0.0, 0.0)
|
||||
}
|
||||
|
||||
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
|
||||
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
||||
let vs = st.v_scale / 100.0;
|
||||
@@ -187,7 +238,8 @@ pub(crate) fn shape_range(
|
||||
} else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) {
|
||||
f.clone()
|
||||
} else {
|
||||
let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone());
|
||||
// Ask for the run's own weight, so a bold Chinese run gets a bold CJK face.
|
||||
let f = db.fallback_for_weighted(c, primary.id(), crate::fontdb::style_weight(&st.font_style)).unwrap_or_else(|| primary.clone());
|
||||
cache.push((c, f.clone()));
|
||||
f
|
||||
};
|
||||
@@ -296,6 +348,26 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
} else if last_in_cluster {
|
||||
adv += tracking + manual_kern;
|
||||
}
|
||||
// 标点挤压, once per cluster: trim the outer blank of brackets and the right blank of
|
||||
// comma/full-stop forms. Never take more than half the original cell or touch the ink.
|
||||
let (tl, tr) = if last_in_cluster && !feats.vertical { cjk_punctuation_trim(ch) } else { (0.0, 0.0) };
|
||||
let (mut tl, mut tr) = (tl * size * hs, tr * size * hs);
|
||||
// Trim only actual side bearings: a font may already use narrow or centred forms.
|
||||
if (tl > 0.0 || tr > 0.0)
|
||||
&& let Some(bounds) =
|
||||
face.skrifa().and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(skrifa::GlyphId::new(gid)))
|
||||
{
|
||||
tl = tl.min(((bounds.x_min as f64 + xo as f64) * k * hs).max(0.0));
|
||||
tr = tr.min(((xa as f64 - bounds.x_max as f64 - xo as f64) * k * hs).max(0.0));
|
||||
}
|
||||
let room = adv.max(0.0) * 0.5;
|
||||
let spend = tl + tr;
|
||||
if spend > room && spend > 0.0 {
|
||||
let scale = room / spend;
|
||||
tl *= scale;
|
||||
tr *= scale;
|
||||
}
|
||||
adv -= tl + tr;
|
||||
out.push(SGlyph {
|
||||
face: face.clone(),
|
||||
gid,
|
||||
@@ -303,7 +375,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
len: end.saturating_sub(cl).max(1),
|
||||
run,
|
||||
adv,
|
||||
dx: xo as f64 * k * hs,
|
||||
dx: xo as f64 * k * hs - tl,
|
||||
dy: -(yo as f64) * k * vs,
|
||||
sx: k * hs,
|
||||
sy: k * vs,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Chinese composition (中文排版): 避头尾 line breaking, 标点挤压 and 两端对齐.
|
||||
//!
|
||||
//! Regression coverage for shared CJK punctuation break restrictions, side-bearing-limited
|
||||
//! punctuation compression, and horizontal inter-character justification. This is an initial
|
||||
//! composition implementation, not a claim of full typesetting-standard conformance.
|
||||
|
||||
use super::*;
|
||||
use crate::shape::can_break_between;
|
||||
use kurbo::{Affine, Shape};
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
/// Deterministic on native and web, without installed system fonts.
|
||||
fn db() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(Vec::new()))
|
||||
}
|
||||
|
||||
fn has_chinese_font() -> bool {
|
||||
let available = db().face("Noto Sans CJK SC", "Regular").is_some_and(|face| face.glyph_for('中') != 0);
|
||||
if !available {
|
||||
eprintln!("skipped: Chinese glyph metrics require the optional craft-fonts input");
|
||||
}
|
||||
available
|
||||
}
|
||||
|
||||
fn style(size: f64) -> CharStyle {
|
||||
CharStyle { size, font_family: "Noto Sans CJK SC".into(), ..CharStyle::default() }
|
||||
}
|
||||
|
||||
fn cjk_area(text: &str, frame: Rect, justify: Justify) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, style(12.0));
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.justify = justify;
|
||||
t
|
||||
}
|
||||
|
||||
/// The advance the face gives `c` before any 标点挤压.
|
||||
fn raw_advance(family: &str, c: char, size: f64) -> f64 {
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
let f = db().face(family, "Regular").expect("face");
|
||||
let gid = f.glyph_for(c);
|
||||
assert_ne!(gid, 0, "{family} {c}: missing glyph");
|
||||
let sk = f.skrifa().expect("skrifa");
|
||||
let m = sk.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(skrifa::GlyphId::new(gid));
|
||||
f64::from(m.unwrap_or(0.0)) / f.units_per_em() * size
|
||||
}
|
||||
|
||||
/// The advance the layout gives a one-character text object of `c`.
|
||||
fn laid_advance(family: &str, c: char, size: f64) -> f64 {
|
||||
let st = CharStyle { font_family: family.into(), size, ..CharStyle::default() };
|
||||
layout(db(), &TextObject::point(Point::ZERO, c.to_string().as_str(), st)).glyphs.first().expect("glyph").advance
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_keeps_punctuation_with_its_text() {
|
||||
// Closing punctuation and closing brackets may not begin a line.
|
||||
for &c in &[',', '。', '、', ';', ':', '!', '?', ')', '】', '》', '」', '”', '’', '…'] {
|
||||
assert!(!can_break_between('字', Some(c)), "{c} must not begin a line");
|
||||
}
|
||||
// CJK opening brackets may not end a line.
|
||||
for &c in &['(', '【', '《', '「', '“', '‘', '〔', '〖'] {
|
||||
assert!(!can_break_between(c, Some('字')), "{c} must not end a line");
|
||||
}
|
||||
// Opening brackets may start a line; closing brackets may end one.
|
||||
for &c in &['(', '【', '《', '「'] {
|
||||
assert!(can_break_between('字', Some(c)), "{c} may begin a line");
|
||||
}
|
||||
for c in [')', '】', '》', '」', '〕', '〗'] {
|
||||
assert!(can_break_between(c, Some('字')), "{c} may end a line");
|
||||
}
|
||||
// Latin brackets keep Western behaviour: a break before them is fine.
|
||||
for &c in &['(', '[', '{'] {
|
||||
assert!(can_break_between('a', Some(c)), "a Latin word may break before {c}");
|
||||
assert!(!can_break_between(c, Some('a')), "{c} must not end a line");
|
||||
}
|
||||
// Ordinary ideographs and mixed CJK/Latin boundaries may break.
|
||||
assert!(can_break_between('中', Some('文')));
|
||||
assert!(can_break_between('中', Some('a')));
|
||||
assert!(can_break_between('a', Some('中')));
|
||||
assert!(can_break_between('中', None), "the paragraph end is always a break");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_is_squeezed_on_its_outer_side() {
|
||||
if !has_chinese_font() {
|
||||
return;
|
||||
}
|
||||
for c in ['(', '【', '《', '「'] {
|
||||
let (r, l) = (raw_advance("Noto Sans CJK SC", c, 20.0), laid_advance("Noto Sans CJK SC", c, 20.0));
|
||||
assert!(l < r - 1.0, "{c}: laid {l} is not tighter than raw {r}");
|
||||
assert!(l >= r * 0.5 - 1e-9, "{c}: laid {l} below the half-em floor of {r}");
|
||||
}
|
||||
for c in [',', '。', '、'] {
|
||||
let (r, l) = (raw_advance("Noto Sans CJK SC", c, 20.0), laid_advance("Noto Sans CJK SC", c, 20.0));
|
||||
assert!(l < r - 1.0 && l >= r * 0.5 - 1e-9, "{c}: laid {l} against raw {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_chinese_punctuation_is_squeezed_out_of_existence() {
|
||||
if !has_chinese_font() {
|
||||
return;
|
||||
}
|
||||
// The trim must never take more than half a mark's cell, so the advance stays positive and the
|
||||
// glyph keeps its id even in faces that carry these marks narrower than a full em.
|
||||
let marks = ",。、;:!?()【】《》「」“”‘’…·";
|
||||
for family in ["Noto Sans CJK SC", "Source Sans 3"] {
|
||||
for c in marks.chars() {
|
||||
let st = CharStyle { font_family: family.into(), size: 20.0, ..CharStyle::default() };
|
||||
let l = layout(db(), &TextObject::point(Point::ZERO, c.to_string().as_str(), st));
|
||||
let Some(g) = l.glyphs.first() else { panic!("{family} {c}: no glyph") };
|
||||
assert!(g.advance > 0.0, "{family} {c}: advance {} squeezed to nothing", g.advance);
|
||||
assert!(g.gid != 0, "{family} {c}: missing glyph (gid 0)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_brackets_do_not_overlap_the_enclosed_text() {
|
||||
if !has_chinese_font() {
|
||||
return;
|
||||
}
|
||||
let l = layout(db(), &TextObject::point(Point::ZERO, "甲(乙)丙《丁》戊", style(20.0)));
|
||||
for pair in l.glyphs.windows(2) {
|
||||
let a = pair[0].outline.bounding_box();
|
||||
let b = pair[1].outline.bounding_box();
|
||||
assert!(a.x1 <= b.x0 + 0.01, "overlapping ink: {a:?} and {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_curly_quotes_and_apostrophes_keep_the_fonts_spacing() {
|
||||
for c in ['‘', '’', '“', '”'] {
|
||||
let raw = raw_advance("Source Sans 3", c, 40.0);
|
||||
let laid = laid_advance("Source Sans 3", c, 40.0);
|
||||
assert!((raw - laid).abs() < 1e-6, "{c}: {raw} vs {laid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_frames_keep_brackets_with_the_enclosed_character() {
|
||||
if !has_chinese_font() {
|
||||
return;
|
||||
}
|
||||
let text = "甲甲《乙》丙丙丙";
|
||||
for width in [12.0, 24.0, 30.0, 40.0, 50.0] {
|
||||
let l = layout(db(), &cjk_area(text, Rect::new(0.0, 0.0, width, 1000.0), Justify::Left));
|
||||
for line in &l.lines {
|
||||
let s = &text[line.start..line.end];
|
||||
assert!(!s.starts_with('》') && !s.ends_with('《'), "width {width}: {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_chinese_reaches_the_frame_edge() {
|
||||
if !has_chinese_font() {
|
||||
return;
|
||||
}
|
||||
let text: String = "中文排版测试两端对齐标点挤压".repeat(12);
|
||||
let frame = Rect::new(0.0, 0.0, 200.0, 4000.0);
|
||||
let l = layout(db(), &cjk_area(&text, frame, Justify::JustifyLeft));
|
||||
assert!(l.lines.len() > 3, "the text wraps: {} lines", l.lines.len());
|
||||
for line in &l.lines[..l.lines.len() - 1] {
|
||||
let avail = line.avail.1 - line.avail.0;
|
||||
assert!((line.x1 - line.x0 - avail).abs() < 1e-6, "justified line does not reach the edge: {line:?}");
|
||||
}
|
||||
// The last line is left ragged rather than stretched.
|
||||
assert!(l.lines.last().is_some_and(|l| l.x1 - l.x0 < 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_justification_does_not_track_out_latin_words() {
|
||||
let frame = Rect::new(0.0, 0.0, 70.0, 4000.0);
|
||||
let mut text = cjk_area("abcdefghijklmnopqrstuvwx".repeat(4).as_str(), frame, Justify::Left);
|
||||
text.runs[0].style.font_family = "Source Sans 3".into();
|
||||
let left = layout(db(), &text);
|
||||
text.para.justify = Justify::JustifyLeft;
|
||||
let justified = layout(db(), &text);
|
||||
assert!(left.lines.len() > 2);
|
||||
for (a, b) in left.glyphs.iter().zip(&justified.glyphs) {
|
||||
assert!((a.advance - b.advance).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_never_begin_with_closing_punctuation_or_end_with_an_opening_one() {
|
||||
if !has_chinese_font() {
|
||||
return;
|
||||
}
|
||||
let text: String = "他说:「中文排版,非常讲究。」然后他走了。".repeat(20);
|
||||
let l = layout(db(), &cjk_area(&text, Rect::new(0.0, 0.0, 120.0, 6000.0), Justify::Left));
|
||||
assert!(l.lines.len() > 5);
|
||||
for line in &l.lines {
|
||||
let s = text.get(line.start..line.end).unwrap_or("");
|
||||
let first = s.chars().next();
|
||||
assert!(first.is_none_or(|c| !",。、;:!?)】》」”’…".contains(c)), "line begins with closing punctuation {first:?}: {s:?}");
|
||||
let last = s.chars().next_back();
|
||||
assert!(last.is_none_or(|c| !"(【《「“‘[{".contains(c)), "line ends with an opening bracket {last:?}: {s:?}");
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui:
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add);
|
||||
}
|
||||
@@ -193,20 +193,27 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
sync(app, &mut d);
|
||||
let t = Tokens::get(ctx);
|
||||
let mut b = Buttons::default();
|
||||
let width = (ctx.content_rect().width() - 36.0).clamp(360.0, 1020.0);
|
||||
let height = (ctx.content_rect().height() - 48.0).clamp(200.0, HEIGHT + 36.0);
|
||||
window(ctx, KIND, 0, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.horizontal_top(|ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(640.0);
|
||||
ui.set_min_height(HEIGHT - 32.0);
|
||||
ui.set_width((width - DETAILS - 72.0).max(160.0));
|
||||
ui.set_height(height - 32.0);
|
||||
presets(app, ui, &mut d);
|
||||
});
|
||||
});
|
||||
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(DETAILS);
|
||||
ui.set_min_height(HEIGHT);
|
||||
details(app, ui, &mut d, &mut b);
|
||||
ui.set_height(height - 36.0);
|
||||
egui::ScrollArea::vertical().id_salt("newdoc-details").max_height(height - 80.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
details(app, ui, &mut d);
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
footer(ui, &mut b, "Create");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,7 +226,8 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let names: Vec<&str> = newdoc::category_names().collect();
|
||||
let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0);
|
||||
if let Some(i) = widgets::tab_bar(ui, &names, cat) {
|
||||
let tab = egui::ScrollArea::horizontal().id_salt("newdoc-tabs").show(ui, |ui| widgets::tab_bar(ui, &names, cat)).inner;
|
||||
if let Some(i) = tab {
|
||||
d.fields.insert("category".into(), json!(names[i]));
|
||||
}
|
||||
let list = names.get(cat).and_then(|c| newdoc::category(&app.session, c)).unwrap_or_default();
|
||||
@@ -238,7 +246,7 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.label(egui::RichText::new(hint).color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(ui.available_height()).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
|
||||
let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"));
|
||||
@@ -296,10 +304,9 @@ pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::
|
||||
}
|
||||
|
||||
/// The Preset Details column.
|
||||
fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut Buttons) {
|
||||
fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0);
|
||||
let top = ui.cursor().top();
|
||||
ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -351,10 +358,11 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
|
||||
if widgets::flat_button(ui, tl!("More Settings"), DETAILS).clicked() {
|
||||
d.kind = MORE.into();
|
||||
}
|
||||
// Close and Create at the bottom right.
|
||||
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
|
||||
}
|
||||
|
||||
fn footer(ui: &mut egui::Ui, b: &mut Buttons, create: &str) {
|
||||
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
b.create = widgets::primary_button(ui, tl!("Create")).clicked();
|
||||
b.create = widgets::primary_button(ui, tl!(create)).clicked();
|
||||
ui.add_space(8.0);
|
||||
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
|
||||
});
|
||||
@@ -491,87 +499,89 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.set_width(560.0);
|
||||
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
widgets::label_row(ui, tl!("Name:"), L, |ui| {
|
||||
form::text(ui, &mut d, "name", 300.0);
|
||||
});
|
||||
// Profile: the preset categories (the tabs); Size: the profile's presets.
|
||||
let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect();
|
||||
let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]");
|
||||
widgets::label_row(ui, tl!("Profile:"), L, |ui| {
|
||||
if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) {
|
||||
d.fields.insert("category".into(), json!(p));
|
||||
if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) {
|
||||
apply(&mut d, &first);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let (lay, cols, spacing, rtl) = layout(&d);
|
||||
let n = d.f64("artboards", 1.0);
|
||||
let several = n > 1.0;
|
||||
widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| {
|
||||
artboards(ui, &mut d, 76.0);
|
||||
ui.add_space(10.0);
|
||||
for (l, icon) in [
|
||||
(ArtboardLayout::GridByRow, "dc-grid-view"),
|
||||
(ArtboardLayout::GridByColumn, "grid-3x3"),
|
||||
(ArtboardLayout::Row, "arrow-left-right"),
|
||||
(ArtboardLayout::Column, "arrow-up-down"),
|
||||
] {
|
||||
if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() {
|
||||
set_layout(&mut d, "layout", json!(l.id()));
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() {
|
||||
set_layout(&mut d, "rightToLeft", json!(!rtl));
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(several, |ui| {
|
||||
widgets::label_row(ui, tl!("Spacing:"), L, |ui| {
|
||||
if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) {
|
||||
set_layout(&mut d, "spacing", json!(v.max(0.0)));
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
|
||||
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
|
||||
ui.add_enabled_ui(grid, |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
|
||||
set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64));
|
||||
egui::ScrollArea::vertical().id_salt("newdoc-more-body").max_height((ctx.content_rect().height() - 160.0).max(100.0)).show(ui, |ui| {
|
||||
widgets::label_row(ui, tl!("Name:"), L, |ui| {
|
||||
form::text(ui, &mut d, "name", 300.0);
|
||||
});
|
||||
// Profile: the preset categories (the tabs); Size: the profile's presets.
|
||||
let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect();
|
||||
let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]");
|
||||
widgets::label_row(ui, tl!("Profile:"), L, |ui| {
|
||||
if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) {
|
||||
d.fields.insert("category".into(), json!(p));
|
||||
if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) {
|
||||
apply(&mut d, &first);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let (lay, cols, spacing, rtl) = layout(&d);
|
||||
let n = d.f64("artboards", 1.0);
|
||||
let several = n > 1.0;
|
||||
widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| {
|
||||
artboards(ui, &mut d, 76.0);
|
||||
ui.add_space(10.0);
|
||||
for (l, icon) in [
|
||||
(ArtboardLayout::GridByRow, "dc-grid-view"),
|
||||
(ArtboardLayout::GridByColumn, "grid-3x3"),
|
||||
(ArtboardLayout::Row, "arrow-left-right"),
|
||||
(ArtboardLayout::Column, "arrow-up-down"),
|
||||
] {
|
||||
if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() {
|
||||
set_layout(&mut d, "layout", json!(l.id()));
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() {
|
||||
set_layout(&mut d, "rightToLeft", json!(!rtl));
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(several, |ui| {
|
||||
widgets::label_row(ui, tl!("Spacing:"), L, |ui| {
|
||||
if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) {
|
||||
set_layout(&mut d, "spacing", json!(v.max(0.0)));
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
|
||||
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
|
||||
ui.add_enabled_ui(grid, |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
|
||||
set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let sizes = newdoc::category(&app.session, profile).unwrap_or_default();
|
||||
let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect();
|
||||
let preset = d.str("preset");
|
||||
widgets::label_row(ui, tl!("Size:"), L, |ui| {
|
||||
// The profile's sizes are built in; the preset may be one the user saved.
|
||||
let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) };
|
||||
if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) {
|
||||
apply(&mut d, s);
|
||||
}
|
||||
});
|
||||
let unit = unit(&d);
|
||||
widgets::label_row(ui, tl!("Width:"), L, |ui| {
|
||||
form::length(ui, &mut d, "width", unit, 120.0);
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Units:")).color(t.text));
|
||||
units_dropdown(ui, &mut d, 120.0);
|
||||
});
|
||||
widgets::label_row(ui, tl!("Height:"), L, |ui| {
|
||||
form::length(ui, &mut d, "height", unit, 120.0);
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text));
|
||||
orientation(ui, &mut d);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0));
|
||||
ui.add_space(4.0);
|
||||
widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0));
|
||||
ui.add_space(8.0);
|
||||
advanced(ui, &mut d, L, 220.0);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let sizes = newdoc::category(&app.session, profile).unwrap_or_default();
|
||||
let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect();
|
||||
let preset = d.str("preset");
|
||||
widgets::label_row(ui, tl!("Size:"), L, |ui| {
|
||||
// The profile's sizes are built in; the preset may be one the user saved.
|
||||
let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) };
|
||||
if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) {
|
||||
apply(&mut d, s);
|
||||
}
|
||||
});
|
||||
let unit = unit(&d);
|
||||
widgets::label_row(ui, tl!("Width:"), L, |ui| {
|
||||
form::length(ui, &mut d, "width", unit, 120.0);
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Units:")).color(t.text));
|
||||
units_dropdown(ui, &mut d, 120.0);
|
||||
});
|
||||
widgets::label_row(ui, tl!("Height:"), L, |ui| {
|
||||
form::length(ui, &mut d, "height", unit, 120.0);
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text));
|
||||
orientation(ui, &mut d);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0));
|
||||
ui.add_space(4.0);
|
||||
widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0));
|
||||
ui.add_space(8.0);
|
||||
advanced(ui, &mut d, L, 220.0);
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked();
|
||||
@@ -602,6 +612,37 @@ mod tests {
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
#[test]
|
||||
fn create_controls_stay_visible_on_short_screens() {
|
||||
for height in [500.0, 600.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.newDialog", json!({})).unwrap();
|
||||
set(&mut app, "advanced", json!(true));
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, height));
|
||||
for (kind, label) in [(super::KIND, "Create"), (super::MORE, "Create Document")] {
|
||||
app.ui.dialog.as_mut().unwrap().kind = kind.into();
|
||||
let mut found = false;
|
||||
for _ in 0..4 {
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
for shape in &out.shapes {
|
||||
if let egui::Shape::Text(text) = &shape.shape
|
||||
&& text.galley.text() == label
|
||||
{
|
||||
assert!(screen.contains_rect(text.galley.rect.translate(text.pos.to_vec2())), "{kind}: create button outside screen");
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert!(found, "{kind}: visible create button");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cards translate the built-in presets' names only: Recent and Saved show the user's names.
|
||||
#[test]
|
||||
fn only_built_in_preset_names_are_translated() {
|
||||
|
||||
+94
-27
@@ -86,38 +86,57 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.ui.open_panel.clone() else { return };
|
||||
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
|
||||
let display = tl!(label);
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256
|
||||
// points grows towards the canvas rather than over the panel icons.
|
||||
let right = screen.right() - ICON_COL - 300.0 - 6.0;
|
||||
let rail = egui::containers::panel::PanelState::load(ctx, egui::Id::new("icon_column"))
|
||||
.map_or(screen.right() - ICON_COL - 300.0, |p| p.outer_rect.left());
|
||||
let right = (rail - 2.0).max(screen.left() + 1.0);
|
||||
let top = (screen.top() + 110.0).min(screen.bottom() - 100.0).max(screen.top());
|
||||
let bounds = egui::Rect::from_min_max(screen.left_top(), egui::pos2(right, screen.bottom()));
|
||||
let width = (bounds.width() - 2.0).clamp(1.0, 400.0);
|
||||
let mut open = true;
|
||||
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
|
||||
area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
|
||||
ui.set_width(256.0);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
|
||||
let tab = egui::Rect::from_min_size(
|
||||
strip.min,
|
||||
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
|
||||
);
|
||||
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
|
||||
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
|
||||
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
|
||||
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
|
||||
if cr.clicked() {
|
||||
open = false;
|
||||
}
|
||||
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
|
||||
panels::panel_menu(app, ui, &id, menu_r);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(236.0);
|
||||
panels::show_icon_panel(app, ui, &id);
|
||||
egui::Area::new(egui::Id::new("icon-panel"))
|
||||
.order(egui::Order::Foreground)
|
||||
.anchor(egui::Align2::RIGHT_TOP, vec2(0.0, top - screen.top()))
|
||||
.constrain_to(bounds)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
|
||||
ui.set_min_width(width.min(280.0));
|
||||
ui.set_max_width(width);
|
||||
ui.set_max_height((screen.bottom() - top - 2.0).max(28.0));
|
||||
let (mut strip, _) = ui.allocate_exact_size(vec2(width.min(280.0), 26.0), Sense::hover());
|
||||
egui::ScrollArea::both()
|
||||
.id_salt(("icon-panel-body", &id))
|
||||
.max_width(width)
|
||||
.max_height((screen.bottom() - top - 34.0).max(1.0))
|
||||
.auto_shrink([true, true])
|
||||
.show(ui, |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(width.min(280.0) - 20.0);
|
||||
panels::show_icon_panel(app, ui, &id);
|
||||
});
|
||||
});
|
||||
// Content can require more room than the preferred width. Size the entire header
|
||||
// after laying it out, keeping its controls at the actual panel edge.
|
||||
strip.max.x = ui.min_rect().right();
|
||||
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
|
||||
let tab = egui::Rect::from_min_size(
|
||||
strip.min,
|
||||
vec2(ui.painter().layout_no_wrap(display.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
|
||||
);
|
||||
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, display, theme::semibold(12.0), t.text);
|
||||
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
|
||||
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
|
||||
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
|
||||
if cr.clicked() {
|
||||
open = false;
|
||||
}
|
||||
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
|
||||
panels::panel_menu(app, ui, &id, menu_r);
|
||||
});
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
@@ -131,6 +150,54 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::toolbar::tests::{wheel, widget_rects};
|
||||
|
||||
#[test]
|
||||
fn all_floating_panels_fit_beside_the_actual_dock_and_share_a_full_width_header() {
|
||||
for (size, dock_width, document) in
|
||||
[(vec2(1440.0, 900.0), 230.0, true), (vec2(800.0, 500.0), 520.0, true), (vec2(1440.0, 900.0), 300.0, false)]
|
||||
{
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
if document {
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
}
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
for &(id, _, _) in ICON_PANELS {
|
||||
app.ui.open_panel = Some(id.into());
|
||||
app.ui.status.clear();
|
||||
let mut out = egui::FullOutput::default();
|
||||
for k in 0..4 {
|
||||
out = ctx.run_ui(egui::RawInput { time: Some(f64::from(k) + 1.0), screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
egui::Panel::right("dock").exact_size(dock_width).show(ui, |_| {});
|
||||
egui::Panel::right("icon_column").exact_size(ICON_COL).show(ui, |_| {});
|
||||
floating_panel(&mut app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let area = egui::AreaState::load(&ctx, egui::Id::new("icon-panel")).unwrap().rect();
|
||||
let rail = egui::containers::panel::PanelState::load(&ctx, egui::Id::new("icon_column")).unwrap().outer_rect.left();
|
||||
assert!(screen.contains_rect(area), "{id}: {area:?} outside {screen:?}");
|
||||
assert!(app.ui.status.is_empty(), "{id}: painting a panel reported an error: {}", app.ui.status);
|
||||
assert!(area.right() <= rail, "{id}: panel overlaps the icon column");
|
||||
if id == "stroke" && size.y >= 900.0 {
|
||||
assert!(area.height() > 400.0, "a tall panel should use available height before scrolling: {area:?}");
|
||||
}
|
||||
let header = out
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).panel_darker && (r.rect.height() - 26.0).abs() < 0.1 => Some(r.rect),
|
||||
_ => None,
|
||||
})
|
||||
.expect("header");
|
||||
assert!((header.width() - area.width()).abs() <= 2.0, "{id}: header {header:?}, panel {area:?}");
|
||||
assert!(widget_rects(&ctx, vec2(14.0, 14.0)).iter().any(|r| header.contains_rect(*r)), "{id}: close control inside header");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_flyout_stays_left_of_the_icon_column() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -60,7 +60,7 @@ fn plural_czech(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 4] = [
|
||||
pub static LANGUAGES: [LangInfo; 5] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs
|
||||
// not yet. `complete_menus` once the catalog covers every menu string and `tl!` literal.
|
||||
@@ -77,6 +77,14 @@ pub static LANGUAGES: [LangInfo; 4] = [
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "zh-hans",
|
||||
name: "简体中文",
|
||||
source: include_str!("zh-hans.tsv"),
|
||||
plural: plural_none,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
|
||||
@@ -23,7 +23,7 @@ fn tags_map_to_languages() {
|
||||
// Simplified Chinese locales never pick up the Traditional catalog (they resolve to a
|
||||
// `zh-hans` catalog once one is registered, and to English until then).
|
||||
for tag in ["zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh"] {
|
||||
assert_ne!(lang_from_tag(tag), Some(ZH()), "{tag}");
|
||||
assert_eq!(lang_from_tag(tag), Lang::from_code("zh-hans"), "{tag}");
|
||||
}
|
||||
assert_eq!(lang_from_tag(""), None);
|
||||
assert_eq!(lang_from_tag("_"), None);
|
||||
@@ -51,6 +51,7 @@ fn os_language_lists_are_parsed() {
|
||||
|
||||
#[test]
|
||||
fn preferences_resolve_with_fallback() {
|
||||
assert_eq!(Lang::from_pref("zh-Hans"), Lang::from_code("zh-hans").unwrap());
|
||||
assert_eq!(Lang::from_pref("zh-hant"), ZH());
|
||||
assert_eq!(Lang::from_pref("ZH-Hant"), ZH());
|
||||
assert_eq!(Lang::from_pref("en"), Lang::EN);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -274,6 +274,7 @@ pub struct VectorcraftApp {
|
||||
/// Synthetic input events (from the control channel) injected one step per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
styled: bool,
|
||||
font_language: Option<&'static str>,
|
||||
fonts_ready: bool,
|
||||
/// Installed fonts added to the UI's for characters its own fonts lack (CJK names…).
|
||||
ui_fonts: ui_fonts::UiFonts,
|
||||
@@ -360,6 +361,7 @@ impl VectorcraftApp {
|
||||
screenshot_token: 0,
|
||||
synthetic: vec![],
|
||||
styled: false,
|
||||
font_language: None,
|
||||
fonts_ready: false,
|
||||
ui_fonts: Default::default(),
|
||||
frame: 0,
|
||||
@@ -669,8 +671,11 @@ impl VectorcraftApp {
|
||||
|
||||
fn logic_frame(&mut self, ctx: &egui::Context) {
|
||||
i18n::set_current(self.ui_language());
|
||||
if !self.styled {
|
||||
if !self.styled || self.font_language != Some(self.ui_language().code()) {
|
||||
theme::install_fonts(ctx);
|
||||
self.font_language = Some(self.ui_language().code());
|
||||
self.fonts_ready = false;
|
||||
self.ui_fonts = Default::default();
|
||||
theme::apply(ctx, self.ui.brightness);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
self.styled = true;
|
||||
|
||||
@@ -17,6 +17,9 @@ const KINDS: [(&str, &str); 5] =
|
||||
|
||||
/// (name, type) of every brush in the active document's library.
|
||||
pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<String>) {
|
||||
if app.session.active().is_none() {
|
||||
return (vec![], None);
|
||||
}
|
||||
let Ok(v) = app.run("brush.list", json!({})) else { return (vec![], None) };
|
||||
let list = v["brushes"]
|
||||
.as_array()
|
||||
|
||||
@@ -67,11 +67,23 @@ pub fn restore(app: &mut VectorcraftApp) {
|
||||
&& code != "en"
|
||||
&& crate::i18n::Lang::from_code(&code).is_some()
|
||||
{
|
||||
p.interface_language = code;
|
||||
p.interface_language = crate::i18n::Lang::from_code(&code).map_or(code.clone(), |lang| lang.code().to_string());
|
||||
}
|
||||
app.session.apply_prefs(p);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn old_chinese_locale_migrates_without_overwriting_an_explicit_new_preference() {
|
||||
for (prefs, expected) in [(Value::Null, "zh-hans"), (json!({"interfaceLanguage": "ja"}), "ja")] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui = serde_json::from_value(json!({"locale": "zh-Hans", "engine_prefs": prefs})).unwrap();
|
||||
restore(&mut app);
|
||||
assert_eq!(app.session.prefs.interface_language, expected);
|
||||
assert!(app.ui.legacy_language.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Applied {
|
||||
brightness: Brightness,
|
||||
|
||||
@@ -215,7 +215,7 @@ pub struct UiState {
|
||||
/// The interface language older versions saved here (`ja`, `cs`…). It now lives in the
|
||||
/// `interfaceLanguage` preference, which [`crate::prefs_dialog::restore`] carries it over to;
|
||||
/// it is never written back.
|
||||
#[serde(rename = "language", skip_serializing)]
|
||||
#[serde(rename = "language", alias = "locale", skip_serializing)]
|
||||
pub legacy_language: Option<String>,
|
||||
pub brightness: Brightness,
|
||||
pub dock_tab: DockTab,
|
||||
|
||||
@@ -267,12 +267,20 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
|
||||
/// every family, after the app's own fonts and before the installed fonts `ui_fonts` adds: BIZ
|
||||
/// UDPGothic first (bold first in the semibold family), then the Mincho faces.
|
||||
fn add_craft_fonts(fonts: &mut FontDefinitions) {
|
||||
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) {
|
||||
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
|
||||
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.scripts.contains(&"Hans")) {
|
||||
let data = if f.scripts.contains(&"Hans") { crate::ui_fonts::craft_font_data(f) } else { FontData::from_static(f.bytes) };
|
||||
fonts.font_data.insert(craft_font_name(f), Arc::new(data));
|
||||
}
|
||||
for (family, stack) in fonts.families.iter_mut() {
|
||||
let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into());
|
||||
let chinese: Vec<String> = vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans")).map(craft_font_name).collect();
|
||||
if crate::i18n::current().code() == "zh-hans" {
|
||||
stack.extend(chinese.iter().cloned());
|
||||
}
|
||||
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name));
|
||||
if crate::i18n::current().code() != "zh-hans" {
|
||||
stack.extend(chinese);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-29
@@ -12,20 +12,23 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::epaint::Shape;
|
||||
use egui::epaint::text::FontTweak;
|
||||
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
|
||||
use egui::{FontData, FontFamily, FontId, LayerId};
|
||||
use vectorcraft_text::{FontDb, FontFace};
|
||||
|
||||
type FallbackFace = (FontFamily, Arc<FontFace>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct UiFonts {
|
||||
/// Characters looked at already: the UI fonts have them, a fallback was added (or is being
|
||||
/// looked for), or no font has them.
|
||||
seen: HashSet<char>,
|
||||
seen: HashSet<(FontFamily, char)>,
|
||||
/// The fonts added to egui, by name.
|
||||
added: HashSet<String>,
|
||||
added: HashSet<(FontFamily, String)>,
|
||||
/// The faces covering the characters being looked for (native: looked for on a thread).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pending: Option<std::sync::mpsc::Receiver<Vec<Arc<FontFace>>>>,
|
||||
pending: Option<std::sync::mpsc::Receiver<Vec<FallbackFace>>>,
|
||||
}
|
||||
|
||||
impl UiFonts {
|
||||
@@ -42,12 +45,14 @@ impl UiFonts {
|
||||
return;
|
||||
}
|
||||
let find = move || {
|
||||
let mut faces: Vec<Arc<FontFace>> = vec![];
|
||||
for c in missing {
|
||||
if let Some(f) = FontDb::global().face_covering(c)
|
||||
&& !faces.iter().any(|x| x.id() == f.id())
|
||||
let mut faces: Vec<FallbackFace> = vec![];
|
||||
for (family, c) in missing {
|
||||
// The face is looked up at the weight the character was painted in, so a semibold
|
||||
// heading gets a semibold CJK face rather than the family's regular one.
|
||||
if let Some(f) = FontDb::global().face_covering_weighted(c, family_weight(&family))
|
||||
&& !faces.iter().any(|(fam, x)| *fam == family && x.id() == f.id())
|
||||
{
|
||||
faces.push(f);
|
||||
faces.push((family, f));
|
||||
}
|
||||
}
|
||||
faces
|
||||
@@ -74,7 +79,7 @@ impl UiFonts {
|
||||
|
||||
/// Characters painted this frame that the UI fonts lack and haven't been looked for, now
|
||||
/// marked seen. None while a search is running.
|
||||
fn missing(&mut self, ctx: &egui::Context) -> Vec<char> {
|
||||
fn missing(&mut self, ctx: &egui::Context) -> Vec<(FontFamily, char)> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.pending.is_some() {
|
||||
return vec![];
|
||||
@@ -83,12 +88,17 @@ impl UiFonts {
|
||||
if !layers.contains(&LayerId::background()) {
|
||||
layers.push(LayerId::background());
|
||||
}
|
||||
let mut chars: Vec<char> = vec![];
|
||||
let mut chars = HashSet::new();
|
||||
let seen = &self.seen;
|
||||
let mut note = |text: &str| {
|
||||
let mut note = |text: &str, family: &FontFamily, _size: f32| {
|
||||
// Most UI text is ASCII, which the UI fonts cover.
|
||||
if !text.is_ascii() {
|
||||
chars.extend(text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !c.is_control() && !seen.contains(c)));
|
||||
chars.extend(
|
||||
text.chars()
|
||||
.filter(|c| !c.is_ascii() && !c.is_whitespace() && !c.is_control())
|
||||
.map(|c| (family.clone(), c))
|
||||
.filter(|key| !seen.contains(key)),
|
||||
);
|
||||
}
|
||||
};
|
||||
ctx.graphics(|g| {
|
||||
@@ -98,30 +108,25 @@ impl UiFonts {
|
||||
}
|
||||
}
|
||||
});
|
||||
if chars.is_empty() {
|
||||
return chars;
|
||||
}
|
||||
chars.sort_unstable();
|
||||
chars.dedup();
|
||||
let font = FontId::new(13.0, FontFamily::Proportional);
|
||||
let missing: Vec<char> = ctx.fonts_mut(|f| chars.iter().copied().filter(|c| !f.has_glyph(&font, *c)).collect());
|
||||
let missing = ctx.fonts_mut(|f| chars.iter().filter(|(family, c)| !f.has_glyph(&FontId::new(13.0, family.clone()), *c)).cloned().collect());
|
||||
self.seen.extend(chars);
|
||||
missing
|
||||
}
|
||||
|
||||
/// Add `faces` to every egui family, after the fonts it has.
|
||||
fn install(&mut self, ctx: &egui::Context, faces: &[Arc<FontFace>]) {
|
||||
let families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
|
||||
for face in faces {
|
||||
/// Add each face only to the family that requested its weight.
|
||||
fn install(&mut self, ctx: &egui::Context, faces: &[FallbackFace]) {
|
||||
for (family, face) in faces {
|
||||
let name = format!("{} {} #{}", face.family, face.style, face.face_index());
|
||||
if !self.added.insert(name.clone()) {
|
||||
if !self.added.insert((family.clone(), name.clone())) {
|
||||
continue;
|
||||
}
|
||||
// The face's file data, shared with the process-wide font database (which keeps every
|
||||
// face it loads for the session) instead of copied: one handle is leaked per font added.
|
||||
let face: &'static FontFace = Box::leak(Box::new(face.clone()));
|
||||
let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) };
|
||||
let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect();
|
||||
// Align fallback metrics with the Latin UI baseline without changing the point size.
|
||||
let tweak = latin_face().map_or_else(FontTweak::default, |latin| baseline_tweak(face, &latin));
|
||||
let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }.tweak(tweak);
|
||||
let families = vec![InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }];
|
||||
ctx.add_font(FontInsert { name, data, families });
|
||||
}
|
||||
if !faces.is_empty() {
|
||||
@@ -139,18 +144,86 @@ impl UiFonts {
|
||||
}
|
||||
|
||||
/// Call `f` with the text of `shape` and the shapes it holds.
|
||||
fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) {
|
||||
fn painted_text(shape: &Shape, f: &mut impl FnMut(&str, &FontFamily, f32)) {
|
||||
match shape {
|
||||
Shape::Text(t) => f(t.galley.text()),
|
||||
Shape::Text(t) => {
|
||||
let text = t.galley.text();
|
||||
for section in &t.galley.job.sections {
|
||||
let Some(slice) = text.get(section.byte_range.start.0..section.byte_range.end.0) else { continue };
|
||||
f(slice, §ion.format.font_id.family, section.format.font_id.size);
|
||||
}
|
||||
}
|
||||
Shape::Vec(v) => v.iter().for_each(|s| painted_text(s, f)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn baseline_tweak(face: &FontFace, family: &FontFace) -> FontTweak {
|
||||
FontTweak { scale: 1.0, y_offset_factor: baseline_tweak_factor(face, family), ..Default::default() }
|
||||
}
|
||||
|
||||
fn baseline_tweak_factor(face: &FontFace, family: &FontFace) -> f32 {
|
||||
let em = |f: &FontFace| {
|
||||
let (ascent, descent) = f.vertical_metrics();
|
||||
let upem = f.units_per_em().max(1.0);
|
||||
(ascent / upem, (ascent + descent) / upem)
|
||||
};
|
||||
let (face_ascent, face_height) = em(face);
|
||||
let (family_ascent, family_height) = em(family);
|
||||
baseline_factor(face_ascent, face_height, family_ascent, family_height)
|
||||
}
|
||||
|
||||
fn baseline_factor(face_ascent: f64, face_height: f64, family_ascent: f64, family_height: f64) -> f32 {
|
||||
// Positive `y_offset_factor` moves a glyph down, and epaint counts the tweak in the pen
|
||||
// position as well as the raster offset, so the correction is twice the fallback's own
|
||||
// displacement, with the sign that brings it back to the family's ink.
|
||||
(2.0 * (face_ascent - family_ascent + 0.5 * (family_height - face_height))) as f32
|
||||
}
|
||||
|
||||
fn latin_face() -> Option<Arc<FontFace>> {
|
||||
static METRICS: std::sync::OnceLock<Option<Arc<FontFace>>> = std::sync::OnceLock::new();
|
||||
METRICS
|
||||
.get_or_init(|| {
|
||||
let db = vectorcraft_text::FontDb::with_font_dirs(Vec::new());
|
||||
db.add_font(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec());
|
||||
db.face("Source Sans 3", "Regular")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn family_weight(family: &FontFamily) -> f32 {
|
||||
if *family == FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into()) { 600.0 } else { 400.0 }
|
||||
}
|
||||
|
||||
pub(crate) fn craft_font_data(font: &vectorcraft_text::CraftFont) -> FontData {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
let db = DB.get_or_init(|| FontDb::with_font_dirs(Vec::new()));
|
||||
let tweak = db.face(font.family, font.style).zip(latin_face()).map_or_else(FontTweak::default, |(face, latin)| baseline_tweak(&face, &latin));
|
||||
FontData::from_static(font.bytes).tweak(tweak)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_characters_are_tracked_per_family_not_font_size() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut fonts = UiFonts::default();
|
||||
ctx.run_ui(Default::default(), |ui| {
|
||||
ui.label(egui::RichText::new("\u{10FFFD}").font(FontId::proportional(26.0)));
|
||||
ui.label(egui::RichText::new("\u{10FFFD}").font(crate::theme::semibold(12.0)));
|
||||
let missing = fonts.missing(ui.ctx());
|
||||
assert_eq!(missing.len(), 2);
|
||||
let mut weights: Vec<_> = missing.iter().map(|(family, _)| family_weight(family) as u32).collect();
|
||||
weights.sort_unstable();
|
||||
assert_eq!(weights, [400, 600]);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
/// Run a frame painting `text`, then the fallback search after it.
|
||||
fn frame(ctx: &egui::Context, fonts: &mut UiFonts, text: &str) {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
@@ -224,7 +297,9 @@ mod tests {
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut fonts = UiFonts::default();
|
||||
frame(&ctx, &mut fonts, text);
|
||||
assert!(!has_glyph(&ctx, '标'), "the UI fonts have no CJK");
|
||||
if vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Hans")) {
|
||||
assert!(has_glyph(&ctx, '标'), "Chinese is available without waiting for system fonts");
|
||||
}
|
||||
fonts.finish(&ctx);
|
||||
// The fonts arrive with the next frame.
|
||||
frame(&ctx, &mut fonts, text);
|
||||
|
||||
Reference in new issue
Block a user