mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 20:32:31 +00:00
This commit is contained in:
commit
2adfe0c4e2
7 files changed
+123
-5
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user