diff --git a/crates/expr/Cargo.toml b/crates/expr/Cargo.toml index 613162e16..6f157698b 100644 --- a/crates/expr/Cargo.toml +++ b/crates/expr/Cargo.toml @@ -13,6 +13,7 @@ workspace = true effectcraft-time = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-keyframe = { workspace = true } +serde_json = { workspace = true } effectcraft-project = { workspace = true } effectcraft-path = { workspace = true } effectcraft-render = { workspace = true } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 233629702..4b92e224e 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -49,6 +49,17 @@ pub enum Req { SourceRect { comp: u64, layer: u64, t: Secs, extents: bool }, /// `[[time, duration, comment, chapter, url], …]` for a layer, or the comp when `layer` is None. Markers { comp: u64, layer: Option }, + /// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as + /// `layer.setText` keys), or null for other properties. + Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool }, +} + +/// A text document as the style API's JSON. +pub fn doc_json(d: &effectcraft_keyframe::TextDoc) -> String { + use effectcraft_keyframe::text_doc::{char_style_json, para_style_json}; + let runs: Vec = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect(); + let paras: Vec = d.paras().iter().map(para_style_json).collect(); + serde_json::json!({"doc": d, "text": d.text, "runs": runs, "paras": paras}).to_string() } /// Plain data answer, converted to a JS value by the runtime. @@ -292,6 +303,16 @@ impl<'a> Resolver<'a> { let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) }; value_resp(&v, prop_dims(l, p)) } + Req::Doc { comp, layer, path, t, pre } => { + let (l, p) = self.prop(*comp, *layer, path)?; + let t = f64::from_bits(*t); + let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) }; + match v { + Value::Text(d) => Resp::Str(doc_json(&d)), + Value::Str(s) => Resp::Str(doc_json(&effectcraft_keyframe::TextDoc::plain(&s))), + _ => Resp::Null, + } + } Req::Xform { comp, layer, t } => { let l = self.layer(*comp, *layer)?; let ctx = self.ctx_at(*comp, f64::from_bits(*t))?; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 1e3a61ab2..67ddf4585 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -17,7 +17,7 @@ var textTotal = 1; var selectorValue = [100, 100, 100]; // Request ops (must match runtime.rs). -var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9; +var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10; function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; @@ -34,6 +34,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { function __finish(r) { r = __v(r); if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. if (Array.isArray(r)) return r.map(function (x) { return __v(x); }); @@ -640,6 +641,18 @@ Prop.prototype = { inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); }, outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); }, isClosed: function () { return this.value.isClosed(); }, + // Source Text style API. + __docAt: function (t) { + var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self); + if (d === null) __err('Property "' + this.name + '" is not a Source Text property'); + return JSON.parse(d); + }, + get style() { return this.getStyleAt(0); }, + getStyleAt: function (i, t) { + if (t === undefined) t = time; + return new __TextStyle(this.__docAt(__v(t)), i === undefined ? 0 : Math.floor(__v(i)), [], false); + }, + createStyle: function () { return new __TextStyle(this.__docAt(time), 0, [], true); }, valueOf: function () { return this.value; }, toString: function () { return String(this.value); }, }; @@ -658,6 +671,138 @@ function __mkProp(c, l, path) { return new Proxy(new Prop(c, l, path), __propHandler); } +// Text styles (the Source Text style API) ------------------------------------------------------ +// A TextStyle is the source document (as JSON from the host), the character index it reads its +// attributes at, and the setter calls made on it ([key, value, start, count]). Setters return +// new objects; returning a style from a Source Text expression applies it. + +function __TextStyle(doc, idx, ops, empty) { this.__d = doc; this.__i = idx; this.__ops = ops; this.__empty = empty; } + +function __styleSetter(key, conv) { + return function (v, start, count) { + v = __v(v); + if (conv) v = conv(v, this); + var s = start === undefined ? -1 : Math.max(0, Math.floor(__v(start))); + var n = count === undefined ? -1 : Math.max(0, Math.floor(__v(count))); + return new __TextStyle(this.__d, this.__i, this.__ops.concat([[key, v, s, n]]), this.__empty); + }; +} + +function __colorIn(c) { + if (typeof c === 'string') return c; + return [c[0], c[1], c[2]]; +} + +__TextStyle.prototype = { + __isTextStyle: true, + __base: function () { + var runs = this.__d.runs, pos = 0, i = this.__i; + for (var k = 0; k < runs.length; k++) { + if (i < pos + runs[k].len || k === runs.length - 1) return runs[k].style; + pos += runs[k].len; + } + return {}; + }, + __para: function () { + var t = this.text, p = 0; + for (var k = 0; k < Math.min(this.__i, t.length); k++) if (t[k] === '\n' || t[k] === '\r') p++; + var paras = this.__d.paras; + return paras[Math.min(p, paras.length - 1)]; + }, + __get: function (key, para) { + for (var k = this.__ops.length - 1; k >= 0; k--) { + var o = this.__ops[k]; + if (o[0] !== key) continue; + if (o[2] < 0 || (this.__i >= o[2] && (o[3] < 0 || this.__i < o[2] + o[3]))) return o[1]; + } + return (para ? this.__para() : this.__base())[key]; + }, + get text() { + var t = this.__d.text; + for (var k = 0; k < this.__ops.length; k++) { + var o = this.__ops[k]; + if (o[0] !== 'text') continue; + if (o[2] < 0) t = String(o[1]); + else t = t.slice(0, o[2]) + String(o[1]) + (o[3] < 0 ? '' : t.slice(o[2] + o[3])); + } + return t; + }, + get font() { return this.__get('font'); }, + get fontSize() { return this.__get('size'); }, + get isFauxBold() { return this.__get('fauxBold'); }, + get isFauxItalic() { return this.__get('fauxItalic'); }, + get isAllCaps() { return this.__get('allCaps'); }, + get isSmallCaps() { return this.__get('smallCaps'); }, + get tracking() { return this.__get('tracking'); }, + get autoLeading() { return this.__get('leading') === 'auto'; }, + get leading() { var l = this.__get('leading'); return l === 'auto' ? this.fontSize * 1.2 : l; }, + get baselineShift() { return this.__get('baselineShift'); }, + get applyFill() { return this.__get('applyFill'); }, + get fillColor() { var c = this.__get('fill'); return [c[0], c[1], c[2]]; }, + get applyStroke() { return this.__get('applyStroke'); }, + get strokeColor() { var c = this.__get('stroke'); return [c[0], c[1], c[2]]; }, + get strokeWidth() { return this.__get('strokeWidth'); }, + get horizontalScaling() { return this.__get('hScale'); }, + get verticalScaling() { return this.__get('vScale'); }, + get tsume() { return this.__get('tsume'); }, + get baselineOption() { return this.__get('baseline'); }, + get isSuperscript() { return this.__get('baseline') === 'superscript'; }, + get isSubscript() { return this.__get('baseline') === 'subscript'; }, + get kerningType() { var k = this.__get('kerning'); return typeof k === 'number' ? 'manual' : k; }, + get kerning() { var k = this.__get('kerning'); return typeof k === 'number' ? k : 0; }, + get isLigature() { return this.__get('ligatures'); }, + get justification() { return this.__get('justify', true); }, + get firstLineIndent() { return this.__get('indentFirst', true); }, + get startIndent() { return this.__get('indentLeft', true); }, + get endIndent() { return this.__get('indentRight', true); }, + get spaceBefore() { return this.__get('spaceBefore', true); }, + get spaceAfter() { return this.__get('spaceAfter', true); }, + get direction() { return this.__get('direction', true); }, + get isEveryLineComposer() { return this.__get('composer', true) === 'everyLine'; }, + get isHangingRoman() { return this.__get('hangingPunctuation', true); }, + setText: __styleSetter('text', function (v) { return String(v); }), + replaceText: __styleSetter('text', function (v) { return String(v); }), + setFont: __styleSetter('font', function (v) { return String(v); }), + setFontSize: __styleSetter('size'), + setFauxBold: __styleSetter('fauxBold', Boolean), + setFauxItalic: __styleSetter('fauxItalic', Boolean), + setAllCaps: __styleSetter('allCaps', Boolean), + setSmallCaps: __styleSetter('smallCaps', Boolean), + setTracking: __styleSetter('tracking'), + setLeading: __styleSetter('leading'), + setAutoLeading: __styleSetter('leading', function (v, s) { return v ? 'auto' : s.leading; }), + setBaselineShift: __styleSetter('baselineShift'), + setApplyFill: __styleSetter('applyFill', Boolean), + setFillColor: __styleSetter('fill', __colorIn), + setApplyStroke: __styleSetter('applyStroke', Boolean), + setStrokeColor: __styleSetter('stroke', __colorIn), + setStrokeWidth: __styleSetter('strokeWidth'), + setHorizontalScaling: __styleSetter('hScale'), + setVerticalScaling: __styleSetter('vScale'), + setTsume: __styleSetter('tsume'), + setBaselineOption: __styleSetter('baseline', function (v) { return String(v).toLowerCase().replace('_baseline', ''); }), + setSuperscript: __styleSetter('superscript', Boolean), + setSubscript: __styleSetter('subscript', Boolean), + setKerningType: __styleSetter('kerning', function (v) { v = String(v).toLowerCase(); return v === 'manual' ? 0 : v; }), + setKerning: __styleSetter('kerning', Number), + setLigature: __styleSetter('ligatures', Boolean), + setJustification: __styleSetter('justify', function (v) { return String(v); }), + setFirstLineIndent: __styleSetter('indentFirst'), + setStartIndent: __styleSetter('indentLeft'), + setEndIndent: __styleSetter('indentRight'), + setSpaceBefore: __styleSetter('spaceBefore'), + setSpaceAfter: __styleSetter('spaceAfter'), + setDirection: __styleSetter('direction', function (v) { v = String(v).toLowerCase(); return v.indexOf('right') === 0 || v === 'rtl' ? 'rtl' : 'ltr'; }), + setEveryLineComposer: __styleSetter('composer', function (v) { return v ? 'everyLine' : 'singleLine'; }), + setHangingRoman: __styleSetter('hangingPunctuation', Boolean), + __out: function () { + return { __style: true, __json: JSON.stringify({ doc: this.__empty ? null : this.__d.doc, ops: this.__ops }) }; + }, + toString: function () { return this.text; }, + valueOf: function () { return this.text; }, +}; +Object.defineProperty(__TextStyle.prototype, 'length', { get: function () { return this.text.length; } }); + // Markers --------------------------------------------------------------------------------------- function Markers(c, l) { this.__c = c; this.__l = l; this.__m = null; } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 651774769..af7ed9b66 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -70,7 +70,14 @@ pub enum Out { Num(f64), Str(String), Arr(Vec), - Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool }, + Path { + points: Vec<[f64; 2]>, + ins: Vec<[f64; 2]>, + outs: Vec<[f64; 2]>, + closed: bool, + }, + /// A text style object: JSON `{doc: TextDoc | null, ops: [[key, value, start, count], …]}`. + Style(String), Object(String), } @@ -131,6 +138,13 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult= 0.0).then_some(l as u64) } } + 10 => Req::Doc { + comp: id(args, 1, ctx)?, + layer: id(args, 2, ctx)?, + path: string(args, 3, ctx)?, + t: secs(num(args, 4, ctx)?), + pre: arg(args, 5).to_boolean(), + }, _ => return Err(JsNativeError::typ().with_message("unknown host request").into()), }; let found = FRAMES.with(|f| { @@ -278,6 +292,10 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { } return Ok(Out::Arr(out)); } + if o.get(js_string!("__style"), ctx).map_err(err)?.to_boolean() { + let j = o.get(js_string!("__json"), ctx).map_err(err)?; + return Ok(Out::Style(j.as_string().map(|s| s.to_std_string_escaped()).unwrap_or_default())); + } if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() { let pts = |name: JsString, ctx: &mut Context| -> Result, String> { let v = o.get(name, ctx).map_err(err)?; @@ -408,6 +426,9 @@ pub fn to_value(out: &Out, current: &Value) -> Result { Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::>().join(","), Out::Undefined | Out::Null => String::new(), Out::Path { .. } => "[object Path]".into(), + Out::Style(j) => { + serde_json::from_str::(j).ok().map(|v| styled_doc(&v, &effectcraft_keyframe::TextDoc::default()).text).unwrap_or_default() + } Out::Object(s) => s.clone(), } }; @@ -428,11 +449,17 @@ pub fn to_value(out: &Out, current: &Value) -> Result { } Value::Bool(_) => Value::Bool(truthy(out)), Value::Enum(_) => Value::Enum((finite(numeric(out).unwrap_or(1.0))? - 1.0).round().max(0.0) as u32), - Value::Text(doc) => { - let mut d = doc.clone(); - d.text = text(out); - Value::Text(d) - } + Value::Text(doc) => match out { + Out::Style(j) => { + let v: serde_json::Value = serde_json::from_str(j).map_err(|e| format!("Error: bad text style: {e}"))?; + Value::Text(Box::new(styled_doc(&v, doc))) + } + _ => { + let mut d = doc.clone(); + d.set_text(&text(out)); + Value::Text(d) + } + }, Value::Str(_) => Value::Str(text(out)), Value::Path(_) => match out { Out::Path { points, ins, outs, closed } => { @@ -450,6 +477,48 @@ pub fn to_value(out: &Out, current: &Value) -> Result { }) } +/// Apply a returned text style (`{doc, ops}`) to the property's own document `own`: the style's +/// source document (another layer's, or this one's) gives the formatting, then the setter calls +/// apply in order. Without `setText` the text stays `own`'s. +pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> effectcraft_keyframe::TextDoc { + use effectcraft_keyframe::TextDoc; + let mut d = own.clone(); + if let Some(src) = v.get("doc").filter(|s| !s.is_null()).and_then(|s| serde_json::from_value::(s.clone()).ok()) { + if src.text == own.text { + // The same text (this layer's own style, or an identical one): every run carries over. + d = TextDoc { box_size: own.box_size, box_pos: own.box_pos, vertical: own.vertical, ..src }; + } else { + let first = src.style_at(0); + d.runs.clear(); + d.apply_style_all(|s| *s = first.clone()); + let p0 = src.para(0); + d.set_paras(vec![p0; d.para_count()]); + d.stroke_over_fill = src.stroke_over_fill; + } + } + for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() { + let Some(key) = op.get(0).and_then(|k| k.as_str()) else { continue }; + let val = op.get(1).cloned().unwrap_or_default(); + let start = op.get(2).and_then(|x| x.as_i64()).unwrap_or(-1); + let count = op.get(3).and_then(|x| x.as_i64()).unwrap_or(-1); + let n = d.char_len(); + let range = (start >= 0).then(|| { + let a = (start as usize).min(n); + a..if count < 0 { n } else { (a + count as usize).min(n) } + }); + if key == "text" { + let s = val.as_str().unwrap_or_default(); + match range { + Some(r) => d.replace_range(r, s, None), + None => d.set_text(s), + } + continue; + } + let _ = d.set_attr(key, &val, range); + } + d +} + /// Number → string like JS (`3`, not `3.0`). fn js_number(x: f64) -> String { if x.is_finite() && x == x.trunc() && x.abs() < 1e21 { format!("{}", x as i64) } else { format!("{x}") } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 8ff451bde..fd02acef8 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -723,6 +723,60 @@ fn source_text_reading_and_string_methods() { assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0); } +#[test] +fn source_text_style_api() { + let mut f = fx(); + let doc = |f: &Fx, e: &str| -> TextDoc { + match f.ev(f.title, "text/sourceText", e, 0.0) { + Ok(Value::Text(d)) => *d, + r => panic!("{e}: {r:?}"), + } + }; + // Getters read the first character's style. + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fontSize", 0.0), 40.0); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.isAllCaps ? 1 : 0", 0.0), 0.0); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.autoLeading ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.leading", 0.0), 48.0); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.fillColor[1]", 0.0), 1.0); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setFontSize(12).fontSize", 0.0), 12.0); + // Setters chain and return a styled document. + let d = doc(&f, "text.sourceText.style.setFontSize(80).setFillColor([1, 0, 0]).setAllCaps(true)"); + assert_eq!(d.text, "Hello"); + assert!(d.is_uniform()); + assert_eq!((d.size, d.fill, d.all_caps), (80.0, [1.0, 0.0, 0.0, 1.0], true)); + // Character ranges create runs; setText replaces the text. + let d = doc(&f, "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)"); + assert_eq!(d.text, "Hi there"); + let sizes: Vec<(usize, f64, bool)> = d.runs().iter().map(|r| (r.len, r.style.size, r.style.faux_bold)).collect(); + assert_eq!(sizes, vec![(2, 40.0, true), (1, 40.0, false), (5, 20.0, false)]); + // getStyleAt reads a styled document per character (and at a time). + { + let l = f.layer_mut(f.title); + let p = l.props.prop_mut("text/sourceText").unwrap(); + let Value::Text(d) = &mut p.value else { panic!() }; + d.apply_style(1..3, |s| s.size = 99.0); + } + assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(2, 0).fontSize", 0.0), 99.0); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.getStyleAt(4).fontSize", 0.0), 40.0); + // Returning the own style keeps its runs; plain strings keep the first character's style. + let d = doc(&f, "text.sourceText.style"); + assert_eq!(d.runs().len(), 3); + let d = doc(&f, "'Bye'"); + assert_eq!((d.text.as_str(), d.size), ("Bye", 40.0)); + // Paragraph setters. + let d = doc(&f, "text.sourceText.style.setJustification('CENTER_JUSTIFY').setStartIndent(12).setSpaceBefore(4).setEveryLineComposer(false)"); + assert_eq!(d.justify, effectcraft_keyframe::Justify::Center); + assert_eq!((d.indent_left, d.space_before), (12.0, 4.0)); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.style.setStartIndent(7).startIndent", 0.0), 7.0); + // createStyle sets only what's given and keeps the existing runs. + let d = doc(&f, "thisComp.layer('Title').text.sourceText.createStyle().setTsume(50).setBaselineOption('superscript')"); + assert_eq!(d.text, "Hello"); + assert!(d.runs().iter().all(|r| r.style.tsume == 50.0 && r.style.baseline == effectcraft_keyframe::BaselineOption::Superscript)); + assert_eq!(d.runs().len(), 3); + // Non-text properties have no style. + assert!(f.ev(f.a, ROT, "transform.rotation.style.fontSize", 0.0).is_err()); +} + #[test] fn numbers_to_source_text() { let f = fx(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index d97ec53d2..731e167ca 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -8,10 +8,12 @@ //! (pixels/second), evaluated through an arc-length table. //! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours. +pub mod text_doc; pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun}; pub use value::{Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs new file mode 100644 index 000000000..9652b91ac --- /dev/null +++ b/crates/keyframe/src/text_doc.rs @@ -0,0 +1,1112 @@ +//! Source Text documents: text with character style runs and per-paragraph settings. +//! +//! A [`TextDoc`] keeps its *base* character style and paragraph settings in flat fields (the +//! style of the first character and the settings of the first paragraph), which is all a +//! single-style document needs and what older projects contain. Mixed formatting is stored in +//! [`TextDoc::runs`] (character style runs covering the text, by character count) and +//! [`TextDoc::paragraphs`] (one entry per `\n`-separated paragraph). Both lists are empty while the +//! document is uniform, so single-style documents serialize exactly as before. +//! +//! Positions are **character** indices (Unicode scalar values), as in After Effects' expression +//! and scripting APIs. + +use std::ops::Range; + +use serde::{Deserialize, Serialize}; +use serde_json::Value as J; + +/// Horizontal paragraph alignment (AE's seven Paragraph panel buttons). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Justify { + #[default] + Left, + Center, + Right, + JustifyLastLeft, + JustifyLastCenter, + JustifyLastRight, + JustifyAll, +} + +impl Justify { + pub const ALL: [Justify; 7] = + [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLastLeft, Justify::JustifyLastCenter, Justify::JustifyLastRight, Justify::JustifyAll]; + /// The `layer.setText` / expression name. + pub fn key(self) -> &'static str { + match self { + Justify::Left => "left", + Justify::Center => "center", + Justify::Right => "right", + Justify::JustifyLastLeft => "justifyLeft", + Justify::JustifyLastCenter => "justifyCenter", + Justify::JustifyLastRight => "justifyRight", + Justify::JustifyAll => "justifyAll", + } + } + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() { + "left" | "leftjustify" => Justify::Left, + "center" | "centre" | "centerjustify" => Justify::Center, + "right" | "rightjustify" => Justify::Right, + "justify" | "justifyleft" | "justifylastleft" | "fulljustifylastlineleft" => Justify::JustifyLastLeft, + "justifycenter" | "justifylastcenter" | "fulljustifylastlinecenter" => Justify::JustifyLastCenter, + "justifyright" | "justifylastright" | "fulljustifylastlineright" => Justify::JustifyLastRight, + "justifyall" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll, + _ => return None, + }) + } +} + +/// Kerning between a character and the one before it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub enum Kerning { + /// The font's kerning pairs (`kern` / GPOS). + #[default] + Metrics, + /// Spacing from the glyph outlines. + Optical, + /// A manual value in 1/1000 em before this character (replaces metrics for the pair). + Manual(f64), +} + +impl Kerning { + pub fn parse(v: &J) -> Option { + match v { + J::Number(n) => n.as_f64().map(Kerning::Manual), + J::String(s) => match s.to_ascii_lowercase().as_str() { + "metrics" | "metric" | "auto" => Some(Kerning::Metrics), + "optical" => Some(Kerning::Optical), + "none" | "0" | "manual" => Some(Kerning::Manual(0.0)), + s => s.parse::().ok().map(Kerning::Manual), + }, + _ => None, + } + } + pub fn to_json(self) -> J { + match self { + Kerning::Metrics => J::from("metrics"), + Kerning::Optical => J::from("optical"), + Kerning::Manual(v) => J::from(v), + } + } +} + +/// Superscript / subscript. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BaselineOption { + #[default] + Normal, + Superscript, + Subscript, +} + +impl BaselineOption { + pub fn key(self) -> &'static str { + match self { + BaselineOption::Normal => "normal", + BaselineOption::Superscript => "superscript", + BaselineOption::Subscript => "subscript", + } + } + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "normal" | "none" => BaselineOption::Normal, + "superscript" | "super" => BaselineOption::Superscript, + "subscript" | "sub" => BaselineOption::Subscript, + _ => return None, + }) + } +} + +/// Paragraph direction. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Direction { + #[default] + Ltr, + Rtl, +} + +/// Line-breaking composer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Composer { + /// Balances the breaks of the whole paragraph (AE's default for new text). + #[default] + EveryLine, + /// Breaks each line as soon as it is full. + SingleLine, +} + +fn single_line() -> Composer { + Composer::SingleLine +} +fn yes() -> bool { + true +} + +/// The formatting of a run of characters (Character panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CharStyle { + pub font: String, + pub style: String, + pub size: f64, + pub fill: [f32; 4], + pub stroke: [f32; 4], + pub stroke_width: f64, + pub apply_fill: bool, + pub apply_stroke: bool, + /// 1/1000 em after each character. + pub tracking: f64, + /// Baseline-to-baseline distance; None = auto (120% of the size). + pub leading: Option, + /// Pixels, positive = up. + pub baseline_shift: f64, + pub h_scale: f64, + pub v_scale: f64, + /// Tsume: percent (0–100) of the space around each character removed. + pub tsume: f64, + pub faux_bold: bool, + pub faux_italic: bool, + pub all_caps: bool, + pub small_caps: bool, + pub baseline: BaselineOption, + pub kerning: Kerning, + pub ligatures: bool, +} + +impl Default for CharStyle { + fn default() -> Self { + TextDoc::default().base_style() + } +} + +impl CharStyle { + /// The line advance this style asks for (Auto Leading = 120% of the size). + pub fn line_advance(&self) -> f64 { + self.leading.unwrap_or(self.size * 1.2) + } +} + +/// A run of `len` characters sharing a style. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StyleRun { + pub len: usize, + pub style: CharStyle, +} + +/// Paragraph panel settings of one paragraph. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaStyle { + pub justify: Justify, + /// Start (left in LTR) and end margins, and the first line's extra indent, in pixels. + pub indent_left: f64, + pub indent_right: f64, + pub indent_first: f64, + pub space_before: f64, + pub space_after: f64, + pub direction: Direction, + pub composer: Composer, + /// Roman hanging punctuation: punctuation at the edges of paragraph text hangs outside the box. + pub hanging_punctuation: bool, +} + +impl Default for ParaStyle { + fn default() -> Self { + TextDoc::default().base_para() + } +} + +/// The value of a text layer's Source Text (hold-interpolated). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextDoc { + pub text: String, + pub font: String, + pub style: String, + pub size: f64, + pub fill: [f32; 4], + pub stroke: [f32; 4], + pub stroke_width: f64, + pub apply_fill: bool, + pub apply_stroke: bool, + pub stroke_over_fill: bool, + pub tracking: f64, + /// None = auto (120% of size). + pub leading: Option, + pub justify: Justify, + pub baseline_shift: f64, + pub h_scale: f64, + pub v_scale: f64, + pub faux_bold: bool, + pub faux_italic: bool, + pub all_caps: bool, + pub small_caps: bool, + /// Paragraph text box (width, height) with top-left at `box_pos`; None = point text. + pub box_size: Option<[f64; 2]>, + pub box_pos: [f64; 2], + pub tsume: f64, + pub baseline: BaselineOption, + pub kerning: Kerning, + #[serde(default = "yes")] + pub ligatures: bool, + pub indent_left: f64, + pub indent_right: f64, + pub indent_first: f64, + pub space_before: f64, + pub space_after: f64, + pub direction: Direction, + /// Documents saved before composers existed broke lines greedily. + #[serde(default = "single_line")] + pub composer: Composer, + pub hanging_punctuation: bool, + /// Vertical type (columns right to left, characters upright). + pub vertical: bool, + /// Character style runs covering the text (empty = the whole text uses the base style). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub runs: Vec, + /// One entry per paragraph (empty = every paragraph uses the base settings). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, +} + +impl Default for TextDoc { + fn default() -> Self { + TextDoc { + text: String::new(), + font: "Inter".into(), + style: "Regular".into(), + size: 72.0, + fill: [1.0, 1.0, 1.0, 1.0], + stroke: [0.0, 0.0, 0.0, 1.0], + stroke_width: 0.0, + apply_fill: true, + apply_stroke: false, + stroke_over_fill: true, + tracking: 0.0, + leading: None, + justify: Justify::Left, + baseline_shift: 0.0, + h_scale: 100.0, + v_scale: 100.0, + faux_bold: false, + faux_italic: false, + all_caps: false, + small_caps: false, + box_size: None, + box_pos: [0.0, 0.0], + tsume: 0.0, + baseline: BaselineOption::Normal, + kerning: Kerning::Metrics, + ligatures: true, + indent_left: 0.0, + indent_right: 0.0, + indent_first: 0.0, + space_before: 0.0, + space_after: 0.0, + direction: Direction::Ltr, + composer: Composer::EveryLine, + hanging_punctuation: false, + vertical: false, + runs: Vec::new(), + paragraphs: Vec::new(), + } + } +} + +/// Split `runs` so a run starts at character `at`; returns that run's index. +fn split_at(runs: &mut Vec, at: usize) -> usize { + let mut pos = 0; + for i in 0..runs.len() { + if pos == at { + return i; + } + let len = runs[i].len; + if at < pos + len { + let mut tail = runs[i].clone(); + tail.len = pos + len - at; + runs[i].len = at - pos; + runs.insert(i + 1, tail); + return i + 1; + } + pos += len; + } + runs.len() +} + +impl TextDoc { + /// A document with `text` in the default style. + pub fn plain(text: &str) -> TextDoc { + TextDoc { text: text.into(), ..Default::default() } + } + + pub fn char_len(&self) -> usize { + self.text.chars().count() + } + + /// Byte offset of character `ci` (clamped to the end). + pub fn byte_of(&self, ci: usize) -> usize { + self.text.char_indices().nth(ci).map_or(self.text.len(), |(b, _)| b) + } + + /// Character index of byte offset `b`. + pub fn char_of_byte(&self, b: usize) -> usize { + self.text.char_indices().take_while(|(i, _)| *i < b).count() + } + + /// The text of characters `r`. + pub fn substring(&self, r: Range) -> String { + self.text.chars().skip(r.start).take(r.end.saturating_sub(r.start)).collect() + } + + /// The base (first character's) style. + pub fn base_style(&self) -> CharStyle { + CharStyle { + font: self.font.clone(), + style: self.style.clone(), + size: self.size, + fill: self.fill, + stroke: self.stroke, + stroke_width: self.stroke_width, + apply_fill: self.apply_fill, + apply_stroke: self.apply_stroke, + tracking: self.tracking, + leading: self.leading, + baseline_shift: self.baseline_shift, + h_scale: self.h_scale, + v_scale: self.v_scale, + tsume: self.tsume, + faux_bold: self.faux_bold, + faux_italic: self.faux_italic, + all_caps: self.all_caps, + small_caps: self.small_caps, + baseline: self.baseline, + kerning: self.kerning, + ligatures: self.ligatures, + } + } + + fn write_base(&mut self, s: &CharStyle) { + self.font = s.font.clone(); + self.style = s.style.clone(); + self.size = s.size; + self.fill = s.fill; + self.stroke = s.stroke; + self.stroke_width = s.stroke_width; + self.apply_fill = s.apply_fill; + self.apply_stroke = s.apply_stroke; + self.tracking = s.tracking; + self.leading = s.leading; + self.baseline_shift = s.baseline_shift; + self.h_scale = s.h_scale; + self.v_scale = s.v_scale; + self.tsume = s.tsume; + self.faux_bold = s.faux_bold; + self.faux_italic = s.faux_italic; + self.all_caps = s.all_caps; + self.small_caps = s.small_caps; + self.baseline = s.baseline; + self.kerning = s.kerning; + self.ligatures = s.ligatures; + } + + /// The base (first paragraph's) settings. + pub fn base_para(&self) -> ParaStyle { + ParaStyle { + justify: self.justify, + indent_left: self.indent_left, + indent_right: self.indent_right, + indent_first: self.indent_first, + space_before: self.space_before, + space_after: self.space_after, + direction: self.direction, + composer: self.composer, + hanging_punctuation: self.hanging_punctuation, + } + } + + fn write_base_para(&mut self, p: &ParaStyle) { + self.justify = p.justify; + self.indent_left = p.indent_left; + self.indent_right = p.indent_right; + self.indent_first = p.indent_first; + self.space_before = p.space_before; + self.space_after = p.space_after; + self.direction = p.direction; + self.composer = p.composer; + self.hanging_punctuation = p.hanging_punctuation; + } + + /// Whether every character shares one style. + pub fn is_uniform(&self) -> bool { + self.runs.is_empty() + } + + /// Style runs covering the whole text (at least one run, of length 0 for empty text). + pub fn runs(&self) -> Vec { + let n = self.char_len(); + if self.runs.is_empty() { + return vec![StyleRun { len: n, style: self.base_style() }]; + } + let mut out = Vec::with_capacity(self.runs.len()); + let mut pos = 0; + for r in &self.runs { + if pos >= n { + break; + } + let len = r.len.min(n - pos); + if len > 0 { + out.push(StyleRun { len, style: r.style.clone() }); + } + pos += len; + } + match out.last_mut() { + Some(last) if pos < n => last.len += n - pos, + None => out.push(StyleRun { len: n, style: self.runs[0].style.clone() }), + _ => {} + } + out + } + + /// Replace the style runs: zero-length runs are dropped, equal neighbours merged, the base + /// style becomes the first run's, and a single run collapses to the uniform form. + pub fn set_runs(&mut self, runs: Vec) { + let first = runs.first().map(|r| r.style.clone()); + let mut out: Vec = Vec::with_capacity(runs.len()); + for r in runs.into_iter().filter(|r| r.len > 0) { + match out.last_mut() { + Some(last) if last.style == r.style => last.len += r.len, + _ => out.push(r), + } + } + if let Some(s) = out.first().map(|r| r.style.clone()).or(first) { + self.write_base(&s); + } + if out.len() <= 1 { + out.clear(); + } + self.runs = out; + } + + /// The style of character `ci` (the last character's past the end). + pub fn style_at(&self, ci: usize) -> CharStyle { + if self.runs.is_empty() { + return self.base_style(); + } + let mut pos = 0; + let runs = self.runs(); + for r in &runs { + if ci < pos + r.len { + return r.style.clone(); + } + pos += r.len; + } + runs.last().map(|r| r.style.clone()).unwrap_or_else(|| self.base_style()) + } + + /// The style text typed at caret `at` takes: the character before it, or the first one. + pub fn insertion_style(&self, at: usize) -> CharStyle { + self.style_at(at.saturating_sub(1)) + } + + /// Change the style of characters `r` (clamped). + pub fn apply_style(&mut self, r: Range, mut f: impl FnMut(&mut CharStyle)) { + let n = self.char_len(); + let (a, b) = (r.start.min(n), r.end.min(n)); + if n == 0 { + // Empty text: the base style is what the next typed character gets. + let mut s = self.base_style(); + f(&mut s); + self.write_base(&s); + return; + } + if a >= b { + return; + } + let mut runs = self.runs(); + let i = split_at(&mut runs, a); + let j = split_at(&mut runs, b); + for run in &mut runs[i..j] { + f(&mut run.style); + } + self.set_runs(runs); + } + + /// Apply a style to every character. + pub fn apply_style_all(&mut self, f: impl FnMut(&mut CharStyle)) { + let n = self.char_len(); + self.apply_style(0..n.max(1), f); + } + + /// Number of paragraphs (`\n`-separated). + pub fn para_count(&self) -> usize { + self.text.matches('\n').count() + 1 + } + + /// Character ranges of the paragraphs (without their newline). + pub fn para_ranges(&self) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + let mut i = 0; + for c in self.text.chars() { + if c == '\n' { + out.push(start..i); + start = i + 1; + } + i += 1; + } + out.push(start..i); + out + } + + /// Paragraph index of character / caret position `ci`. + pub fn para_of(&self, ci: usize) -> usize { + self.text.chars().take(ci).filter(|c| *c == '\n').count() + } + + /// Paragraph settings, one per paragraph. + pub fn paras(&self) -> Vec { + let n = self.para_count(); + let base = self.base_para(); + if self.paragraphs.is_empty() { + return vec![base; n]; + } + let mut v: Vec = self.paragraphs.iter().take(n).cloned().collect(); + let last = v.last().cloned().unwrap_or(base); + v.resize(n, last); + v + } + + /// Settings of paragraph `i`. + pub fn para(&self, i: usize) -> ParaStyle { + if self.paragraphs.is_empty() { + return self.base_para(); + } + let v = self.paras(); + v[i.min(v.len() - 1)].clone() + } + + /// Replace the paragraph settings (collapsed when they're all equal). + pub fn set_paras(&mut self, mut v: Vec) { + if let Some(first) = v.first().cloned() { + self.write_base_para(&first); + } + if v.iter().all(|p| Some(p) == v.first()) { + v.clear(); + } + self.paragraphs = v; + } + + /// Change the paragraphs touched by characters `r` (an empty range: the caret's paragraph). + pub fn apply_para(&mut self, r: Range, mut f: impl FnMut(&mut ParaStyle)) { + let pa = self.para_of(r.start); + let pb = if r.end > r.start { self.para_of(r.end - 1) } else { pa }; + let mut v = self.paras(); + for p in v.iter_mut().take(pb + 1).skip(pa) { + f(p); + } + self.set_paras(v); + } + + /// Replace characters `r` with `s`. The new text takes `style`, or the style of the first + /// replaced character, or (inserting) the style of the character before the caret. + pub fn replace_range(&mut self, r: Range, s: &str, style: Option<&CharStyle>) { + let n = self.char_len(); + let (a, b) = (r.start.min(n), r.end.min(n).max(r.start.min(n))); + let st = match style { + Some(s) => s.clone(), + None if b > a => self.style_at(a), + None => self.insertion_style(a), + }; + let mut runs = self.runs(); + let i = split_at(&mut runs, a); + let j = split_at(&mut runs, b); + let ins = s.chars().count(); + runs.splice(i..j, std::iter::once(StyleRun { len: ins, style: st })); + // Paragraphs: the joined paragraph keeps the first one's settings; new paragraphs copy it. + let mut paras = self.paras(); + let pa = self.para_of(a); + let pb = self.para_of(b); + let added = s.matches('\n').count(); + let keep = paras[pa].clone(); + paras.splice(pa..=pb.min(paras.len() - 1), std::iter::repeat_n(keep, added + 1)); + let (ba, bb) = (self.byte_of(a), self.byte_of(b)); + self.text.replace_range(ba..bb, s); + self.set_runs(runs); + self.set_paras(paras); + } + + /// Replace the whole text, keeping the first character's style and the paragraph settings. + pub fn set_text(&mut self, s: &str) { + if self.runs.is_empty() && self.paragraphs.is_empty() { + self.text = s.to_string(); + return; + } + let n = self.char_len(); + let st = self.style_at(0); + self.replace_range(0..n, s, Some(&st)); + } + + /// Characters `r` as their own document (formatting kept). + pub fn slice(&self, r: Range) -> TextDoc { + let n = self.char_len(); + let (a, b) = (r.start.min(n), r.end.min(n).max(r.start.min(n))); + let mut runs = self.runs(); + let i = split_at(&mut runs, a); + let j = split_at(&mut runs, b); + let sub: Vec = runs[i..j].to_vec(); + let paras = self.paras(); + let (pa, pb) = (self.para_of(a), self.para_of(b)); + let mut d = self.clone(); + d.text = self.substring(a..b); + d.runs.clear(); + d.paragraphs.clear(); + if sub.is_empty() { + d.write_base(&self.style_at(a)); + } + d.set_runs(sub); + d.set_paras(paras[pa..=pb.min(paras.len() - 1)].to_vec()); + d + } + + /// Replace characters `r` with another document's text and character formatting. + pub fn insert_doc(&mut self, r: Range, frag: &TextDoc) { + let a = r.start.min(self.char_len()); + self.replace_range(r, &frag.text, None); + let mut pos = a; + for run in frag.runs() { + let st = run.style.clone(); + self.apply_style(pos..pos + run.len, |s| *s = st.clone()); + pos += run.len; + } + } + + /// Re-establish the invariants after direct field edits (runs clamped, lists collapsed). + pub fn normalize(&mut self) { + let runs = self.runs(); + if !self.runs.is_empty() { + self.set_runs(runs); + } + if !self.paragraphs.is_empty() { + let p = self.paras(); + self.set_paras(p); + } + } + + /// Set a document-level, character or paragraph attribute by its `layer.setText` key over + /// characters `r` (None = the whole text). Returns false for unknown keys. + pub fn set_attr(&mut self, key: &str, v: &J, r: Option>) -> Result { + match key { + "strokeOverFill" => { + self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?; + return Ok(true); + } + "vertical" => { + self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?; + return Ok(true); + } + "box" => { + match v { + J::Null => self.box_size = None, + J::Array(a) if a.len() >= 4 => { + let g = |i: usize| a[i].as_f64().unwrap_or(0.0); + self.box_pos = [g(0), g(1)]; + self.box_size = Some([g(2).max(1.0), g(3).max(1.0)]); + } + _ => return Err("box: expected [x, y, width, height] or null".into()), + } + return Ok(true); + } + _ => {} + } + let n = self.char_len(); + let r = r.unwrap_or(0..n); + let mut probe = ParaStyle::default(); + if apply_para_attr(&mut probe, key, v)? { + let mut err = None; + self.apply_para(r, |p| { + if let Err(e) = apply_para_attr(p, key, v) { + err = Some(e); + } + }); + return err.map_or(Ok(true), Err); + } + let mut probe = CharStyle::default(); + if apply_char_attr(&mut probe, key, v)? { + if r.start >= r.end && n > 0 { + return Ok(true); + } + self.apply_style(r.start..r.end.max(r.start + usize::from(n == 0)), |s| { + let _ = apply_char_attr(s, key, v); + }); + return Ok(true); + } + Ok(false) + } +} + +fn color_json(v: &J) -> Option<[f32; 4]> { + match v { + J::String(s) => { + let s = s.trim_start_matches('#'); + let h = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|x| x as f32 / 255.0); + Some([h(0)?, h(2)?, h(4)?, if s.len() >= 8 { h(6)? } else { 1.0 }]) + } + J::Array(a) if a.len() >= 3 => { + let g = |i: usize, d: f64| a.get(i).and_then(J::as_f64).unwrap_or(d) as f32; + Some([g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]) + } + _ => None, + } +} + +fn num(key: &str, v: &J) -> Result { + v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number")) +} +fn flag(key: &str, v: &J) -> Result { + v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean")) +} + +/// Character attribute keys of `layer.setText` (also used by the expression style API). +pub const CHAR_ATTRS: &[&str] = &[ + "font", + "style", + "size", + "fill", + "stroke", + "strokeWidth", + "applyFill", + "applyStroke", + "tracking", + "leading", + "baselineShift", + "hScale", + "vScale", + "tsume", + "fauxBold", + "fauxItalic", + "allCaps", + "smallCaps", + "baseline", + "superscript", + "subscript", + "kerning", + "ligatures", +]; + +/// Paragraph attribute keys of `layer.setText`. +pub const PARA_ATTRS: &[&str] = + &["justify", "indentLeft", "indentRight", "indentFirst", "spaceBefore", "spaceAfter", "direction", "composer", "hangingPunctuation"]; + +/// Set one character attribute; Ok(false) for keys that aren't character attributes. +pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { + match key { + "font" => s.font = v.as_str().ok_or("font: expected a string")?.to_string(), + "style" => s.style = v.as_str().ok_or("style: expected a string")?.to_string(), + "size" => s.size = num(key, v)?.clamp(0.1, 10_000.0), + "fill" => s.fill = color_json(v).ok_or("fill: expected #rrggbb or [r, g, b]")?, + "stroke" => { + s.stroke = color_json(v).ok_or("stroke: expected #rrggbb or [r, g, b]")?; + s.apply_stroke = true; + } + "strokeWidth" => { + s.stroke_width = num(key, v)?.max(0.0); + s.apply_stroke = s.stroke_width > 0.0; + } + "applyFill" => s.apply_fill = flag(key, v)?, + "applyStroke" => s.apply_stroke = flag(key, v)?, + "tracking" => s.tracking = num(key, v)?, + "leading" => { + s.leading = match v { + J::Number(n) => n.as_f64().map(|x| x.max(0.0)), + J::String(_) | J::Null => None, + J::Bool(true) => None, + _ => return Err("leading: expected a number or \"auto\"".into()), + } + } + "baselineShift" => s.baseline_shift = num(key, v)?, + "hScale" => s.h_scale = num(key, v)?.clamp(1.0, 1000.0), + "vScale" => s.v_scale = num(key, v)?.clamp(1.0, 1000.0), + "tsume" => s.tsume = num(key, v)?.clamp(0.0, 100.0), + "fauxBold" => s.faux_bold = flag(key, v)?, + "fauxItalic" => s.faux_italic = flag(key, v)?, + "allCaps" => { + s.all_caps = flag(key, v)?; + if s.all_caps { + s.small_caps = false; + } + } + "smallCaps" => { + s.small_caps = flag(key, v)?; + if s.small_caps { + s.all_caps = false; + } + } + "baseline" => s.baseline = v.as_str().and_then(BaselineOption::parse).ok_or("baseline: expected normal|superscript|subscript")?, + "superscript" => { + s.baseline = if flag(key, v)? { BaselineOption::Superscript } else { BaselineOption::Normal }; + } + "subscript" => { + s.baseline = if flag(key, v)? { BaselineOption::Subscript } else { BaselineOption::Normal }; + } + "kerning" => s.kerning = Kerning::parse(v).ok_or("kerning: expected metrics|optical|number")?, + "ligatures" => s.ligatures = flag(key, v)?, + _ => return Ok(false), + } + Ok(true) +} + +/// Set one paragraph attribute; Ok(false) for keys that aren't paragraph attributes. +pub fn apply_para_attr(p: &mut ParaStyle, key: &str, v: &J) -> Result { + match key { + "justify" => { + p.justify = v.as_str().and_then(Justify::parse).ok_or("justify: expected left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll")? + } + "indentLeft" => p.indent_left = num(key, v)?, + "indentRight" => p.indent_right = num(key, v)?, + "indentFirst" => p.indent_first = num(key, v)?, + "spaceBefore" => p.space_before = num(key, v)?, + "spaceAfter" => p.space_after = num(key, v)?, + "direction" => { + p.direction = match v.as_str().map(str::to_ascii_lowercase).as_deref() { + Some("ltr" | "lefttoright" | "left-to-right") => Direction::Ltr, + Some("rtl" | "righttoleft" | "right-to-left") => Direction::Rtl, + _ => return Err("direction: expected ltr|rtl".into()), + } + } + "composer" => { + p.composer = match v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")).as_deref() { + Some("everyline" | "every") => Composer::EveryLine, + Some("singleline" | "single") => Composer::SingleLine, + _ => return Err("composer: expected everyLine|singleLine".into()), + } + } + "hangingPunctuation" => p.hanging_punctuation = flag(key, v)?, + _ => return Ok(false), + } + Ok(true) +} + +/// A character style as `layer.setText` keys. +pub fn char_style_json(s: &CharStyle) -> J { + let c = |c: [f32; 4]| J::from(vec![c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64]); + serde_json::json!({ + "font": s.font, "style": s.style, "size": s.size, "fill": c(s.fill), "stroke": c(s.stroke), + "strokeWidth": s.stroke_width, "applyFill": s.apply_fill, "applyStroke": s.apply_stroke, + "tracking": s.tracking, "leading": s.leading.map_or(J::from("auto"), J::from), "baselineShift": s.baseline_shift, + "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, + "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), + "ligatures": s.ligatures, + }) +} + +/// Paragraph settings as `layer.setText` keys. +pub fn para_style_json(p: &ParaStyle) -> J { + serde_json::json!({ + "justify": p.justify.key(), "indentLeft": p.indent_left, "indentRight": p.indent_right, "indentFirst": p.indent_first, + "spaceBefore": p.space_before, "spaceAfter": p.space_after, + "direction": if p.direction == Direction::Rtl { "rtl" } else { "ltr" }, + "composer": if p.composer == Composer::EveryLine { "everyLine" } else { "singleLine" }, + "hangingPunctuation": p.hanging_punctuation, + }) +} + +/// Word boundaries for caret movement: the start of the word left of `ci`. +pub fn word_left(text: &str, ci: usize) -> usize { + let chars: Vec = text.chars().collect(); + let mut i = ci.min(chars.len()); + while i > 0 && !chars[i - 1].is_alphanumeric() { + i -= 1; + } + while i > 0 && chars[i - 1].is_alphanumeric() { + i -= 1; + } + i +} + +/// The end of the word right of `ci`. +pub fn word_right(text: &str, ci: usize) -> usize { + let chars: Vec = text.chars().collect(); + let mut i = ci.min(chars.len()); + while i < chars.len() && !chars[i].is_alphanumeric() { + i += 1; + } + while i < chars.len() && chars[i].is_alphanumeric() { + i += 1; + } + i +} + +/// The word (or run of spaces / punctuation) containing character `ci`, for double-click. +pub fn word_at(text: &str, ci: usize) -> Range { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + return 0..0; + } + let i = ci.min(chars.len() - 1); + let class = |c: char| { + if c.is_alphanumeric() { + 0 + } else if c == '\n' { + 2 + } else if c.is_whitespace() { + 1 + } else { + 3 + } + }; + let k = class(chars[i]); + let (mut a, mut b) = (i, i + 1); + while a > 0 && class(chars[a - 1]) == k && k != 2 { + a -= 1; + } + while b < chars.len() && class(chars[b]) == k && k != 2 { + b += 1; + } + a..b +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sizes(d: &TextDoc) -> Vec<(usize, f64)> { + d.runs().iter().map(|r| (r.len, r.style.size)).collect() + } + + #[test] + fn style_runs_split_and_merge() { + let mut d = TextDoc::plain("Hello world"); + assert!(d.is_uniform()); + d.apply_style(6..11, |s| s.size = 40.0); + assert_eq!(sizes(&d), vec![(6, 72.0), (5, 40.0)]); + d.apply_style(2..8, |s| s.fill = [1.0, 0.0, 0.0, 1.0]); + assert_eq!(d.runs().len(), 4); + assert_eq!(d.style_at(7).fill, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(d.style_at(7).size, 40.0); + // Undo the colour: neighbours merge again. + d.apply_style(0..11, |s| s.fill = [1.0; 4]); + assert_eq!(sizes(&d), vec![(6, 72.0), (5, 40.0)]); + d.apply_style(0..11, |s| s.size = 10.0); + assert!(d.is_uniform()); + assert_eq!(d.size, 10.0); + } + + #[test] + fn edits_keep_runs_aligned() { + let mut d = TextDoc::plain("abcdef"); + d.apply_style(2..4, |s| s.size = 20.0); + // Typing after "cd" continues its style. + d.replace_range(4..4, "XY", None); + assert_eq!(d.text, "abcdXYef"); + assert_eq!(sizes(&d), vec![(2, 72.0), (4, 20.0), (2, 72.0)]); + // Typing at the very start takes the first character's style. + d.replace_range(0..0, "_", None); + assert_eq!(sizes(&d), vec![(3, 72.0), (4, 20.0), (2, 72.0)]); + // Deleting the whole styled span merges the outer runs. + d.replace_range(3..7, "", None); + assert_eq!(d.text, "_abef"); + assert!(d.is_uniform()); + // Replacing a selection uses the first selected character's style. + d.apply_style(1..2, |s| s.size = 5.0); + d.replace_range(1..3, "Q", None); + assert_eq!(d.text, "_Qef"); + assert_eq!(sizes(&d), vec![(1, 72.0), (1, 5.0), (2, 72.0)]); + // Deleting everything keeps the style of what was there. + d.replace_range(0..4, "", None); + assert_eq!(d.text, ""); + assert!(d.is_uniform()); + assert_eq!(d.size, 72.0); + } + + #[test] + fn paragraphs_follow_edits() { + let mut d = TextDoc::plain("one\ntwo\nthree"); + assert_eq!(d.para_count(), 3); + d.apply_para(4..5, |p| p.indent_left = 30.0); + assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); + // Enter inside "two" splits it: both halves keep the indent. + d.replace_range(5..5, "\n", None); + assert_eq!(d.text, "one\nt\nwo\nthree"); + assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 30.0, 0.0]); + // Joining the first two paragraphs keeps the first's settings. + d.replace_range(3..4, "", None); + assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); + assert_eq!(d.para_ranges(), vec![0..4, 5..7, 8..13]); + // Space before only on paragraph 3 via a range ending in it. + d.apply_para(9..10, |p| p.space_before = 12.0); + assert_eq!(d.para(2).space_before, 12.0); + assert_eq!(d.para(1).space_before, 0.0); + } + + #[test] + fn single_style_serde_is_unchanged_and_old_docs_load() { + let d = TextDoc::plain("Hi"); + let j = serde_json::to_value(&d).unwrap(); + assert!(j.get("runs").is_none() && j.get("paragraphs").is_none()); + // A document saved before style runs existed (no new fields). + let old = r#"{"text":"Old","font":"Inter","style":"Bold","size":50.0,"fill":[1,0,0,1],"stroke":[0,0,0,1], + "stroke_width":0.0,"apply_fill":true,"apply_stroke":false,"stroke_over_fill":true,"tracking":10.0, + "leading":null,"justify":"Center","baseline_shift":0.0,"h_scale":100.0,"v_scale":100.0,"faux_bold":false, + "faux_italic":false,"all_caps":false,"small_caps":false,"box_size":[300.0,200.0],"box_pos":[0.0,0.0]}"#; + let d: TextDoc = serde_json::from_str(old).unwrap(); + assert!(d.is_uniform()); + assert_eq!(d.style_at(2).size, 50.0); + assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking"); + assert!(d.ligatures); + assert_eq!(d.kerning, Kerning::Metrics); + // Round trip with runs. + let mut m = d.clone(); + m.apply_style(0..1, |s| s.kerning = Kerning::Manual(-40.0)); + let back: TextDoc = serde_json::from_value(serde_json::to_value(&m).unwrap()).unwrap(); + assert_eq!(back, m); + assert_eq!(back.style_at(0).kerning, Kerning::Manual(-40.0)); + } + + #[test] + fn slice_and_insert_doc_carry_formatting() { + let mut d = TextDoc::plain("red blue"); + d.apply_style(0..3, |s| s.fill = [1.0, 0.0, 0.0, 1.0]); + let frag = d.slice(2..6); + assert_eq!(frag.text, "d bl"); + assert_eq!(frag.runs().len(), 2); + let mut e = TextDoc::plain("xx"); + e.insert_doc(1..1, &frag); + assert_eq!(e.text, "xd blx"); + assert_eq!(e.style_at(1).fill, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(e.style_at(2).fill, [1.0; 4]); + } + + #[test] + fn attrs_by_key() { + let mut d = TextDoc::plain("abc\ndef"); + assert!(d.set_attr("size", &J::from(20), Some(0..2)).unwrap()); + assert!(d.set_attr("kerning", &J::from("optical"), None).unwrap()); + assert!(d.set_attr("justify", &J::from("justifyAll"), Some(5..5)).unwrap()); + assert!(d.set_attr("superscript", &J::from(true), Some(2..3)).unwrap()); + assert!(!d.set_attr("nope", &J::from(1), None).unwrap()); + assert!(d.set_attr("size", &J::from("big"), None).is_err()); + assert_eq!(d.style_at(1).size, 20.0); + assert_eq!(d.style_at(2).baseline, BaselineOption::Superscript); + assert_eq!(d.style_at(6).kerning, Kerning::Optical); + assert_eq!(d.para(1).justify, Justify::JustifyAll); + assert_eq!(d.para(0).justify, Justify::Left); + let j = char_style_json(&d.style_at(0)); + let mut s = CharStyle::default(); + for (k, v) in j.as_object().unwrap() { + assert!(apply_char_attr(&mut s, k, v).unwrap(), "{k}"); + } + assert_eq!(s, d.style_at(0)); + } + + #[test] + fn word_boundaries() { + let t = "hello, big world"; + assert_eq!(word_right(t, 0), 5); + assert_eq!(word_right(t, 5), 10); + assert_eq!(word_left(t, 16), 11); + assert_eq!(word_left(t, 11), 7); + assert_eq!(word_at(t, 8), 7..10); + assert_eq!(word_at(t, 6), 6..7); + } +} diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index a5c685d43..abf112fcf 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -52,76 +52,7 @@ impl ShapePath { } } -/// Horizontal paragraph alignment. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Justify { - #[default] - Left, - Center, - Right, - JustifyLastLeft, - JustifyLastCenter, - JustifyLastRight, - JustifyAll, -} - -/// The value of a text layer's Source Text (hold-interpolated). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TextDoc { - pub text: String, - pub font: String, - pub style: String, - pub size: f64, - pub fill: [f32; 4], - pub stroke: [f32; 4], - pub stroke_width: f64, - pub apply_fill: bool, - pub apply_stroke: bool, - pub stroke_over_fill: bool, - pub tracking: f64, - /// None = auto (120% of size). - pub leading: Option, - pub justify: Justify, - pub baseline_shift: f64, - pub h_scale: f64, - pub v_scale: f64, - pub faux_bold: bool, - pub faux_italic: bool, - pub all_caps: bool, - pub small_caps: bool, - /// Paragraph text box (width, height) with top-left at `box_pos`; None = point text. - pub box_size: Option<[f64; 2]>, - pub box_pos: [f64; 2], -} - -impl Default for TextDoc { - fn default() -> Self { - TextDoc { - text: String::new(), - font: "Inter".into(), - style: "Regular".into(), - size: 72.0, - fill: [1.0, 1.0, 1.0, 1.0], - stroke: [0.0, 0.0, 0.0, 1.0], - stroke_width: 0.0, - apply_fill: true, - apply_stroke: false, - stroke_over_fill: true, - tracking: 0.0, - leading: None, - justify: Justify::Left, - baseline_shift: 0.0, - h_scale: 100.0, - v_scale: 100.0, - faux_bold: false, - faux_italic: false, - all_caps: false, - small_caps: false, - box_size: None, - box_pos: [0.0, 0.0], - } - } -} +pub use crate::text_doc::{Justify, TextDoc}; /// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -345,10 +276,13 @@ impl Value { Value::Text(t) => { if let Some(s) = j.as_str() { let mut t = (**t).clone(); - t.text = s.to_string(); + t.set_text(s); Some(Value::Text(Box::new(t))) } else { - serde_json::from_value::(j.clone()).ok().map(|t| Value::Text(Box::new(t))) + serde_json::from_value::(j.clone()).ok().map(|mut t| { + t.normalize(); + Value::Text(Box::new(t)) + }) } } Value::Layer(_) => Some(Value::Layer(j.as_u64())), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165ea2987..37b0f7756 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -13,7 +13,7 @@ use effectcraft_project::{Layer, PropGroup}; use effectcraft_raster::Image; use effectcraft_text::path_text::{self, PathMeasure}; use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly}; -use effectcraft_text::{CharGlyph, TextLayout, char_glyph, layout_doc}; +use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc}; use crate::eval::EvalCtx; @@ -402,6 +402,8 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + /// The character's style fills it (Character panel fill on). + pub apply_fill: bool, } impl PlacedGlyph { @@ -479,14 +481,14 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let mut subs: Vec> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect(); let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3); let (lay, xfs) = if relayout { - let src: Vec = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect(); - let mut chars = src.clone(); + let mut chars: Vec = doc.text.chars().collect(); for (g, s) in lay.glyphs.iter().zip(&subs) { if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) { *slot = *c; } } - let d2 = TextDoc { text: chars.into_iter().collect(), all_caps: false, ..doc.clone() }; + // Same character count: the style runs still line up. + let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() }; let l2 = layout_doc(&d2); let by_char: std::collections::HashMap = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect(); let x2: Vec = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect(); @@ -506,11 +508,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { while li_end < n && lay.glyphs[li_end].line_index == line { li_end += 1; } - let em = doc.size / 1000.0; let mut pen = 0.0; let (mut la, mut la_k) = (0.0, 0.0); for gi in li_start..li_end { let x = &xfs[gi]; + let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; pen += x.track_before * em; shift[gi] = pen; pen += x.track_after * em; @@ -581,7 +583,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { } } - let base_stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 }; + let base = doc.base_style(); let mut glyphs = Vec::with_capacity(n); for gi in 0..n { let g = &lay.glyphs[gi]; @@ -597,10 +599,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { x.rotation_y = 0.0; } let o = origin(gi); + let st = lay.styles.get(g.run).unwrap_or(&base); + let size = st.size; // Outline (substituted characters aligned in the original slot). let (outline, adv) = match subs[gi] { Some(c) => { - let (p, a) = char_glyph(&doc, c); + let (p, a) = char_glyph_style(st, c); let dx = match x.char_align { 0 => 0.0, 2 => g.advance - a, @@ -614,8 +618,8 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { continue; } let pivot = match extents.get(&group_key(g)) { - Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * doc.size], - _ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * doc.size], + Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * size], + _ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * size], }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; @@ -635,9 +639,10 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { } None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m, }; - let fill = adjust_color(doc.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity); - let stroke = adjust_color(doc.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity); - glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0) }); + let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity); + let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity); + let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 }; + glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill }); } Some(TextGeom { doc, glyphs, fill_stroke, blend }) } @@ -654,21 +659,21 @@ type Pass = (usize, bool); fn passes(geom: &TextGeom) -> Vec { let doc = &geom.doc; let n = geom.glyphs.len(); - let fill_on = doc.apply_fill; + let fill_on = |i: usize| geom.glyphs[i].apply_fill; let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0; let mut v = Vec::with_capacity(n * 2); match geom.fill_stroke { 1 => { v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true))); - v.extend((0..n).filter(|_| fill_on).map(|i| (i, false))); + v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false))); } 2 => { - v.extend((0..n).filter(|_| fill_on).map(|i| (i, false))); + v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false))); v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true))); } _ => { for i in 0..n { - let f = fill_on.then_some((i, false)); + let f = fill_on(i).then_some((i, false)); let s = has_stroke(i).then_some((i, true)); if doc.stroke_over_fill { v.extend(f); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 75db90b2a..824b73730 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1,6 +1,9 @@ //! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning, -//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading, -//! tracking, baseline shift, all caps / small caps, underline. Results are cached. +//! ligatures, mark positioning, complex scripts), line breaking (UAX #14, greedy or every-line), +//! alignment and justification, indents, paragraph spacing, leading, tracking, tsume, manual and +//! optical kerning, baseline shift, superscript / subscript, horizontal / vertical scale, all caps +//! / small caps, hanging punctuation, underline. Text may mix character styles (runs) and +//! paragraph settings. Results are cached. //! //! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the //! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify), @@ -34,6 +37,19 @@ pub enum Align { Justify, } +/// Superscript / subscript position. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Script { + #[default] + Normal, + Super, + Sub, +} + +/// Superscript / subscript size and baseline offset (fractions of the font size). +pub const SCRIPT_SIZE: f32 = 0.583; +pub const SCRIPT_OFFSET: f32 = 0.333; + /// Character formatting. #[derive(Clone, Debug, PartialEq)] pub struct TextStyle { @@ -45,6 +61,10 @@ pub struct TextStyle { pub tracking: f32, /// Metric kerning (`kern`). pub kerning: bool, + /// Optical kerning (spacing from the outlines; replaces metric kerning). + pub optical: bool, + /// Manual kerning before every character of the run, in 1/1000 em (replaces metrics). + pub manual_kern: Option, /// Standard ligatures (`liga`, `clig`). pub ligatures: bool, /// Baseline shift in pixels (positive = up). @@ -53,6 +73,14 @@ pub struct TextStyle { pub faux_italic: bool, pub caps: Caps, pub underline: bool, + /// Horizontal / vertical scale (1 = 100%). + pub h_scale: f32, + pub v_scale: f32, + /// Tsume: fraction (0–1) of the space around each glyph removed. + pub tsume: f32, + pub script: Script, + /// Baseline-to-baseline distance in pixels; None = auto (120% of the size). + pub leading: Option, } impl Default for TextStyle { @@ -63,26 +91,87 @@ impl Default for TextStyle { size: 100.0, tracking: 0.0, kerning: true, + optical: false, + manual_kern: None, ligatures: true, baseline_shift: 0.0, faux_bold: false, faux_italic: false, caps: Caps::Normal, underline: false, + h_scale: 1.0, + v_scale: 1.0, + tsume: 0.0, + script: Script::Normal, + leading: None, } } } +impl TextStyle { + /// The size glyphs are drawn at (superscript / subscript are smaller). + pub fn glyph_size(&self) -> f32 { + match self.script { + Script::Normal => self.size, + _ => self.size * SCRIPT_SIZE, + } + } + /// Upward baseline offset: baseline shift plus the superscript / subscript offset. + pub fn rise(&self) -> f32 { + self.baseline_shift + + match self.script { + Script::Normal => 0.0, + Script::Super => self.size * SCRIPT_OFFSET, + Script::Sub => -self.size * SCRIPT_OFFSET, + } + } + /// The line advance this style asks for. + pub fn line_advance(&self) -> f32 { + self.leading.unwrap_or(self.size * 1.2) + } +} + /// Paragraph formatting. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct ParagraphStyle { pub align: Align, - /// Extra line spacing in pixels added to the font's natural line height (Leading). + /// How the last line of a justified paragraph is aligned (`Justify` = justify it too). + pub justify_last: Align, + /// Extra line spacing in pixels added to every line advance. pub leading: f32, - /// Wrap width (area text); None = point text. + /// Wrap width (area text); None = point text. Taken from the first paragraph. pub width: Option, /// Base direction: None = from the first strong character. pub rtl: Option, + /// Start / end margins (left / right in left-to-right paragraphs) and the first line's indent. + pub indent_start: f32, + pub indent_end: f32, + pub indent_first: f32, + pub space_before: f32, + pub space_after: f32, + /// Every-line composer (balanced breaks) instead of single-line (greedy). + pub every_line: bool, + /// Roman hanging punctuation. + pub hanging: bool, +} + +impl Default for ParagraphStyle { + fn default() -> Self { + Self { + align: Align::Left, + justify_last: Align::Left, + leading: 0.0, + width: None, + rtl: None, + indent_start: 0.0, + indent_end: 0.0, + indent_first: 0.0, + space_before: 0.0, + space_after: 0.0, + every_line: false, + hanging: false, + } + } } fn hf(h: &mut impl Hasher, v: f32) { @@ -93,19 +182,23 @@ impl Hash for TextStyle { fn hash(&self, h: &mut H) { self.family.hash(h); self.style.hash(h); - hf(h, self.size); - hf(h, self.tracking); - hf(h, self.baseline_shift); - (self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h); + for v in [self.size, self.tracking, self.baseline_shift, self.h_scale, self.v_scale, self.tsume] { + hf(h, v); + } + self.manual_kern.map(f32::to_bits).hash(h); + self.leading.map(f32::to_bits).hash(h); + (self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h); } } impl Hash for ParagraphStyle { fn hash(&self, h: &mut H) { - self.align.hash(h); - hf(h, self.leading); + (self.align, self.justify_last).hash(h); + for v in [self.leading, self.indent_start, self.indent_end, self.indent_first, self.space_before, self.space_after] { + hf(h, v); + } self.width.map(f32::to_bits).hash(h); - self.rtl.hash(h); + (self.rtl, self.every_line, self.hanging).hash(h); } } @@ -121,6 +214,11 @@ pub struct Glyph { pub cluster: usize, pub synth_bold: bool, pub synth_italic: bool, + /// Horizontal / vertical scale of the outline (1 = 100%). + pub h_scale: f32, + pub v_scale: f32, + /// Index of the style run the glyph belongs to. + pub run: usize, } /// One laid-out line. @@ -137,6 +235,10 @@ pub struct Line { pub glyphs: Range, /// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset. pub carets: Vec<(usize, f32)>, + /// Paragraph index. + pub para: usize, + /// The line ends its paragraph. + pub para_end: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -175,7 +277,20 @@ impl Layout { let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else { return 0; }; - l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0) + self.hit_line(l, x) + } + /// Nearest caret byte offset to `x` on line `l`. A soft-wrapped line's end is the caret + /// before its trailing space, so clicking past it doesn't land on the next line. + pub fn hit_line(&self, l: &Line, x: f32) -> usize { + let end = self.line_end(l); + l.carets.iter().filter(|c| c.0 <= end).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0) + } + /// The last caret stop of a line (before a wrapped line's trailing space). + pub fn line_end(&self, l: &Line) -> usize { + if l.para_end { + return l.range.end; + } + l.carets.iter().rev().nth(1).map(|c| c.0).filter(|b| *b >= l.range.start).unwrap_or(l.range.end) } /// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`. pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> { @@ -193,7 +308,10 @@ impl Layout { } let x0 = xs.iter().copied().fold(f32::MAX, f32::min); let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max); - if b > l.range.end { + if x0 > x1 { + x1 = l.x; + } + if b > l.range.end && l.para_end { x1 += l.ascent * 0.25; // selected newline } out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]); @@ -218,9 +336,12 @@ struct ShapedGlyph { id: u32, cluster: usize, adv: f32, + /// Space added before the glyph (manual / optical kerning). + pre: f32, dx: f32, dy: f32, size: f32, + run: usize, } struct Item { @@ -239,7 +360,17 @@ fn upper_single(c: char) -> char { const SMALL_CAPS_SCALE: f32 = 0.78; -fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec { +/// Horizontal ink extent `(x0, x1)` of a glyph in font units. +fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { + let p = crate::outline_units(face, gid)?; + if p.elements().is_empty() { + return None; + } + let r = kurbo::Shape::bounding_box(&*p); + Some((r.x0 as f32, r.x1 as f32)) +} + +fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize) -> Vec { let f = fonts::face(face); let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() }; let shaper = data.shaper(&font).build(); @@ -250,7 +381,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); buf.guess_segment_properties(); let mut feats = Vec::new(); - if !style.kerning { + if !style.kerning || style.optical || style.manual_kern.is_some() { feats.push(Feature::new(Tag::new(b"kern"), 0, ..)); } if !style.ligatures { @@ -259,7 +390,7 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId } let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats)); let k = size / f.units_per_em(); - let _ = text; + let hs = style.h_scale; out.glyph_infos() .iter() .zip(out.glyph_positions()) @@ -267,77 +398,180 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId face, id: i.glyph_id, cluster: i.cluster as usize, - adv: p.x_advance as f32 * k, - dx: p.x_offset as f32 * k, + adv: p.x_advance as f32 * k * hs, + pre: 0.0, + dx: p.x_offset as f32 * k * hs, dy: p.y_offset as f32 * k, size, + run, }) .collect() } +/// Tsume, tracking, manual and optical kerning on one shaped item (logical order). +fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_start: bool) { + let side = |g: &ShapedGlyph| -> Option<(f32, f32)> { + let (x0, x1) = ink_x(g.face, g.id)?; + let k = g.size / fonts::face(g.face).units_per_em() * style.h_scale; + Some((x0 * k, g.adv - x1 * k)) + }; + let is_space = |g: &ShapedGlyph| text[g.cluster..].chars().next().is_none_or(char::is_whitespace); + if style.tsume > 0.0 { + let t = style.tsume.clamp(0.0, 1.0); + for g in glyphs.iter_mut() { + if is_space(g) { + continue; + } + if let Some((lsb, rsb)) = side(g) { + g.dx -= t * lsb.max(0.0); + g.adv -= t * (lsb.max(0.0) + rsb.max(0.0)); + } + } + } + if style.optical && glyphs.len() > 1 { + // Pull every pair's ink gap halfway toward the item's median gap. + let sides: Vec> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect(); + let gaps: Vec> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect(); + let mut sorted: Vec = gaps.iter().flatten().copied().collect(); + sorted.sort_by(f32::total_cmp); + if let Some(&median) = sorted.get(sorted.len() / 2) { + for (i, gap) in gaps.iter().enumerate() { + if let Some(gap) = gap + && glyphs[i + 1].cluster != glyphs[i].cluster + { + glyphs[i + 1].pre += (median - gap) * 0.5; + } + } + } + } + if let Some(m) = style.manual_kern { + let k = m * style.size / 1000.0; + for i in 0..glyphs.len() { + let first_of_cluster = i == 0 || glyphs[i - 1].cluster != glyphs[i].cluster; + if first_of_cluster && !(para_start && glyphs[i].cluster == 0) { + glyphs[i].pre += k; + } + } + } + if style.tracking != 0.0 { + let t = style.tracking * style.size / 1000.0; + for k in 0..glyphs.len() { + if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster { + glyphs[k].adv += t; + } + } + } +} + struct ParaLine { range: Range, glyphs: Vec, carets: Vec<(usize, f32)>, content: f32, left_trim: f32, + /// Hanging punctuation widths at the left / right edge. + hang: (f32, f32), ascent: f32, descent: f32, + /// Line advance (largest leading on the line). + advance: f32, + align: Align, + avail: Option, + margins: (f32, f32), + underline: Option<(f32, f32)>, +} + +fn is_hanging_close(c: char) -> bool { + matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '-' | '\u{2013}' | '\u{2014}' | '\'' | '"' | '\u{2019}' | '\u{201d}' | ')' | '\u{2026}') +} +fn is_hanging_open(c: char) -> bool { + matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}') +} + +/// The style run index covering global byte `b` (the run before it at a boundary, for empty +/// paragraphs at the end). +fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { + runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0)) } /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. -fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec { - let pm = fonts::face(primary.face).metrics(style.size); +fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option) -> Vec { + let rtl_para = para.rtl == Some(true); + let margins_for = |first: bool| -> (f32, f32) { + let fi = if first { para.indent_first } else { 0.0 }; + if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } + }; + let avail_for = |first: bool| -> Option { + let (l, r) = margins_for(first); + width.map(|w| (w - l - r).max(1.0)) + }; + let style_metrics = |si: usize| { + let st = &runs[si].1; + fonts::face(primaries[si].face).metrics(st.glyph_size()) + }; + let eff_align = |last: bool| -> Align { + if (last || width.is_none()) && para.align == Align::Justify { + if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } + } else { + para.align + } + }; if text.is_empty() { + let si = run_at(runs, base); + let pm = style_metrics(si); return vec![ParaLine { range: base..base, glyphs: vec![], carets: vec![(base, 0.0)], content: 0.0, left_trim: 0.0, + hang: (0.0, 0.0), ascent: pm.ascent, descent: pm.descent, + advance: runs[si].1.line_advance(), + align: eff_align(true), + avail: avail_for(true), + margins: margins_for(true), + underline: None, }]; } let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); let bidi = BidiInfo::new(text, default_level); let pinfo = &bidi.paragraphs[0]; let base_rtl = pinfo.level.is_rtl(); - // per char: (byte, char, shaped char), face, level, small - let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text + // per char: (byte, char, shaped char, face, rtl, small caps, run) + let chars: Vec<(usize, char, char, FaceId, bool, bool, usize)> = text .char_indices() .map(|(b, c)| { + let si = run_at(runs, base + b); + let style = &runs[si].1; let (sc, small) = match style.caps { Caps::Normal => (c, false), Caps::All => (upper_single(c), false), Caps::Small if c.is_lowercase() => (upper_single(c), true), Caps::Small => (c, false), }; - let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) }; - (b, c, sc, face, bidi.levels[b].is_rtl(), small) + let primary = primaries[si].face; + let face = if sc.is_whitespace() { primary } else { fonts::fallback_for(sc, primary) }; + (b, c, sc, face, bidi.levels[b].is_rtl(), small, si) }) .collect(); - // items: runs of equal (face, rtl, small) + // items: runs of equal (face, rtl, small, style) let mut items: Vec = Vec::new(); let mut i = 0; while i < chars.len() { - let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5); + let key = |c: &(usize, char, char, FaceId, bool, bool, usize)| (c.3, c.4, c.5, c.6); + let k0 = key(&chars[i]); let mut j = i + 1; - while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small { + while j < chars.len() && key(&chars[j]) == k0 { j += 1; } + let (face, rtl, small, si) = k0; + let style = &runs[si].1; let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); - let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size }; - let mut glyphs = shape_item(text, &sub, rtl, face, size, style); - // tracking after each cluster - if style.tracking != 0.0 { - let t = style.tracking * style.size / 1000.0; - for k in 0..glyphs.len() { - if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster { - glyphs[k].adv += t; - } - } - } + let size = style.glyph_size() * if small { SMALL_CAPS_SCALE } else { 1.0 }; + let mut glyphs = shape_item(&sub, rtl, face, size, style, si); + space_item(&mut glyphs, style, text, true); let end = if j < chars.len() { chars[j].0 } else { text.len() }; items.push(Item { range: chars[i].0..end, rtl, glyphs }); i = j; @@ -346,7 +580,7 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, let mut char_adv = vec![0.0f32; text.len() + 1]; for it in &items { for g in &it.glyphs { - char_adv[g.cluster.min(text.len())] += g.adv; + char_adv[g.cluster.min(text.len())] += g.adv + g.pre; } } let range_w = |r: Range| -> f32 { char_adv[r].iter().sum() }; @@ -362,28 +596,83 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, } (end, w) }; + // Hanging punctuation widths (left, right) of a line's content. + let hang_of = |r: &Range, ce: usize| -> (f32, f32) { + if !para.hanging || width.is_none() || r.start >= ce { + return (0.0, 0.0); + } + let first = text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| char_adv[r.start]); + let last = text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| char_adv[r.start + b]); + (first, last) + }; + let fits_w = |r: &Range| -> f32 { + let (ce, _) = trailing_ws(r); + let (hl, hr) = hang_of(r, ce); + range_w(r.start..ce) - hl - hr + }; // line breaking let mut ranges: Vec> = Vec::new(); - match para.width { + match width { None => ranges.push(0..text.len()), - Some(maxw) => { - let mut start = 0usize; - let mut last_ok: Option = None; - for (p, _) in unicode_linebreak::linebreaks(text) { - loop { - let r = start..p; - let (ce, _) = trailing_ws(&r); - let w = range_w(start..ce); - if w <= maxw || last_ok.is_none() { - last_ok = Some(p); - break; + Some(_) => { + let opps: Vec = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect(); + let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX); + if para.every_line && opps.len() > 1 && opps.len() <= 1500 { + // Minimise the summed squared slack of every line but the last. + let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); + let m = nodes.len(); + let mut best = vec![(f32::INFINITY, 0usize); m]; + best[0] = (0.0, 0); + for j in 1..m { + for i in (0..j).rev() { + if !best[i].0.is_finite() { + continue; + } + let r = nodes[i]..nodes[j]; + let w = fits_w(&r); + let maxw = avail(usize::from(i != 0)); + let over = w > maxw; + if over && i + 1 != j { + break; + } + let slack = maxw - w; + let cost = if over { + 1e12 + w + } else if j == m - 1 { + 0.0 + } else { + slack * slack + }; + let total = best[i].0 + cost; + if total < best[j].0 { + best[j] = (total, i); + } } - let b = last_ok.take().expect("checked"); - ranges.push(start..b); - start = b; } + let mut j = m - 1; + while j > 0 { + let i = best[j].1; + ranges.push(nodes[i]..nodes[j]); + j = i; + } + ranges.reverse(); + } else { + let mut start = 0usize; + let mut last_ok: Option = None; + for p in opps { + loop { + let w = fits_w(&(start..p)); + if w <= avail(ranges.len()) || last_ok.is_none() { + last_ok = Some(p); + break; + } + let b = last_ok.take().expect("checked"); + ranges.push(start..b); + start = b; + } + } + ranges.push(start..text.len()); } - ranges.push(start..text.len()); ranges.retain(|r| !r.is_empty()); if ranges.is_empty() { ranges.push(0..text.len()); @@ -393,24 +682,28 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, let nlines = ranges.len(); let mut out = Vec::new(); for (li, r) in ranges.into_iter().enumerate() { + let last = li + 1 == nlines; + let align = eff_align(last); + let avail = avail_for(li == 0); let (ce, tw) = trailing_ws(&r); + let hang = hang_of(&r, ce); let content = range_w(r.start..ce); // justification let mut space_extra = 0.0; - if para.align == Align::Justify - && li + 1 < nlines - && let Some(maxw) = para.width + if align == Align::Justify + && let Some(maxw) = avail { let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count(); if spaces > 0 { - space_extra = ((maxw - content) / spaces as f32).max(0.0); + space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0); } } - let (levels, runs) = bidi.visual_runs(pinfo, r.clone()); + let (levels, vruns) = bidi.visual_runs(pinfo, r.clone()); let mut pen = 0.0f32; let mut glyphs = Vec::new(); let mut extents: HashMap = HashMap::new(); - for run in runs { + let mut underline: Option<(f32, f32)> = None; + for run in vruns { let rtl = levels[run.start].is_rtl(); let mut idx: Vec = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect(); if rtl { @@ -418,21 +711,31 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, } for k in idx { for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) { + let st = &runs[g.run].1; let x0 = pen; + pen += g.pre; glyphs.push(Glyph { face: g.face, id: g.id, x: pen + g.dx, - y: -g.dy - style.baseline_shift, + y: -g.dy - st.rise(), size: g.size, cluster: base + g.cluster, - synth_bold: primary.synth_bold || style.faux_bold, - synth_italic: primary.synth_italic || style.faux_italic, + synth_bold: primaries[g.run].synth_bold || st.faux_bold, + synth_italic: primaries[g.run].synth_italic || st.faux_italic, + h_scale: st.h_scale, + v_scale: st.v_scale, + run: g.run, }); pen += g.adv; if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce { pen += space_extra; } + if st.underline && g.cluster < ce { + let u = underline.get_or_insert((x0, pen)); + u.0 = u.0.min(x0); + u.1 = u.1.max(pen); + } let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl)); e.0 = e.0.min(x0); e.1 = e.1.max(pen); @@ -475,53 +778,95 @@ fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, carets.push((base + r.end, end_x)); carets.sort_by_key(|c| c.0); carets.dedup_by_key(|c| c.0); - let mut asc = pm.ascent; - let mut desc = pm.descent; + // Line metrics: the tallest face and the largest leading on the line. + let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + let mut seen: Vec = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect(); + seen.sort_unstable(); + seen.dedup(); + if seen.is_empty() { + seen.push(chars.first().map_or(0, |c| c.6)); + } + for &si in &seen { + let m = style_metrics(si); + let rise = runs[si].1.rise(); + asc = asc.max(m.ascent + rise.max(0.0)); + desc = desc.max(m.descent - rise.min(0.0)); + adv = adv.max(runs[si].1.line_advance()); + } for g in &glyphs { - if g.face != primary.face { + if g.face != primaries[g.run].face { let m = fonts::face(g.face).metrics(g.size); asc = asc.max(m.ascent); desc = desc.max(m.descent); } } - let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content }; + let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content }; out.push(ParaLine { range: base + r.start..base + r.end, glyphs, carets, content: content_w, left_trim: if base_rtl { tw } else { 0.0 }, + hang, ascent: asc, descent: desc, + advance: adv, + align, + avail, + margins: margins_for(li == 0), + underline, }); } out } -/// Lay out `text` (paragraphs separated by `\n`). +/// Lay out `text` (paragraphs separated by `\n`) in one style. pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout { - let primary = fonts::resolve(&style.family, &style.style); - let pm = fonts::face(primary.face).metrics(style.size); - let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading; - let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() }; + layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para)) +} + +/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least +/// one) and paragraph settings (one per `\n`-separated paragraph; the last repeats). The wrap +/// width is the first paragraph's. +pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], paras: &[ParagraphStyle]) -> Layout { + let fallback = [(0..text.len(), TextStyle::default())]; + let runs = if runs.is_empty() { &fallback[..] } else { runs }; + let default_para = ParagraphStyle::default(); + let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para); + let width = para_at(0).width; + let primaries: Vec = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect(); + let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() }; let mut base = 0usize; - let mut plines = Vec::new(); - for p in text.split('\n') { + let mut plines: Vec<(usize, ParaLine)> = Vec::new(); + for (pi, p) in text.split('\n').enumerate() { let p_clean = p.strip_suffix('\r').unwrap_or(p); - plines.extend(paragraph(p_clean, base, style, para, primary)); + plines.extend(paragraph(p_clean, base, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l))); base += p.len() + 1; } - let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 }; - for (i, pl) in plines.into_iter().enumerate() { - let baseline = first_baseline + line_h * i as f32; - let shift = match (para.width, para.align) { - (None, Align::Left | Align::Justify) => 0.0, - (None, Align::Center) => -pl.content / 2.0, - (None, Align::Right) => -pl.content, - (Some(_), Align::Left | Align::Justify) => 0.0, - (Some(w), Align::Center) => (w - pl.content) / 2.0, - (Some(w), Align::Right) => w - pl.content, - } - pl.left_trim; + let mut baseline = 0.0f32; + for (i, (pi, pl)) in plines.into_iter().enumerate() { + let para = para_at(pi); + if i == 0 { + baseline = if width.is_some() { pl.ascent } else { 0.0 }; + } else { + let prev_para = lay.lines.last().map_or(pi, |l| l.para); + let mut adv = pl.advance + para.leading; + if prev_para != pi { + adv += para_at(prev_para).space_after + para.space_before; + } + baseline += adv; + } + let shown = pl.content - pl.hang.0 - pl.hang.1; + let (ml, mr) = pl.margins; + let shift = match (pl.avail, pl.align) { + (None, Align::Left | Align::Justify) => ml, + (None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0, + (None, Align::Right) => -shown - mr, + (Some(_), Align::Left | Align::Justify) => ml, + (Some(a), Align::Center) => ml + (a - shown) / 2.0, + (Some(a), Align::Right) => ml + a - shown, + } - pl.left_trim + - pl.hang.0; let g0 = lay.glyphs.len(); lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| { g.x += shift; @@ -529,8 +874,10 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> g })); let x = shift + pl.left_trim; - if style.underline && pl.content > 0.0 { - lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]); + if let Some((u0, u1)) = pl.underline { + let si = run_at(runs, pl.range.start); + let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size); + lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]); } lay.lines.push(Line { range: pl.range, @@ -541,15 +888,20 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> descent: pl.descent, glyphs: g0..lay.glyphs.len(), carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(), + para: pi, + para_end: false, }); } + for i in 0..lay.lines.len() { + lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para); + } let (mut x0, mut x1) = (f32::MAX, f32::MIN); for l in &lay.lines { x0 = x0.min(l.x); x1 = x1.max(l.x + l.width); } - if let Some(w) = para.width { - x0 = 0.0; + if let Some(w) = width { + x0 = x0.min(0.0); x1 = x1.max(w); } let first = &lay.lines[0]; @@ -563,12 +915,17 @@ fn cache() -> &'static Mutex)>> { C.get_or_init(Default::default) } -/// Lay out `text` (cached by text and styles). +/// Lay out `text` in one style (cached by text and styles). pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc { + layout_rich(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para)) +} + +/// [`layout_rich_uncached`], cached by text, runs and paragraph settings. +pub fn layout_rich(text: &str, runs: &[(Range, TextStyle)], paras: &[ParagraphStyle]) -> Arc { let mut h = std::collections::hash_map::DefaultHasher::new(); text.hash(&mut h); - style.hash(&mut h); - para.hash(&mut h); + runs.hash(&mut h); + paras.hash(&mut h); let key = h.finish(); static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -576,7 +933,7 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc= 512 { // evict the oldest quarter @@ -720,4 +1077,135 @@ mod tests { assert_eq!(l.caret(0).0, 0.0); assert!(l.bounds[3] > l.bounds[1]); } + + fn rich(text: &str, runs: &[(Range, TextStyle)], para: ParagraphStyle) -> Layout { + layout_rich_uncached(text, runs, &[para]) + } + + #[test] + fn mixed_sizes_share_a_baseline_and_take_the_largest_leading() { + let text = "small BIG\nnext"; + let runs = [(0..6, st(20.0)), (6..9, st(80.0)), (9..text.len(), st(20.0))]; + let l = rich(text, &runs, ParagraphStyle::default()); + assert_eq!(l.lines.len(), 2); + let y0 = l.glyphs[0].y; + assert!(l.glyphs[..9].iter().all(|g| (g.y - y0).abs() < 1e-3), "one baseline per line"); + assert!(l.glyphs[7].size > l.glyphs[0].size * 3.0); + // Line 2's advance is its own leading (20 px text: 24 px); line 1 is as tall as the big run. + assert!((l.lines[1].baseline - l.lines[0].baseline - 24.0).abs() < 1e-3, "{}", l.lines[1].baseline); + assert!(l.lines[0].ascent > 60.0); + // A big run on the second line pushes it down by its 120% leading. + let text2 = "aa\nBB"; + let l2 = rich(text2, &[(0..3, st(20.0)), (3..5, st(80.0))], ParagraphStyle::default()); + assert!((l2.lines[1].baseline - 96.0).abs() < 1e-3); + // Explicit leading wins over auto. + let mut fixed = st(20.0); + fixed.leading = Some(50.0); + let l3 = rich("a\nb", &[(0..3, fixed)], ParagraphStyle::default()); + assert!((l3.lines[1].baseline - 50.0).abs() < 1e-3); + } + + #[test] + fn super_subscript_tsume_and_kerning_metrics() { + let base = st(100.0); + let sup = TextStyle { script: Script::Super, ..base.clone() }; + let sub = TextStyle { script: Script::Sub, ..base.clone() }; + let l = rich("x2y2", &[(0..1, base.clone()), (1..2, sup), (2..3, base.clone()), (3..4, sub)], ParagraphStyle::default()); + let g = &l.glyphs; + assert!((g[1].size - 58.3).abs() < 0.1); + assert!((g[1].y - (g[0].y - 33.3)).abs() < 0.1, "superscript raised by a third of the size"); + assert!((g[3].y - (g[2].y + 33.3)).abs() < 0.1, "subscript lowered"); + // Tsume removes side bearings; full tsume is narrower than none. + let w0 = measure("onion", &base); + let w1 = measure("onion", &TextStyle { tsume: 1.0, ..base.clone() }); + assert!(w1 < w0 - 5.0, "{w1} vs {w0}"); + // Manual kerning: +100/1000 em before each of the 5 later characters of AVAVAV, metrics off. + let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..base.clone() }); + let manual = measure("AVAVAV", &TextStyle { manual_kern: Some(100.0), ..base.clone() }); + assert!((manual - nokern - 50.0).abs() < 0.5, "{manual} {nokern}"); + let optical = measure("AVAVAV", &TextStyle { optical: true, ..base.clone() }); + assert!(optical > 0.0 && (optical - nokern).abs() < 100.0); + // Horizontal scale stretches advances. + let wide = measure("onion", &TextStyle { h_scale: 2.0, ..base.clone() }); + assert!((wide - 2.0 * w0).abs() < 0.5); + let l = layout("o", &TextStyle { h_scale: 2.0, v_scale: 0.5, ..base }, &ParagraphStyle::default()); + assert_eq!((l.glyphs[0].h_scale, l.glyphs[0].v_scale), (2.0, 0.5)); + } + + #[test] + fn indents_and_paragraph_spacing() { + let s = st(20.0); + let text = "first paragraph here\nsecond one"; + let p0 = ParagraphStyle { width: Some(400.0), indent_start: 30.0, indent_first: 15.0, space_after: 10.0, ..Default::default() }; + let p1 = ParagraphStyle { width: Some(400.0), indent_end: 50.0, align: Align::Right, space_before: 7.0, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p0.clone(), p1]); + assert!((l.lines[0].x - 45.0).abs() < 1e-3, "left + first-line indent: {}", l.lines[0].x); + assert!((l.lines[1].x + l.lines[1].width - 350.0).abs() < 0.5, "right indent: {}", l.lines[1].x + l.lines[1].width); + assert!((l.lines[1].baseline - l.lines[0].baseline - (24.0 + 10.0 + 7.0)).abs() < 1e-3); + assert_eq!((l.lines[0].para, l.lines[1].para), (0, 1)); + assert!(l.lines[0].para_end && l.lines[1].para_end); + // Wrapped lines after the first don't get the first-line indent. + let long = "word ".repeat(30); + let l = layout_rich_uncached(&long, &[(0..long.len(), s.clone())], &[p0]); + assert!(l.lines.len() > 2); + assert!((l.lines[1].x - 30.0).abs() < 1e-3); + assert!(l.lines.iter().all(|ln| ln.x + ln.width <= 400.0 + 0.5)); + assert!(!l.lines[0].para_end); + // Point text: a right-aligned paragraph's end indent moves it left of the origin. + let l = layout_rich_uncached("abc", &[(0..3, s)], &[ParagraphStyle { align: Align::Right, indent_end: 12.0, ..Default::default() }]); + assert!((l.lines[0].x + l.lines[0].width + 12.0).abs() < 1e-3); + } + + #[test] + fn justify_last_line_modes_and_every_line_composer() { + let text = "the quick brown fox jumps over the lazy dog and keeps running far"; + let s = st(30.0); + let p = |last: Align| ParagraphStyle { width: Some(320.0), align: Align::Justify, justify_last: last, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let n = left.lines.len(); + assert!(n >= 3); + assert!((left.lines[0].width - 320.0).abs() < 0.5); + assert!(left.lines[n - 1].width < 320.0 && left.lines[n - 1].x.abs() < 1e-3); + let center = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Center)]); + let lc = ¢er.lines[n - 1]; + assert!((lc.x - (320.0 - lc.width) / 2.0).abs() < 0.5); + let right = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Right)]); + let lr = &right.lines[n - 1]; + assert!((lr.x + lr.width - 320.0).abs() < 0.5); + let all = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Justify)]); + assert!((all.lines[n - 1].width - 320.0).abs() < 0.5, "justify all stretches the last line"); + // Every-line composer: never worse (summed squared slack) than greedy breaking. + let slack = |l: &Layout| -> f32 { l.lines[..l.lines.len() - 1].iter().map(|ln| (320.0 - ln.width).powi(2)).sum() }; + let greedy = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[ParagraphStyle { width: Some(320.0), ..Default::default() }]); + let every = layout_rich_uncached(text, &[(0..text.len(), s)], &[ParagraphStyle { width: Some(320.0), every_line: true, ..Default::default() }]); + assert!(slack(&every) <= slack(&greedy) + 1e-3, "{} vs {}", slack(&every), slack(&greedy)); + assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5)); + } + + #[test] + fn hanging_punctuation_overhangs_the_box() { + let s = st(40.0); + let text = "\"Hi.\""; + let p = ParagraphStyle { width: Some(400.0), hanging: true, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + assert!(l.lines[0].x < -1.0, "opening quote hangs left: {}", l.lines[0].x); + let r = layout_rich_uncached( + text, + &[(0..text.len(), s)], + &[ParagraphStyle { width: Some(400.0), hanging: true, align: Align::Right, ..Default::default() }], + ); + assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right"); + } + + #[test] + fn rtl_paragraph_carets_run_right_to_left() { + let text = "\u{5d0}\u{5d1}\u{5d2}"; + let l = layout_rich_uncached(text, &[(0..text.len(), st(40.0))], &[ParagraphStyle { rtl: Some(true), ..Default::default() }]); + let xs: Vec = l.lines[0].carets.iter().map(|c| c.1).collect(); + assert_eq!(xs.len(), 4); + assert!(xs.windows(2).all(|w| w[1] < w[0]), "{xs:?}"); + // Hit testing returns the logical position nearest the point. + assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2); + assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0); + } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ab77f44f9..1bd93920c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -11,13 +11,15 @@ pub mod path_text; pub mod selectors; pub mod sfnt; +pub use kurbo; + use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; -use effectcraft_keyframe::{Justify, TextDoc}; +use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, TextDoc}; pub use fonts::{FaceId, Resolved, families, resolve}; use kurbo::{Affine, BezPath, Point}; -pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout as layout_text, measure}; +pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure}; use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::{GlyphId, MetadataProvider}; @@ -43,7 +45,7 @@ impl OutlinePen for Pen { } /// Glyph outline in font units (y up), cached. -fn outline_units(face: FaceId, gid: u32) -> Option> { +pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { static C: OnceLock>>>> = OnceLock::new(); let c = C.get_or_init(Default::default); if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) { @@ -64,12 +66,14 @@ fn outline_units(face: FaceId, gid: u32) -> Option> { v } -/// The outline of a laid-out glyph in pixels, origin on its baseline (y down). +/// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its +/// horizontal / vertical scale and faux italic. pub fn glyph_outline(g: &Glyph) -> BezPath { let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() }; let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64; let slant = if g.synth_italic { 0.21 } else { 0.0 }; - Affine::new([k, 0.0, slant * k, -k, 0.0, 0.0]) * (*units).clone() + let (hs, vs) = (g.h_scale as f64, g.v_scale as f64); + Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone() } /// One character of laid-out text. @@ -89,7 +93,10 @@ pub struct CharGlyph { /// The source character (after All Caps). pub ch: char, pub synth_bold: bool, + /// The glyph's drawn size (superscript / small caps are smaller than the run's size). pub size: f64, + /// Index into [`TextLayout::styles`]. + pub run: usize, } #[derive(Clone, Debug, Default)] @@ -101,27 +108,111 @@ pub struct TextLayout { pub lines: usize, /// Bounds in layer space (x0, y0, x1, y1). pub bounds: [f64; 4], - /// Line baselines (layer space) and widths for the viewer's text cursor. + /// Line origins (layer space) `[x, baseline, width, height]`. pub line_boxes: Vec<[f64; 4]>, + /// The character style of each run (glyphs refer to them by index). + pub styles: Vec, + /// The paragraph layout (layout space) and its map into layer space. + pub layout: Arc, + pub to_layer: Affine, + /// Byte offset of every character, plus the text length. + pub byte_of_char: Vec, + pub vertical: bool, } +/// A character style → [`TextStyle`]. +pub fn text_style(s: &CharStyle) -> TextStyle { + TextStyle { + family: s.font.clone(), + style: s.style.clone(), + size: s.size as f32, + tracking: s.tracking as f32, + kerning: true, + optical: s.kerning == Kerning::Optical, + manual_kern: if let Kerning::Manual(v) = s.kerning { Some(v as f32) } else { None }, + ligatures: s.ligatures, + baseline_shift: s.baseline_shift as f32, + faux_bold: s.faux_bold, + faux_italic: s.faux_italic, + caps: if s.all_caps { + Caps::All + } else if s.small_caps { + Caps::Small + } else { + Caps::Normal + }, + underline: false, + h_scale: (s.h_scale / 100.0) as f32, + v_scale: (s.v_scale / 100.0) as f32, + tsume: (s.tsume / 100.0) as f32, + script: match s.baseline { + BaselineOption::Normal => Script::Normal, + BaselineOption::Superscript => Script::Super, + BaselineOption::Subscript => Script::Sub, + }, + leading: s.leading.map(|l| l as f32), + } +} + +/// Paragraph settings → [`ParagraphStyle`] (wrap `width` for paragraph text). +pub fn paragraph_style(p: &ParaStyle, width: Option) -> ParagraphStyle { + let (align, justify_last) = match p.justify { + Justify::Left => (Align::Left, Align::Left), + Justify::Center => (Align::Center, Align::Center), + Justify::Right => (Align::Right, Align::Right), + Justify::JustifyLastLeft => (Align::Justify, Align::Left), + Justify::JustifyLastCenter => (Align::Justify, Align::Center), + Justify::JustifyLastRight => (Align::Justify, Align::Right), + Justify::JustifyAll => (Align::Justify, Align::Justify), + }; + ParagraphStyle { + align, + justify_last, + leading: 0.0, + width, + // Left-to-right paragraphs keep the Unicode default (first strong character). + rtl: (p.direction == Direction::Rtl).then_some(true), + indent_start: p.indent_left as f32, + indent_end: p.indent_right as f32, + indent_first: p.indent_first as f32, + space_before: p.space_before as f32, + space_after: p.space_after as f32, + every_line: p.composer == Composer::EveryLine, + hanging: p.hanging_punctuation, + } +} + +/// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns +/// running right to left), characters kept upright. +const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + /// Lay out a Source Text value in layer space. Point text: the origin is the start of the first /// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from -/// `box_pos`. +/// `box_pos`. Vertical text runs down from the origin (point) or the box's top-right corner. pub fn layout_doc(doc: &TextDoc) -> TextLayout { - let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }; - let (style, para) = styles(doc); - let lay = layout_text(&text, &style, ¶); - let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0); - // Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0. - let (dx, dy) = match doc.box_size { - Some(_) => (doc.box_pos[0], doc.box_pos[1]), - // The layout already pivots point text around x = 0 by alignment. - None => (0.0, -first_base), + let text = doc.text.as_str(); + let byte_of_char: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); + let nchars = byte_of_char.len() - 1; + let mut runs = Vec::new(); + let mut styles = Vec::new(); + let mut ci = 0; + for r in doc.runs() { + let a = byte_of_char[ci.min(nchars)]; + ci += r.len; + let b = byte_of_char[ci.min(nchars)]; + runs.push((a..b, text_style(&r.style))); + styles.push(r.style); + } + let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32); + let paras: Vec = doc.paras().iter().map(|p| paragraph_style(p, width)).collect(); + let lay = layout::layout_rich(text, &runs, ¶s); + let to_layer = match (doc.vertical, doc.box_size) { + (false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])), + (false, None) => Affine::IDENTITY, + (true, Some(b)) => Affine::translate((doc.box_pos[0] + b[0], doc.box_pos[1])) * ROT90, + (true, None) => ROT90, }; - let hs = doc.h_scale / 100.0; - let vs = doc.v_scale / 100.0; - let mut out = TextLayout { lines: lay.lines.len(), ..Default::default() }; + let mut out = TextLayout { lines: lay.lines.len(), to_layer, vertical: doc.vertical, ..Default::default() }; // Map byte clusters → char/word indices. let mut char_of_byte = vec![0usize; text.len() + 1]; let mut word_of_char = Vec::new(); @@ -144,22 +235,30 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { nospace += 1; } } - out.chars = text.chars().count(); + char_of_byte[text.len()] = nchars; + out.chars = nchars; out.chars_no_space = nospace; out.words = words; let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; for (li, line) in lay.lines.iter().enumerate() { - out.line_boxes.push([line.x as f64 + dx, line.baseline as f64 + dy, line.width as f64, (line.ascent + line.descent) as f64]); + let o = to_layer * Point::new(line.x as f64, line.baseline as f64); + out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]); for gi in line.glyphs.clone() { let g = &lay.glyphs[gi]; let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0); - let ch = text[g.cluster..].chars().next().unwrap_or(' '); + let src = text[g.cluster..].chars().next().unwrap_or(' '); + let st = styles.get(g.run); + let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src }; let mut path = glyph_outline(g); - if hs != 1.0 || vs != 1.0 { - path = Affine::scale_non_uniform(hs, vs) * path; - } let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width); - let origin = Point::new(g.x as f64 * hs + dx, g.y as f64 + dy); + let advance = (next_x - g.x) as f64; + if doc.vertical { + // Keep the character upright around its centre. + let c = Point::new(advance / 2.0, -0.35 * g.size as f64); + let rc = ROT90 * c; + path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path; + } + let origin = to_layer * Point::new(g.x as f64, g.y as f64); if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) { b[0] = b[0].min(origin.x + r.x0); b[1] = b[1].min(origin.y + r.y0); @@ -169,62 +268,102 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { out.glyphs.push(CharGlyph { path, origin, - advance: (next_x - g.x) as f64 * hs, + advance, char_index: ci, char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0), word_index: word_of_char.get(ci).copied().unwrap_or(0), line_index: li, - is_space: ch.is_whitespace(), + is_space: src.is_whitespace(), ch, synth_bold: g.synth_bold, size: g.size as f64, + run: g.run, }); } } if b[0].is_finite() { out.bounds = b; } + out.styles = styles; + out.byte_of_char = byte_of_char; + out.layout = lay; out } -/// Character and paragraph styles of a Source Text value. -fn styles(doc: &TextDoc) -> (TextStyle, ParagraphStyle) { - let style = TextStyle { - family: doc.font.clone(), - style: doc.style.clone(), - size: doc.size as f32, - tracking: doc.tracking as f32, - baseline_shift: doc.baseline_shift as f32, - faux_bold: doc.faux_bold, - faux_italic: doc.faux_italic, - caps: if doc.small_caps { Caps::Small } else { Caps::Normal }, - ..Default::default() - }; - let align = match doc.justify { - Justify::Left | Justify::JustifyLastLeft => Align::Left, - Justify::Center | Justify::JustifyLastCenter => Align::Center, - Justify::Right | Justify::JustifyLastRight => Align::Right, - Justify::JustifyAll => Align::Justify, - }; - let natural = doc.size * 1.2; - let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0); - (style, ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None }) +impl TextLayout { + /// Character index of byte offset `b`. + pub fn char_of_byte(&self, b: usize) -> usize { + self.byte_of_char.partition_point(|x| *x < b).min(self.chars) + } + fn byte(&self, ci: usize) -> usize { + self.byte_of_char.get(ci.min(self.chars)).copied().unwrap_or(0) + } + /// Layout line holding caret `ci`. + pub fn line_of_char(&self, ci: usize) -> usize { + self.layout.line_of(self.byte(ci)) + } + /// The caret at character boundary `ci` as a segment (top, bottom) in layer space. + pub fn caret(&self, ci: usize) -> (Point, Point) { + let (x, base, li) = self.layout.caret(self.byte(ci)); + let (asc, desc) = self.layout.lines.get(li).map_or((10.0, 3.0), |l| (l.ascent, l.descent)); + (self.to_layer * Point::new(x as f64, (base - asc) as f64), self.to_layer * Point::new(x as f64, (base + desc) as f64)) + } + /// The caret's position along its line (layout space), for up / down movement. + pub fn caret_x(&self, ci: usize) -> f64 { + self.layout.caret(self.byte(ci)).0 as f64 + } + /// The character boundary nearest a layer-space point. + pub fn hit(&self, p: Point) -> usize { + let q = self.to_layer.inverse() * p; + self.char_of_byte(self.layout.hit(q.x as f32, q.y as f32)) + } + /// The caret one line up (`dir` < 0) or down from `ci`, keeping the line position `goal`. + /// Past the first / last line it goes to the start / end of the text. + pub fn move_vertical(&self, ci: usize, dir: i32, goal: Option) -> (usize, f64) { + let goal = goal.unwrap_or_else(|| self.caret_x(ci)); + let li = self.line_of_char(ci) as i64 + dir as i64; + if li < 0 { + return (0, goal); + } + match self.layout.lines.get(li as usize) { + Some(l) => (self.char_of_byte(self.layout.hit_line(l, goal as f32)), goal), + None => (self.chars, goal), + } + } + /// Start and end carets of the visual line holding `ci`. + pub fn line_span(&self, ci: usize) -> (usize, usize) { + let li = self.line_of_char(ci); + match self.layout.lines.get(li) { + Some(l) => (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l))), + None => (0, self.chars), + } + } + /// Selection highlight quads (layer space) for characters `a..b`. + pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> { + self.layout + .selection_rects(self.byte(a.min(b)), self.byte(a.max(b))) + .into_iter() + .map(|r| { + let m = |x: f32, y: f32| self.to_layer * Point::new(x as f64, y as f64); + [m(r[0], r[1]), m(r[2], r[1]), m(r[2], r[3]), m(r[0], r[3])] + }) + .collect() + } } -/// Outline (origin on the baseline) and advance of a single character in a Source Text's -/// style: used by Character Offset / Character Value substitutions. -pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) { - let (style, _) = styles(doc); +/// Outline (origin on the baseline) and advance of a single character in a character style: +/// used by Character Offset / Character Value substitutions. +pub fn char_glyph_style(s: &CharStyle, ch: char) -> (BezPath, f64) { + let style = text_style(s); let lay = layout_text(&ch.to_string(), &style, &ParagraphStyle::default()); - let hs = doc.h_scale / 100.0; - let vs = doc.v_scale / 100.0; let Some(g) = lay.glyphs.first() else { return (BezPath::new(), 0.0) }; - let mut path = glyph_outline(g); - if hs != 1.0 || vs != 1.0 { - path = Affine::scale_non_uniform(hs, vs) * path; - } - let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0) * hs; - (Affine::translate((-(g.x as f64) * hs, 0.0)) * path, adv) + let adv = lay.lines.first().map(|l| l.width as f64).unwrap_or(0.0); + (Affine::translate((-(g.x as f64), 0.0)) * glyph_outline(g), adv) +} + +/// [`char_glyph_style`] in a Source Text's base style. +pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) { + char_glyph_style(&doc.base_style(), ch) } #[cfg(test)] @@ -244,6 +383,61 @@ mod tests { assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty())); } + #[test] + fn styled_runs_carets_and_hit_testing() { + let mut doc = TextDoc { text: "Hello big\nworld".into(), size: 40.0, ..Default::default() }; + doc.apply_style(6..9, |s| { + s.size = 80.0; + s.fill = [1.0, 0.0, 0.0, 1.0]; + }); + let l = layout_doc(&doc); + let g = l.glyphs.iter().find(|g| g.char_index == 7).unwrap(); + assert_eq!(l.styles[g.run].size, 80.0); + assert_eq!(l.styles[g.run].fill, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(g.size, 80.0); + let small = l.glyphs.iter().find(|g| g.char_index == 1).unwrap(); + assert_eq!(small.origin.y, g.origin.y, "one baseline for mixed sizes"); + // Line 2 sits one 40 px auto leading (48 px) below line 1. + let w = l.glyphs.iter().find(|g| g.char_index == 10).unwrap(); + assert!((w.origin.y - g.origin.y - 48.0).abs() < 1e-6); + // Every caret hit-tests back to itself; carets advance along each line. + for ci in 0..=doc.char_len() { + let (a, b) = l.caret(ci); + assert!(b.y > a.y); + assert_eq!(l.hit(a.midpoint(b)), ci, "caret {ci}"); + } + assert!(l.caret(8).0.x > l.caret(7).0.x); + // Up / down keep the line position; line spans and selections. + let (down, goal) = l.move_vertical(2, 1, None); + assert_eq!(l.line_of_char(down), 1); + assert!((goal - l.caret_x(2)).abs() < 1e-6); + assert_eq!(l.move_vertical(down, -1, Some(goal)).0, 2); + assert_eq!(l.move_vertical(2, -1, None).0, 0); + assert_eq!(l.move_vertical(12, 1, None).0, 15); + assert_eq!(l.line_span(3), (0, 9)); + assert_eq!(l.line_span(12), (10, 15)); + let q = l.selection_quads(3, 12); + assert_eq!(q.len(), 2); + assert_eq!(l.char_of_byte(doc.byte_of(12)), 12); + } + + #[test] + fn paragraph_box_offset_and_vertical_type() { + let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; + let l = layout_doc(&doc); + let (a, _) = l.caret(0); + assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // Vertical: characters run down a column, carets are horizontal. + let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); + let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); + assert!(ys.windows(2).all(|w| w[1] > w[0]), "{ys:?}"); + let col2 = v.glyphs.iter().find(|g| g.line_index == 1).unwrap(); + assert!(col2.origin.x < v.glyphs[0].origin.x - 10.0, "next column to the left"); + let (p0, p1) = v.caret(1); + assert!((p0.y - p1.y).abs() < 1e-6 && (p0.x - p1.x).abs() > 5.0); + assert_eq!(v.hit(p0.midpoint(p1)), 1); + } + #[test] fn centered_point_text_straddles_origin() { let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };