mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
This commit is contained in:
1 parent
352c849139
commit
c08322b7bc
20 files changed
+1638
-55
No files matched your search
@@ -18,9 +18,9 @@ pub enum Kerning {
|
||||
/// Font kerning (`kern` feature).
|
||||
#[default]
|
||||
Metrics,
|
||||
/// Photoshop's optical kerning; rendered like `Metrics` for now.
|
||||
/// Photoshop's optical kerning: pair spacing computed from the glyph outlines.
|
||||
Optical,
|
||||
/// No kerning.
|
||||
/// No automatic kerning (Photoshop's "0" / manual kerning).
|
||||
Off,
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ pub struct CharStyle {
|
||||
pub strikethrough: bool,
|
||||
pub faux_bold: bool,
|
||||
pub faux_italic: bool,
|
||||
/// Automatic (pair) kerning mode.
|
||||
pub kerning: Kerning,
|
||||
/// Manual kerning in 1/1000 em between this character and the next one (Photoshop stores it
|
||||
/// per character run as `Kerning`). Added to the automatic kerning of the pair.
|
||||
pub kern: f32,
|
||||
pub caps: Caps,
|
||||
/// Standard ligatures (`liga`/`clig`).
|
||||
pub ligatures: bool,
|
||||
@@ -111,6 +115,7 @@ impl Default for CharStyle {
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
kerning: Kerning::Metrics,
|
||||
kern: 0.0,
|
||||
caps: Caps::Normal,
|
||||
ligatures: true,
|
||||
discretionary_ligatures: false,
|
||||
|
||||
@@ -82,6 +82,24 @@ fn range_param(text: &str, p: &Value) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's manual kerning range (1/1000 em).
|
||||
pub const KERN_MIN: f64 = -1000.0;
|
||||
pub const KERN_MAX: f64 = 10_000.0;
|
||||
|
||||
/// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or
|
||||
/// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`).
|
||||
pub fn check_kerning(p: &Value) -> std::result::Result<(), String> {
|
||||
match p.get("kerning") {
|
||||
None => Ok(()),
|
||||
Some(Value::Number(n)) => match n.as_f64() {
|
||||
Some(k) if (KERN_MIN..=KERN_MAX).contains(&k) => Ok(()),
|
||||
_ => Err(format!("kerning must be between {KERN_MIN} and {KERN_MAX} (1/1000 em)")),
|
||||
},
|
||||
Some(Value::String(v)) if matches!(v.to_ascii_lowercase().as_str(), "metrics" | "optical" | "off" | "none" | "0") => Ok(()),
|
||||
Some(_) => Err("kerning must be a number (1/1000 em) or \"metrics\", \"optical\" or \"off\"".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies character-style keys from JSON. Returns true if any key was present.
|
||||
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
let mut any = false;
|
||||
@@ -157,14 +175,26 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
hit(true);
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("kerning").and_then(Value::as_str) {
|
||||
s.kerning = match v {
|
||||
match p.get("kerning") {
|
||||
// A number is Photoshop's manual kerning: no automatic pair kerning plus the value.
|
||||
Some(v) if v.is_number() => {
|
||||
if let Some(k) = v.as_f64().filter(|k| k.is_finite()) {
|
||||
s.kerning = Kerning::Off;
|
||||
s.kern = k.clamp(KERN_MIN, KERN_MAX) as f32;
|
||||
hit(true);
|
||||
}
|
||||
}
|
||||
Some(Value::String(v)) => {
|
||||
s.kerning = match v.to_ascii_lowercase().as_str() {
|
||||
"optical" => Kerning::Optical,
|
||||
"none" | "off" | "0" => Kerning::Off,
|
||||
_ => Kerning::Metrics,
|
||||
};
|
||||
s.kern = 0.0;
|
||||
hit(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = p.get("caps").and_then(Value::as_str) {
|
||||
s.caps = match v {
|
||||
"small" | "smallCaps" => Caps::SmallCaps,
|
||||
@@ -435,7 +465,7 @@ fn info(s: &Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":pt,"color":"#rrggbb"|[r,g,b,a],"tracking":1/1000em,"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":"metrics|optical|none","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":pt,"color":"#rrggbb"|[r,g,b,a],"tracking":1/1000em,"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -491,13 +521,58 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Edit Type",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"text":str? (replace all, styles kept),"replace":{"start":char,"end":char,"text":str}?,"runs":[{"start":char,"end":char,…character keys}]?,"box":[x,y,w,h]? (to paragraph text),"point":[x,y]? (to point text),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"antialias":"none|sharp|crisp|strong|smooth"?,"name":str?}"##,
|
||||
params: r##"{"layer":id?,"text":str? (replace all, styles kept),"replace":{"start":char,"end":char,"text":str}?,"runs":[{"start":char,"end":char,…character keys}]?,"box":[x,y,w,h]? (to paragraph text),"point":[x,y]? (to point text),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"antialias":"none|sharp|crisp|strong|smooth"?,"name":str?,"kerning":1/1000em|"metrics"|"optical"|"off"? (with "range":[startChar,endChar]?, default all),"kernPair":{"at":caretChar,"by":1/1000em}? (Photoshop Alt+←/→: the pair before the caret becomes manual, its current kerning + by)}"##,
|
||||
enabled: has_doc,
|
||||
journal: true,
|
||||
run: |s, p| {
|
||||
let id = layer_id(s, p)?;
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
with_text_layer(s, p, "Edit Type", |t, _| {
|
||||
check_kerning(p).map_err(|m| bad("type.edit", m))?;
|
||||
if let Some(Value::Array(runs)) = p.get("runs") {
|
||||
for r in runs {
|
||||
check_kerning(r).map_err(|m| bad("type.edit", m))?;
|
||||
}
|
||||
}
|
||||
let kern_pair = match p.get("kernPair") {
|
||||
None => None,
|
||||
Some(k) => {
|
||||
let at = k.get("at").and_then(Value::as_u64).ok_or_else(|| bad("type.edit", "kernPair.at must be a caret position (character index)"))?;
|
||||
let by = k
|
||||
.get("by")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite() && v.abs() <= KERN_MAX)
|
||||
.ok_or_else(|| bad("type.edit", "kernPair.by must be a number of 1/1000 em"))?;
|
||||
Some((at as usize, by))
|
||||
}
|
||||
};
|
||||
let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" };
|
||||
with_text_layer(s, p, label, |t, doc| {
|
||||
if let Some((at, by)) = kern_pair {
|
||||
// Photoshop's Alt+←/→: the pair before the caret becomes manually kerned,
|
||||
// starting from what it shows now (its metrics/optical or manual value).
|
||||
let n = t.text.chars().count();
|
||||
if at == 0 || at >= n {
|
||||
return Err(bad("type.edit", "kernPair needs a caret between two characters"));
|
||||
}
|
||||
let a = byte_at(&t.text, at - 1);
|
||||
let b = byte_at(&t.text, at);
|
||||
let now = {
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
eng.pair_kerning(t, doc.resolution_dpi, a)
|
||||
}
|
||||
.ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?;
|
||||
let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32;
|
||||
style_range(t, a, b, &|st| {
|
||||
st.kerning = Kerning::Off;
|
||||
st.kern = value;
|
||||
});
|
||||
}
|
||||
if p.get("kerning").is_some() {
|
||||
let (a, b) = range_param(&t.text, p);
|
||||
style_range(t, a, b, &|st| {
|
||||
apply_char_props(st, &json!({ "kerning": p.get("kerning") }));
|
||||
});
|
||||
}
|
||||
if let Some(v) = p.get("text").and_then(Value::as_str) {
|
||||
let n = t.text.len();
|
||||
replace_text(t, 0, n, v);
|
||||
@@ -569,6 +644,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
journal: true,
|
||||
run: |s, p| {
|
||||
let id = layer_id(s, p)?;
|
||||
check_kerning(p).map_err(|m| bad("type.setStyle", m))?;
|
||||
with_text_layer(s, p, "Set Type Style", |t, _| {
|
||||
let (a, b) = range_param(&t.text, p);
|
||||
let mut probe = CharStyle::default();
|
||||
@@ -793,4 +869,129 @@ mod tests {
|
||||
assert_eq!(t.runs[0].len, 3);
|
||||
assert_eq!(t.runs[0].style.size_pt, 40.0);
|
||||
}
|
||||
|
||||
fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> {
|
||||
let t = text_layer(s, id);
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
for r in t.char_runs() {
|
||||
out.extend(std::iter::repeat_n((r.style.kerning, r.style.kern), t.text[at..at + r.len].chars().count()));
|
||||
at += r.len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #206: `type.edit {"kerning"}` over a range takes a number (manual, 1/1000 em) or a mode.
|
||||
#[test]
|
||||
fn edit_sets_kerning_over_a_range() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "AVAT", "size": 30})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("type.edit", json!({"layer": id, "range": [0, 1], "kerning": 100})).unwrap();
|
||||
s.execute("type.edit", json!({"layer": id, "range": [2, 4], "kerning": "optical"})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id), vec![(Kerning::Off, 100.0), (Kerning::Metrics, 0.0), (Kerning::Optical, 0.0), (Kerning::Optical, 0.0)]);
|
||||
s.execute("type.edit", json!({"layer": id, "kerning": "metrics"})).unwrap();
|
||||
assert!(kerning_of(&s, id).iter().all(|k| *k == (Kerning::Metrics, 0.0)));
|
||||
// Runs and setStyle take the same values; type.info reports them.
|
||||
s.execute("type.edit", json!({"layer": id, "runs": [{"start": 1, "end": 2, "kerning": -50}]})).unwrap();
|
||||
s.execute("type.setStyle", json!({"layer": id, "range": [3, 4], "kerning": "off"})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id)[1], (Kerning::Off, -50.0));
|
||||
assert_eq!(kerning_of(&s, id)[3], (Kerning::Off, 0.0));
|
||||
let info = s.execute("type.info", json!({"layer": id})).unwrap();
|
||||
assert_eq!(info["runs"][1]["style"]["kern"], json!(-50.0));
|
||||
}
|
||||
|
||||
/// Bad kerning params are errors, never panics, and leave the layer alone.
|
||||
#[test]
|
||||
fn kerning_params_fail_gracefully() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "AV\nT", "size": 30})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = text_layer(&s, id);
|
||||
for p in [
|
||||
json!({"kerning": "tight"}),
|
||||
json!({"kerning": 1e9}),
|
||||
json!({"kerning": -5000}),
|
||||
json!({"kerning": true}),
|
||||
json!({"kerning": [1]}),
|
||||
json!({"runs": [{"start": 0, "end": 1, "kerning": "x"}]}),
|
||||
json!({"kernPair": 3}),
|
||||
json!({"kernPair": {"by": 20}}),
|
||||
json!({"kernPair": {"at": 1}}),
|
||||
json!({"kernPair": {"at": 1, "by": "20"}}),
|
||||
json!({"kernPair": {"at": 1, "by": 1e12}}),
|
||||
json!({"kernPair": {"at": 0, "by": 20}}),
|
||||
json!({"kernPair": {"at": 4, "by": 20}}),
|
||||
json!({"kernPair": {"at": 99, "by": 20}}),
|
||||
json!({"kernPair": {"at": 2, "by": 20}}),
|
||||
json!({"kernPair": {"at": 3, "by": 20}}),
|
||||
json!({"kernPair": {"at": -1, "by": 20}}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["layer"] = json!(id);
|
||||
assert!(s.execute("type.edit", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("type.setStyle", json!({"layer": id, "kerning": "tight"})).is_err());
|
||||
assert_eq!(text_layer(&s, id).runs, before.runs);
|
||||
}
|
||||
|
||||
/// Cost of one Alt+←/→ press (`kernPair`: two measuring layouts + the edit's re-render) on
|
||||
/// a headline and on a 2000-character paragraph. Release timings for the dev log:
|
||||
/// `cargo test --release -p photocraft-engine kern_pair_cost -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn kern_pair_cost() {
|
||||
let para = "The quick brown fox jumps over the lazy dog. ".repeat(45);
|
||||
for (name, text, size, bx, kerning) in [
|
||||
("headline", "AVATAR Wave", 72, false, "metrics"),
|
||||
("paragraph", para.as_str(), 12, true, "metrics"),
|
||||
("optical paragraph", para.as_str(), 12, true, "optical"),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2000, "height": 1500})).unwrap();
|
||||
let mut p = json!({"x": 20, "y": 100, "text": text, "size": size, "font": "Inter", "kerning": kerning});
|
||||
if bx {
|
||||
p["box"] = json!([20, 20, 900, 1400]);
|
||||
}
|
||||
let id = s.execute("type.create", p).unwrap()["layer"].as_u64().unwrap();
|
||||
let n = 20;
|
||||
let t0 = std::time::Instant::now();
|
||||
for i in 0..n {
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1 + i % 3, "by": 20}})).unwrap();
|
||||
}
|
||||
let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
|
||||
let t1 = std::time::Instant::now();
|
||||
for i in 0..n {
|
||||
s.execute("type.setStyle", json!({"layer": id, "range": [1 + i % 3, 2 + i % 3], "tracking": i})).unwrap();
|
||||
}
|
||||
let base = t1.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
|
||||
eprintln!("{name} ({} chars): kernPair {per:.2} ms/press; a plain style edit {base:.2} ms", text.chars().count());
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's Alt+←/→: the pair before the caret becomes manual, starting from the kerning
|
||||
/// it shows (the font's pair kerning for "AV"), ±20 per press, one history step each.
|
||||
#[test]
|
||||
fn kern_pair_steps_from_the_shown_kerning() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "AVA", "size": 40, "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let metric = {
|
||||
let t = text_layer(&s, id);
|
||||
photocraft_text::shared().lock().unwrap().pair_kerning(&t, 72.0, 0).unwrap()
|
||||
};
|
||||
assert!(metric < -10.0, "Inter kerns AV: {metric}");
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1, "by": 20}})).unwrap();
|
||||
let k0 = kerning_of(&s, id)[0];
|
||||
assert_eq!(k0, (Kerning::Off, metric.round() + 20.0));
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1, "by": 100}})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id)[0].1, metric.round() + 120.0);
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1, "by": -20}})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id)[0].1, metric.round() + 100.0);
|
||||
// Other characters are untouched; each press is its own undoable step.
|
||||
assert_eq!(&kerning_of(&s, id)[1..], &[(Kerning::Metrics, 0.0), (Kerning::Metrics, 0.0)]);
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps + 3);
|
||||
assert!(s.undo());
|
||||
assert_eq!(kerning_of(&s, id)[0].1, metric.round() + 120.0);
|
||||
assert!(s.undo() && s.undo());
|
||||
assert_eq!(kerning_of(&s, id)[0], (Kerning::Metrics, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ fn perturbed_char() -> CharStyle {
|
||||
faux_bold: true,
|
||||
faux_italic: true,
|
||||
kerning: Kerning::Off,
|
||||
kern: -12345.0,
|
||||
caps: Caps::AllCaps,
|
||||
ligatures: false,
|
||||
discretionary_ligatures: true,
|
||||
|
||||
@@ -123,7 +123,11 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
|
||||
transform: Affine::translate(10.5, 20.25),
|
||||
cache: Some(cache.clone()),
|
||||
psd_raw: Some(blob(9, 100)),
|
||||
runs: vec![TextRun { len: 6, style: CharStyle::default() }, TextRun { len: 9, style: CharStyle::default() }],
|
||||
runs: vec![
|
||||
TextRun { len: 1, style: CharStyle { kerning: text::Kerning::Off, kern: 120.0, ..CharStyle::default() } },
|
||||
TextRun { len: 5, style: CharStyle::default() },
|
||||
TextRun { len: 9, style: CharStyle { kerning: text::Kerning::Optical, ..CharStyle::default() } },
|
||||
],
|
||||
paragraphs: vec![ParagraphRun { len: 15, style: ParagraphStyle::default() }],
|
||||
shape: TextShape::Box { x: 1.0, y: 2.0, width: 100.0, height: 50.0 },
|
||||
orientation: text::Orientation::Vertical,
|
||||
|
||||
@@ -231,6 +231,45 @@ fn vector_fields_roundtrip_and_default_when_absent() {
|
||||
assert!(old.layers.iter().all(|l| l.vector_mask.is_none()));
|
||||
}
|
||||
|
||||
/// Type kerning (#206): the auto mode and manual kerning survive .pcraft; manifests written
|
||||
/// before `kern` existed load with no manual kerning.
|
||||
#[test]
|
||||
fn text_kerning_roundtrips_and_defaults() {
|
||||
use photocraft_doc::LayerContent;
|
||||
use photocraft_doc::text::Kerning;
|
||||
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
|
||||
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
|
||||
let back = load_from_bytes(&bytes).unwrap();
|
||||
let runs = |d: &Document| {
|
||||
d.walk()
|
||||
.into_iter()
|
||||
.find_map(|(_, _, l)| match &l.content {
|
||||
LayerContent::Text(t) => Some(t.runs.iter().map(|r| (r.len, r.style.kerning, r.style.kern)).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(runs(&back), vec![(1, Kerning::Off, 120.0), (5, Kerning::Metrics, 0.0), (9, Kerning::Optical, 0.0)]);
|
||||
assert_eq!(runs(&back), runs(&doc));
|
||||
let m = read_manifest(&bytes).unwrap();
|
||||
let mut v = serde_json::to_value(&m.document).unwrap();
|
||||
fn strip(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
serde_json::Value::Object(o) => {
|
||||
o.remove("kern");
|
||||
o.values_mut().for_each(strip);
|
||||
}
|
||||
serde_json::Value::Array(a) => a.iter_mut().for_each(strip),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
strip(&mut v);
|
||||
assert!(!v.to_string().contains("\"kern\""));
|
||||
let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap();
|
||||
let s = serde_json::to_string(&old).unwrap();
|
||||
assert!(s.contains("\"kern\":0.0"), "kern defaults to 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_restrictions_and_bevel_elements_roundtrip() {
|
||||
use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect};
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(crate) struct Ctx<'a> {
|
||||
pub warnings: Vec<String>,
|
||||
/// Document resolution (type sizes are converted to points with it).
|
||||
pub dpi: f32,
|
||||
/// The parsed `Txt2` block (type settings EngineData lacks, e.g. optical kerning).
|
||||
pub txt2: Option<photocraft_text::engine_data::Value>,
|
||||
}
|
||||
|
||||
fn doc_mode(m: PsdMode) -> Option<ColorMode> {
|
||||
@@ -251,6 +253,9 @@ impl Ctx<'_> {
|
||||
let (text, transform) = blocks::parse_tysh(&data).unwrap_or_default();
|
||||
TextLayer { text, transform, ..Default::default() }
|
||||
});
|
||||
if let Some(txt2) = &self.txt2 {
|
||||
photocraft_text::psd::apply_txt2(&mut t, &data, txt2);
|
||||
}
|
||||
t.cache = Some(self.record_surface(rec, &name));
|
||||
t.psd_raw = principal(b"TySh");
|
||||
LayerContent::Text(t)
|
||||
@@ -490,6 +495,7 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
|
||||
cmyk: fmt.mode == ColorMode::Cmyk,
|
||||
warnings,
|
||||
dpi: doc.resolution_dpi,
|
||||
txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)),
|
||||
};
|
||||
|
||||
let (w, hh) = (h.width as usize, h.height as usize);
|
||||
|
||||
@@ -45,13 +45,31 @@ pub fn import(doc: &mut Document) {
|
||||
});
|
||||
pmap.insert(*ix, id);
|
||||
}
|
||||
link(t, &sh.run_char, &cmap, &sh.run_para, &pmap);
|
||||
link(t, &sh.run_char, &sh.run_char_lens, &cmap, &sh.run_para, &pmap);
|
||||
}
|
||||
doc.text_styles = styles;
|
||||
}
|
||||
|
||||
fn link(t: &mut TextLayer, run_char: &[Option<usize>], cmap: &HashMap<usize, u32>, run_para: &[Option<usize>], pmap: &HashMap<usize, u32>) {
|
||||
if t.runs.len() == run_char.len() {
|
||||
fn link(
|
||||
t: &mut TextLayer,
|
||||
run_char: &[Option<usize>],
|
||||
run_char_lens: &[usize],
|
||||
cmap: &HashMap<usize, u32>,
|
||||
run_para: &[Option<usize>],
|
||||
pmap: &HashMap<usize, u32>,
|
||||
) {
|
||||
if run_char_lens.len() == run_char.len() && !run_char.is_empty() {
|
||||
// Each model run takes the sheet of the `StyleRun` entry its first character is in.
|
||||
let (mut off, mut i, mut end) = (0usize, 0usize, run_char_lens.first().copied().unwrap_or(0));
|
||||
for r in t.runs.iter_mut() {
|
||||
while off >= end && i + 1 < run_char_lens.len() {
|
||||
i += 1;
|
||||
end = end.saturating_add(run_char_lens.get(i).copied().unwrap_or(0));
|
||||
}
|
||||
r.style.style_sheet = run_char.get(i).copied().flatten().and_then(|i| cmap.get(&i).copied());
|
||||
off = off.saturating_add(r.len);
|
||||
}
|
||||
} else if t.runs.len() == run_char.len() {
|
||||
for (r, ix) in t.runs.iter_mut().zip(run_char) {
|
||||
r.style.style_sheet = ix.and_then(|i| cmap.get(&i).copied());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Kerning of PSD type layers (#206): manual kerning in EngineData (`AutoKerning`/`Kerning`
|
||||
//! style-run fields), the automatic mode in `Txt2`, and the layout against Photoshop's own
|
||||
//! glyph positions.
|
||||
//!
|
||||
//! The Photoshop-authored oracles (`corpus/photoshop/text/kerning-*.psd`) run under the
|
||||
//! `corpus` feature (`cargo xtask test-corpus`).
|
||||
|
||||
#[cfg(feature = "corpus")]
|
||||
use std::path::Path;
|
||||
|
||||
use photocraft_doc::text::{CharStyle, Kerning, TextRun};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, TextLayer};
|
||||
use photocraft_text::engine_data::Value as E;
|
||||
|
||||
/// `(AutoKerning, Kerning)` of every UTF-16 unit of the engine text of a `TySh` block.
|
||||
fn kerning_fields(tysh: &[u8]) -> Vec<(bool, i64)> {
|
||||
let t = photocraft_text::psd::parse_tysh(tysh).unwrap();
|
||||
let e = photocraft_text::psd::engine_data(&t.text).unwrap();
|
||||
let runs = e.path(&["EngineDict", "StyleRun", "RunArray"]).and_then(E::as_array).unwrap();
|
||||
let lens = e.path(&["EngineDict", "StyleRun", "RunLengthArray"]).and_then(E::as_array).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for (r, n) in runs.iter().zip(lens) {
|
||||
let d = r.path(&["StyleSheet", "StyleSheetData"]).unwrap();
|
||||
let f = (d.get("AutoKerning").and_then(E::as_bool).unwrap_or(true), d.get("Kerning").and_then(E::as_i64).unwrap_or(0));
|
||||
out.extend(std::iter::repeat_n(f, n.as_i64().unwrap() as usize));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Per character: (mode, manual kern).
|
||||
fn chars(t: &TextLayer) -> Vec<(Kerning, f32)> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
for r in t.char_runs() {
|
||||
let n = t.text.get(at..at + r.len).map_or(0, |s| s.chars().count());
|
||||
out.extend(std::iter::repeat_n((r.style.kerning, r.style.kern), n));
|
||||
at += r.len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn layer(text: &str, runs: &[(usize, Kerning, f32)]) -> TextLayer {
|
||||
let base = CharStyle { font_family: "Inter".into(), size_pt: 24.0, ..Default::default() };
|
||||
let mut t = TextLayer {
|
||||
text: text.into(),
|
||||
runs: runs.iter().map(|&(len, kerning, kern)| TextRun { len, style: CharStyle { kerning, kern, ..base.clone() } }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
t.sync_summary();
|
||||
t
|
||||
}
|
||||
|
||||
/// The model survives a PSD round trip, and the EngineData fields follow Photoshop's form: a
|
||||
/// manual kern after character c sits on c + 1; "no automatic kerning" on c is
|
||||
/// `AutoKerning false` on c + 2 (how Photoshop reads it).
|
||||
#[test]
|
||||
fn manual_and_off_kerning_round_trip_through_psd() {
|
||||
use Kerning::{Metrics as M, Off as O};
|
||||
// "AVATAR Wave": +100 after A, -50 after T, characters 6..8 without automatic kerning.
|
||||
let t = layer("AVATAR Wave", &[(1, O, 100.0), (2, M, 0.0), (1, O, -50.0), (2, M, 0.0), (3, O, 0.0), (2, M, 0.0)]);
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut t2 = t.clone();
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t2, doc.resolution_dpi, doc.pixel_format());
|
||||
doc.layers.push(Layer::new("kerned", LayerContent::Text(t2)));
|
||||
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Text(b) = &back.layers[0].content else { panic!("not text") };
|
||||
assert_eq!(chars(b), chars(&t));
|
||||
|
||||
let tysh = photocraft_text::psd::build_tysh(&t, 72.0, None);
|
||||
let f = kerning_fields(&tysh);
|
||||
// 11 characters + the trailing paragraph break.
|
||||
assert_eq!(f.len(), 12);
|
||||
assert_eq!(f[1], (false, 100), "kern after A on V, Photoshop's form");
|
||||
assert_eq!(f[4], (true, -50), "kern after T on A; character 2 isn't manual");
|
||||
for j in [8, 9, 10] {
|
||||
assert!(!f[j].0, "character {} without automatic kerning", j - 2);
|
||||
}
|
||||
assert!(f.iter().enumerate().filter(|(j, _)| ![1, 4, 8, 9, 10].contains(j)).all(|(_, x)| *x == (true, 0)), "{f:?}");
|
||||
}
|
||||
|
||||
/// Kerning that can't be represented (non-finite) and bad PSD fields never panic and stay sane.
|
||||
#[test]
|
||||
fn hostile_kerning_values() {
|
||||
let t = layer("AV", &[(1, Kerning::Off, f32::NAN), (1, Kerning::Metrics, f32::INFINITY)]);
|
||||
let tysh = photocraft_text::psd::build_tysh(&t, 72.0, None);
|
||||
assert!(kerning_fields(&tysh).iter().all(|f| f.1 == 0));
|
||||
let back = photocraft_text::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
assert!(chars(&back).iter().all(|c| c.1 == 0.0));
|
||||
// Huge values clamp.
|
||||
let t = layer("AV", &[(1, Kerning::Off, 1e30), (1, Kerning::Metrics, 0.0)]);
|
||||
let f = kerning_fields(&photocraft_text::psd::build_tysh(&t, 72.0, None));
|
||||
assert_eq!(f[1].1, 10_000);
|
||||
}
|
||||
|
||||
/// Glyph pen positions Photoshop stored for text object `index` in `Txt2` (`/21 /1`).
|
||||
#[cfg(feature = "corpus")]
|
||||
fn photoshop_positions(txt2: &E, index: usize) -> Option<Vec<f64>> {
|
||||
fn find(v: &E, depth: usize) -> Option<Vec<f64>> {
|
||||
if depth > 100 {
|
||||
return None;
|
||||
}
|
||||
match v {
|
||||
E::Dict(items) => {
|
||||
if let Some(p) = v.get("21").and_then(|d| d.get("1")).and_then(E::as_array) {
|
||||
return Some(p.iter().filter_map(E::as_f64).collect());
|
||||
}
|
||||
items.iter().find_map(|(_, x)| find(x, depth + 1))
|
||||
}
|
||||
E::Array(a) => a.iter().find_map(|x| find(x, depth + 1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
find(txt2.path(&["1", "1"])?.as_array()?.get(index)?, 0)
|
||||
}
|
||||
|
||||
/// Photoshop-authored kerning oracles (`corpus/photoshop/text/kerning-*.psd`, made from scratch
|
||||
/// by photocraft-corpus's generator): the automatic mode read from `Txt2` per character, the
|
||||
/// EngineData kerning fields after export, and our layout against Photoshop's glyph positions.
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn photoshop_kerning_oracles() {
|
||||
use Kerning::{Metrics as M, Off as O, Optical as P};
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text");
|
||||
assert!(dir.is_dir(), "{} is missing: run `cargo xtask corpus --all`", dir.display());
|
||||
let mut engine = photocraft_text::TextEngine::with_system_fonts();
|
||||
let arial = engine.fonts.has_family("Arial");
|
||||
// "AVATAR Wavy To. LT" (18 characters).
|
||||
let all = |m: Kerning| vec![(m, 0.0f32); 18];
|
||||
let partial: Vec<(Kerning, f32)> = (0..18).map(|i| (if (1..=3).contains(&i) { O } else { M }, 0.0)).collect();
|
||||
// (file, expected modes, EngineData fields as Photoshop wrote them survive our export,
|
||||
// worst / mean advance error allowed in px).
|
||||
type Case = (&'static str, Vec<(Kerning, f32)>, bool, f64, f64);
|
||||
let cases: [Case; 5] = [
|
||||
("kerning-metrics.psd", all(M), true, 0.02, 0.02),
|
||||
("kerning-off.psd", all(O), false, 0.02, 0.02),
|
||||
("kerning-off-partial.psd", partial, false, 0.02, 0.02),
|
||||
// The fitted optical model: within ~30/1000 em per pair, ~12 on average.
|
||||
("kerning-optical.psd", all(P), true, 1.0, 0.4),
|
||||
("kerning-optical-24pt.psd", all(P), true, 0.75, 0.3),
|
||||
];
|
||||
for (name, want, same_fields, worst_max, mean_max) in cases {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
|
||||
let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).unwrap();
|
||||
let doc = photocraft_io::import(name, &bytes).unwrap().document;
|
||||
let t = doc
|
||||
.layers
|
||||
.iter()
|
||||
.find_map(|l| match &l.content {
|
||||
LayerContent::Text(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(chars(t), want, "{name}");
|
||||
|
||||
let original = t.psd_raw.as_ref().map(|r| r.to_vec()).unwrap();
|
||||
let tysh = photocraft_text::psd::parse_tysh(&original).unwrap();
|
||||
let rebuilt = photocraft_text::psd::build_tysh(t, doc.resolution_dpi, None);
|
||||
if same_fields {
|
||||
assert_eq!(kerning_fields(&rebuilt), kerning_fields(&original), "{name}");
|
||||
}
|
||||
// Re-import (with the document's Txt2, as an exported file keeps it): same model.
|
||||
let mut back = photocraft_text::psd::text_layer_from_tysh(&rebuilt, doc.resolution_dpi).unwrap();
|
||||
photocraft_text::psd::apply_txt2(&mut back, &rebuilt, &txt2);
|
||||
assert_eq!(chars(&back), want, "{name} after export");
|
||||
|
||||
// Layout against Photoshop's glyph positions (needs Arial, as Photoshop used).
|
||||
if !arial {
|
||||
eprintln!("{name}: Arial not installed, layout comparison skipped");
|
||||
continue;
|
||||
}
|
||||
let index = match &tysh.text.get("TextIndex") {
|
||||
Some(photocraft_psd::descriptor::Value::Integer(i)) => *i as usize,
|
||||
_ => panic!("{name}: no TextIndex"),
|
||||
};
|
||||
let ps = photoshop_positions(&txt2, index).unwrap();
|
||||
let x: Vec<f64> = engine.layout(t, doc.resolution_dpi).glyphs.iter().map(|g| f64::from(g.x)).collect();
|
||||
let (mut worst, mut sum) = (0.0f64, 0.0f64);
|
||||
for i in 0..x.len() - 1 {
|
||||
let d = (x[i + 1] - x[i]) - (ps[i + 1] - ps[i]);
|
||||
worst = worst.max(d.abs());
|
||||
sum += d.abs();
|
||||
}
|
||||
let mean = sum / (x.len() - 1) as f64;
|
||||
eprintln!("{name}: advance error vs Photoshop: mean {mean:.3} px, worst {worst:.3} px");
|
||||
assert!(worst <= worst_max && mean <= mean_max, "{name}: mean {mean}, worst {worst}");
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ fn corpus_text_layers() {
|
||||
continue;
|
||||
};
|
||||
let doc = imp.document;
|
||||
// The document's Txt2 (kept verbatim on export) carries what EngineData can't, e.g.
|
||||
// optical kerning; re-reads apply it like an import does.
|
||||
let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.2));
|
||||
let mut all = Vec::new();
|
||||
walk(&doc.layers, &mut all);
|
||||
for l in all {
|
||||
@@ -82,7 +85,10 @@ fn corpus_text_layers() {
|
||||
assert!(st.size_pt > 0.0 && st.postscript_name.is_some(), "{name}/{}: {st:?}", l.name);
|
||||
// Round trip of the model through our own TySh writer.
|
||||
let rebuilt = photocraft_text::psd::build_tysh(t, doc.resolution_dpi, None);
|
||||
let back = photocraft_text::psd::text_layer_from_tysh(&rebuilt, doc.resolution_dpi).unwrap();
|
||||
let mut back = photocraft_text::psd::text_layer_from_tysh(&rebuilt, doc.resolution_dpi).unwrap();
|
||||
if let Some(txt2) = &txt2 {
|
||||
photocraft_text::psd::apply_txt2(&mut back, &rebuilt, txt2);
|
||||
}
|
||||
assert_eq!(back.text, t.text);
|
||||
assert_eq!(back.char_runs(), t.char_runs(), "{name}/{}", l.name);
|
||||
assert_eq!(back.paragraph_runs(), t.paragraph_runs(), "{name}/{}", l.name);
|
||||
|
||||
+151
-10
@@ -277,11 +277,29 @@ const RLM: &str = "\u{200F}";
|
||||
|
||||
pub(crate) struct Layouter {
|
||||
lcx: LayoutContext<RunBrush>,
|
||||
optical: crate::optical::Cache,
|
||||
}
|
||||
|
||||
/// A cluster of a line in visual order, for kerning.
|
||||
struct KernSlot {
|
||||
/// Index of the run in the paragraph layout.
|
||||
run: usize,
|
||||
/// Byte offset in the paragraph text.
|
||||
start: usize,
|
||||
first: Option<u32>,
|
||||
last: Option<u32>,
|
||||
font: FontData,
|
||||
coords: Vec<i16>,
|
||||
size: f32,
|
||||
rtl: bool,
|
||||
blank: bool,
|
||||
/// Vertical type, upright glyph (its outline doesn't run along the column).
|
||||
upright: bool,
|
||||
}
|
||||
|
||||
impl Layouter {
|
||||
pub fn new() -> Self {
|
||||
Self { lcx: LayoutContext::new() }
|
||||
Self { lcx: LayoutContext::new(), optical: crate::optical::Cache::default() }
|
||||
}
|
||||
|
||||
pub fn layout(&mut self, fonts: &mut FontDb, t: &TextLayer, dpi: f32) -> TextLayout {
|
||||
@@ -484,9 +502,109 @@ impl Layouter {
|
||||
prev_baseline = Some(baseline);
|
||||
let last_line = li + 1 == nlines;
|
||||
let adv = m.advance - m.trailing_whitespace;
|
||||
let map = |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end);
|
||||
|
||||
// Kerning after each cluster (px), per run in visual cluster order: manual
|
||||
// kerning plus optical pair kerning. Applied after shaping (like horizontal
|
||||
// scale) in line space, so it moves the following glyphs, carets and the line
|
||||
// extent; in vertical type that is along the column. Optical kerning measures
|
||||
// horizontal outlines, so it applies to rotated (Latin) glyphs only there.
|
||||
let mut slots: Vec<KernSlot> = Vec::new();
|
||||
let mut line_runs: Vec<usize> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
if line_runs.contains(&run.index()) {
|
||||
continue;
|
||||
}
|
||||
line_runs.push(run.index());
|
||||
for c in run.visual_clusters() {
|
||||
let mut gl = c.glyphs();
|
||||
let first = gl.next().map(|g| g.id);
|
||||
let last = gl.last().map(|g| g.id).or(first);
|
||||
slots.push(KernSlot {
|
||||
run: run.index(),
|
||||
start: c.text_range().start,
|
||||
first,
|
||||
last,
|
||||
font: run.font().clone(),
|
||||
coords: run.normalized_coords().to_vec(),
|
||||
size: run.font_size(),
|
||||
rtl: c.is_rtl(),
|
||||
blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(),
|
||||
upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut kern_px: Vec<f32> = vec![0.0; slots.len()];
|
||||
for j in 0..slots.len().saturating_sub(1) {
|
||||
let (a, b) = (&slots[j], &slots[j + 1]);
|
||||
if a.start < prefix.len() {
|
||||
continue;
|
||||
}
|
||||
let st = &out.styles[style_at(map(a.start))];
|
||||
let next = &out.styles[style_at(map(b.start))];
|
||||
let mut units = if st.kern.is_finite() { st.kern } else { 0.0 };
|
||||
// Optical pairs: both characters optical (a mode change splits shaping
|
||||
// runs, which ends automatic kerning, as with Metrics).
|
||||
if st.kerning == Kerning::Optical
|
||||
&& next.kerning == Kerning::Optical
|
||||
&& st.kern == 0.0
|
||||
&& next.kern == 0.0
|
||||
&& !a.rtl
|
||||
&& !b.rtl
|
||||
&& !a.blank
|
||||
&& !b.blank
|
||||
&& !a.upright
|
||||
&& !b.upright
|
||||
&& a.font.data.id() == b.font.data.id()
|
||||
&& a.font.index == b.font.index
|
||||
&& a.coords == b.coords
|
||||
&& (a.size - b.size).abs() < 1e-3
|
||||
&& let (Some(l), Some(r)) = (a.last, b.first)
|
||||
{
|
||||
let coords: Vec<skrifa::instance::NormalizedCoord> =
|
||||
a.coords.iter().map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)).collect();
|
||||
if let Some(k) = self.optical.pair(a.font.data.id(), a.font.data.as_ref(), a.font.index, &coords, l, r) {
|
||||
units += k + crate::optical::size_adjust(st.size_pt);
|
||||
}
|
||||
}
|
||||
kern_px[j] = units / 1000.0 * a.size;
|
||||
}
|
||||
let line_kern: f32 = kern_px.iter().sum();
|
||||
// Per run on the line: (run index, glyph → slot, glyphs emitted, first slot whose
|
||||
// kerning isn't applied yet).
|
||||
let mut cursors: Vec<(usize, Vec<usize>, usize, usize)> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
if cursors.iter().any(|c| c.0 == run.index()) {
|
||||
continue;
|
||||
}
|
||||
let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
let mut g2s = Vec::new();
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
g2s.extend(c.glyphs().map(|_| base + ci));
|
||||
}
|
||||
cursors.push((run.index(), g2s, 0, base));
|
||||
}
|
||||
// Parley aligned box lines without the kerning.
|
||||
let kern_align = if is_box {
|
||||
match ps.align {
|
||||
TextAlign::Center => line_kern / 2.0,
|
||||
TextAlign::Right => line_kern,
|
||||
_ => 0.0,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dx = if is_box {
|
||||
let base = line_origin + indent_start;
|
||||
let slack = avail.unwrap_or(0.0) - adv;
|
||||
let base = line_origin + indent_start - kern_align;
|
||||
let slack = avail.unwrap_or(0.0) - adv - line_kern;
|
||||
base + if last_line {
|
||||
match ps.align {
|
||||
TextAlign::JustifyCenter => slack * 0.5 - m.offset,
|
||||
@@ -498,20 +616,19 @@ impl Layouter {
|
||||
}
|
||||
} else {
|
||||
let target = match ps.align {
|
||||
TextAlign::Center | TextAlign::JustifyCenter => -adv / 2.0,
|
||||
TextAlign::Right | TextAlign::JustifyRight => -adv,
|
||||
TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern) / 2.0,
|
||||
TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern),
|
||||
_ => indent_start,
|
||||
};
|
||||
target - m.offset
|
||||
};
|
||||
let justify_all = is_box && last_line && ps.align == TextAlign::JustifyAll;
|
||||
let line_index = out.lines.len();
|
||||
let map = |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end);
|
||||
let lr = line.text_range();
|
||||
let g0 = out.glyphs.len();
|
||||
let c0 = out.clusters.len();
|
||||
let mut vinfo: Vec<VGlyph> = Vec::new();
|
||||
let mut extra = 0.0f32; // horizontal-scale growth along the line
|
||||
let mut extra = 0.0f32; // horizontal-scale growth and kerning along the line
|
||||
let mut seen_runs: Vec<usize> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
@@ -534,8 +651,9 @@ impl Layouter {
|
||||
if !seen_runs.contains(&run.index()) {
|
||||
seen_runs.push(run.index());
|
||||
let mut cx = run_x0;
|
||||
for c in run.visual_clusters() {
|
||||
let a = c.advance() * hs;
|
||||
let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
let a = c.advance() * hs + kern_px.get(base + ci).copied().unwrap_or(0.0);
|
||||
let r = c.text_range();
|
||||
if r.end > prefix.len() || prefix.is_empty() {
|
||||
out.clusters.push(ClusterInfo { range: map(r.start)..map(r.end), x: cx, advance: a, line: line_index, rtl: c.is_rtl() });
|
||||
@@ -552,7 +670,18 @@ impl Layouter {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut pen = gr.offset();
|
||||
let mut cursor = cursors.iter_mut().find(|c| c.0 == run.index());
|
||||
for g in gr.glyphs() {
|
||||
// Kerning of the clusters before this glyph's cluster.
|
||||
if let Some(c) = cursor.as_deref_mut() {
|
||||
if let Some(&slot) = c.1.get(c.2) {
|
||||
while c.3 < slot {
|
||||
extra += kern_px.get(c.3).copied().unwrap_or(0.0);
|
||||
c.3 += 1;
|
||||
}
|
||||
}
|
||||
c.2 += 1;
|
||||
}
|
||||
out.glyphs.push(PlacedGlyph {
|
||||
face,
|
||||
id: g.id,
|
||||
@@ -575,6 +704,16 @@ impl Layouter {
|
||||
extra += g.advance * (hs - 1.0);
|
||||
pen += g.advance;
|
||||
}
|
||||
// The run's last glyph: its remaining clusters' kerning follows it.
|
||||
if let Some(c) = cursor
|
||||
&& c.2 >= c.1.len()
|
||||
{
|
||||
let end = slots.iter().rposition(|s| s.run == c.0).map_or(c.3, |p| p + 1);
|
||||
while c.3 < end {
|
||||
extra += kern_px.get(c.3).copied().unwrap_or(0.0);
|
||||
c.3 += 1;
|
||||
}
|
||||
}
|
||||
let run_x1 = dx + gr.offset() + gr.advance() + extra;
|
||||
let shift = st.baseline_shift_pt * k;
|
||||
if vertical {
|
||||
@@ -785,7 +924,9 @@ impl skrifa::outline::OutlinePen for YMax {
|
||||
/// OpenType feature settings of a character style (CSS `font-feature-settings` items).
|
||||
fn feature_list(st: &CharStyle) -> Vec<String> {
|
||||
let mut feats: Vec<String> = Vec::new();
|
||||
if st.kerning == Kerning::Off {
|
||||
// Optical and manual kerning replace the font's kerning table; a character with a manual
|
||||
// kern is manually kerned whatever its mode (as in Photoshop).
|
||||
if st.kerning != Kerning::Metrics || st.kern != 0.0 {
|
||||
feats.push("\"kern\" 0".into());
|
||||
}
|
||||
if !st.ligatures {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod engine_data;
|
||||
pub mod fonts;
|
||||
pub mod glyphs;
|
||||
pub mod layout;
|
||||
pub mod optical;
|
||||
pub mod psd;
|
||||
pub mod psd_styles;
|
||||
pub mod raster;
|
||||
@@ -61,6 +62,58 @@ impl TextEngine {
|
||||
self.layouter.layout(&mut self.fonts, layer, dpi)
|
||||
}
|
||||
|
||||
/// Kerning (1/1000 em) between the character starting at byte `at` and the next one, as
|
||||
/// laid out: the manual kern, or the automatic (metrics / optical) kerning of the pair.
|
||||
/// `None` when there is no such pair on one line.
|
||||
pub fn pair_kerning(&mut self, layer: &TextLayer, dpi: f32, at: usize) -> Option<f32> {
|
||||
let runs = layer.char_runs();
|
||||
let ch = layer.text.get(at..)?.chars().next()?;
|
||||
let next = at + ch.len_utf8();
|
||||
if ch == '\n' || ch == '\r' || layer.text.get(next..)?.chars().next().is_none_or(|c| c == '\n' || c == '\r') {
|
||||
return None;
|
||||
}
|
||||
let gap = |l: &TextLayout| {
|
||||
let a = l.clusters.iter().find(|c| c.range.start == at)?;
|
||||
let b = l.clusters.iter().find(|c| c.range.start == next)?;
|
||||
(a.line == b.line && !a.rtl).then_some(b.x - a.x)
|
||||
};
|
||||
let with = gap(&self.layout(layer, dpi))?;
|
||||
// Em of the pair's first character (px).
|
||||
let mut acc = 0;
|
||||
let style_run = runs.iter().find(|r| {
|
||||
acc += r.len;
|
||||
at < acc
|
||||
})?;
|
||||
let em = style_run.style.size_pt * if dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
if em.is_nan() || em <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The same layer with the pair's first character unkerned.
|
||||
let mut plain = layer.clone();
|
||||
plain.runs = Vec::with_capacity(runs.len() + 2);
|
||||
let mut start = 0;
|
||||
for r in runs {
|
||||
let end = start + r.len;
|
||||
let mut push = |s: usize, e: usize, unkern: bool| {
|
||||
if e > s {
|
||||
let mut style = r.style.clone();
|
||||
if unkern {
|
||||
style.kerning = photocraft_doc::text::Kerning::Off;
|
||||
style.kern = 0.0;
|
||||
}
|
||||
plain.runs.push(photocraft_doc::text::TextRun { len: e - s, style });
|
||||
}
|
||||
};
|
||||
push(start, at.clamp(start, end), false);
|
||||
push(at.clamp(start, end), next.clamp(start, end), true);
|
||||
push(next.clamp(start, end), end, false);
|
||||
start = end;
|
||||
}
|
||||
let without = gap(&self.layout(&plain, dpi))?;
|
||||
let k = (with - without) / em * 1000.0;
|
||||
k.is_finite().then_some(k)
|
||||
}
|
||||
|
||||
/// Lays out and rasterizes a text layer into document space.
|
||||
pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) {
|
||||
let l = self.layout(layer, dpi);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Optical kerning (Photoshop's Character panel › Kerning › Optical): pair spacing computed from
|
||||
//! the glyph outlines instead of the font's kerning table.
|
||||
//!
|
||||
//! Every glyph gets a horizontal *profile*: at [`SAMPLES`] heights across the em, the distance
|
||||
//! from the start of its advance to its leftmost ink and from its rightmost ink to the end of its
|
||||
//! advance (em units, size independent, cached per face and glyph). For a pair, the gap at each
|
||||
//! height is the right distance of the first glyph plus the left distance of the second. Two
|
||||
//! features describe a pair: its closest approach and the mean white space beyond it (capped,
|
||||
//! so open counters and overhangs count only so far). The kerning is a linear function of how
|
||||
//! those differ from the font's own "oo" pair, plus a size term (Photoshop spaces small type
|
||||
//! looser). The coefficients were fitted (least squares) to Photoshop 2026's optical kerning of
|
||||
//! 94 pairs in each of Arial, Georgia, Times New Roman and Verdana at 100 pt, and the size term to
|
||||
//! 12/24/48 pt; the residual is about 8/1000 em RMS for the sans and Georgia, 17 for Times (see
|
||||
//! the tests). Clean-room: only Photoshop's output positions were observed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
/// Number of sampled heights per glyph.
|
||||
pub const SAMPLES: usize = 48;
|
||||
/// Sampled band, em units above the baseline.
|
||||
const Y_LO: f32 = -0.25;
|
||||
const Y_HI: f32 = 1.0;
|
||||
|
||||
/// Left/right side distances at each sampled height (em); `None` where the glyph has no ink.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Profile {
|
||||
pub left: [Option<f32>; SAMPLES],
|
||||
pub right: [Option<f32>; SAMPLES],
|
||||
/// Advance width (em).
|
||||
pub advance: f32,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Whether the glyph has any ink (spaces don't).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.left.iter().all(Option::is_none)
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of sample `i` (em).
|
||||
pub fn sample_y(i: usize) -> f32 {
|
||||
Y_LO + (Y_HI - Y_LO) * (i as f32 + 0.5) / SAMPLES as f32
|
||||
}
|
||||
|
||||
/// Line segments of a flattened outline (font units).
|
||||
#[derive(Default)]
|
||||
struct Flatten {
|
||||
segs: Vec<[f32; 4]>,
|
||||
start: (f32, f32),
|
||||
at: (f32, f32),
|
||||
}
|
||||
|
||||
impl Flatten {
|
||||
fn line(&mut self, x: f32, y: f32) {
|
||||
self.segs.push([self.at.0, self.at.1, x, y]);
|
||||
self.at = (x, y);
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for Flatten {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.start = (x, y);
|
||||
self.at = (x, y);
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.line(x, y);
|
||||
}
|
||||
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
|
||||
let (x0, y0) = self.at;
|
||||
for i in 1..=8 {
|
||||
let t = i as f32 / 8.0;
|
||||
let u = 1.0 - t;
|
||||
self.line(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y);
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
|
||||
let (x0, y0) = self.at;
|
||||
for i in 1..=12 {
|
||||
let t = i as f32 / 12.0;
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
self.line(a * x0 + b * c0x + c * c1x + d * x, a * y0 + b * c0y + c * c1y + d * y);
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
let (x, y) = self.start;
|
||||
if (x, y) != self.at {
|
||||
self.line(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the profile of glyph `gid` (`None` if the font or glyph can't be read).
|
||||
pub fn profile(font: &skrifa::FontRef<'_>, coords: &[NormalizedCoord], gid: u32) -> Option<Profile> {
|
||||
let loc = LocationRef::new(coords);
|
||||
let upem = f32::from(font.metrics(Size::unscaled(), loc).units_per_em);
|
||||
if upem.is_nan() || upem <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let advance = font.glyph_metrics(Size::unscaled(), loc).advance_width(GlyphId::new(gid)).unwrap_or(0.0) / upem;
|
||||
let mut pen = Flatten::default();
|
||||
let outline = font.outline_glyphs().get(GlyphId::new(gid))?;
|
||||
outline.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen).ok()?;
|
||||
let mut left = [None; SAMPLES];
|
||||
let mut right = [None; SAMPLES];
|
||||
for i in 0..SAMPLES {
|
||||
let y = sample_y(i) * upem;
|
||||
let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
for &[x0, y0, x1, y1] in &pen.segs {
|
||||
if (y0 <= y && y < y1) || (y1 <= y && y < y0) {
|
||||
let x = x0 + (x1 - x0) * (y - y0) / (y1 - y0);
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
if lo.is_finite() && hi.is_finite() {
|
||||
left[i] = Some(lo / upem);
|
||||
right[i] = Some(advance - hi / upem);
|
||||
}
|
||||
}
|
||||
Some(Profile { left, right, advance })
|
||||
}
|
||||
|
||||
/// Band of heights used for pair gaps (em above the baseline).
|
||||
const BAND: (f32, f32) = (-0.1, 1.0);
|
||||
/// How far (em) beyond the closest approach a gap still counts as white space.
|
||||
const DEPTH: f32 = 0.04;
|
||||
/// Closest approaches above this (em) stop opening the pair further.
|
||||
const NEAR: f32 = 0.1;
|
||||
/// Fitted coefficients (see the module docs): intercept, closest approach, white space beyond
|
||||
/// it, saturated closest approach.
|
||||
const C0: f32 = -0.0033;
|
||||
const C_MIN: f32 = 0.5384;
|
||||
const C_EXCESS: f32 = 0.5526;
|
||||
const C_NEAR: f32 = -0.1612;
|
||||
/// Optical kerning never moves a pair further than this (1/1000 em).
|
||||
const LIMIT: f32 = 400.0;
|
||||
|
||||
/// Shape features of a pair: closest approach and mean white space beyond it (capped at
|
||||
/// [`DEPTH`]), em. `None` when the glyphs share no inked height inside the band.
|
||||
pub fn pair_features(l: &Profile, r: &Profile) -> Option<(f32, f32)> {
|
||||
let mut gaps = [0.0f32; SAMPLES];
|
||||
let mut n = 0;
|
||||
for i in 0..SAMPLES {
|
||||
let y = sample_y(i);
|
||||
if y < BAND.0 || y > BAND.1 {
|
||||
continue;
|
||||
}
|
||||
if let (Some(a), Some(b)) = (l.right[i], r.left[i]) {
|
||||
gaps[n] = a + b;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let gaps = gaps.get(..n).filter(|g| !g.is_empty())?;
|
||||
let min = gaps.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let excess = gaps.iter().map(|g| (g - min).min(DEPTH)).sum::<f32>() / n as f32;
|
||||
Some((min, excess))
|
||||
}
|
||||
|
||||
/// Photoshop's optical kerning is looser at small sizes: pairs open by about
|
||||
/// 11 × ln(95 / size) thousandths of an em below 95 pt (measured at 12, 24, 48, 96 and 100 pt;
|
||||
/// flat below 15 pt here, where the measurements level off).
|
||||
pub fn size_adjust(size_pt: f32) -> f32 {
|
||||
if !size_pt.is_finite() || size_pt <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
11.0 * (95.0 / size_pt.clamp(15.0, 95.0)).ln()
|
||||
}
|
||||
|
||||
/// Optical kerning (1/1000 em) of a pair with features `f`, for a font whose "oo" pair has
|
||||
/// features `reference`, at 95 pt and above (see [`size_adjust`]).
|
||||
pub fn kern_from_features(f: (f32, f32), reference: (f32, f32)) -> f32 {
|
||||
let k = C0 + C_MIN * (reference.0 - f.0) + C_EXCESS * (reference.1 - f.1) + C_NEAR * (reference.0.min(NEAR) - f.0.min(NEAR));
|
||||
let k = k * 1000.0;
|
||||
if k.is_finite() { k.clamp(-LIMIT, LIMIT) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Per-font cache of glyph profiles and reference features. Keyed by the caller's font id
|
||||
/// (parley's blob id plus face index) and variation coordinates.
|
||||
#[derive(Default)]
|
||||
pub struct Cache {
|
||||
profiles: HashMap<(u64, u32), Option<Profile>>,
|
||||
reference: HashMap<u64, Option<(f32, f32)>>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Entries kept before the cache is cleared (bounds memory with many fonts).
|
||||
const MAX_PROFILES: usize = 50_000;
|
||||
|
||||
fn font_key(font_id: u64, index: u32, coords: &[NormalizedCoord]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(font_id, index).hash(&mut h);
|
||||
for c in coords {
|
||||
c.to_bits().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn get(&mut self, fk: u64, font: &skrifa::FontRef<'_>, coords: &[NormalizedCoord], gid: u32) -> Option<Profile> {
|
||||
if self.profiles.len() > Self::MAX_PROFILES {
|
||||
self.profiles.clear();
|
||||
}
|
||||
self.profiles.entry((fk, gid)).or_insert_with(|| profile(font, coords, gid)).clone()
|
||||
}
|
||||
|
||||
/// Features of the font's "oo" pair (its own idea of round-to-round spacing).
|
||||
fn reference(&mut self, fk: u64, font: &skrifa::FontRef<'_>, coords: &[NormalizedCoord]) -> Option<(f32, f32)> {
|
||||
if let Some(r) = self.reference.get(&fk) {
|
||||
return *r;
|
||||
}
|
||||
let r = ['o', 'O', 'n', 'H'].iter().find_map(|&c| {
|
||||
let g = font.charmap().map(c)?;
|
||||
let p = self.get(fk, font, coords, g.to_u32())?;
|
||||
pair_features(&p, &p)
|
||||
});
|
||||
self.reference.insert(fk, r);
|
||||
r
|
||||
}
|
||||
|
||||
/// Optical kerning of glyphs `left`, `right` of one face in 1/1000 em, or `None` when it
|
||||
/// doesn't apply (spaces, glyphs that share no inked height, unreadable fonts).
|
||||
pub fn pair(&mut self, font_id: u64, data: &[u8], index: u32, coords: &[NormalizedCoord], left: u32, right: u32) -> Option<f32> {
|
||||
let font = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let fk = Self::font_key(font_id, index, coords);
|
||||
let l = self.get(fk, &font, coords, left)?;
|
||||
let r = self.get(fk, &font, coords, right)?;
|
||||
let f = pair_features(&l, &r)?;
|
||||
Some(kern_from_features(f, self.reference(fk, &font, coords)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Text set in Photoshop 2026 at 100 pt with Kerning › Optical; `PHOTOSHOP` holds the
|
||||
/// kerning Photoshop applied after each character (1/1000 em: optical positions minus the
|
||||
/// unkerned ones, read from the saved PSDs).
|
||||
const SAMPLE: &str = "HHOOHnnoonvAVTAToTyLTWaYoPAFAVaxyrnilHIOHnHoxoxcdbpkkwwAAVVff1471LYTeKOnHeHaHsHtHgHzHkHxHvHwrst";
|
||||
#[rustfmt::skip]
|
||||
const PHOTOSHOP: [(&str, &str, &[i32]); 4] = [
|
||||
("ArialMT", "/System/Library/Fonts/Supplemental/Arial.ttf", &[-16, -19, -20, -17, -20, -27, -22, -17, -20, -44, -52, -84, 15, -108, -110, -118, -122, -123, -18, -130, 14, -50, -111, -117, -15, -84, -12, -86, -84, -73, -6, 1, -19, -7, -27, -24, -23, -24, -22, -17, -20, -23, -15, -44, -42, -44, -45, -6, -28, -23, -23, -17, 7, 16, -40, 34, -84, 26, -23, 15, -34, -76, -74, -88, -69, -141, 15, -120, -16, -67, -13, -23, -17, -19, -18, -22, -19, -21, -20, -20, -16, -21, -28, -29, -21, -19, -22, -22, -24, -24, -19, -18, -11, -28]),
|
||||
("Georgia", "/System/Library/Fonts/Supplemental/Georgia.ttf", &[-28, -51, -18, -53, -24, -12, -38, -17, -37, -36, -79, -110, 33, -44, -42, -115, -115, -113, -46, -61, 22, -84, -62, -102, -50, -109, 15, -91, -110, -95, 11, 16, 13, 2, -12, -6, -17, -23, -53, -53, -24, -16, -50, -41, -40, -41, -37, -10, -28, -38, -37, 12, 7, 31, -63, 49, -110, 47, -39, 13, -14, -6, -58, -10, -20, -59, 36, -110, -40, -51, -19, -16, -50, -39, -33, -10, -26, -40, -58, -10, -37, -15, -18, -18, -18, 2, -7, -6, -49, -46, -53, 13, -11, -19]),
|
||||
("TimesNewRomanPSMT", "/System/Library/Fonts/Supplemental/Times New Roman.ttf", &[3, -38, -17, -38, 4, 11, -26, -16, -24, -36, -96, -159, -3, -70, -74, -114, -117, -122, -44, -105, 0, -93, -114, -137, -36, -120, 8, -117, -159, -104, -5, -14, 10, 22, -1, -13, 2, -1, -40, -38, 4, 10, -38, -47, -49, -47, -46, -15, -17, -16, -29, 20, 8, 12, -91, 13, -159, 12, -79, -2, 14, -98, -65, -71, -55, -133, -4, -116, -29, -67, -3, 10, -36, -29, -22, -4, -16, -28, -42, -5, -35, -1, 0, -3, 7, 16, 2, 6, -46, -44, -44, 8, -7, -22]),
|
||||
("Verdana", "/System/Library/Fonts/Supplemental/Verdana.ttf", &[-33, -26, -21, -27, -35, -35, -27, -20, -28, -52, -66, -82, 33, -93, -93, -120, -120, -121, -32, -102, 16, -67, -103, -104, -26, -72, -20, -70, -82, -59, -27, 4, -30, -12, -37, -37, -35, -47, -56, -27, -35, -33, -26, -53, -53, -53, -57, -16, -37, -28, -30, -19, 0, -10, -49, 26, -82, 26, -20, 17, -38, -98, -87, -69, -72, -102, 38, -120, -22, -78, -24, -33, -28, -20, -33, -33, -23, -22, -29, -27, -28, -35, -41, -37, -37, -19, -32, -32, -32, -32, -38, -37, 5, -15]),
|
||||
];
|
||||
|
||||
/// The fitted model against Photoshop's optical kerning (macOS system fonts; skipped where
|
||||
/// they aren't installed). Reports the error per font; fails if it regresses.
|
||||
#[test]
|
||||
fn optical_matches_photoshop() {
|
||||
let chars: Vec<char> = SAMPLE.chars().collect();
|
||||
let (mut sum, mut n) = (0.0f64, 0usize);
|
||||
for (name, path, ps) in PHOTOSHOP {
|
||||
let Ok(data) = std::fs::read(path) else { continue };
|
||||
let mut cache = Cache::default();
|
||||
let font = skrifa::FontRef::from_index(&data, 0).unwrap();
|
||||
let (mut fs, mut fneg, mut fpos) = (0.0f64, 0usize, 0usize);
|
||||
for (i, w) in chars.windows(2).enumerate() {
|
||||
let g = |c: char| font.charmap().map(c).unwrap().to_u32();
|
||||
let ours = cache.pair(1, &data, 0, &[], g(w[0]), g(w[1])).unwrap_or(0.0);
|
||||
let theirs = ps[i] as f32;
|
||||
fs += f64::from((ours - theirs).powi(2));
|
||||
// Direction agreement on clear cases.
|
||||
if theirs < -40.0 && ours > 0.0 {
|
||||
fneg += 1;
|
||||
}
|
||||
if theirs > 15.0 && ours < -15.0 {
|
||||
fpos += 1;
|
||||
}
|
||||
}
|
||||
let rms = (fs / ps.len() as f64).sqrt();
|
||||
eprintln!("{name}: rms {rms:.1}/1000 em over {} pairs", ps.len());
|
||||
assert!(rms < 20.0, "{name}: rms {rms}");
|
||||
// Times' serif stems (kH, kk) are opened by Photoshop and tightened slightly here.
|
||||
assert!(fneg + fpos <= 2, "{name}: {} pairs kerned the wrong way", fneg + fpos);
|
||||
sum += fs;
|
||||
n += ps.len();
|
||||
}
|
||||
if n > 0 {
|
||||
let rms = (sum / n as f64).sqrt();
|
||||
eprintln!("all: rms {rms:.1}/1000 em");
|
||||
assert!(rms < 12.5, "rms {rms}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes glyph profiles of macOS system fonts as JSON for fitting [`PARAMS`]
|
||||
/// (`OPTICAL_DUMP=<file> cargo test -p photocraft-text optical_dump -- --ignored`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn optical_dump() {
|
||||
let Some(out) = std::env::var_os("OPTICAL_DUMP") else { return };
|
||||
let chars = "HOnovAVTyLWaYPFxrilIcdbpkwf1475eKsgzt";
|
||||
let mut json = String::from("{");
|
||||
for (name, path) in [
|
||||
("ArialMT", "/System/Library/Fonts/Supplemental/Arial.ttf"),
|
||||
("Georgia", "/System/Library/Fonts/Supplemental/Georgia.ttf"),
|
||||
("TimesNewRomanPSMT", "/System/Library/Fonts/Supplemental/Times New Roman.ttf"),
|
||||
("Verdana", "/System/Library/Fonts/Supplemental/Verdana.ttf"),
|
||||
] {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let font = skrifa::FontRef::from_index(&data, 0).unwrap();
|
||||
json.push_str(&format!("\"{name}\":{{"));
|
||||
for c in chars.chars() {
|
||||
let g = font.charmap().map(c).unwrap().to_u32();
|
||||
let p = profile(&font, &[], g).unwrap();
|
||||
let f = |v: &[Option<f32>]| v.iter().map(|x| x.map_or("null".to_string(), |x| format!("{x:.5}"))).collect::<Vec<_>>().join(",");
|
||||
json.push_str(&format!("\"{c}\":{{\"adv\":{},\"l\":[{}],\"r\":[{}]}},", p.advance, f(&p.left), f(&p.right)));
|
||||
}
|
||||
json.pop();
|
||||
json.push_str("},");
|
||||
}
|
||||
json.pop();
|
||||
json.push('}');
|
||||
std::fs::write(out, json).unwrap();
|
||||
}
|
||||
}
|
||||
+207
-12
@@ -104,6 +104,83 @@ pub fn engine_data(text: &Descriptor) -> Option<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the document's `Txt2` block (Photoshop's text engine data for all type layers).
|
||||
pub fn parse_txt2(data: &[u8]) -> Option<E> {
|
||||
// Txt2 is a bare sequence of `/key value` pairs (no enclosing `<< >>`).
|
||||
let mut wrapped = Vec::with_capacity(data.len() + 4);
|
||||
wrapped.extend_from_slice(b"<<");
|
||||
wrapped.extend_from_slice(data);
|
||||
wrapped.extend_from_slice(b">>");
|
||||
ed::parse(&wrapped).ok().filter(|v| matches!(v, E::Dict(_)))
|
||||
}
|
||||
|
||||
/// Applies the automatic kerning modes that Photoshop keeps only in `Txt2` (EngineData writes
|
||||
/// Optical and "0" alike as `AutoKerning true`) to `layer`, read from `tysh`. Only when the
|
||||
/// layer's `TextIndex` entry still holds the layer's text (an edit elsewhere leaves `Txt2`
|
||||
/// stale).
|
||||
///
|
||||
/// Observed layout: `Txt2 /1` is the list of text objects; an object's `/0` is its text model
|
||||
/// with `/0` the text (`\r` breaks, trailing `\r`) and `/6 /0` its style runs, each
|
||||
/// `<< /0 << /0 << … /6 <<style>> >> >> /1 utf16-length >>`; style key `/11` is the auto-kern
|
||||
/// mode of the pair after each character: 0 manual, 1 metrics, 2 optical.
|
||||
pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
|
||||
let Some(index) = parse_tysh(tysh).and_then(|t| match t.text.get("TextIndex") {
|
||||
Some(D::Integer(i)) => usize::try_from(*i).ok(),
|
||||
_ => None,
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.get("0")) else {
|
||||
return;
|
||||
};
|
||||
let Some(text) = model.get("0").and_then(E::as_str) else {
|
||||
return;
|
||||
};
|
||||
if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text {
|
||||
return;
|
||||
}
|
||||
let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else {
|
||||
return;
|
||||
};
|
||||
let modes: Vec<(f64, Option<i64>)> =
|
||||
style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6", "11"]).and_then(E::as_i64))).collect();
|
||||
let lens = utf16_to_byte_lengths(text, &modes.iter().map(|m| m.0).collect::<Vec<_>>());
|
||||
// Auto-kern mode per byte segment, then split the model runs at segment boundaries.
|
||||
let mut segs: Vec<(usize, Option<i64>)> = lens.into_iter().zip(modes.iter().map(|m| m.1)).collect();
|
||||
segs.retain(|s| s.0 > 0);
|
||||
let runs = layer.char_runs();
|
||||
let mut out: Vec<TextRun> = Vec::new();
|
||||
let (mut si, mut seg_end) = (0usize, segs.first().map_or(usize::MAX, |s| s.0));
|
||||
let mut at = 0usize;
|
||||
for r in runs {
|
||||
let end = at + r.len;
|
||||
while at < end {
|
||||
while at >= seg_end && si + 1 < segs.len() {
|
||||
si += 1;
|
||||
seg_end = seg_end.saturating_add(segs.get(si).map_or(0, |s| s.0));
|
||||
}
|
||||
let piece_end = if at < seg_end { end.min(seg_end) } else { end };
|
||||
let mut style = r.style.clone();
|
||||
// Txt2 has the exact mode (EngineData's reading also marks neighbours of manual
|
||||
// kerns manual, like Photoshop's legacy reader).
|
||||
match segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end) {
|
||||
Some(0) => style.kerning = Kerning::Off,
|
||||
Some(1) => style.kerning = Kerning::Metrics,
|
||||
Some(2) => style.kerning = Kerning::Optical,
|
||||
_ => {}
|
||||
}
|
||||
let len = piece_end - at;
|
||||
match out.last_mut() {
|
||||
Some(last) if last.style == style => last.len += len,
|
||||
_ => out.push(TextRun { len, style }),
|
||||
}
|
||||
at = piece_end;
|
||||
}
|
||||
}
|
||||
layer.runs = out;
|
||||
layer.sync_summary();
|
||||
}
|
||||
|
||||
/// Builds a [`TextLayer`] (model, text and transform; no cache, no `psd_raw`) from `TySh` data.
|
||||
pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
|
||||
let t = parse_tysh(data)?;
|
||||
@@ -153,7 +230,7 @@ pub(crate) fn arr_f(v: Option<&E>) -> Vec<f64> {
|
||||
}
|
||||
|
||||
/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths.
|
||||
fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec<usize> {
|
||||
pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec<usize> {
|
||||
let mut out = Vec::with_capacity(lens.len());
|
||||
let mut chars = text.chars().peekable();
|
||||
for &l in lens {
|
||||
@@ -202,7 +279,17 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
.map(|a| a.iter().filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])).collect())
|
||||
.unwrap_or_default();
|
||||
let slens = utf16_to_byte_lengths(&ed_text, &arr_f(srun.and_then(|r| r.get("RunLengthArray"))));
|
||||
let incoming: Vec<(usize, bool, f32)> = sdata
|
||||
.iter()
|
||||
.zip(&slens)
|
||||
.map(|(d, len)| {
|
||||
let auto = lookup(d, base_style, "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, base_style, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
(*len, auto, kern)
|
||||
})
|
||||
.collect();
|
||||
layer.runs = sdata.iter().zip(slens).map(|(d, len)| TextRun { len, style: char_style(base_style, d, &fonts, k) }).collect();
|
||||
layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming);
|
||||
|
||||
// Paragraph runs.
|
||||
let prun = e.path(&["EngineDict", "ParagraphRun"]);
|
||||
@@ -266,6 +353,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C
|
||||
}
|
||||
}
|
||||
s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics };
|
||||
s.kern = num("Kerning").filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
s.caps = match g("FontCaps").and_then(E::as_i64) {
|
||||
Some(1) => Caps::SmallCaps,
|
||||
Some(2) => Caps::AllCaps,
|
||||
@@ -313,6 +401,116 @@ fn real(v: f32) -> E {
|
||||
E::Real(f64::from(v))
|
||||
}
|
||||
|
||||
/// Manual kerning as Photoshop stores it: a whole number of 1/1000 em (its UI range is ±1000;
|
||||
/// anything non-finite writes 0).
|
||||
pub(crate) fn kern_units(v: f32) -> i64 {
|
||||
if v.is_finite() { v.round().clamp(-10_000.0, 10_000.0) as i64 } else { 0 }
|
||||
}
|
||||
|
||||
// Kerning in EngineData, as observed in Photoshop 2026 (writing, and reading files without
|
||||
// `Txt2`): a manual kern k (1/1000 em) after character c is written on character c + 1 as
|
||||
// `AutoKerning false` + `Kerning k`; everything else is `true` + 0. Photoshop's reader takes
|
||||
// `Kerning k` on character j as the kern after j - 1 (making j - 1 manual), and
|
||||
// `AutoKerning false` on j as "character j - 2 is manually kerned" (no automatic kerning). The
|
||||
// automatic mode itself (Metrics or Optical) isn't in EngineData: Photoshop keeps it in the
|
||||
// document's `Txt2` (see [`apply_txt2`]). We write and read the same way, so Photoshop reads
|
||||
// our kerning as we do and Photoshop's own fields round-trip unchanged. (The mode of the last
|
||||
// character has no slot and reads back as Metrics.)
|
||||
|
||||
fn manual(s: &CharStyle) -> bool {
|
||||
s.kerning == Kerning::Off || kern_units(s.kern) != 0
|
||||
}
|
||||
|
||||
/// EngineData style runs for `ed_text` (the layer text with `\r` breaks and the trailing `\r`):
|
||||
/// (model run index, `(AutoKerning, Kerning)`, UTF-16 length), split where those change.
|
||||
fn pair_runs(ed_text: &str, runs: &[TextRun]) -> Vec<(usize, (bool, i64), usize)> {
|
||||
// Model run of each character (the trailing `\r` takes the last run).
|
||||
let mut owner: Vec<(usize, usize)> = Vec::with_capacity(ed_text.len());
|
||||
let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len));
|
||||
for (b, ch) in ed_text.char_indices() {
|
||||
while b >= run_end && ri + 1 < runs.len() {
|
||||
ri += 1;
|
||||
run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
}
|
||||
owner.push((ri, ch.len_utf16()));
|
||||
}
|
||||
let style = |i: usize| owner.get(i).and_then(|o| runs.get(o.0)).map(|r| &r.style);
|
||||
let mut out: Vec<(usize, (bool, i64), usize)> = Vec::new();
|
||||
for (j, &(ri, n)) in owner.iter().enumerate() {
|
||||
let k = j.checked_sub(1).and_then(style).map_or(0, |s| kern_units(s.kern));
|
||||
// `false` marks character j - 2 manual: written when it is and its own kern (on j - 1)
|
||||
// doesn't say so already; harmless (and Photoshop's own form) next to a kern on j.
|
||||
let off = match j.checked_sub(2) {
|
||||
Some(p) => style(p).is_some_and(|s| manual(s) && (kern_units(s.kern) == 0 || k != 0)),
|
||||
None => k != 0,
|
||||
};
|
||||
let fields = (!off, k);
|
||||
match out.last_mut() {
|
||||
Some(last) if last.0 == ri && last.1 == fields => last.2 += n,
|
||||
_ => out.push((ri, fields, n)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads EngineData's kerning fields back into the model (see the notes above). `incoming` is
|
||||
/// per EngineData style run: (UTF-8 length in the engine text, `AutoKerning`, `Kerning`).
|
||||
fn kerning_from_pairs(text: &str, runs: Vec<TextRun>, incoming: &[(usize, bool, f32)]) -> Vec<TextRun> {
|
||||
if text.is_empty() || runs.is_empty() {
|
||||
return runs;
|
||||
}
|
||||
// Byte offset of every character, plus the trailing break.
|
||||
let starts: Vec<usize> = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect();
|
||||
let n = starts.len().saturating_sub(1);
|
||||
let mut kern = vec![0.0f32; n];
|
||||
let mut off = vec![false; n];
|
||||
let (mut si, mut seg_end) = (0usize, incoming.first().map_or(0, |s| s.0));
|
||||
for (j, &b) in starts.iter().enumerate() {
|
||||
while b >= seg_end && si < incoming.len() {
|
||||
si += 1;
|
||||
seg_end = seg_end.saturating_add(incoming.get(si).map_or(0, |s| s.0));
|
||||
}
|
||||
let Some(&(_, auto, k)) = incoming.get(si) else { break };
|
||||
if k != 0.0
|
||||
&& let Some(p) = j.checked_sub(1)
|
||||
&& let (Some(kp), Some(op)) = (kern.get_mut(p), off.get_mut(p))
|
||||
{
|
||||
*kp = k;
|
||||
*op = true;
|
||||
}
|
||||
if !auto && let Some(op) = j.checked_sub(2).and_then(|p| off.get_mut(p)) {
|
||||
*op = true;
|
||||
}
|
||||
}
|
||||
let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len));
|
||||
let mut out: Vec<TextRun> = Vec::new();
|
||||
for (c, w) in starts.windows(2).enumerate() {
|
||||
let (b, len) = (w[0], w[1] - w[0]);
|
||||
while b >= run_end && ri + 1 < runs.len() {
|
||||
ri += 1;
|
||||
run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
}
|
||||
let Some(r) = runs.get(ri) else { break };
|
||||
let k = kern.get(c).copied().unwrap_or(0.0);
|
||||
let kerning = if off.get(c).copied().unwrap_or(false) { Kerning::Off } else { Kerning::Metrics };
|
||||
match out.last_mut() {
|
||||
Some(last) if last.style.kern == k && last.style.kerning == kerning && same_but_kerning(&last.style, &r.style) => last.len += len,
|
||||
_ => {
|
||||
let mut style = r.style.clone();
|
||||
style.kerning = kerning;
|
||||
style.kern = k;
|
||||
out.push(TextRun { len, style });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether two styles differ at most in their kerning.
|
||||
fn same_but_kerning(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
CharStyle { kerning: b.kerning, kern: b.kern, ..a.clone() } == *b
|
||||
}
|
||||
|
||||
/// PostScript name for a style (the PSD `FontSet` stores these).
|
||||
pub(crate) fn postscript_for(s: &CharStyle) -> String {
|
||||
if let Some(ps) = &s.postscript_name {
|
||||
@@ -382,7 +580,7 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
|
||||
("VerticalScale".into(), real(s.vertical_scale)),
|
||||
("Tracking".into(), E::Int(s.tracking.round() as i64)),
|
||||
("AutoKerning".into(), E::Bool(s.kerning != Kerning::Off)),
|
||||
("Kerning".into(), E::Int(0)),
|
||||
("Kerning".into(), E::Int(kern_units(s.kern))),
|
||||
("BaselineShift".into(), real(s.baseline_shift_pt / k)),
|
||||
(
|
||||
"FontCaps".into(),
|
||||
@@ -465,17 +663,14 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
|
||||
};
|
||||
let mut sruns = Vec::new();
|
||||
let mut slens = Vec::new();
|
||||
let mut at = 0usize;
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
let piece = &text[at..at + r.len];
|
||||
at += r.len;
|
||||
let mut n = utf16_len(piece);
|
||||
if i + 1 == runs.len() {
|
||||
n += 1; // trailing paragraph break
|
||||
}
|
||||
for (ri, pair, n) in pair_runs(&ed_text, &runs) {
|
||||
let Some(r) = runs.get(ri) else { continue };
|
||||
let fi = font_index(postscript_for(&r.style));
|
||||
sruns.push(E::Dict(vec![("StyleSheet".into(), E::Dict(vec![("StyleSheetData".into(), style_sheet_data(&r.style, fi, k))]))]));
|
||||
slens.push(E::Int(n));
|
||||
let mut data = style_sheet_data(&r.style, fi, k);
|
||||
data.set("AutoKerning", E::Bool(pair.0));
|
||||
data.set("Kerning", E::Int(pair.1));
|
||||
sruns.push(E::Dict(vec![("StyleSheet".into(), E::Dict(vec![("StyleSheetData".into(), data)]))]));
|
||||
slens.push(E::Int(n as i64));
|
||||
}
|
||||
let mut pruns = Vec::new();
|
||||
let mut plens = Vec::new();
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct PsdStyleSheets {
|
||||
pub para_sheets: Vec<(usize, String, ParagraphStyle, Option<CharStyle>)>,
|
||||
/// Per `StyleRun` entry: the character sheet index it refers to, if any.
|
||||
pub run_char: Vec<Option<usize>>,
|
||||
/// Per `StyleRun` entry: its length in UTF-8 bytes of the layer text (entries don't map
|
||||
/// one to one to model runs: kerning moves pair data between characters).
|
||||
pub run_char_lens: Vec<usize>,
|
||||
/// Per `ParagraphRun` entry: the paragraph sheet index it refers to, if any.
|
||||
pub run_para: Vec<Option<usize>>,
|
||||
}
|
||||
@@ -91,6 +94,8 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
let pnames = names(res.get("ParagraphSheetSet"));
|
||||
if let Some(a) = e.path(&["EngineDict", "StyleRun", "RunArray"]).and_then(E::as_array) {
|
||||
out.run_char = a.iter().map(|r| reference(r.get("StyleSheet"), &snames, normal_c)).collect();
|
||||
let ed_text = e.path(&["EngineDict", "Editor", "Text"]).and_then(E::as_str).unwrap_or("");
|
||||
out.run_char_lens = crate::psd::utf16_to_byte_lengths(ed_text, &crate::psd::arr_f(e.path(&["EngineDict", "StyleRun", "RunLengthArray"])));
|
||||
}
|
||||
if let Some(a) = e.path(&["EngineDict", "ParagraphRun", "RunArray"]).and_then(E::as_array) {
|
||||
out.run_para = a.iter().map(|r| reference(r.get("ParagraphSheet"), &pnames, normal_p)).collect();
|
||||
@@ -168,12 +173,27 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
||||
let char_ix = |id: Option<u32>| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let para_ix = |id: Option<u32>| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let runs = layer.char_runs();
|
||||
// Each entry lies inside one model run: find it by the entry's start offset.
|
||||
let ed_text = e.path(&["EngineDict", "Editor", "Text"]).and_then(E::as_str).unwrap_or("").to_string();
|
||||
let entry_lens = crate::psd::utf16_to_byte_lengths(&ed_text, &crate::psd::arr_f(e.path(&["EngineDict", "StyleRun", "RunLengthArray"])));
|
||||
let run_at = |off: usize| {
|
||||
let mut end = 0usize;
|
||||
runs.iter()
|
||||
.find(|r| {
|
||||
end = end.saturating_add(r.len);
|
||||
off < end
|
||||
})
|
||||
.or(runs.last())
|
||||
};
|
||||
if let Some(E::Array(a)) = e.get_mut("EngineDict").and_then(|d| d.get_mut("StyleRun")).and_then(|r| r.get_mut("RunArray")) {
|
||||
let mut off = 0usize;
|
||||
for (i, r) in a.iter_mut().enumerate() {
|
||||
let start = off;
|
||||
off = off.saturating_add(entry_lens.get(i).copied().unwrap_or(0));
|
||||
if let Some(sheet) = r.get_mut("StyleSheet") {
|
||||
remove_key(sheet, "Parent");
|
||||
remove_key(sheet, "Name");
|
||||
if let Some(ix) = char_ix(runs.get(i).and_then(|r| r.style.style_sheet)) {
|
||||
if let Some(ix) = char_ix(run_at(start).and_then(|r| r.style.style_sheet)) {
|
||||
sheet.set("Parent", E::Int(ix));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,97 @@ fn warp_bends_rendered_text_and_outlines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Txt2` as Photoshop writes it (a bare `/key value` sequence; text objects at `/1 /1`, style
|
||||
/// runs at `/0 /6 /0`, auto-kern mode at `/11`), for the layer text `text`.
|
||||
fn txt2_with_modes(text: &str, modes: &[(usize, i64)]) -> Vec<u8> {
|
||||
let mut v = b"\n\n/98 << /0 14 >> /0 << >> /1 << /1 [ << /0 << /0 (\xfe\xff".to_vec();
|
||||
for u in format!("{text}\r").encode_utf16() {
|
||||
let b = u.to_be_bytes();
|
||||
for x in b {
|
||||
if matches!(x, b'(' | b')' | b'\\') {
|
||||
v.push(b'\\');
|
||||
}
|
||||
v.push(x);
|
||||
}
|
||||
}
|
||||
v.extend_from_slice(b") /6 << /0 [ ");
|
||||
for (len, m) in modes {
|
||||
v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /11 {m} >> >> >> /1 {len} >> ").as_bytes());
|
||||
}
|
||||
v.extend_from_slice(b"] >> >> >> ] >>");
|
||||
v
|
||||
}
|
||||
|
||||
fn with_text_index(tysh: &[u8], index: i32) -> Vec<u8> {
|
||||
let mut t = crate::psd::parse_tysh(tysh).unwrap();
|
||||
t.text.items.retain(|(k, _)| !k.is("TextIndex"));
|
||||
t.text.items.push((photocraft_psd::descriptor::Id::new("TextIndex"), photocraft_psd::descriptor::Value::Integer(index)));
|
||||
crate::psd::write_tysh(&t)
|
||||
}
|
||||
|
||||
/// Optical (and "0") kerning only lives in `Txt2`: import applies it when the layer's
|
||||
/// `TextIndex` object still holds the same text.
|
||||
#[test]
|
||||
fn txt2_carries_optical_kerning() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let t = styled("AVA", CharStyle::default());
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
|
||||
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics));
|
||||
// "AVA\r": two optical characters, then manual ("0") for the last and the break.
|
||||
let txt2 = crate::psd::parse_txt2(&txt2_with_modes("AVA", &[(2, 2), (2, 0)])).unwrap();
|
||||
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
|
||||
let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect();
|
||||
assert_eq!(modes, vec![(2, Kerning::Optical), (1, Kerning::Off)]);
|
||||
// Stale Txt2 (other text), another index or garbage: nothing changes, nothing panics.
|
||||
for (data, index) in [(txt2_with_modes("AVX", &[(4, 2)]), 0), (txt2_with_modes("AVA", &[(4, 2)]), 3), (b"<< /1 [ (x".to_vec(), 0), (Vec::new(), 0)] {
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), index);
|
||||
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
if let Some(txt2) = crate::psd::parse_txt2(&data) {
|
||||
crate::psd::apply_txt2(&mut l, &tysh, &txt2);
|
||||
}
|
||||
assert!(
|
||||
l.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics),
|
||||
"case {index}: {:?}",
|
||||
l.char_runs().iter().map(|r| r.style.kerning).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
// Run lengths longer than the text, zero lengths and odd modes are tolerated.
|
||||
let txt2 = crate::psd::parse_txt2(&txt2_with_modes("AVA", &[(0, 2), (100, 2), (5, 9)])).unwrap();
|
||||
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
crate::psd::apply_txt2(&mut l, &tysh, &txt2);
|
||||
assert!(l.char_runs().iter().all(|r| r.style.kerning == Kerning::Optical));
|
||||
}
|
||||
|
||||
/// EngineData pair fields: manual kerning and "no automatic kerning" round-trip exactly, in
|
||||
/// Photoshop's form (see `psd::pair_runs`).
|
||||
#[test]
|
||||
fn psd_round_trips_manual_kerning() {
|
||||
use photocraft_doc::text::Kerning::{Metrics as M, Off as O};
|
||||
let s = CharStyle::default();
|
||||
let cases: Vec<Vec<(usize, photocraft_doc::text::Kerning, f32)>> = vec![
|
||||
vec![(1, O, 100.0), (1, O, -50.0), (2, M, 0.0)],
|
||||
vec![(1, M, 0.0), (1, O, 0.0), (2, M, 0.0)],
|
||||
vec![(4, O, 0.0)],
|
||||
vec![(2, M, 0.0), (1, O, 25.0), (1, M, 0.0)],
|
||||
vec![(3, M, 0.0), (1, O, 300.0)],
|
||||
];
|
||||
for runs in cases {
|
||||
let t = runs_of("AVAT", &runs.iter().map(|&(len, kerning, kern)| (len, CharStyle { kerning, kern, ..s.clone() })).collect::<Vec<_>>());
|
||||
let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap();
|
||||
let per = |t: &TextLayer| t.char_runs().iter().flat_map(|r| std::iter::repeat_n((r.style.kerning, r.style.kern), r.len)).collect::<Vec<_>>();
|
||||
let mut want = per(&t);
|
||||
// The last character's mode has no EngineData slot (it reads back as Metrics unless
|
||||
// it has a manual kern).
|
||||
if let Some(last) = want.last_mut()
|
||||
&& last.1 == 0.0
|
||||
{
|
||||
last.0 = M;
|
||||
}
|
||||
assert_eq!(per(&back), want, "{runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_round_trips_antialias_opentype_and_warp() {
|
||||
use photocraft_doc::text::{AntiAlias, TextWarp};
|
||||
@@ -504,6 +595,7 @@ fn clusters_sit_on_rendered_glyphs() {
|
||||
("plain", styled("HOHOH", big.clone())),
|
||||
("tracking", styled("HOHOH", CharStyle { tracking: 300.0, ..big.clone() })),
|
||||
("hscale", styled("HOHOH", CharStyle { horizontal_scale: 1.6, ..big.clone() })),
|
||||
("kerned", styled("HOHOH", CharStyle { kern: 250.0, kerning: photocraft_doc::text::Kerning::Optical, ..big.clone() })),
|
||||
("mixed", mixed),
|
||||
("box", boxed),
|
||||
];
|
||||
@@ -529,3 +621,90 @@ fn clusters_sit_on_rendered_glyphs() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runs_of(text: &str, styles: &[(usize, CharStyle)]) -> TextLayer {
|
||||
TextLayer { text: text.into(), runs: styles.iter().map(|(len, style)| TextRun { len: *len, style: style.clone() }).collect(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Manual kerning (1/1000 em) after a character moves everything after it by kern × size.
|
||||
#[test]
|
||||
fn manual_kerning_moves_the_next_glyph() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let mut e = TextEngine::new();
|
||||
let s = CharStyle { size_pt: 100.0, ..Default::default() };
|
||||
let plain = e.layout(&styled("HOH", s.clone()), 72.0);
|
||||
let kerned = e.layout(&runs_of("HOH", &[(1, CharStyle { kern: 100.0, ..s.clone() }), (2, s.clone())]), 72.0);
|
||||
let x = |l: &crate::TextLayout, i: usize| l.glyphs[i].x;
|
||||
assert_eq!(x(&kerned, 0), x(&plain, 0));
|
||||
// 100/1000 em at 100 px = 10 px, for the next glyph and everything after it.
|
||||
assert!((x(&kerned, 1) - x(&plain, 1) - 10.0).abs() < 1e-3, "{} vs {}", x(&kerned, 1), x(&plain, 1));
|
||||
assert!((x(&kerned, 2) - x(&plain, 2) - 10.0).abs() < 1e-3);
|
||||
assert!((width(&kerned) - width(&plain) - 10.0).abs() < 1e-3);
|
||||
// Carets follow: the cluster after the kerned pair starts 10 px later.
|
||||
assert!((kerned.caret(1).0 - plain.caret(1).0 - 10.0).abs() < 1e-3);
|
||||
// Negative kerning tightens; kerning on the last character doesn't move anything.
|
||||
let tight = e.layout(&runs_of("HOH", &[(1, CharStyle { kern: -50.0, ..s.clone() }), (2, s.clone())]), 72.0);
|
||||
assert!((x(&tight, 1) - x(&plain, 1) + 5.0).abs() < 1e-3);
|
||||
let last = e.layout(&runs_of("HOH", &[(2, s.clone()), (1, CharStyle { kern: 500.0, ..s.clone() })]), 72.0);
|
||||
assert!((width(&last) - width(&plain)).abs() < 1e-3);
|
||||
// Off replaces the font's pair kerning: "AV" with Off is wider than with Metrics.
|
||||
let mut av = |st: CharStyle| e.layout(&styled("AV", st), 72.0).glyphs[1].x;
|
||||
let metric = av(s.clone());
|
||||
let off = av(CharStyle { kerning: Kerning::Off, ..s.clone() });
|
||||
assert!(off > metric + 1.0, "Inter kerns AV: {off} vs {metric}");
|
||||
// Centred point text stays centred around the anchor with kerning.
|
||||
let centred = with_para(
|
||||
runs_of("HOH", &[(1, CharStyle { kern: 300.0, ..s.clone() }), (2, s.clone())]),
|
||||
ParagraphStyle { align: TextAlign::Center, ..Default::default() },
|
||||
);
|
||||
let l = e.layout(¢red, 72.0);
|
||||
assert!((l.lines[0].x0 + l.lines[0].x1).abs() < 0.5, "{:?}", l.lines[0]);
|
||||
}
|
||||
|
||||
/// Optical kerning computes pair spacing from the outlines: tighter for open pairs ("AV", "To")
|
||||
/// than the unkerned advance, about neutral for straight stems, and never absurd.
|
||||
#[test]
|
||||
fn optical_kerning_tightens_open_pairs() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let mut e = TextEngine::new();
|
||||
let s = CharStyle { size_pt: 100.0, ..Default::default() };
|
||||
let gap = |e: &mut TextEngine, text: &str, k: Kerning| {
|
||||
let l = e.layout(&styled(text, CharStyle { kerning: k, ..s.clone() }), 72.0);
|
||||
l.glyphs[1].x - l.glyphs[0].x
|
||||
};
|
||||
for pair in ["AV", "To", "LT", "Ty"] {
|
||||
let off = gap(&mut e, pair, Kerning::Off);
|
||||
let optical = gap(&mut e, pair, Kerning::Optical);
|
||||
assert!(optical < off - 3.0, "{pair}: optical {optical} vs unkerned {off}");
|
||||
}
|
||||
for pair in ["HH", "nn", "oo", "HO"] {
|
||||
let off = gap(&mut e, pair, Kerning::Off);
|
||||
let optical = gap(&mut e, pair, Kerning::Optical);
|
||||
assert!((optical - off).abs() < 6.0, "{pair}: optical {optical} vs unkerned {off}");
|
||||
}
|
||||
// A space breaks the pair; a manual kern replaces the automatic one (as in Photoshop).
|
||||
assert_eq!(gap(&mut e, "A V", Kerning::Optical), gap(&mut e, "A V", Kerning::Off));
|
||||
for mode in [Kerning::Optical, Kerning::Metrics] {
|
||||
let l = e.layout(&styled("AV", CharStyle { kerning: mode, kern: 100.0, ..s.clone() }), 72.0);
|
||||
let off = gap(&mut e, "AV", Kerning::Off);
|
||||
assert!((l.glyphs[1].x - l.glyphs[0].x - off - 10.0).abs() < 1e-3, "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A mode change splits shaping runs: the pairs on both sides of a manually kerned character
|
||||
/// lose their automatic kerning (Photoshop renders it the same way).
|
||||
#[test]
|
||||
fn kerning_modes_split_pairs() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let mut e = TextEngine::new();
|
||||
let s = CharStyle { size_pt: 100.0, ..Default::default() };
|
||||
let off = CharStyle { kerning: Kerning::Off, ..s.clone() };
|
||||
let xs = |e: &mut TextEngine, t: &TextLayer| e.layout(t, 72.0).glyphs.iter().map(|g| g.x).collect::<Vec<_>>();
|
||||
let metric = xs(&mut e, &styled("AVAV", s.clone()));
|
||||
let plain = xs(&mut e, &styled("AVAV", off.clone()));
|
||||
let mixed = xs(&mut e, &runs_of("AVAV", &[(2, s.clone()), (1, off.clone()), (1, s.clone())]));
|
||||
let adv = |v: &[f32], i: usize| v[i + 1] - v[i];
|
||||
assert!((adv(&mixed, 0) - adv(&metric, 0)).abs() < 1e-3, "AV before stays kerned");
|
||||
assert!((adv(&mixed, 1) - adv(&plain, 1)).abs() < 1e-3, "VA into the manual character");
|
||||
assert!((adv(&mixed, 2) - adv(&plain, 2)).abs() < 1e-3, "AV out of it");
|
||||
}
|
||||
@@ -276,3 +276,44 @@ fn hostile_vertical_input_does_not_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual kerning in vertical type moves the following glyphs down the column (#206); with a
|
||||
/// CJK font, upright characters too, and the punctuation squeeze still applies.
|
||||
#[test]
|
||||
fn manual_kerning_runs_along_the_column() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let kerned = |t: &TextLayer, kern: f32| {
|
||||
let mut k = t.clone();
|
||||
let first = k.text.chars().next().map_or(0, char::len_utf8);
|
||||
let style = k.runs[0].style.clone();
|
||||
k.runs = vec![TextRun { len: first, style: CharStyle { kerning: Kerning::Off, kern, ..style.clone() } }, TextRun { len: k.text.len() - first, style }];
|
||||
k
|
||||
};
|
||||
let mut e = TextEngine::new();
|
||||
// Rotated Latin: 100/1000 em at 40 px = 4 px further down for the second glyph on.
|
||||
let t = vertical("HOH", "Inter", 40.0);
|
||||
let plain = e.layout(&t, 72.0);
|
||||
let k = e.layout(&kerned(&t, 100.0), 72.0);
|
||||
assert!(k.vertical);
|
||||
assert_eq!(k.glyphs[0].y, plain.glyphs[0].y);
|
||||
for i in 1..3 {
|
||||
assert!((k.glyphs[i].y - plain.glyphs[i].y - 4.0).abs() < 1e-3, "{i}: {} vs {}", k.glyphs[i].y, plain.glyphs[i].y);
|
||||
assert!((k.glyphs[i].x - plain.glyphs[i].x).abs() < 1e-3, "stays on the column");
|
||||
}
|
||||
let (b0, b1) = (plain.bounds().unwrap(), k.bounds().unwrap());
|
||||
assert!((b1[3] - b0[3] - 4.0).abs() < 1e-3, "the column grows by the kern");
|
||||
// Optical kerning in vertical type (rotated Latin) lays out without trouble.
|
||||
let mut opt = vertical("AVATAR", "Inter", 40.0);
|
||||
opt.runs[0].style.kerning = Kerning::Optical;
|
||||
let o = e.layout(&opt, 72.0);
|
||||
assert_eq!(o.glyphs.len(), 6);
|
||||
|
||||
let Some((mut e, fam)) = cjk_engine() else { return };
|
||||
let t = vertical("東京、「都」", &fam, 40.0);
|
||||
let plain = e.layout(&t, 72.0);
|
||||
let k = e.layout(&kerned(&t, -250.0), 72.0);
|
||||
assert!((k.glyphs[1].y - plain.glyphs[1].y + 10.0).abs() < 1e-3, "upright CJK moves up by 10 px");
|
||||
// The squeeze between 、 and 「 is unchanged (same gap as without kerning).
|
||||
let gap = |l: &crate::TextLayout| l.glyphs[3].y - l.glyphs[2].y;
|
||||
assert!((gap(&k) - gap(&plain)).abs() < 1e-3);
|
||||
}
|
||||
@@ -132,3 +132,78 @@ fn clicking_outside_a_dialog_keeps_it_open_and_the_canvas_pans_and_zooms() {
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.dialogs.is_empty());
|
||||
}
|
||||
|
||||
/// Type tool (#206): Alt+←/→ at a collapsed caret kerns the pair before it by 20/1000 em (100
|
||||
/// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends
|
||||
/// the selection by a word.
|
||||
#[test]
|
||||
fn type_tool_alt_arrows_kern_the_pair() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let mut h = harness();
|
||||
let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "AVA To", "size": 40, "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let text = |h: &Harness<'static, PhotocraftApp>| {
|
||||
let st = h.state().session.active().unwrap();
|
||||
match &st.doc.layer(photocraft_doc::LayerId(id)).unwrap().content {
|
||||
photocraft_doc::LayerContent::Text(t) => t.clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
};
|
||||
let kern = |h: &Harness<'static, PhotocraftApp>| {
|
||||
let t = text(h);
|
||||
let r = t.char_runs();
|
||||
(r[0].style.kerning, r[0].style.kern, r.len())
|
||||
};
|
||||
let steps = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().history.entries().len();
|
||||
let metric = photocraft_text::shared().lock().unwrap().pair_kerning(&text(&h), 72.0, 0).unwrap().round();
|
||||
h.state_mut().ui.text_edit =
|
||||
Some(crate::state::TextEdit { layer: id, caret: 1, anchor: 1, session: "kern-test".into(), created: false, dragging: false, preedit: None });
|
||||
h.run_steps(2);
|
||||
let s0 = steps(&h);
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert_eq!(kern(&h).0, Kerning::Off);
|
||||
assert_eq!(kern(&h).1, metric + 20.0);
|
||||
h.key_press_modifiers(Modifiers::ALT | Modifiers::COMMAND, Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert_eq!(kern(&h).1, metric + 120.0);
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
assert_eq!(kern(&h).1, metric + 100.0);
|
||||
assert_eq!(steps(&h), s0 + 3, "one history step per press");
|
||||
// The caret didn't move; ⌘/Ctrl+→ moves by word, Alt+Shift+→ selects by word.
|
||||
assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((1, 1)));
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((3, 3)));
|
||||
h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((3, 6)));
|
||||
// With a selection Alt+→ moves by word instead of kerning; at the text end there's no pair.
|
||||
let before = kern(&h);
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight);
|
||||
h.run_steps(2);
|
||||
assert_eq!(kern(&h), before);
|
||||
assert_eq!(steps(&h), s0 + 3);
|
||||
// Undo walks back one press at a time.
|
||||
assert!(h.state_mut().session.undo());
|
||||
h.run_steps(1);
|
||||
assert_eq!(kern(&h).1, metric + 120.0);
|
||||
}
|
||||
|
||||
/// The Character panel's kerning field reads and parses Photoshop's values.
|
||||
#[test]
|
||||
fn kerning_field_values() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
assert_eq!(crate::type_tool::kerning_label((Kerning::Metrics, 0.0)), "Metrics");
|
||||
assert_eq!(crate::type_tool::kerning_label((Kerning::Optical, 0.0)), "Optical");
|
||||
assert_eq!(crate::type_tool::kerning_label((Kerning::Off, 0.0)), "0");
|
||||
assert_eq!(crate::type_tool::kerning_label((Kerning::Off, -49.6)), "-50");
|
||||
for (s, v) in [("metrics", Some(json!("metrics"))), (" Optical ", Some(json!("optical"))), ("120", Some(json!(120.0))), ("-25.4", Some(json!(-25.0)))] {
|
||||
assert_eq!(crate::type_tool::parse_kerning(s), v, "{s}");
|
||||
}
|
||||
for s in ["", "tight", "1e9", "-5000", "NaN", "inf"] {
|
||||
assert_eq!(crate::type_tool::parse_kerning(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
+104
-18
@@ -209,6 +209,18 @@ fn insert(app: &mut PhotocraftApp, s: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt+←/→: kern the pair before the caret by `by` (1/1000 em). No pair (caret at a text or line
|
||||
/// edge): nothing happens, like Photoshop. Not coalesced: one history step per press.
|
||||
fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) {
|
||||
let Some(text) = current_text(app, id) else { return };
|
||||
let before = caret.checked_sub(1).and_then(|i| text.chars().nth(i));
|
||||
let after = text.chars().nth(caret);
|
||||
if before.is_none_or(|c| c == '\n') || after.is_none_or(|c| c == '\n') {
|
||||
return;
|
||||
}
|
||||
let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}}));
|
||||
}
|
||||
|
||||
/// IME composition. The preedit text is written into the layer (so it lays out and reflows like
|
||||
/// typed text) and replaced by every update; `commit` makes the result final.
|
||||
fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) {
|
||||
@@ -393,11 +405,17 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
insert(app, "");
|
||||
}
|
||||
}
|
||||
// Photoshop: Alt/Option+←/→ at a collapsed caret kerns the pair before it by
|
||||
// 20/1000 em (100 with ⌘/Ctrl), one history step per press.
|
||||
Key::ArrowLeft | Key::ArrowRight if m.alt && !m.shift && a == b => {
|
||||
let step = if m.command { 100.0 } else { 20.0 };
|
||||
kern_pair(app, id, ed.caret, if *key == Key::ArrowRight { step } else { -step });
|
||||
}
|
||||
Key::ArrowLeft | Key::ArrowRight => {
|
||||
let fwd = *key == Key::ArrowRight;
|
||||
let to = if m.command {
|
||||
line_edge(app, id, ed.caret, fwd)
|
||||
} else if m.alt {
|
||||
// Word movement: ⌘/Ctrl (Photoshop's; Alt is kerning), and Alt+Shift
|
||||
// extends the selection by words; Home/End go to the line edges.
|
||||
let to = if m.command || m.alt {
|
||||
word_boundary(&text, ed.caret, fwd)
|
||||
} else if a != b && !m.shift {
|
||||
if fwd { b } else { a }
|
||||
@@ -794,6 +812,85 @@ fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, ph
|
||||
Some((c, p))
|
||||
}
|
||||
|
||||
/// Kerning shown for the target: at a collapsed caret, the pair before it (Photoshop's
|
||||
/// Character panel); else the style at the selection.
|
||||
fn kerning_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::Kerning, f32)> {
|
||||
let ed = app.ui.text_edit.as_ref().filter(|e| e.caret == e.anchor && e.caret > 0)?;
|
||||
let st = app.session.active()?;
|
||||
let t = text_layer(&st.doc, LayerId(ed.layer))?;
|
||||
let at = byte_of(&t.text, ed.caret - 1);
|
||||
let mut end = 0;
|
||||
t.char_runs()
|
||||
.into_iter()
|
||||
.find(|r| {
|
||||
end += r.len;
|
||||
at < end
|
||||
})
|
||||
.map(|r| (r.style.kerning, r.style.kern))
|
||||
}
|
||||
|
||||
/// The Character panel's kerning text: "Metrics", "Optical" or the manual value.
|
||||
pub(crate) fn kerning_label((mode, kern): (photocraft_doc::text::Kerning, f32)) -> String {
|
||||
use photocraft_doc::text::Kerning;
|
||||
match mode {
|
||||
_ if kern != 0.0 && kern.is_finite() => format!("{}", kern.round() as i64),
|
||||
Kerning::Metrics => "Metrics".into(),
|
||||
Kerning::Optical => "Optical".into(),
|
||||
Kerning::Off => "0".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a typed kerning value: Metrics / Optical (any case) or a number in 1/1000 em
|
||||
/// (Photoshop's range, -1000..10000). `None` for anything else.
|
||||
pub(crate) fn parse_kerning(s: &str) -> Option<serde_json::Value> {
|
||||
let s = s.trim();
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"metrics" => Some(json!("metrics")),
|
||||
"optical" => Some(json!("optical")),
|
||||
_ => s.parse::<f64>().ok().filter(|v| v.is_finite() && (-1000.0..=10000.0).contains(v)).map(|v| json!(v.round())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's editable kerning combo: type a value or Metrics/Optical, or pick a preset.
|
||||
fn kerning_field(ui: &mut egui::Ui, shown: &str, width: f32) -> Option<serde_json::Value> {
|
||||
let id = ui.make_persistent_id("props-kern-text");
|
||||
let mut buf: String = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.to_string());
|
||||
let mut out = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut buf).desired_width((width - 40.0).max(24.0)).id(id.with("edit")));
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
if resp.lost_focus() && buf.trim() != shown {
|
||||
out = parse_kerning(&buf);
|
||||
}
|
||||
let presets = ["Metrics", "Optical", "0", "-100", "-75", "-50", "-25", "-10", "-5", "5", "10", "25", "50", "75", "100", "200"];
|
||||
egui::ComboBox::from_id_salt("props-kern-presets").selected_text("").width(16.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
for p in presets {
|
||||
if ui.selectable_label(p == shown, p).clicked() {
|
||||
out = parse_kerning(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Applies a kerning value: at a collapsed caret to the pair before it, else to the selection
|
||||
/// (or the whole layer).
|
||||
fn apply_kerning(app: &mut PhotocraftApp, ctx: &egui::Context, v: serde_json::Value) {
|
||||
match app.ui.text_edit.clone().filter(|e| e.caret == e.anchor) {
|
||||
Some(ed) if ed.caret > 0 => {
|
||||
let _ = app.run("type.edit", json!({"layer": ed.layer, "range": [ed.caret - 1, ed.caret], "kerning": v}));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => apply(app, ctx, json!({ "kerning": v })),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
|
||||
@@ -932,23 +1029,12 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
}
|
||||
});
|
||||
row(ui, &mut |ui| {
|
||||
let mut k = match c.kerning {
|
||||
photocraft_doc::text::Kerning::Metrics => "metrics",
|
||||
photocraft_doc::text::Kerning::Optical => "optical",
|
||||
photocraft_doc::text::Kerning::Off => "off",
|
||||
}
|
||||
.to_string();
|
||||
let shown = kerning_label(kerning_at(app).unwrap_or((c.kerning, c.kern)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
icon_label(ui, "text-cursor", "Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret)");
|
||||
if let Some(v) = kerning_field(ui, &shown, w) {
|
||||
apply_kerning(app, ui.ctx(), v);
|
||||
}
|
||||
});
|
||||
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
|
||||
|
||||
@@ -212,7 +212,9 @@ da13596f17faea5cb22783b4b322948fabab98192e06f00fed3eeffb81ac99dd text/caps-smal
|
||||
11e38a0dbda2275979be9287c6e7c8518db2e0d00eebc89ef05d973ad02597dd text/caps-small-caps-opentype-source-serif.psd
|
||||
11c27c05a1cce82a4905f7fb98a310a07a5a05e058f976f6426f341beba7ceb9 text/faux-bold-italic-runs.psd
|
||||
c4d90278476b5c22120cdfa7646269bdce3dc3908013b4e17c55facad035206e text/kerning-metrics.psd
|
||||
84798092da42111a7b1a9a9f773a1c6e211335cb89cf915c4cbaa52c0084ae6c text/kerning-off-partial.psd
|
||||
088fdc7779603923c9ce9b873e7ed369aa77fa15f400481e5cc6bb499ebd90c7 text/kerning-off.psd
|
||||
a08ceb324f2fa34ceb5e20024c8ee20055a9121058759a8d39f0b8f7db6c0da0 text/kerning-optical-24pt.psd
|
||||
4515c4ae650a32efc4ff4c10f6fbf89ccd11383f3502ad584dfee0463762e331 text/kerning-optical.psd
|
||||
e98d48f0704cf01538b362de847470a7260d49bbc9b63d2071fa9988cb0d0603 text/leading-manual-30-paragraph.psd
|
||||
b04abd8de6091818b7b777a8c408c181f402cbd5b75a399bad6e6283ecab5b92 text/leading-mixed-runs-point.psd
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::pinned::{PinnedCorpus, Upstream};
|
||||
|
||||
/// https://github.com/storytold/photocraft-corpus: our Photoshop-authored oracle PSDs
|
||||
/// (`photoshop/`). Bump through a PR after committing there.
|
||||
pub const PHOTOCRAFT_CORPUS_COMMIT: &str = "16427e2078634493e27cdc4a55b4d4786c49e575";
|
||||
pub const PHOTOCRAFT_CORPUS_COMMIT: &str = "f5b1178cab15309e05b7b504545df3c701f6d8fd";
|
||||
/// https://github.com/psd-tools/psd-tools (MIT): `tests/psd_files` (main, 2026-10-05).
|
||||
pub const PSD_TOOLS_COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2";
|
||||
/// https://github.com/Agamnentzar/ag-psd (MIT): `test/` (master, 2026-07-02).
|
||||
@@ -21,7 +21,7 @@ pub const AG_PSD_COMMIT: &str = "387049670cb89b88fb8fe1b7c01aeacf98dd2e3b";
|
||||
/// PngSuite (public domain), a fixed release archive.
|
||||
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||
|
||||
/// `corpus/photoshop`: 256 PSDs authored with Photoshop by the photocraft-corpus generator.
|
||||
/// `corpus/photoshop`: 258 PSDs authored with Photoshop by the photocraft-corpus generator.
|
||||
pub const PHOTOSHOP: PinnedCorpus = PinnedCorpus {
|
||||
name: "photoshop",
|
||||
dest: "photoshop",
|
||||
|
||||
Reference in new issue
Block a user