diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 6f7836fa46..900e487f07 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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(), diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 439d1f008b..030fe18510 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -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 Vec { vec![ diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 797dfe7f8a..93b4261a00 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -84,7 +84,7 @@ fn id_param(cmd: &str, p: &Value) -> Result> { /// 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 }], diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..5a4fe87b2d 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -213,6 +213,29 @@ fn exhausted_document_ids_and_maximal_text_runs_roundtrip() { assert_eq!(text.paragraph_runs().iter().map(|r| r.len).sum::(), 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 = 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); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b79f4e3ceb..8ca0b6c2aa 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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 = runs diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 8c502c75af..a2350d10f2 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -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 = 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") diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a6c24e5da0..9022c9defb 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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.