From 601a9701a535cf67e2abc9eab013598bf9989a44 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:32:24 +0100 Subject: [PATCH] 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?. --- crates/engine/src/textfit.rs | 49 +++++++++++++++++++++--------- crates/engine/src/textfit_tests.rs | 27 ++++++++++++++++ docs/mcp.md | 8 +++-- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index f0cf3bba..e4e84739 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -25,16 +25,28 @@ pub fn specs() -> Vec { } /// 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::().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::() + .split_whitespace() + .count() + == 1 + }); + (l.lines.len(), l.height, orphan) } fn fit(s: &mut Session, p: &Value) -> Result { @@ -52,7 +64,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { 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 { } 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 { 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 { 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 { 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})) } diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index 8d0a2ee5..8ee0f11b 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -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"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5af124f9..4bcfac09 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 " "). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each copy is exported there as `.` (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.