mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
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:
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user