Files
figcraft/crates/format/src/fig/text_wrap_tests.rs
T
BFlat 07681afebe M6: .fig import keeps the file's saved line breaks while the text is unedited (#1236) (#1519)
* M6: .fig import keeps the file's saved line breaks while the text and its box are unedited (#1236)

The line breaker already keeps trailing spaces and the last glyph's letter spacing out of the fit test and doesn't round box widths; imported text wrapped a word early or mid-word because local fonts measure slightly differently from the file's. The import keeps the saved line starts (derivedTextData) on the text with the box width and a stable key of its layout content; layout follows them while both match and no line overflows its box by more than 3% (+1 px).

* M6: local check that imported text wraps at the line breaks its file saved (#1236)

Runs only when FIGCRAFT_FIG_SAMPLES names a folder of .fig files: each fixed-width text with saved breaks wraps there once imported, or falls back by the overflow slack; prints the counts.
2026-10-29 17:04:29 +07:00

103 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The line breaks a `.fig` file saved with its text (`derivedTextData`) come across as the text's
//! saved breaks, on messages built in code.
use figcraft_doc::{Document, TextData};
use super::kiwi::Kv;
type Fields = Vec<(&'static str, Kv<'static>)>;
fn object(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn guid(id: u64) -> Kv<'static> {
object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, ty: &'static str, name: &str, parent: u64, mut more: Fields) -> Kv<'static> {
let mut fields = vec![
("guid", guid(id)),
("type", Kv::Enum(ty)),
("name", Kv::Str(name.into())),
("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))])),
];
fields.append(&mut more);
object(fields)
}
/// The saved layout: one baseline per line, starting at these UTF-16 offsets.
fn derived(firsts: &[u64]) -> Kv<'static> {
let lines = firsts.iter().map(|f| object(vec![("firstCharacter", Kv::Uint(*f))])).collect();
object(vec![("baselines", Kv::Array(lines))])
}
/// A 100 × 40 text layer named `name` with `chars`, sizing `resize` and the saved layout `firsts`.
fn label(id: u64, name: &str, parent: u64, chars: &str, resize: &'static str, firsts: &[u64]) -> Kv<'static> {
node(
id,
"TEXT",
name,
parent,
vec![
("size", object(vec![("x", Kv::Float(100.0)), ("y", Kv::Float(40.0))])),
("fontSize", Kv::Float(14.0)),
("textAutoResize", Kv::Enum(resize)),
("textData", object(vec![("characters", Kv::Str(chars.into()))])),
("derivedTextData", derived(firsts)),
],
)
}
fn import(nodes: Vec<Kv<'static>>) -> Document {
let page = vec![
object(vec![("guid", guid(1)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Document".into()))]),
node(2, "CANVAS", "Page", 1, vec![]),
];
let message = object(vec![("nodeChanges", Kv::Array(page.into_iter().chain(nodes).collect()))]);
let value = super::map::to_rest(&message, "Synthetic wrap", crate::rest::MAX_NODES).0;
crate::rest::import_fig(&value, &|_| None).unwrap().0
}
fn texts<'d>(doc: &'d Document, name: &str) -> Vec<&'d TextData> {
doc.nodes().filter(|n| n.name == name).filter_map(|n| n.kind.text()).collect()
}
#[test]
fn saved_line_starts_come_across_as_chars_without_hard_breaks() {
// UTF-16: the emoji takes two units, so "c" is unit 6 but char 5; "e" follows a hard break.
let doc = import(vec![
label(10, "Wrapped", 2, "😀 ab cd\nef", "HEIGHT", &[0, 6, 9]),
label(11, "Unwrapped", 2, "Followers", "NONE", &[0]),
label(12, "Auto width", 2, "ab cd", "WIDTH_AND_HEIGHT", &[0, 3]),
label(13, "Inside a pair", 2, "😀 ab", "HEIGHT", &[0, 1]),
label(14, "Out of order", 2, "ab cd ef", "HEIGHT", &[0, 6, 3]),
label(15, "Not from the start", 2, "ab cd", "HEIGHT", &[3]),
]);
let wrapped = texts(&doc, "Wrapped")[0];
assert_eq!(wrapped.saved_breaks_for(100.0), Some(&[5][..]));
assert_eq!(texts(&doc, "Unwrapped")[0].saved_breaks_for(100.0), Some(&[][..]), "one line: no wrapped start");
for name in ["Auto width", "Inside a pair", "Out of order", "Not from the start"] {
assert_eq!(texts(&doc, name)[0].saved_breaks, None, "{name}");
}
}
#[test]
fn a_saved_layout_for_other_text_is_left_out() {
let row = object(vec![
("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])),
("textData", object(vec![("characters", Kv::Str("Something much longer".into()))])),
]);
let doc = import(vec![
node(20, "SYMBOL", "Main", 2, vec![]),
label(21, "Label", 20, "Hi there", "HEIGHT", &[0, 3]),
node(30, "INSTANCE", "Inst", 2, vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![row]))]))]),
]);
let labels = texts(&doc, "Label");
let main = labels.iter().find(|t| t.characters == "Hi there").unwrap();
assert_eq!(main.saved_breaks_for(100.0), Some(&[3][..]));
// The instance's text comes from its override, the saved layout only from the main.
let inst = labels.iter().find(|t| t.characters == "Something much longer").unwrap();
assert_eq!(inst.saved_breaks, None);
}