Merge PR #5807: Type: keep superscript and subscript when a text layer is re-rendered (#5645)

This commit is contained in:
David Mann committed 2026-10-20 22:39:12 +02:00
commit 2adfe0c4e2
7 files changed
+123 -5

No files matched your search

+13
View File
@@ -32,6 +32,16 @@ pub enum Caps {
AllCaps,
}
/// Superscript / subscript (Photoshop's `FontBaseline`). The run is drawn smaller and raised or
/// lowered; `size_pt` and `baseline_shift_pt` keep their own values.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FontBaseline {
#[default]
Normal,
Superscript,
Subscript,
}
/// An OpenType feature setting, e.g. `{ tag: "ss01", value: 1 }`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FontFeature {
@@ -79,6 +89,8 @@ pub struct CharStyle {
/// per character run as `Kerning`). Added to the automatic kerning of the pair.
pub kern: f32,
pub caps: Caps,
/// Superscript or subscript.
pub baseline: FontBaseline,
/// Standard ligatures (`liga`/`clig`).
pub ligatures: bool,
/// Discretionary ligatures (`dlig`).
@@ -117,6 +129,7 @@ impl Default for CharStyle {
kerning: Kerning::Metrics,
kern: 0.0,
caps: Caps::Normal,
baseline: FontBaseline::Normal,
ligatures: true,
discretionary_ligatures: false,
features: Vec::new(),
+11 -2
View File
@@ -10,7 +10,8 @@ use std::sync::Arc;
use photocraft_color::Color;
use photocraft_doc::text::{
AntiAlias, Caps, CharStyle, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape,
AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection,
TextRun, TextShape,
};
use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, TextLayer};
use serde_json::{Value, json};
@@ -363,6 +364,14 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
};
hit(true);
}
if let Some(v) = p.get("baseline").and_then(Value::as_str) {
s.baseline = match v {
"superscript" => FontBaseline::Superscript,
"subscript" => FontBaseline::Subscript,
_ => FontBaseline::Normal,
};
hit(true);
}
if let Some(Value::Object(m)) = p.get("features") {
for (tag, v) in m {
let value = v.as_u64().map(|x| x as u16).or_else(|| v.as_bool().map(u16::from)).unwrap_or(1);
@@ -826,7 +835,7 @@ pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result<TextLa
Ok(t)
}
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"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"##;
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"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","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
pub fn specs() -> Vec<CommandSpec> {
vec![
+2 -1
View File
@@ -84,7 +84,7 @@ fn id_param(cmd: &str, p: &Value) -> Result<Option<u32>> {
/// A character style with every field changed from the default, to see which fields a set of
/// Character-panel keys touches.
fn perturbed_char() -> CharStyle {
use photocraft_doc::text::{Caps, FontFeature, FontVariation, Kerning};
use photocraft_doc::text::{Caps, FontBaseline, FontFeature, FontVariation, Kerning};
CharStyle {
font_family: "\u{1}".into(),
font_style: "\u{1}".into(),
@@ -105,6 +105,7 @@ fn perturbed_char() -> CharStyle {
kerning: Kerning::Off,
kern: -12345.0,
caps: Caps::AllCaps,
baseline: FontBaseline::Subscript,
ligatures: false,
discretionary_ligatures: true,
features: vec![FontFeature { tag: "\u{1}".into(), value: 9 }],
+23
View File
@@ -213,6 +213,29 @@ fn exhausted_document_ids_and_maximal_text_runs_roundtrip() {
assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::<usize>(), text.text.len());
}
#[test]
fn text_run_baseline_roundtrips() {
use photocraft_doc::text::FontBaseline;
let mut doc = Document::new("scripts", photocraft_doc::Size::new(2, 1), ColorMode::Rgb, SampleType::U8);
doc.layers.push(Layer::new(
"Text",
LayerContent::Text(TextLayer {
text: "H2O2".into(),
runs: vec![
TextRun { len: 1, style: CharStyle::default() },
TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Subscript, ..Default::default() } },
TextRun { len: 1, style: CharStyle::default() },
TextRun { len: 1, style: CharStyle { baseline: FontBaseline::Superscript, ..Default::default() } },
],
..Default::default()
}),
));
let loaded = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
let LayerContent::Text(text) = &loaded.layers.last().unwrap().content else { panic!("text layer must roundtrip") };
let baselines: Vec<FontBaseline> = text.char_runs().iter().map(|r| r.style.baseline).collect();
assert_eq!(baselines, [FontBaseline::Normal, FontBaseline::Subscript, FontBaseline::Normal, FontBaseline::Superscript]);
}
#[test]
fn zip_incremental_only_new_tiles() {
let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16);
+19 -1
View File
@@ -22,7 +22,7 @@ use parley::{
PositionedLayoutItem, StyleProperty,
};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape};
use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, TextAlign, TextDirection, TextShape};
use skrifa::raw::types::Tag;
use crate::fonts::FontDb;
@@ -31,6 +31,23 @@ use crate::fonts::FontDb;
/// readable hierarchy for every font instead of silently rendering lowercase text unchanged.
const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7;
/// Photoshop's default superscript / subscript size and position (Type preferences, stored in a
/// PSD's text resources as `SuperscriptSize` .583, `SuperscriptPosition` .333 and their
/// `Subscript` twins): the run is drawn at 58.3% of its size and moved by 33.3% of its size.
const SCRIPT_SIZE: f32 = 0.583;
const SCRIPT_POSITION: f32 = 0.333;
/// The style a run is drawn with: superscript and subscript shrink it and shift its baseline.
fn drawn_style(s: &mut CharStyle) {
let dir = match s.baseline {
FontBaseline::Normal => return,
FontBaseline::Superscript => 1.0,
FontBaseline::Subscript => -1.0,
};
s.baseline_shift_pt += dir * SCRIPT_POSITION * s.size_pt;
s.size_pt *= SCRIPT_SIZE;
}
/// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the
/// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones.
pub const DEFAULT_TAB_STOP_PT: f32 = 36.0;
@@ -480,6 +497,7 @@ impl Layouter {
crate::served::request(&s.font_family);
}
fonts.select_named_face(&mut s);
drawn_style(&mut s);
out.styles.push(s);
}
let run_starts: Vec<usize> = runs
+29 -1
View File
@@ -11,7 +11,9 @@
use photocraft_color::{Color, ColorMode};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp};
use photocraft_doc::text::{
AntiAlias, Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp,
};
use photocraft_geom::{Affine, Point};
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor};
@@ -626,6 +628,11 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C
Some(2) => Caps::AllCaps,
_ => Caps::Normal,
};
s.baseline = match g("FontBaseline").and_then(E::as_i64) {
Some(1) => FontBaseline::Superscript,
Some(2) => FontBaseline::Subscript,
_ => FontBaseline::Normal,
};
if let Some(c) = g("FillColor") {
// Positions matter (alpha, then the channels): a non-finite entry voids the colour.
let v: Vec<f64> = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default();
@@ -861,6 +868,14 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
Caps::AllCaps => 2,
}),
),
(
"FontBaseline".into(),
E::Int(match s.baseline {
FontBaseline::Normal => 0,
FontBaseline::Superscript => 1,
FontBaseline::Subscript => 2,
}),
),
("Underline".into(), E::Bool(s.underline)),
("Strikethrough".into(), E::Bool(s.strikethrough)),
("Ligatures".into(), E::Bool(s.ligatures)),
@@ -1365,6 +1380,19 @@ mod nonfinite_tests {
assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0);
}
/// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back.
#[test]
fn font_baseline_round_trips() {
use photocraft_doc::text::FontBaseline;
let d = parse(b"<< /FontBaseline 2 >>").unwrap();
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript);
let d = parse(b"<< /FontBaseline 1 >>").unwrap();
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript);
let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() };
let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0);
assert_eq!(back.baseline, FontBaseline::Subscript);
}
#[test]
fn warp_values_beyond_f32_clamp_to_the_warp_range() {
let warp = Descriptor::new("warp")
+26
View File
@@ -504,6 +504,32 @@ fn styles_change_pixels() {
assert!(hrect.width() as f32 > prect.width() as f32 * 1.7);
}
/// #5645: superscript and subscript runs are drawn at 58.3% of their size, raised or lowered by
/// 33.3% of it, as Photoshop draws them; they used to be drawn as plain text.
#[test]
fn superscript_and_subscript_shrink_and_shift_the_run() {
use photocraft_doc::text::FontBaseline;
let mut e = TextEngine::new();
let base = CharStyle { size_pt: 60.0, ..Default::default() };
let rect = |e: &mut TextEngine, baseline: FontBaseline| {
let (_, r) =
e.render(&TextLayer { transform: Affine::translate(5.0, 100.0), ..styled("H", CharStyle { baseline, ..base.clone() }) }, 72.0, PixelFormat::RGBA8);
r.rect
};
let plain = rect(&mut e, FontBaseline::Normal);
let sup = rect(&mut e, FontBaseline::Superscript);
let sub = rect(&mut e, FontBaseline::Subscript);
// Scripts render at 58.3% of the size. The pixel-snapped bounds of the antialiased glyph land
// within 2 px of the exact scaled height (measured: +2.2 / +1.2 px at 46 px plain height).
let expect = plain.height() as f32 * 0.583;
for r in [&sup, &sub] {
assert!((r.height() as f32 - expect).abs() <= 2.5, "plain {plain:?}, scripted {r:?}");
}
// The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below.
assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}");
assert!((sub.y1 - plain.y1 - 20).abs() <= 2, "plain {plain:?}, subscript {sub:?}");
}
/// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one
/// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the
/// ink must grow only by the dilation (about perimeter x radius), not by edge saturation.