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 <noreply@anthropic.com>
This commit is contained in:
parent
ea1bd0e0c4
commit
f78435927b
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<u64> },
|
||||
/// 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<serde_json::Value> = d.runs().iter().map(|r| serde_json::json!({"len": r.len, "style": char_style_json(&r.style)})).collect();
|
||||
let paras: Vec<serde_json::Value> = 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))?;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@ pub enum Out {
|
|||
Num(f64),
|
||||
Str(String),
|
||||
Arr(Vec<Out>),
|
||||
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<JsV
|
|||
let l = num(args, 2, ctx)?;
|
||||
Req::Markers { comp: id(args, 1, ctx)?, layer: (l >= 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<Out, String> {
|
|||
}
|
||||
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<Vec<[f64; 2]>, String> {
|
||||
let v = o.get(name, ctx).map_err(err)?;
|
||||
|
|
@ -408,6 +426,9 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::<Vec<_>>().join(","),
|
||||
Out::Undefined | Out::Null => String::new(),
|
||||
Out::Path { .. } => "[object Path]".into(),
|
||||
Out::Style(j) => {
|
||||
serde_json::from_str::<serde_json::Value>(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, String> {
|
|||
}
|
||||
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<Value, String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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::<TextDoc>(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}") }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<f64>,
|
||||
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::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
|
||||
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|mut t| {
|
||||
t.normalize();
|
||||
Value::Text(Box::new(t))
|
||||
})
|
||||
}
|
||||
}
|
||||
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
|
||||
|
|
|
|||
|
|
@ -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<TextGeom> {
|
|||
let mut subs: Vec<Option<char>> = 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<char> = (if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() }).chars().collect();
|
||||
let mut chars = src.clone();
|
||||
let mut chars: Vec<char> = 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<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
|
||||
let x2: Vec<CharXf> = 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<TextGeom> {
|
|||
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<TextGeom> {
|
|||
}
|
||||
}
|
||||
|
||||
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<TextGeom> {
|
|||
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<TextGeom> {
|
|||
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<TextGeom> {
|
|||
}
|
||||
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<Pass> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<f32>,
|
||||
/// 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<f32>,
|
||||
}
|
||||
|
||||
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<f32>,
|
||||
/// Base direction: None = from the first strong character.
|
||||
pub rtl: Option<bool>,
|
||||
/// 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<H: Hasher>(&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<H: Hasher>(&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<usize>,
|
||||
/// 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<ShapedGlyph> {
|
||||
/// 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<ShapedGlyph> {
|
||||
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<Option<(f32, f32)>> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect();
|
||||
let gaps: Vec<Option<f32>> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect();
|
||||
let mut sorted: Vec<f32> = 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<usize>,
|
||||
glyphs: Vec<Glyph>,
|
||||
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<f32>,
|
||||
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<usize>, 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<ParaLine> {
|
||||
let pm = fonts::face(primary.face).metrics(style.size);
|
||||
fn paragraph(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option<f32>) -> Vec<ParaLine> {
|
||||
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<f32> {
|
||||
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<Item> = 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<usize>| -> 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<usize>, 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<usize>| -> 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<Range<usize>> = Vec::new();
|
||||
match para.width {
|
||||
match width {
|
||||
None => ranges.push(0..text.len()),
|
||||
Some(maxw) => {
|
||||
let mut start = 0usize;
|
||||
let mut last_ok: Option<usize> = 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<usize> = 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<usize> = 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<usize> = 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<usize, (f32, f32, bool)> = 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<usize> = (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<usize> = 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<usize>, 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<Resolved> = 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<HashMap<u64, (u64, Arc<Layout>)>> {
|
|||
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> {
|
||||
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<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
|
||||
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<Layou
|
|||
e.0 = now;
|
||||
return e.1.clone();
|
||||
}
|
||||
let l = Arc::new(layout_uncached(text, style, para));
|
||||
let l = Arc::new(layout_rich_uncached(text, runs, paras));
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() >= 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<usize>, 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<f32> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Arc<BezPath>> {
|
||||
pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
||||
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = 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<Arc<BezPath>> {
|
|||
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<CharStyle>,
|
||||
/// The paragraph layout (layout space) and its map into layer space.
|
||||
pub layout: Arc<Layout>,
|
||||
pub to_layer: Affine,
|
||||
/// Byte offset of every character, plus the text length.
|
||||
pub byte_of_char: Vec<usize>,
|
||||
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<f32>) -> 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<usize> = 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<ParagraphStyle> = 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<f64>) -> (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<f64> = 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() };
|
||||
|
|
|
|||
Loading…
Reference in New Issue