fix(text): persist OpenType numeral tags through PSD EngineData

Map lnum/pnum/tnum as EngineData bools and the width×height combos as
FigureStyle (Krita/Illustrator), so Numerals choices survive PSD round-trip.
This commit is contained in:
tareq
2026-11-03 14:49:11 +01:00
parent f4eb3c86fd
commit 18610324d4
4 changed files with 127 additions and 54 deletions
+27 -51
View File
@@ -667,9 +667,7 @@ fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result<Valu
} else {
0
};
let cur = representative
.and_then(|id| st.doc.layer(id))
.is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if feature_on_at(t, start_char, tag)));
let cur = representative.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if feature_on_at(t, start_char, tag)));
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
with_text_targets(s, p, "Change OpenType Feature", |t, (a, b)| {
style_range(t, a.min(b), a.max(b), &|st| set_feature(st, tag, on));
@@ -909,63 +907,41 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("type.openType.ornaments", "Ornaments", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ornm")),
spec!("type.openType.ordinals", "Ordinals", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ordn")),
spec!("type.openType.fractions", "Fractions", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "frac")),
spec!(
"type.openType.numerals.default",
"Default",
&["Type", "OpenType", "Numerals"],
FP,
has_format_target,
|s, p| set_numerals(s, p, NumeralsStyle::Default)
),
spec!("type.openType.numerals.default", "Default", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::Default
)),
spec!("type.openType.numerals.lining", "Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::Lining)
}),
spec!("type.openType.numerals.oldstyle", "Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::Oldstyle)
}),
spec!(
"type.openType.numerals.proportional",
"Proportional",
&["Type", "OpenType", "Numerals"],
FP,
has_format_target,
|s, p| set_numerals(s, p, NumeralsStyle::Proportional)
),
spec!("type.openType.numerals.proportional", "Proportional", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::Proportional
)),
spec!("type.openType.numerals.tabular", "Tabular", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::Tabular)
}),
spec!(
"type.openType.numerals.proportionalLining",
"Proportional Lining",
&["Type", "OpenType", "Numerals"],
FP,
has_format_target,
|s, p| set_numerals(s, p, NumeralsStyle::ProportionalLining)
),
spec!(
"type.openType.numerals.tabularLining",
"Tabular Lining",
&["Type", "OpenType", "Numerals"],
FP,
has_format_target,
|s, p| set_numerals(s, p, NumeralsStyle::TabularLining)
),
spec!(
"type.openType.numerals.proportionalOldstyle",
"Proportional Oldstyle",
&["Type", "OpenType", "Numerals"],
FP,
has_format_target,
|s, p| set_numerals(s, p, NumeralsStyle::ProportionalOldstyle)
),
spec!(
"type.openType.numerals.tabularOldstyle",
"Tabular Oldstyle",
&["Type", "OpenType", "Numerals"],
FP,
has_format_target,
|s, p| set_numerals(s, p, NumeralsStyle::TabularOldstyle)
),
spec!("type.openType.numerals.proportionalLining", "Proportional Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::ProportionalLining)
}),
spec!("type.openType.numerals.tabularLining", "Tabular Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::TabularLining
)),
spec!("type.openType.numerals.proportionalOldstyle", "Proportional Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::ProportionalOldstyle)
}),
spec!("type.openType.numerals.tabularOldstyle", "Tabular Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::TabularOldstyle
)),
spec!("type.createWorkPath", "Create Work Path", &["Type"], LP, has_text, create_work_path),
spec!("type.convertToShape", "Convert to Shape", &["Type"], LP, has_text, convert_to_shape),
spec!("type.rasterizeTypeLayer", "Rasterize Type Layer", &["Type"], LP, has_text, |s, p| s.execute("type.rasterize", p.clone())),
+9 -1
View File
@@ -1,5 +1,8 @@
use super::edit::{TEMPLATE_NODES, edited_object, fits, is_stub, names_one_frame, new_object, pad_frames, td_edited_object, with_modes};
use super::import::{OPENTYPE_KEYS, TXT2_FEATURES, TXT2_STYLISTIC_SETS, kern_units, manual, object_frame, pair_runs, parse_txt2, postscript_for, real};
use super::import::{
OPENTYPE_KEYS, TXT2_FEATURES, TXT2_STYLISTIC_SETS, figure_style_from_features, kern_units, manual, object_frame, pair_runs, parse_txt2, postscript_for,
real,
};
use super::{
AntiAlias, Caps, CharStyle, Color, ColorMode, D, Descriptor, E, FontBaseline, Id, Kerning, Orientation, ParagraphStyle, TextAlign, TextLayer, TextShape,
TySh, UnicodeString, ed, engine_data, enum_value, enumv, parse_tysh, write_tysh,
@@ -25,6 +28,11 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
.filter(|(_, tag)| s.features.iter().any(|f| f.tag == *tag && f.value > 0))
.map(|(key, _)| (key.to_string(), E::Bool(true)))
.collect();
// Exact width×height numeral combos also go out as `/FigureStyle` for Krita / Illustrator.
let figure = figure_style_from_features(&s.features);
if figure != 0 {
opentype.push(("FigureStyle".into(), E::Int(figure)));
}
let mut dict = vec![
("Font".into(), E::Int(font as i64)),
("FontSize".into(), real(s.size_pt / k)),
+68 -2
View File
@@ -588,7 +588,15 @@ pub(crate) fn char_style(base: &[Option<&E>], d: &E, fonts: &[String], k: f32) -
s.discretionary_ligatures = flag("DLigatures").unwrap_or(false);
for (key, tag) in OPENTYPE_KEYS {
if flag(key) == Some(true) {
s.features.push(photocraft_doc::text::FontFeature { tag: tag.to_string(), value: 1 });
push_feature(&mut s, tag);
}
}
// Krita / Illustrator write `/FigureStyle` as an int for the four width×height numeral combos
// (Photoshop's UI historically only toggled OldStyle). Expand it after the bool keys so a
// file that carries both does not duplicate tags.
if let Some(code) = g("FigureStyle").and_then(E::as_i64) {
for tag in figure_style_tags(code) {
push_feature(&mut s, tag);
}
}
s.kerning = if flag("AutoKerning") == Some(false) { Kerning::Off } else { Kerning::Metrics };
@@ -789,13 +797,71 @@ pub(crate) fn postscript_for(s: &CharStyle) -> String {
/// EngineData style keys for the Type › OpenType toggles and the OpenType feature each one turns
/// on (the standard and discretionary ligature keys map to dedicated [`CharStyle`] fields).
pub const OPENTYPE_KEYS: [(&str, &str); 8] = [
///
/// `lnum` / `pnum` / `tnum` are written as bools so PhotoCraft round-trips every Numerals menu
/// choice; Photoshop's EngineData historically only had `OldStyle` (`onum`), with the four
/// width×height combos in the integer [`figure_style_from_features`] / `FigureStyle` field.
pub const OPENTYPE_KEYS: [(&str, &str); 11] = [
("ContextualLigatures", "calt"),
("Swash", "swsh"),
("OldStyle", "onum"),
("LiningFigures", "lnum"),
("ProportionalFigures", "pnum"),
("TabularFigures", "tnum"),
("StylisticAlternates", "salt"),
("Titling", "titl"),
("Ornaments", "ornm"),
("Ordinals", "ordn"),
("Fractions", "frac"),
];
/// Tags enabled by EngineData `/FigureStyle` (Krita / Illustrator): 0 none, 1 tabular+lining,
/// 2 proportional+oldstyle, 3 proportional+lining, 4 tabular+oldstyle.
pub fn figure_style_tags(code: i64) -> &'static [&'static str] {
match code {
1 => &["tnum", "lnum"],
2 => &["pnum", "onum"],
3 => &["pnum", "lnum"],
4 => &["tnum", "onum"],
_ => &[],
}
}
/// Inverse of [`figure_style_tags`]: exact Numerals combos only (singles stay on the bool keys).
pub fn figure_style_from_features(features: &[photocraft_doc::text::FontFeature]) -> i64 {
let on = |tag: &str| features.iter().any(|f| f.tag == tag && f.value > 0);
match (on("tnum"), on("lnum"), on("pnum"), on("onum")) {
(true, true, false, false) => 1,
(false, false, true, true) => 2,
(false, true, true, false) => 3,
(true, false, false, true) => 4,
_ => 0,
}
}
fn push_feature(s: &mut CharStyle, tag: &str) {
if !s.features.iter().any(|f| f.tag == tag) {
s.features.push(photocraft_doc::text::FontFeature { tag: tag.to_string(), value: 1 });
}
}
#[cfg(test)]
mod figure_style_tests {
use super::{figure_style_from_features, figure_style_tags};
use photocraft_doc::text::FontFeature;
fn feats(tags: &[&str]) -> Vec<FontFeature> {
tags.iter().map(|t| FontFeature { tag: (*t).into(), value: 1 }).collect()
}
#[test]
fn figure_style_round_trips_exact_combos() {
for code in 1..=4 {
let tags = figure_style_tags(code);
assert_eq!(figure_style_from_features(&feats(tags)), code);
}
assert_eq!(figure_style_from_features(&feats(&["lnum"])), 0);
assert_eq!(figure_style_from_features(&feats(&["onum"])), 0);
assert_eq!(figure_style_tags(0), &[] as &[&str]);
}
}
+23
View File
@@ -1732,6 +1732,29 @@ fn psd_round_trips_antialias_opentype_and_warp() {
}
}
/// Numerals menu tags (`lnum` / `onum` / `pnum` / `tnum`) survive TySh EngineData — bool keys for
/// singles, plus `/FigureStyle` for the four width×height combos (Krita / Illustrator).
#[test]
fn psd_round_trips_opentype_numeral_features() {
let cases: &[&[&str]] = &[&["lnum"], &["onum"], &["pnum"], &["tnum"], &["lnum", "tnum"], &["onum", "pnum"], &["lnum", "pnum"], &["onum", "tnum"]];
for tags in cases {
let style = CharStyle {
font_family: "Inter".into(),
size_pt: 20.0,
features: tags.iter().map(|t| FontFeature { tag: (*t).into(), value: 1 }).collect(),
..Default::default()
};
let t = styled("0123456789", style);
let bytes = crate::psd::build_tysh(&t, 72.0, None);
let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).expect("tysh");
let runs = back.char_runs();
let mut got: Vec<String> = runs[0].style.features.iter().map(|f| f.tag.clone()).collect();
got.sort_unstable();
let want: Vec<String> = tags.iter().map(|t| (*t).to_string()).collect();
assert_eq!(got, want, "numeral tags {tags:?}");
}
}
/// A legacy-signature `TySh` (#1469): zero PointBase, `bounds` (the layout box) and `boundingBox`
/// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a
/// `transform` that cancels the remote origin.