mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
- New `figcraft_format::rest`: imports the public REST API file JSON (`GET /v1/files/:key`, and the nodes endpoint's response) using the published reference only. Pages (name, background, flow starting points), the layer tree, names, visibility, lock, opacity, blend mode, masks. - Geometry from `relativeTransform` (children of groups/booleans checked against `absoluteBoundingBox` to settle their space), else absolute bounds + rotation; sizes, corner radii (uniform and per corner), smoothing, clip content, constraints, export settings, layout grids. - Paints: solid, linear/radial/angular/diamond gradients (handles map 1:1), image fills (fill, fit, tile, stretch, rotation, filters) with bytes from a resolver or a grey placeholder keyed by the imageRef; paint opacity, visibility and blend. Strokes: weight, per-side weights, align, caps, join, dashes, miter angle. Effects: drop/inner shadow (offset, radius, spread, show behind), layer and background blur. - Vectors from `fillGeometry` (stroke-only vectors use the `strokeGeometry` outline), ellipse arc data, stars/polygons, boolean operations. Text: characters, type style (family, style or weight + italic, size, line height px/%/auto, letter spacing, case, decoration, alignment, auto resize, truncation, paragraph spacing/indent, OpenType flags, links), per-character overrides → styled runs (UTF-16 aware), list type. - Auto layout: direction incl. grid, alignment, padding, gaps, wrap, hug/fixed/fill sizing (legacy and per-axis fields), absolute positioning, min/max, grid cells. - Components and sets with descriptions and property definitions (names lose their `#id` suffix); instances of components in the file become instances with their property values and listed overrides; outside components and component cycles stay frames. Shared styles are created from the layers that use them, named from the metadata. - Unknown types and fields are skipped with a counted warning list; sizes, depth (256), layer count (200,000), path length and list lengths are capped; nothing panics on junk. - Engine: `file.open` / `file.import` / CLI detect the JSON by content, link imported instances to their mains (sources and overrides), read image fills from an `images` folder next to the file, never save over the JSON, and return `warnings`. `file.import` now remaps ids across all imported pages at once (instances keep their main and layer sources) and brings styles and page backgrounds along. `figcraft-cli info` prints load notes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
8.2 KiB
Rust
211 lines
8.2 KiB
Rust
//! TEXT nodes: characters, the base `style` (TypeStyle) and per-character overrides
|
|
//! (`characterStyleOverrides` + `styleOverrideTable`) → styled runs.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use figcraft_doc::{
|
|
LetterSpacing, LineHeight, ListStyle, 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();
|
|
td.list = list_style(v);
|
|
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);
|
|
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,
|
|
_ => {}
|
|
}
|
|
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")) {
|
|
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(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
|
|
}
|
|
|
|
/// A list style for the whole node from `lineTypes` (bullets win over numbers).
|
|
fn list_style(v: &Value) -> ListStyle {
|
|
let types = list(v, "lineTypes");
|
|
if types.iter().any(|t| t.as_str() == Some("UNORDERED")) {
|
|
ListStyle::Bulleted
|
|
} else if types.iter().any(|t| t.as_str() == Some("ORDERED")) {
|
|
ListStyle::Numbered
|
|
} else {
|
|
ListStyle::None
|
|
}
|
|
}
|