mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
wrap_lines split an over-long word by re-measuring and copying the rest of the word for every piece it cut, and only checked its line limit once the word was done, so one long word in a narrow text box cost word length x pieces. Each new word also re-measured the whole line built so far. Line and piece widths are now running sums of glyph widths (the same sum text_width makes), so every character is measured once, and the line limit is checked while a word is being split. The lines produced are unchanged; a test compares them with the old wrapper.
286 lines
12 KiB
Rust
286 lines
12 KiB
Rust
use super::draw::{Block, Look, rgb_of};
|
|
use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet, Style, StyleTable, Underline, VAlign, Value};
|
|
|
|
const PAD_PX: f32 = 2.0;
|
|
|
|
/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph).
|
|
const MAX_WRAP_LINES: usize = 2000;
|
|
|
|
/// Word-wraps `text` to `width` points.
|
|
///
|
|
/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each
|
|
/// character is measured once: re-measuring the line for every word, or the rest of an
|
|
/// over-long word for every piece cut from it, made a long paragraph or word quadratic.
|
|
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
|
let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() };
|
|
let fits = |units: u64| units as f32 * size / 1000.0 <= width;
|
|
let space = units_of(" ");
|
|
let mut out = Vec::new();
|
|
for para in text.split('\n') {
|
|
let mut line = String::new();
|
|
let mut line_units: u64 = 0;
|
|
for word in para.split(' ') {
|
|
let word_units = units_of(word);
|
|
if line.is_empty() {
|
|
line.push_str(word);
|
|
line_units = word_units;
|
|
} else if fits(line_units + space + word_units) {
|
|
line.push(' ');
|
|
line.push_str(word);
|
|
line_units += space + word_units;
|
|
} else {
|
|
out.push(std::mem::take(&mut line));
|
|
line.push_str(word);
|
|
line_units = word_units;
|
|
}
|
|
// Break a single over-long word by characters: each piece is the longest that fits
|
|
// (at least one character).
|
|
if !fits(line_units) && line.chars().nth(1).is_some() {
|
|
let mut piece = String::new();
|
|
let mut units: u64 = 0;
|
|
for c in line.chars() {
|
|
let w = u64::from(font.char_width(c));
|
|
if !piece.is_empty() && !fits(units + w) {
|
|
out.push(std::mem::take(&mut piece));
|
|
units = 0;
|
|
if out.len() > MAX_WRAP_LINES {
|
|
return out;
|
|
}
|
|
}
|
|
piece.push(c);
|
|
units += w;
|
|
}
|
|
line = piece;
|
|
line_units = units;
|
|
}
|
|
if out.len() > MAX_WRAP_LINES {
|
|
return out;
|
|
}
|
|
}
|
|
out.push(line);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn font_for(style: &Style) -> Font {
|
|
Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic)
|
|
}
|
|
|
|
fn text_align(align: HAlign, value: &Value) -> HAlign {
|
|
match align {
|
|
HAlign::General => match value {
|
|
Value::Number(_) => HAlign::Right,
|
|
Value::Bool(_) | Value::Error(_) => HAlign::Center,
|
|
_ => HAlign::Left,
|
|
},
|
|
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
|
|
HAlign::Justify | HAlign::Fill => HAlign::Left,
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// Is a cell free for text to overflow into?
|
|
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
|
|
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
|
|
}
|
|
|
|
fn extend_text_bounds(sh: &Sheet, c: CellRef, merges: &[RangeRef], k: f32, need: f32, align: HAlign, bounds: (f32, f32)) -> (f32, f32) {
|
|
let (mut clip_l, mut clip_r) = bounds;
|
|
let grow = |dir: i64, mut remaining: f32| -> f32 {
|
|
let mut ext = 0.0;
|
|
let mut col = c.col as i64 + dir;
|
|
while remaining > 0.0 && col >= 0 && col < MAX_COLS as i64 {
|
|
let nc = CellRef::new(c.row, col as u32);
|
|
if !empty_for_overflow(sh, nc, merges) {
|
|
break;
|
|
}
|
|
let width = sh.col_width(nc.col) * k;
|
|
ext += width;
|
|
remaining -= width;
|
|
col += dir;
|
|
if ext > 20_000.0 {
|
|
break;
|
|
}
|
|
}
|
|
ext
|
|
};
|
|
match align {
|
|
HAlign::Right => clip_l -= grow(-1, need),
|
|
HAlign::Center => {
|
|
clip_l -= grow(-1, need / 2.0);
|
|
clip_r += grow(1, need / 2.0);
|
|
}
|
|
_ => clip_r += grow(1, need),
|
|
}
|
|
(clip_l, clip_r)
|
|
}
|
|
|
|
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
|
|
pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
|
|
let mut ext = 0.0;
|
|
let mut col = c.col + 1;
|
|
while col < MAX_COLS && ext < 20_000.0 {
|
|
let nc = CellRef::new(c.row, col);
|
|
if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross {
|
|
break;
|
|
}
|
|
ext += sh.col_width(col);
|
|
col += 1;
|
|
}
|
|
ext
|
|
}
|
|
|
|
pub(super) struct TextPlacement<'a> {
|
|
pub cell: CellRef,
|
|
pub look: &'a Look,
|
|
pub rect: (f32, f32, f32, f32),
|
|
pub merged: bool,
|
|
pub merges: &'a [RangeRef],
|
|
pub limits: (f32, f32),
|
|
}
|
|
|
|
pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) {
|
|
let TextPlacement { cell: c, look: l, rect, merged, merges, limits } = placement;
|
|
let (x, y, w, h) = rect;
|
|
let sh = b.sh;
|
|
let wb = b.wb;
|
|
let v = sh.value(c);
|
|
if v.is_empty() || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
|
|
return;
|
|
}
|
|
if !sh.show_zeros && v.as_f64() == Some(0.0) {
|
|
return;
|
|
}
|
|
let st = &l.style;
|
|
let mut size = st.font.size.clamp(1.0, 409.0) * b.scale;
|
|
let font = font_for(st);
|
|
let pad = PAD_PX * b.k;
|
|
let indent = st.align.indent as f32 * 9.0 * b.k;
|
|
let avail = (w - 2.0 * pad - indent).max(0.0);
|
|
let code = st.num_fmt.as_str();
|
|
let formatted = crate::display::format(&v, code, wb);
|
|
let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
|
|
if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) {
|
|
color = rgb_of(st.font.color, wb).unwrap_or(color);
|
|
}
|
|
let is_num = matches!(v, Value::Number(_));
|
|
let wrap = st.align.wrap;
|
|
let mut text = if sh.show_formulas {
|
|
crate::display::cell_text(wb, sh, c)
|
|
} else if code == "General"
|
|
&& let Value::Number(n) = v
|
|
{
|
|
let digit = gridcraft_pdf::text_width(font, size, "0").max(0.1);
|
|
let max_chars = (avail / digit).floor().max(1.0) as usize;
|
|
gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255)))
|
|
} else {
|
|
// The look's code, which a conditional format can override (#1163).
|
|
crate::display::value_text_as(wb, &v, code)
|
|
};
|
|
if !wrap {
|
|
text = text.replace(['\n', '\r'], " ");
|
|
}
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let mut tw = gridcraft_pdf::text_width(font, size, &text);
|
|
if st.align.shrink && !wrap && tw > avail && tw > 0.0 {
|
|
size = (size * avail / tw).max(1.0);
|
|
tw = gridcraft_pdf::text_width(font, size, &text);
|
|
}
|
|
if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas {
|
|
let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1);
|
|
text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255));
|
|
tw = gridcraft_pdf::text_width(font, size, &text);
|
|
}
|
|
let h_align = text_align(st.align.h, &v);
|
|
let rot = st.align.rotation;
|
|
let rotated = rot != 0 && rot != 255;
|
|
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
|
|
let lines: Vec<String> = match (wrap, rotated) {
|
|
(true, false) => wrap_lines(&text, font, size, avail.max(size)),
|
|
(true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)),
|
|
(true, true) => vec![text.lines().next().unwrap_or_default().to_string()],
|
|
(false, _) => vec![text],
|
|
};
|
|
let line_h = size * 1.2;
|
|
let n = lines.len().max(1) as f32;
|
|
let first_baseline = match st.align.v {
|
|
VAlign::Top => y + pad + size * 0.8,
|
|
VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33,
|
|
VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h,
|
|
};
|
|
// Overflow into empty neighbours (single-line text, not merged).
|
|
// Center Across Selection spans the empty Center Across cells to its right; the text stays within that run.
|
|
let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0;
|
|
let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 };
|
|
let (mut clip_l, mut clip_r) = (x, x + w + across);
|
|
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
|
|
let need = tw + 2.0 * pad + indent - w;
|
|
(clip_l, clip_r) = extend_text_bounds(sh, c, merges, b.k, need, h_align, (clip_l, clip_r));
|
|
}
|
|
let clip_l = clip_l.max(limits.0);
|
|
let clip_r = clip_r.min(limits.1);
|
|
pg.save();
|
|
pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h);
|
|
if rotated {
|
|
// Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell.
|
|
// Wrapped lines stack rightward for upward text and leftward for downward text, so
|
|
// downward text starts its first line far enough in for the last to end at the left pad.
|
|
let deg = (rot as f32).clamp(-90.0, 90.0);
|
|
let angle = -deg.to_radians();
|
|
let last = lines.len().saturating_sub(1) as f32;
|
|
for (i, t) in lines.iter().enumerate() {
|
|
let i = i as f32;
|
|
let (ox, oy) = if deg > 0.0 {
|
|
(x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad)
|
|
} else {
|
|
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
|
|
};
|
|
pg.text_rotated(Point::new(ox, oy), size, font, color, t, angle);
|
|
}
|
|
pg.restore();
|
|
return;
|
|
}
|
|
if rot == 255 {
|
|
// Vertical stacked text.
|
|
let chars: Vec<char> = lines.concat().chars().take(500).collect();
|
|
let mut yy = y + pad + size * 0.8;
|
|
for ch in chars {
|
|
let s = ch.to_string();
|
|
let cw = gridcraft_pdf::text_width(font, size, &s);
|
|
pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s);
|
|
yy += line_h;
|
|
if yy > y + h + line_h {
|
|
break;
|
|
}
|
|
}
|
|
pg.restore();
|
|
return;
|
|
}
|
|
for (i, line) in lines.iter().enumerate() {
|
|
let lw = gridcraft_pdf::text_width(font, size, line);
|
|
let lx = match h_align {
|
|
HAlign::Right => x + w - pad - lw - indent,
|
|
HAlign::Center => x + (w + across - lw) / 2.0,
|
|
_ => x + pad + indent,
|
|
};
|
|
let ly = first_baseline + i as f32 * line_h;
|
|
if ly - size > y + h || ly < y - line_h {
|
|
continue;
|
|
}
|
|
pg.text(lx, ly, size, font, color, line);
|
|
if st.font.underline != Underline::None {
|
|
let uw = (size * 0.06).max(0.2);
|
|
pg.stroke_line(lx, ly + size * 0.12, lx + lw, ly + size * 0.12, uw, color);
|
|
if matches!(st.font.underline, Underline::Double | Underline::DoubleAccounting) {
|
|
pg.stroke_line(lx, ly + size * 0.24, lx + lw, ly + size * 0.24, uw, color);
|
|
}
|
|
}
|
|
if st.font.strike {
|
|
pg.stroke_line(lx, ly - size * 0.3, lx + lw, ly - size * 0.3, (size * 0.06).max(0.2), color);
|
|
}
|
|
}
|
|
pg.restore();
|
|
}
|