From f78435927b175e1a832c965f4d4f23872259430f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:34:13 -0400 Subject: [PATCH] M9.9: text style runs + paragraph settings TextDoc gains character style runs (font, size, colours, tracking, leading, baseline shift, h/v scale, tsume, faux styles, caps, super/subscript, kerning metrics/optical/manual, ligatures) and per-paragraph settings (justification with last-line modes, indents, space before/after, direction, composer, hanging punctuation), serde-compatible with single-style documents. Layout handles mixed runs on a line (shared baseline, largest leading), every-line composer, editing geometry (carets, hit testing, selection quads) and basic vertical type. Renderer colours per run. Expressions get the sourceText style API (style, getStyleAt, createStyle, setters with character ranges). Co-Authored-By: Claude Opus 5.5 --- crates/expr/Cargo.toml | 1 + crates/expr/src/host.rs | 21 + crates/expr/src/prelude.js | 147 +++- crates/expr/src/runtime.rs | 81 ++- crates/expr/src/tests.rs | 54 ++ crates/keyframe/src/lib.rs | 2 + crates/keyframe/src/text_doc.rs | 1112 +++++++++++++++++++++++++++++++ crates/keyframe/src/value.rs | 78 +-- crates/render/src/text.rs | 37 +- crates/text/src/layout.rs | 680 ++++++++++++++++--- crates/text/src/lib.rs | 318 +++++++-- 11 files changed, 2278 insertions(+), 253 deletions(-) create mode 100644 crates/keyframe/src/text_doc.rs diff --git a/crates/expr/Cargo.toml b/crates/expr/Cargo.toml index 613162e1..6f157698 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 23362970..4b92e224 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 1e3a61ab..67ddf458 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 65177476..af7ed9b6 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 8ff451bd..fd02acef 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 d97ec53d..731e167c 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 00000000..9652b91a --- /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 a5c685d4..abf112fc 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 165ea298..37b0f775 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 75db90b2..824b7373 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 ab77f44f..1bd93920 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() };