Files
BFlatandClaude Opus 5.5 e718518a08 M6: import REST API file JSON: pages, layers, paints, text runs, vectors, auto layout, components (#13)
- 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>
2026-10-10 03:36:30 +07:00

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
}
}