mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
A hyperlink longer than 200 characters came back as a different 200-character URL -- one that still looks like a working link -- and the import reported nothing. It applied to a text layer's base style and to a character style override alike. The destination went through `clip_str`, which exists so that names and labels from a file stay readable when they are quoted in a warning. Every other caller is a name, a description, a style id or a layer type; a hyperlink is data, and clipping it silently rewrote it. Two things say so: the FIG importer already keeps the URL whole, so the two importers disagreed, and `rest::props` bounds numbers and list lengths but no string beyond that label helper -- the text content itself is not clipped either. The reporter's integration test is included, with its assertions unchanged. Its helper is restructured to return what it found rather than unwrap: the crate denies `unwrap_used` and `panic`, and `clippy.toml` allows them only inside a `#[test]` function, so the original shape failed the gate before it ran. Its failure message now reports the input and output lengths.
260 lines
11 KiB
Rust
260 lines
11 KiB
Rust
//! TEXT nodes: characters, the base `style` (TypeStyle) and per-character overrides
|
|
//! (`characterStyleOverrides` + `styleOverrideTable`) → styled runs.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use figcraft_doc::{
|
|
DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase,
|
|
TextData, TextDecoration, TextStyle,
|
|
};
|
|
use serde_json::Value;
|
|
|
|
use super::Ctx;
|
|
use super::props::{flag, len, list, num, paints, text};
|
|
|
|
/// Most characters read from one text node.
|
|
const MAX_CHARS: usize = 1 << 20;
|
|
|
|
/// The text content of a TEXT node.
|
|
pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData {
|
|
let raw = text(v, "characters").unwrap_or("");
|
|
let characters: String = raw.chars().take(MAX_CHARS).collect();
|
|
if characters.len() < raw.len() {
|
|
cx.warn("very long text was cut to 1,048,576 characters");
|
|
}
|
|
let style_json = v.get("style").unwrap_or(&Value::Null);
|
|
let base = type_style(cx, style_json, &TextStyle::default());
|
|
let mut td = TextData { characters, style: base, ..TextData::default() };
|
|
paragraph(style_json, &mut td);
|
|
td.runs = runs(cx, v, &td);
|
|
td.normalize();
|
|
lists(v, &mut td);
|
|
td
|
|
}
|
|
|
|
/// Node-level text settings that live in the base TypeStyle.
|
|
fn paragraph(s: &Value, td: &mut TextData) {
|
|
td.align_h = match text(s, "textAlignHorizontal") {
|
|
Some("CENTER") => TextAlignH::Center,
|
|
Some("RIGHT") => TextAlignH::Right,
|
|
Some("JUSTIFIED") => TextAlignH::Justified,
|
|
_ => TextAlignH::Left,
|
|
};
|
|
td.align_v = match text(s, "textAlignVertical") {
|
|
Some("CENTER") => TextAlignV::Middle,
|
|
Some("BOTTOM") => TextAlignV::Bottom,
|
|
_ => TextAlignV::Top,
|
|
};
|
|
td.auto_resize = match text(s, "textAutoResize") {
|
|
Some("HEIGHT") => TextAutoResize::Height,
|
|
Some("WIDTH_AND_HEIGHT") => TextAutoResize::WidthAndHeight,
|
|
Some("TRUNCATE") => TextAutoResize::Truncate,
|
|
// Missing means a fixed box.
|
|
_ => TextAutoResize::None,
|
|
};
|
|
if text(s, "textTruncation") == Some("ENDING") {
|
|
td.auto_resize = TextAutoResize::Truncate;
|
|
}
|
|
td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
|
|
td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0);
|
|
td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0);
|
|
td.list_spacing = len(s, "listSpacing").unwrap_or(0.0).min(10_000.0);
|
|
td.hanging_punctuation = flag(s, "hangingPunctuation").unwrap_or(false);
|
|
td.hanging_list = flag(s, "hangingList").unwrap_or(false);
|
|
if text(s, "leadingTrim") == Some("CAP_HEIGHT") {
|
|
td.leading_trim = figcraft_doc::LeadingTrim::CapHeight;
|
|
}
|
|
if let Some(flags) = s.get("openTypeFlags").and_then(Value::as_object) {
|
|
for (tag, on) in flags.iter().take(64) {
|
|
if figcraft_doc::valid_feature_tag(&tag.to_ascii_lowercase()) {
|
|
td.features.insert(tag.to_ascii_lowercase(), on.as_f64().is_some_and(|x| x != 0.0) || on.as_bool() == Some(true));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `base` with the fields `s` sets (override table entries only carry what differs).
|
|
fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
|
|
let mut t = base.clone();
|
|
if let Some(f) = text(s, "fontFamily").filter(|f| !f.trim().is_empty()) {
|
|
t.font_family = super::props::clip_str(f).to_string();
|
|
}
|
|
if let Some(st) = text(s, "fontStyle").filter(|f| !f.trim().is_empty()) {
|
|
t.font_style = super::props::clip_str(st).to_string();
|
|
} else if s.get("fontWeight").is_some() || s.get("italic").is_some() {
|
|
let weight = num(s, "fontWeight").map_or_else(|| t.weight(), |w| w.clamp(1.0, 1000.0) as u16);
|
|
t.font_style = style_name(weight, flag(s, "italic").unwrap_or_else(|| t.italic()));
|
|
}
|
|
if let Some(size) = num(s, "fontSize").filter(|x| *x > 0.0) {
|
|
t.font_size = size.clamp(0.5, 10_000.0);
|
|
}
|
|
let px = len(s, "lineHeightPx");
|
|
let pct = len(s, "lineHeightPercentFontSize");
|
|
match text(s, "lineHeightUnit") {
|
|
Some("PIXELS") => t.line_height = px.map_or(t.line_height, LineHeight::Pixels),
|
|
Some("FONT_SIZE_%") => t.line_height = pct.map_or(t.line_height, LineHeight::Percent),
|
|
Some("INTRINSIC_%") => t.line_height = LineHeight::Auto,
|
|
_ => {
|
|
if let Some(p) = pct {
|
|
t.line_height = LineHeight::Percent(p);
|
|
} else if let Some(p) = px {
|
|
t.line_height = LineHeight::Pixels(p);
|
|
}
|
|
}
|
|
}
|
|
// Letter spacing is in pixels.
|
|
if let Some(ls) = num(s, "letterSpacing") {
|
|
t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) };
|
|
}
|
|
match text(s, "textCase") {
|
|
Some("ORIGINAL") => t.case = TextCase::Original,
|
|
Some("UPPER") => t.case = TextCase::Upper,
|
|
Some("LOWER") => t.case = TextCase::Lower,
|
|
Some("TITLE") => t.case = TextCase::Title,
|
|
Some("SMALL_CAPS" | "SMALL_CAPS_FORCED") => t.case = TextCase::SmallCaps,
|
|
_ => {}
|
|
}
|
|
match text(s, "textDecoration") {
|
|
Some("NONE") => t.decoration = TextDecoration::None,
|
|
Some("UNDERLINE") => t.decoration = TextDecoration::Underline,
|
|
Some("STRIKETHROUGH") => t.decoration = TextDecoration::Strikethrough,
|
|
_ => {}
|
|
}
|
|
decoration_look(s, &mut t);
|
|
if let Some(f) = paints(cx, s, "fills") {
|
|
t.fills = Some(f);
|
|
}
|
|
if let Some(h) = s.get("hyperlink") {
|
|
match (text(h, "type"), text(h, "url")) {
|
|
// The destination is data, not a label: `clip_str` is for names that go into warnings,
|
|
// and clipping a URL changed it into a different, working-looking one without a word
|
|
// (#459). The FIG importer already keeps it whole.
|
|
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()),
|
|
_ => {}
|
|
}
|
|
}
|
|
t
|
|
}
|
|
|
|
/// The style name for a numeric weight ("Semi Bold Italic").
|
|
fn style_name(weight: u16, italic: bool) -> String {
|
|
let w = match weight {
|
|
0..=149 => "Thin",
|
|
150..=249 => "Extra Light",
|
|
250..=349 => "Light",
|
|
350..=449 => "Regular",
|
|
450..=549 => "Medium",
|
|
550..=649 => "Semi Bold",
|
|
650..=749 => "Bold",
|
|
750..=849 => "Extra Bold",
|
|
_ => "Black",
|
|
};
|
|
match (w, italic) {
|
|
("Regular", true) => "Italic".into(),
|
|
(w, true) => format!("{w} Italic"),
|
|
(w, false) => w.into(),
|
|
}
|
|
}
|
|
|
|
/// Styled runs from the per-character override ids (trailing characters without an id use the
|
|
/// base style). The ids count UTF-16 code units, like the JavaScript strings the file comes from:
|
|
/// a character outside the Basic Multilingual Plane takes two.
|
|
fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
|
|
let ids: Vec<u64> = v
|
|
.get("characterStyleOverrides")
|
|
.and_then(Value::as_array)
|
|
.map(|a| a.iter().take(MAX_CHARS * 2).map(|x| x.as_u64().unwrap_or(0)).collect())
|
|
.unwrap_or_default();
|
|
let Some(table) = v.get("styleOverrideTable").and_then(Value::as_object) else { return Vec::new() };
|
|
if ids.iter().all(|i| *i == 0) || table.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let mut per_char: Vec<u64> = Vec::with_capacity(td.char_count());
|
|
let mut unit = 0usize;
|
|
for ch in td.characters.chars() {
|
|
per_char.push(ids.get(unit).copied().unwrap_or(0));
|
|
unit += ch.len_utf16();
|
|
}
|
|
let mut styles: BTreeMap<u64, TextStyle> = BTreeMap::new();
|
|
let mut out: Vec<StyleRun> = Vec::new();
|
|
let mut i = 0;
|
|
while i < per_char.len() {
|
|
let Some(&id) = per_char.get(i) else { break };
|
|
let mut j = i + 1;
|
|
while per_char.get(j) == Some(&id) {
|
|
j += 1;
|
|
}
|
|
if id != 0 {
|
|
let style = match styles.get(&id) {
|
|
Some(s) => Some(s.clone()),
|
|
None => match table.get(&id.to_string()) {
|
|
Some(o) => {
|
|
let s = type_style(cx, o, &td.style);
|
|
styles.insert(id, s.clone());
|
|
Some(s)
|
|
}
|
|
None => {
|
|
cx.warn("text style overrides refer to missing table entries");
|
|
None
|
|
}
|
|
},
|
|
};
|
|
if let Some(style) = style.filter(|s| *s != td.style) {
|
|
out.push(StyleRun { start: i, end: j, style });
|
|
}
|
|
}
|
|
i = j;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`.
|
|
fn lists(v: &Value, td: &mut TextData) {
|
|
let types = list(v, "lineTypes");
|
|
let indents = list(v, "lineIndentations");
|
|
td.paragraphs = types
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, t)| ParaList {
|
|
list: match t.as_str() {
|
|
Some("UNORDERED") => ListStyle::Bulleted,
|
|
Some("ORDERED") => ListStyle::Numbered,
|
|
_ => ListStyle::None,
|
|
},
|
|
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
|
|
})
|
|
.collect();
|
|
// Clamp the levels and fold one style for all back into the layer's list style.
|
|
td.set_para_lists(0, td.char_count(), |_| {});
|
|
}
|
|
|
|
/// The decoration's style, thickness, offset, skip ink and colour (`{value, unit}` lengths with
|
|
/// `AUTO`, `PIXELS` or `PERCENT`; a colour as a solid paint, or `AUTO`).
|
|
fn decoration_look(s: &Value, t: &mut TextStyle) {
|
|
let length = |k: &str| {
|
|
let m = s.get(k)?;
|
|
let v = num(m, "value").filter(|x| x.abs() <= 10_000.0);
|
|
match text(m, "unit") {
|
|
Some("PIXELS") => v.map(DecorationLength::Pixels),
|
|
Some("PERCENT") => v.map(DecorationLength::Percent),
|
|
Some("AUTO") => Some(DecorationLength::Auto),
|
|
_ => None,
|
|
}
|
|
};
|
|
let style = match text(s, "textDecorationStyle") {
|
|
Some("DOTTED") => Some(DecorationStyle::Dotted),
|
|
Some("WAVY") => Some(DecorationStyle::Wavy),
|
|
Some("SOLID") => Some(DecorationStyle::Solid),
|
|
_ => None,
|
|
};
|
|
let (thickness, offset, skip) = (length("textDecorationThickness"), length("textDecorationOffset"), flag(s, "textDecorationSkipInk"));
|
|
let color = s.get("textDecorationColor").map(|c| c.get("value").and_then(|p| p.get("color")).and_then(super::props::color));
|
|
t.set_look(|l| {
|
|
l.style = style.unwrap_or(l.style);
|
|
l.thickness = thickness.unwrap_or(l.thickness);
|
|
l.offset = offset.unwrap_or(l.offset);
|
|
l.skip_ink = skip.unwrap_or(l.skip_ink);
|
|
l.color = color.unwrap_or(l.color);
|
|
});
|
|
}
|