mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 05:37:00 -06:00
264 lines
10 KiB
Rust
264 lines
10 KiB
Rust
use super::draw::{Block, Look, rgb_of};
|
||
use super::*;
|
||
|
||
const PAD_PX: f32 = 2.0;
|
||
|
||
/// Word-wraps `text` to `width` points.
|
||
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
for para in text.split('\n') {
|
||
let mut line = String::new();
|
||
for word in para.split(' ') {
|
||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
|
||
line = cand;
|
||
} else {
|
||
out.push(std::mem::take(&mut line));
|
||
line = word.to_string();
|
||
}
|
||
// Break a single over-long word by characters. The cut that fits is found by binary
|
||
// search (each try measures one candidate prefix), so an over-long word costs
|
||
// O(n log n) measurements instead of re-measuring the whole cut per character for
|
||
// every emitted line (`a` × 100k pasted text would be cubic worst case).
|
||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||
let chars: Vec<char> = line.chars().collect();
|
||
let (mut lo, mut hi) = (1usize, chars.len());
|
||
while lo < hi {
|
||
let mid = lo + (hi - lo).div_ceil(2);
|
||
let cut: String = chars[..mid].iter().collect();
|
||
if gridcraft_pdf::text_width(font, size, &cut) <= width {
|
||
lo = mid;
|
||
} else {
|
||
hi = mid - 1;
|
||
}
|
||
}
|
||
if lo == chars.len() {
|
||
break;
|
||
}
|
||
out.push(chars[..lo].iter().collect());
|
||
line = chars[lo..].iter().collect();
|
||
}
|
||
if out.len() > 2000 {
|
||
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)
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
|
||
/// 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) fn draw_text(
|
||
pg: &mut Page,
|
||
b: &Block,
|
||
c: CellRef,
|
||
l: &Look,
|
||
rect: (f32, f32, f32, f32),
|
||
merged: bool,
|
||
merges: &[RangeRef],
|
||
limits: (f32, f32),
|
||
) {
|
||
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 = match st.align.h {
|
||
HAlign::General => {
|
||
if is_num {
|
||
HAlign::Right
|
||
} else if matches!(v, Value::Bool(_) | Value::Error(_)) {
|
||
HAlign::Center
|
||
} else {
|
||
HAlign::Left
|
||
}
|
||
}
|
||
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
|
||
HAlign::Justify | HAlign::Fill => HAlign::Left,
|
||
other => other,
|
||
};
|
||
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;
|
||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||
let mut ext = 0.0;
|
||
let mut col = c.col as i64 + dir;
|
||
while need > 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 cw = sh.col_width(nc.col) * b.k;
|
||
ext += cw;
|
||
need -= cw;
|
||
col += dir;
|
||
if ext > 20_000.0 {
|
||
break;
|
||
}
|
||
}
|
||
ext
|
||
};
|
||
match h_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),
|
||
}
|
||
}
|
||
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(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();
|
||
}
|