mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
The scaled-instance round-trip test now asserts the scaled width and height instead of printing them. text_path read the layer's raw size, so a text on a path inside a scaled instance kept the main's path length; it now uses the scaled or fitted size. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
324 lines
15 KiB
Rust
324 lines
15 KiB
Rust
use super::paint::paint;
|
|
use super::{Kv, Mapper, Obj, Value, View, copy_len, copy_str, json, network, num, scaled};
|
|
|
|
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
|
|
/// `length`: the setting is a length, at the scale of the layer that has it.
|
|
fn text_num(v: &View, key: &str, length: bool) -> Option<f64> {
|
|
v.layers.iter().zip(&v.scales).rev().find_map(|(layer, k)| {
|
|
layer.get(key).and_then(|value| match value {
|
|
Kv::Bool(_) => None,
|
|
_ => scaled(value.f64()?, if length { *k } else { 1.0 }),
|
|
})
|
|
})
|
|
}
|
|
|
|
fn text_flag(v: &View, key: &str) -> Option<bool> {
|
|
v.layers.iter().rev().find_map(|layer| layer.get(key).and_then(Kv::bool))
|
|
}
|
|
|
|
fn insert_text_options(style: &mut Obj, v: &View) {
|
|
if let Some(value) = text_num(v, "listSpacing", true) {
|
|
style.insert("listSpacing".into(), json!(value));
|
|
}
|
|
for k in ["hangingList", "hangingPunctuation"] {
|
|
if let Some(value) = text_flag(v, k) {
|
|
style.insert(k.into(), json!(value));
|
|
}
|
|
}
|
|
if let Some(value) = leading_trim(v) {
|
|
style.insert("leadingTrim".into(), json!(value));
|
|
}
|
|
}
|
|
|
|
fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> {
|
|
v.layers.iter().rev().find_map(|layer| match layer.get("leadingTrim").and_then(Kv::str) {
|
|
Some(value @ ("NONE" | "CAP_HEIGHT")) => Some(value),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The chars that start a wrapped line in the layout the file saved with the text
|
|
/// (`derivedTextData`'s `baselines`, each line's `firstCharacter` in UTF-16 units), when that layout
|
|
/// is for these characters; lines after a hard break are left out (empty: it wraps nowhere). None
|
|
/// when the saved layout is missing, for other text or inconsistent.
|
|
fn saved_line_starts(v: &View, chars: &str) -> Option<Vec<usize>> {
|
|
// Derived data from below the layer that set the characters was laid out for other text.
|
|
let top = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
|
|
if top("derivedTextData")? < top("textData")? {
|
|
return None;
|
|
}
|
|
let baselines = v.get("derivedTextData")?.get("baselines")?.list();
|
|
// UTF-16 offset of each char, and of the end.
|
|
let mut offsets: Vec<usize> = Vec::with_capacity(chars.len() + 1);
|
|
let mut unit = 0;
|
|
for c in chars.chars() {
|
|
offsets.push(unit);
|
|
unit += c.len_utf16();
|
|
}
|
|
offsets.push(unit);
|
|
let list: Vec<char> = chars.chars().collect();
|
|
let mut starts = Vec::new();
|
|
let mut prev = None;
|
|
for b in baselines {
|
|
let u = num(b, "firstCharacter").filter(|u| *u >= 0.0 && u.fract() == 0.0 && *u < unit as f64)? as usize;
|
|
let c = offsets.binary_search(&u).ok()?;
|
|
// Lines start at the first char, then further on each time.
|
|
if !prev.map_or(c == 0, |p| c > p) {
|
|
return None;
|
|
}
|
|
prev = Some(c);
|
|
if c > 0 && !list.get(c - 1).is_some_and(|ch| matches!(ch, '\n' | '\u{2028}')) {
|
|
starts.push(c);
|
|
}
|
|
}
|
|
prev.map(|_| starts)
|
|
}
|
|
|
|
impl<'a> Mapper<'a> {
|
|
pub(super) fn text(&mut self, o: &mut Obj, v: &View<'a>) {
|
|
let (key, legacy) = ("styleIdForText", "inheritTextStyleID");
|
|
let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied());
|
|
// Overrides above the one that links the style beat it; it beats the copies below.
|
|
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy);
|
|
let found = v.find("textData");
|
|
let (td, td_scale) = (found.map(|f| f.0), found.map_or(1.0, |f| f.1));
|
|
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
|
|
o.insert("characters".into(), json!(chars));
|
|
// Where the file's own layout wrapped the lines (not a REST field).
|
|
if let Some(starts) = saved_line_starts(v, chars) {
|
|
o.insert("_figLineStarts".into(), json!(starts));
|
|
}
|
|
let mut style = text_style(&sv, None);
|
|
// The style's typography beats the layer's own copy of it (the copy is stale when the style
|
|
// changed). The file cannot tell a stale copy of these four values from a deliberate local
|
|
// one, and "original", "none" and 0 could never be set locally if the style always won, so
|
|
// a value the layer or an override sets itself is taken as deliberate.
|
|
if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") {
|
|
style.insert("textDecoration".into(), json!(decoration));
|
|
}
|
|
if let Some(skip_ink) = v.flag("textDecorationSkipInk") {
|
|
style.insert("textDecorationSkipInk".into(), json!(skip_ink));
|
|
}
|
|
if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") {
|
|
style.insert("textCase".into(), json!(case));
|
|
}
|
|
for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] {
|
|
copy_str(&mut style, v, k, k);
|
|
}
|
|
// Paragraph settings belong to the effective text layer, including explicit zero clears.
|
|
for k in ["paragraphSpacing", "paragraphIndent"] {
|
|
copy_len(&mut style, v, k, k);
|
|
}
|
|
insert_text_options(&mut style, v);
|
|
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
|
|
style.insert("maxLines".into(), json!(n));
|
|
}
|
|
let size = sv.len("fontSize");
|
|
if let Some(b) = o.get_mut("style").and_then(|s| s.get_mut("boundVariables")).map(Value::take) {
|
|
style.insert("boundVariables".into(), b);
|
|
}
|
|
o.insert("style".into(), Value::Object(style));
|
|
let Some(td) = td else { return };
|
|
char_styles(o, td, td_scale, size, &mut |w| self.warn(w));
|
|
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
|
|
let lines: Vec<&str> = raw
|
|
.iter()
|
|
.map(|l| match l.get("lineType").and_then(Kv::str) {
|
|
Some("ORDERED_LIST") => "ORDERED",
|
|
Some("UNORDERED_LIST") => "UNORDERED",
|
|
_ => "NONE",
|
|
})
|
|
.collect();
|
|
if lines.iter().any(|l| *l != "NONE") {
|
|
o.insert("lineTypes".into(), json!(lines));
|
|
}
|
|
// A forced base direction per paragraph (`sourceDirectionality`; not a REST field).
|
|
let dirs: Vec<&str> = raw
|
|
.iter()
|
|
.map(|l| match l.get("sourceDirectionality").and_then(Kv::str) {
|
|
Some("LTR") => "LTR",
|
|
Some("RTL") => "RTL",
|
|
_ => "AUTO",
|
|
})
|
|
.collect();
|
|
if dirs.iter().any(|d| *d != "AUTO") {
|
|
o.insert("lineDirections".into(), json!(dirs));
|
|
}
|
|
// Where a numbered list starts counting, less one (not a REST field).
|
|
let starts: Vec<u32> =
|
|
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
|
|
if starts.iter().any(|n| *n > 0) {
|
|
o.insert("listStartOffsets".into(), json!(starts));
|
|
}
|
|
}
|
|
|
|
/// Carry supported text-path data through the private .fig REST ingress only.
|
|
pub(super) fn text_path(&mut self, o: &mut Obj, v: &View<'a>) {
|
|
let fail = |this: &mut Self, reason: &str| {
|
|
this.warn(format!("text path {reason}; text imports off its path"));
|
|
};
|
|
let Some(start) = v.get("textPathStart") else {
|
|
fail(self, "has no start data");
|
|
return;
|
|
};
|
|
let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else {
|
|
fail(self, "has invalid start data");
|
|
return;
|
|
};
|
|
let Some(vd) = v.get("vectorData") else {
|
|
fail(self, "has no vector data");
|
|
return;
|
|
};
|
|
let Some(blob) = num(vd, "vectorNetworkBlob")
|
|
.filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0)
|
|
.and_then(|i| usize::try_from(i as u64).ok())
|
|
.and_then(|i| self.blobs.get(i))
|
|
.copied()
|
|
else {
|
|
fail(self, "has no vector network");
|
|
return;
|
|
};
|
|
let Some(net) = network::decode(blob) else {
|
|
fail(self, "has damaged vector data");
|
|
return;
|
|
};
|
|
let Some(norm) = vd.get("normalizedSize") else {
|
|
fail(self, "has invalid normalized size");
|
|
return;
|
|
};
|
|
let (Some(nx), Some(ny), Some((w, h))) = (num(norm, "x"), num(norm, "y"), v.size()) else {
|
|
fail(self, "has invalid dimensions");
|
|
return;
|
|
};
|
|
let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option<f64> {
|
|
if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 {
|
|
return None;
|
|
}
|
|
if normalized > 1e-9 {
|
|
if size <= 0.0 {
|
|
return None;
|
|
}
|
|
return Some(size / normalized);
|
|
}
|
|
(size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0)
|
|
};
|
|
let xs: Vec<f64> = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect();
|
|
let ys: Vec<f64> = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect();
|
|
let sx = axis_scale(w, nx, &xs);
|
|
let sy = axis_scale(h, ny, &ys);
|
|
let (Some(sx), Some(sy)) = (sx, sy) else {
|
|
fail(self, "has invalid path scale");
|
|
return;
|
|
};
|
|
if v.str("textAlignVertical").is_some_and(|x| x != "TOP") {
|
|
fail(self, "uses unsupported vertical placement");
|
|
return;
|
|
}
|
|
if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) {
|
|
fail(self, "contains multiple lines");
|
|
return;
|
|
}
|
|
let path = match crate::fig::text_path::from_network(&net, sx, sy, t, forward) {
|
|
Ok(path) => path,
|
|
Err(reason) => {
|
|
fail(self, reason);
|
|
return;
|
|
}
|
|
};
|
|
let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE");
|
|
let decorated = o.get("style").is_some_and(has_decoration)
|
|
|| o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration);
|
|
if decorated {
|
|
self.warn("text path underline or strikethrough decoration is not rendered");
|
|
}
|
|
o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip}));
|
|
}
|
|
}
|
|
|
|
/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST
|
|
/// `characterStyleOverrides` + `styleOverrideTable`; `scale`: the factor the lengths of `td` take
|
|
/// (a scaled instance's layer); `size`: the base font size.
|
|
pub(super) fn char_styles(o: &mut Obj, td: &Kv, scale: f64, size: Option<f64>, warn: &mut dyn FnMut(String)) {
|
|
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
|
|
if ids.iter().all(|i| *i == 0) {
|
|
return;
|
|
}
|
|
let mut table = Obj::new();
|
|
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
|
|
let Some(sid) = num(e, "styleID") else { continue };
|
|
let mut s = text_style(&View::of_scaled(e, scale), size);
|
|
if let Some(fills) = e.get("fillPaints") {
|
|
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect()));
|
|
}
|
|
table.insert((sid as u64).to_string(), Value::Object(s));
|
|
}
|
|
o.insert("characterStyleOverrides".into(), json!(ids));
|
|
o.insert("styleOverrideTable".into(), Value::Object(table));
|
|
}
|
|
|
|
/// The REST TypeStyle fields a node (or text override entry) sets. `size`: the font size that
|
|
/// letter spacing in percent refers to when the entry doesn't set one.
|
|
fn text_style(v: &View, size: Option<f64>) -> Obj {
|
|
let mut s = Obj::new();
|
|
if let Some(f) = v.get("fontName") {
|
|
for (from, to) in [("family", "fontFamily"), ("style", "fontStyle"), ("postscript", "fontPostScriptName")] {
|
|
if let Some(x) = f.get(from).and_then(Kv::str).filter(|x| !x.is_empty()) {
|
|
// Compact and spaced style names ("SemiBold", "Semi Bold") are the same style.
|
|
s.insert(to.into(), if from == "style" { json!(crate::fig::style_name(x)) } else { json!(x) });
|
|
}
|
|
}
|
|
}
|
|
copy_len(&mut s, v, "fontSize", "fontSize");
|
|
copy_len(&mut s, v, "paragraphSpacing", "paragraphSpacing");
|
|
copy_len(&mut s, v, "paragraphIndent", "paragraphIndent");
|
|
insert_text_options(&mut s, v);
|
|
copy_str(&mut s, v, "textCase", "textCase");
|
|
copy_str(&mut s, v, "textDecoration", "textDecoration");
|
|
if let Some(skip_ink) = v.flag("textDecorationSkipInk") {
|
|
s.insert("textDecorationSkipInk".into(), json!(skip_ink));
|
|
}
|
|
copy_str(&mut s, v, "leadingTrim", "leadingTrim");
|
|
let font_size = v.len("fontSize").or(size).unwrap_or(12.0);
|
|
if let Some((ls, k)) = v.find("letterSpacing") {
|
|
let val = num(ls, "value").unwrap_or(0.0);
|
|
let px = match ls.get("units").and_then(Kv::str) {
|
|
Some("PIXELS") => scaled(val, k).unwrap_or(val),
|
|
Some("PERCENT") => {
|
|
// Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent.
|
|
s.insert("_letterSpacingPercent".into(), json!(val));
|
|
val / 100.0 * font_size
|
|
}
|
|
_ => val * font_size,
|
|
};
|
|
s.insert("letterSpacing".into(), json!(px));
|
|
}
|
|
if let Some((lh, k)) = v.find("lineHeight") {
|
|
let val = num(lh, "value").unwrap_or(100.0);
|
|
match lh.get("units").and_then(Kv::str) {
|
|
Some("PIXELS") => {
|
|
s.insert("lineHeightUnit".into(), json!("PIXELS"));
|
|
s.insert("lineHeightPx".into(), json!(scaled(val, k).unwrap_or(val)));
|
|
}
|
|
// shortcut: percent units count from the font's own line height (100 % is "auto"), which
|
|
// the document can only say for 100 %; other values read as % of the font size. Upgrade
|
|
// when line heights can be a share of the font's own.
|
|
Some("PERCENT") if (val - 100.0).abs() < 1e-6 => {
|
|
s.insert("lineHeightUnit".into(), json!("INTRINSIC_%"));
|
|
}
|
|
Some("PERCENT") => {
|
|
s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%"));
|
|
s.insert("lineHeightPercentFontSize".into(), json!(val));
|
|
}
|
|
_ => {
|
|
s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%"));
|
|
s.insert("lineHeightPercentFontSize".into(), json!(val * 100.0));
|
|
}
|
|
}
|
|
}
|
|
// An empty URL is kept: on an override entry it clears the text's own link.
|
|
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) {
|
|
s.insert("hyperlink".into(), json!({"type": "URL", "url": u}));
|
|
}
|
|
s
|
|
}
|