text.fit: keep sizes that fit, respect the floor, skip one-word paragraphs

- Text that already fits keeps its exact size; shrunk sizes round to a
  tenth but never below minSize (per run, its share of it).
- A last line after a hard break is not an orphan, and the no-break space
  is only placed inside the last paragraph.
- docs: data.fill documents formats? and errors?.
This commit is contained in:
vetlucasmartins
2026-10-24 00:32:24 +01:00
parent cf4119dd0c
commit 601a9701a5
3 changed files with 67 additions and 17 deletions
+35 -14
View File
@@ -25,16 +25,28 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// The layout of `t` with every run's font size times `k`, at `width`: (line count, height,
/// words on the last line).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, usize) {
/// whether the last line is an orphan: one word, wrapped from the line above rather than a
/// paragraph of its own after a hard break, which no size can fix).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
let mut t = t.clone();
let n = t.char_count();
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
let l = figcraft_text::layout(&t, Some(width), None);
let last = l.lines.last().map_or(0, |line| {
t.characters.chars().skip(line.char_start).take(line.char_end.saturating_sub(line.char_start)).collect::<String>().split_whitespace().count()
});
(l.lines.len(), l.height, last)
let orphan = l.lines.len() > 1
&& l.lines.last().is_some_and(|line| {
let mut before = t.characters.chars().skip(line.char_start.saturating_sub(1));
let wrapped = line.char_start > 0 && before.next() != Some('\n');
wrapped
&& t.characters
.chars()
.skip(line.char_start)
.take(line.char_end.saturating_sub(line.char_start))
.collect::<String>()
.split_whitespace()
.count()
== 1
});
(l.lines.len(), l.height, orphan)
}
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
@@ -52,7 +64,7 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY });
let size = t.style.font_size.max(1.0);
let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size);
let fits = |m: (usize, f64, usize)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5;
let fits = |m: (usize, f64, bool)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5;
// The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel.
let (mut lo, mut hi) = (min / size, 1.0);
let k = if fits(measure(t, 1.0, width)) {
@@ -73,10 +85,11 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
}
lo
};
// Whole-tenth sizes read better in the panel.
let mut k = ((size * k * 10.0).floor() / 10.0) / size;
// Whole-tenth sizes read better in the panel, but never below the floor; text that fits
// keeps its exact size.
let mut k = if k < 1.0 { ((size * k * 10.0).floor() / 10.0).max(min) / size } else { 1.0 };
let mut m = measure(t, k, width);
let mut orphan = orphans && m.0 > 1 && m.2 == 1;
let mut orphan = orphans && m.2;
let mut nbsp = false;
if orphan {
// Up to 8% smaller (not below the floor) may pull a word down to the last line.
@@ -84,7 +97,7 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
while orphan && tries < 8 && size * k * 0.99 >= min {
k *= 0.99;
m = measure(t, k, width);
orphan = m.0 > 1 && m.2 == 1;
orphan = m.2;
tries += 1;
}
if orphan {
@@ -94,9 +107,17 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(id)?;
let NodeKind::Text(t) = &mut n.kind else { continue };
let count = t.char_count();
t.restyle(0, count, |st| st.font_size = ((st.font_size * k * 10.0).round() / 10.0).clamp(1.0, 5000.0));
if k < 1.0 {
// Each run rounds to a tenth, but not below its share of the floor.
t.restyle(0, count, |st| {
let floor = st.font_size * min / size;
st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0)
});
}
// Same character count, so style runs keep their ranges.
let joined = if nbsp && let Some(i) = t.characters.trim_end().rfind(' ') {
// Only inside the last paragraph: a space before a hard break joins nothing on the last line.
let para = t.characters.rfind('\n').map_or(0, |i| i + 1);
let joined = if nbsp && let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) {
t.characters.replace_range(i..i + 1, "\u{a0}");
Some(i)
} else {
@@ -109,7 +130,7 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
after = measure(t, 1.0, width);
}
// Reported from the final text (rounded sizes, the no-break space), which callers rely on.
out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.0 > 1 && after.2 == 1}));
out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.2}));
}
Ok(json!({"fitted": out}))
}
+27
View File
@@ -102,3 +102,30 @@ fn review_fits_is_reported_from_the_final_text() {
assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}");
}
}
#[test]
fn review_sizes_off_the_tenth_grid_keep_their_size_and_floor() {
let mut s = Session::new();
// Fits already: 14.25px stays 14.25px, not 14.2 or 14.3.
let t = headline(&mut s, "Curto", 600.0);
ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 14.25}}));
ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1}));
assert_eq!(size(&s, t), 14.25);
// Can't fit: it stops at the floor, not a tenth below it.
let long = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 200.0);
let r = ex(&mut s, "text.fit", json!({"ids": [long], "maxLines": 1, "minSize": 12.34}));
assert_eq!(r["fitted"][0]["fits"], false, "{r}");
assert!(size(&s, long) >= 12.34, "{}", size(&s, long));
}
#[test]
fn review_a_one_word_paragraph_is_not_an_orphan() {
let mut s = Session::new();
let t = headline(&mut s, "Two words\nOne", 600.0);
let r = ex(&mut s, "text.fit", json!({"ids": [t]}));
assert_eq!(r["fitted"][0]["orphan"], false, "{r}");
assert_eq!(size(&s, t), 64.0, "nothing to fix, so nothing shrinks");
let doc = s.doc().unwrap();
let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() };
assert_eq!(tx.characters, "Two words\nOne", "no no-break space in an earlier paragraph");
}
+5 -3
View File
@@ -332,12 +332,13 @@ tips; headless, it only sets the preference.
### Filling copies with data
`data.fill {id, rows, gap?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set
`data.fill {id, rows, gap?, formats?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set
of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a
string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy
(default "<frame> <n>"). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each
copy is exported there as `<name>.<format>` (png by default) after the edits land. Returns `{ids,
missing (keys no layer is named), files?}`.
missing (keys no layer is named), files?, errors?}`. `errors` lists the exports that failed as
`[{id, path, error}]`; the copies stay, and the files that were written are in `files`.
Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs
`text.fit` on those layers after the text is set.
@@ -351,7 +352,8 @@ position children keep their place).
`text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size)
the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines
and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by
shrinking up to 8% more, else by keeping the last two words together with a no-break space. Text never grows.
shrinking up to 8% more, else by keeping the last two words together with a no-break space. A one-word paragraph after a hard line break is not an orphan. Text never
grows, text that already fits keeps its exact size, and sizes round to a tenth without going below `minSize`.
Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under
1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines.