refactor(engine): move cmd/print.rs items to cmd/print/

This commit is contained in:
aiongg
2026-10-26 20:43:39 +00:00
parent 0069d91e90
commit 73c52d49b7
6 changed files with 1552 additions and 1500 deletions
File diff suppressed because it is too large Load Diff
+467
View File
@@ -0,0 +1,467 @@
use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::draw_text;
use super::*;
// ---------------------------------------------------------------- drawing
pub(super) fn rgb_of(c: Color, wb: &Workbook) -> Option<Rgb> {
c.resolve(&wb.theme)
}
fn blend(fg: [u8; 4]) -> Rgb {
let a = fg[3] as u16;
let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8;
[mix(fg[0]), mix(fg[1]), mix(fg[2])]
}
/// The effective look of a cell: own style, table style, conditional formats.
pub(super) struct Look {
pub(super) style: Style,
fill: Option<Rgb>,
pub(super) cf: Option<crate::cf::CfLook>,
}
fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option<crate::cf::CfCache>) -> Look {
let mut style = match sh.table_at(c) {
Some(t) => crate::tables::table_cell_style(wb, si, t, c),
None => wb.styles.get(sh.style_id(c)).clone(),
};
let cfl = cf.as_mut().and_then(|cache| cache.look(wb, si, c));
if let Some(st) = cfl.as_ref().and_then(|l| l.style.as_ref()) {
if st.fill.pattern != PatternType::None {
style.fill = st.fill;
}
if st.font.color != Color::Auto {
style.font.color = st.font.color;
}
style.font.bold |= st.font.bold;
style.font.italic |= st.font.italic;
style.font.strike |= st.font.strike;
if st.font.underline != Underline::None {
style.font.underline = st.font.underline;
}
// In a differential style General means "not set" (#1163).
if st.num_fmt.as_str() != "General" {
style.num_fmt = st.num_fmt.clone();
}
for (dst, src) in [
(&mut style.borders.left, st.borders.left),
(&mut style.borders.right, st.borders.right),
(&mut style.borders.top, st.borders.top),
(&mut style.borders.bottom, st.borders.bottom),
] {
if !src.is_none() {
*dst = src;
}
}
}
let mut fill = match style.fill.pattern {
PatternType::None => None,
PatternType::Solid => rgb_of(style.fill.fg, wb),
_ => {
let fg = rgb_of(style.fill.fg, wb).unwrap_or([0, 0, 0]);
let bg = rgb_of(style.fill.bg, wb).unwrap_or([255, 255, 255]);
Some([0, 1, 2].map(|i| ((fg[i] as u16 + bg[i] as u16 * 3) / 4) as u8))
}
};
if let Some(rgb) = cfl.as_ref().and_then(|l| l.scale_fill) {
fill = Some(rgb);
}
Look { style, fill, cf: cfl }
}
/// PDF measurement for chart layout.
pub(super) struct PdfMeasure;
impl gridcraft_chart::Measure for PdfMeasure {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
gridcraft_pdf::text_width(if bold { Font::HelveticaBold } else { Font::Helvetica }, size, text)
}
}
/// Maps chart-space coordinates to the page.
#[derive(Clone, Copy)]
pub(super) struct Xf {
pub(super) ox: f32,
pub(super) oy: f32,
pub(super) k: f32,
}
impl Xf {
fn p(&self, x: f32, y: f32) -> (f32, f32) {
(self.ox + x * self.k, self.oy + y * self.k)
}
fn pts(&self, pts: &[[f32; 2]]) -> Vec<[f32; 2]> {
pts.iter().map(|q| [self.ox + q[0] * self.k, self.oy + q[1] * self.k]).collect()
}
}
/// Draws chart primitives onto a page.
pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf) {
use gridcraft_chart::{HAlign as H, Prim, VAlign as V};
let k = xf.k;
for p in prims {
match p {
Prim::Rect { x, y, w, h, fill, stroke, .. } => {
let (px, py) = xf.p(*x, *y);
if let Some(f) = fill {
pg.fill_rect(px, py, w * k, h * k, blend(*f));
}
if let Some((c, lw)) = stroke {
pg.rect_stroke(px, py, w * k, h * k, (lw * k).max(0.1), blend(*c));
}
}
Prim::Line { pts, color, width, dash } => {
let d = if *dash { vec![3.0 * k, 2.0 * k] } else { vec![] };
pg.polyline(&xf.pts(pts), (width * k).max(0.1), blend(*color), &d);
}
Prim::Polygon { pts, fill, stroke } => {
pg.polygon(&xf.pts(pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1))));
}
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
let span = (a1 - a0).abs().min(std::f32::consts::TAU);
let n = ((span / (std::f32::consts::PI / 36.0)).ceil() as usize).clamp(2, 400);
let at = |a: f32, r: f32| [cx + r * a.sin(), cy - r * a.cos()];
let mut pts: Vec<[f32; 2]> = (0..=n).map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_outer)).collect();
if *r_inner > 0.0 {
pts.extend((0..=n).rev().map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_inner)));
} else {
pts.push([*cx, *cy]);
}
pg.polygon(&xf.pts(&pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1))));
}
Prim::Circle { cx, cy, r, fill, stroke } => {
let (px, py) = xf.p(*cx, *cy);
pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1))));
}
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
let font = if *bold { Font::HelveticaBold } else { Font::Helvetica };
let size = size * k;
let w = gridcraft_pdf::text_width(font, size, text);
let dx = match align {
H::Left => 0.0,
H::Center => -w / 2.0,
H::Right => -w,
};
let dy = match valign {
V::Top => size * 0.78,
V::Middle => size * 0.35,
V::Bottom => -size * 0.21,
};
let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) };
let (ax, ay) = xf.p(*x, *y);
pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation);
}
}
}
}
/// Everything needed to draw one page block (a run of rows × a run of columns).
pub(super) struct Block<'a> {
pub(super) wb: &'a Workbook,
si: usize,
pub(super) sh: &'a Sheet,
rows: (u32, u32),
cols: (u32, u32),
/// Page x of each column's left edge, plus the right edge of the last.
pub(super) xs: Vec<f32>,
pub(super) ys: Vec<f32>,
pub(super) k: f32,
pub(super) scale: f32,
}
impl Block<'_> {
fn x(&self, col: u32) -> f32 {
if col >= self.cols.0 && col <= self.cols.1 + 1 {
return self.xs.get((col - self.cols.0) as usize).copied().unwrap_or(0.0);
}
let x0 = self.xs.first().copied().unwrap_or(0.0);
x0 + ((self.sh.col_left(col) - self.sh.col_left(self.cols.0)) as f32) * self.k
}
fn y(&self, row: u32) -> f32 {
if row >= self.rows.0 && row <= self.rows.1 + 1 {
return self.ys.get((row - self.rows.0) as usize).copied().unwrap_or(0.0);
}
let y0 = self.ys.first().copied().unwrap_or(0.0);
y0 + ((self.sh.row_top(row) - self.sh.row_top(self.rows.0)) as f32) * self.k
}
pub(super) fn rect(&self, r: RangeRef) -> (f32, f32, f32, f32) {
let (x0, y0) = (self.x(r.start.col), self.y(r.start.row));
(x0, y0, self.x(r.end.col.saturating_add(1)) - x0, self.y(r.end.row.saturating_add(1)) - y0)
}
pub(super) fn range(&self) -> RangeRef {
RangeRef::new(CellRef::new(self.rows.0, self.cols.0), CellRef::new(self.rows.1, self.cols.1))
}
}
fn border_dash(style: BorderStyle, k: f32) -> Vec<f32> {
let u = k.max(0.2);
match style {
BorderStyle::Dashed | BorderStyle::MediumDashed => vec![4.0 * u, 2.0 * u],
BorderStyle::Dotted => vec![1.0 * u, 1.0 * u],
BorderStyle::Hair => vec![0.75 * u, 0.75 * u],
BorderStyle::DashDot | BorderStyle::MediumDashDot | BorderStyle::SlantDashDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u],
BorderStyle::DashDotDot | BorderStyle::MediumDashDotDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u, 1.0 * u, 1.5 * u],
_ => vec![],
}
}
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) {
if b.is_none() {
return;
}
let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]);
if b.style == BorderStyle::Double {
let off = 1.0 * k;
let lw = (0.75 * k).max(0.2);
let (dx, dy) = if (y0 - y1).abs() < 0.001 { (0.0, off) } else { (off, 0.0) };
pg.stroke_line(x0 - dx, y0 - dy, x1 - dx, y1 - dy, lw, color);
pg.stroke_line(x0 + dx, y0 + dy, x1 + dx, y1 + dy, lw, color);
return;
}
let lw = (b.style.width() * k).clamp(0.25, 6.0);
pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k));
}
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>) {
let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0));
let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by);
if bw <= 0.0 || bh <= 0.0 {
return;
}
let block = b.range();
let sh = b.sh;
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
let covered = |c: CellRef| merges.iter().any(|m| m.contains(c));
// Cells worth visiting.
let cells: Vec<CellRef> = if block.count() <= DENSE_BLOCK {
block.iter().filter(|c| !covered(*c)).collect()
} else {
let mut set: Vec<CellRef> = sh.cells.iter_range(block).map(|(c, _)| c).collect();
set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c)));
set.sort_unstable();
set.dedup();
set.retain(|c| !covered(*c));
set
};
pg.save();
pg.clip_rect(bx, by, bw, bh);
if gridlines {
let grey = [0xB0, 0xB0, 0xB0];
let lw = (0.35 * b.scale).clamp(0.1, 0.5);
for x in &b.xs {
pg.stroke_line(*x, by, *x, by + bh, lw, grey);
}
for y in &b.ys {
pg.stroke_line(bx, *y, bx + bw, *y, lw, grey);
}
}
// Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing.
let skip = |c: &CellRef| {
sh.cond_formats.is_empty()
&& sh.value_ref(*c).is_none_or(|v| v.is_empty())
&& sh.style_id(*c) == StyleId::DEFAULT
&& (sh.tables.is_empty() || sh.table_at(*c).is_none())
};
let looks: Vec<(CellRef, Look)> = cells
.iter()
.filter(|c| !skip(c))
.chain(merges.iter().map(|m| &m.start))
.map(|c| (*c, cell_look(b.wb, b.si, sh, *c, cf)))
.filter(|(c, l)| {
l.fill.is_some()
|| l.cf.is_some()
|| merges.iter().any(|m| m.start == *c)
|| !sh.value(*c).is_empty()
|| l.style.borders != Borders::default()
})
.collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
match merges.iter().find(|m| m.start == c) {
Some(m) => {
let (x, y, w, h) = b.rect(*m);
(x, y, w, h, true)
}
None => {
let (x, y, w, h) = b.rect(RangeRef::cell(c));
(x, y, w, h, false)
}
}
};
// Fills (merged blocks hide the gridlines inside them).
for (c, l) in &looks {
let (x, y, w, h, merged) = rect_of(*c);
if w <= 0.0 || h <= 0.0 {
continue;
}
if let Some(f) = l.fill {
pg.fill_rect(x, y, w, h, f);
} else if merged && gridlines {
pg.fill_rect(x, y, w, h, [255, 255, 255]);
}
if merged && gridlines {
pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]);
}
if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) {
let pad = 1.5 * b.k;
let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0);
pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb);
}
}
// Borders.
for (c, l) in &looks {
let (x, y, w, h, merged) = rect_of(*c);
if w <= 0.0 || h <= 0.0 {
continue;
}
let bd = &l.style.borders;
let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) {
Some(m) => (
b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right,
b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom,
),
None => (bd.right, bd.bottom),
};
draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k);
draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k);
draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k);
draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k);
}
// Text.
for (c, l) in &looks {
let (x, y, w, h, merged) = rect_of(*c);
if w <= 0.0 || h <= 0.0 {
continue;
}
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else {
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
}
}
pg.restore();
}
pub(super) struct PendingPdfLink {
pub(super) page: usize,
pub(super) sheet: usize,
pub(super) rect: (f32, f32, f32, f32),
pub(super) target: String,
}
pub(super) struct PrintedBlock {
pub(super) page: usize,
pub(super) sheet: usize,
pub(super) rows: (u32, u32),
pub(super) cols: (u32, u32),
pub(super) x: f32,
pub(super) y: f32,
pub(super) k: f32,
}
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
/// pages and receives one annotation on each of them.
pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
let range = b.range();
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
if !range.contains(*cell) {
continue;
}
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
let (x, y, w, h) = b.rect(cell_range);
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
continue;
}
let rect = (x0, y0, x1 - x0, y1 - y0);
match crate::links::classify(&link.target) {
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
crate::links::LinkKind::Internal(target) if !link.external => {
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
}
_ => {}
}
}
}
pub(super) fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
let (sheet_name, range) = split_sheet(target);
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
let sh = wb.sheet(sheet)?;
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
Some((block.page, x, y))
}
pub(super) fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) {
let fill = [0xF2, 0xF2, 0xF2];
let line = [0xA0, 0xA0, 0xA0];
let fsize = 9.0 * b.scale.max(0.1);
let lw = (0.35 * b.scale).clamp(0.1, 0.5);
if col_heads {
let y = at.1;
for col in b.cols.0..=b.cols.1 {
let (x0, x1) = (b.x(col), b.x(col + 1));
if x1 - x0 <= 0.0 {
continue;
}
pg.fill_rect(x0, y, x1 - x0, size.1, fill);
pg.rect_stroke(x0, y, x1 - x0, size.1, lw, line);
let t = gridcraft_core::col_to_letters(col);
let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t);
pg.text(x0 + (x1 - x0 - tw) / 2.0, y + size.1 / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t);
}
}
if row_heads {
let x = at.0;
for row in b.rows.0..=b.rows.1 {
let (y0, y1) = (b.y(row), b.y(row + 1));
if y1 - y0 <= 0.0 {
continue;
}
pg.fill_rect(x, y0, size.0, y1 - y0, fill);
pg.rect_stroke(x, y0, size.0, y1 - y0, lw, line);
let t = (row + 1).to_string();
let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t);
pg.text(x + (size.0 - tw) / 2.0, y0 + (y1 - y0) / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t);
}
}
}
/// Builds a block for rows × cols positioned at (x, y) on the page.
pub(super) fn make_block<'a>(
wb: &'a Workbook,
si: usize,
sh: &'a Sheet,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
lay: &SheetLayout,
) -> Block<'a> {
let k = lay.k();
let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize);
let mut acc = x;
xs.push(acc);
for c in cols.0..=cols.1 {
acc += sh.col_width(c) * k;
xs.push(acc);
}
let mut ys = Vec::with_capacity((rows.1 - rows.0 + 2) as usize);
let mut acc = y;
ys.push(acc);
for r in rows.0..=rows.1 {
acc += sh.row_height(r) * k;
ys.push(acc);
}
Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale }
}
@@ -0,0 +1,261 @@
use super::layout::SheetLayout;
use super::*;
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
pub(super) const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
// ---------------------------------------------------------------- header / footer
/// Values for header/footer codes.
#[derive(Clone, Debug, Default)]
pub struct HfContext {
pub page: usize,
pub pages: usize,
pub sheet: String,
pub file: String,
pub path: String,
pub date: String,
pub time: String,
}
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial());
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
(date, time)
}
/// Header/footer codes and the field names the Page Layout view shows for them while editing.
pub const HF_FIELDS: [(char, &str); 8] = [
('P', "&[Page]"),
('N', "&[Pages]"),
('D', "&[Date]"),
('T', "&[Time]"),
('Z', "&[Path]"),
('F', "&[File]"),
('A', "&[Tab]"),
('G', "&[Picture]"),
];
/// Splits a header/footer string into its raw left, centre and right sections (codes kept).
/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing.
pub fn split_header_footer(spec: &str) -> [String; 3] {
let mut out: [String; 3] = Default::default();
let mut cur = 1usize;
let chars: Vec<char> = spec.chars().take(10_000).collect();
let mut i = 0;
while let Some(&c) = chars.get(i) {
let next = chars.get(i + 1).copied();
let sec = cur.min(2);
match (c, next) {
('&', Some('L' | 'l')) => cur = 0,
('&', Some('C' | 'c')) => cur = 1,
('&', Some('R' | 'r')) => cur = 2,
('&', Some('"')) => {
// &"Font,Style" is kept whole.
let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p);
out[sec].extend(&chars[i..end.min(chars.len())]);
i = end;
continue;
}
('&', Some(n)) => {
out[sec].push('&');
out[sec].push(n);
}
('&', None) => {}
(ch, _) => {
out[sec].push(ch);
i += 1;
continue;
}
}
i += 2;
}
out
}
/// Joins raw left, centre and right sections into one header/footer string.
pub fn join_header_footer(sections: &[String; 3]) -> String {
let mut s = String::new();
for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) {
if !text.is_empty() {
s.push_str(code);
s.push_str(text);
}
}
s
}
/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on.
pub fn codes_to_fields(raw: &str) -> String {
let mut out = String::new();
let mut it = raw.chars().peekable();
while let Some(c) = it.next() {
if c != '&' {
out.push(c);
continue;
}
// A dangling `&` at the end is dropped (printing ignores it too).
if let Some(n) = it.next() {
match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) {
Some((_, field)) => out.push_str(field),
None => {
out.push('&');
out.push(n);
}
}
}
}
out
}
/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end
/// becomes a literal ampersand so it can't swallow the next section's code.
pub fn fields_to_codes(text: &str) -> String {
let chars: Vec<char> = text.chars().take(10_000).collect();
let mut out = String::new();
let mut i = 0;
while let Some(&c) = chars.get(i) {
if c != '&' {
out.push(c);
i += 1;
continue;
}
if chars.get(i + 1) == Some(&'&') {
out.push_str("&&");
i += 2;
continue;
}
let rest: String = chars[i..].iter().take(12).collect();
if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) {
out.push('&');
out.push(*code);
i += field.chars().count();
continue;
}
if i + 1 == chars.len() {
out.push_str("&&");
} else {
out.push('&');
}
i += 1;
}
out
}
/// One header/footer section: its text and whether `&B` made it bold.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HfSection {
pub text: String,
pub bold: bool,
pub italic: bool,
pub size: Option<f32>,
}
/// Expands a header/footer string into its left, centre and right sections.
pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
let mut out: [HfSection; 3] = Default::default();
let mut cur = 1usize;
let chars: Vec<char> = spec.chars().take(10_000).collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c != '&' {
if let Some(s) = out.get_mut(cur) {
s.text.push(c);
}
i += 1;
continue;
}
let Some(&code) = chars.get(i + 1) else { break };
i += 2;
let sec = &mut out[cur.min(2)];
match code {
'L' | 'l' => cur = 0,
'C' | 'c' => cur = 1,
'R' | 'r' => cur = 2,
'P' | 'p' => {
// &P, &P+n, &P-n
let mut n = cx.page as i64;
if let Some(&sign) = chars.get(i)
&& (sign == '+' || sign == '-')
{
let digits: String = chars[i + 1..].iter().take_while(|d| d.is_ascii_digit()).take(6).collect();
if let Ok(d) = digits.parse::<i64>() {
n = if sign == '+' { n + d } else { n - d };
i += 1 + digits.len();
}
}
sec.text.push_str(&n.to_string());
}
'N' | 'n' => sec.text.push_str(&cx.pages.to_string()),
'D' | 'd' => sec.text.push_str(&cx.date),
'T' | 't' => sec.text.push_str(&cx.time),
'A' | 'a' => sec.text.push_str(&cx.sheet),
'F' | 'f' => sec.text.push_str(&cx.file),
'Z' | 'z' => sec.text.push_str(&cx.path),
'&' => sec.text.push('&'),
'B' | 'b' => sec.bold = !sec.bold,
'I' | 'i' => sec.italic = !sec.italic,
'"' => {
// &"Font,Style": skip the font name; honour Bold/Italic in the style.
let rest: String = chars[i..].iter().take_while(|c| **c != '"').collect();
let lower = rest.to_ascii_lowercase();
if lower.contains("bold") {
sec.bold = true;
}
if lower.contains("italic") || lower.contains("oblique") {
sec.italic = true;
}
i += rest.chars().count() + 1;
}
d if d.is_ascii_digit() => {
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
i += digits.len() - 1;
if let Ok(v) = digits.parse::<f32>() {
sec.size = Some(v.clamp(1.0, 409.0));
}
}
// Underline, strike, super/subscript, colour, picture: not rendered.
'K' | 'k' => {
i = (i + 6).min(chars.len());
}
_ => {}
}
}
out
}
pub(super) fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
let [_, _, _, _, hm, fm] = lay.margins;
let (l, r) = lay.hf_sides();
let text_scale = lay.hf_text_scale();
let pw = lay.page_w;
let ph = lay.page_h;
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
if spec.is_empty() {
continue;
}
let secs = expand_header_footer(spec, cx);
for (i, sec) in secs.iter().enumerate() {
if sec.text.trim().is_empty() {
continue;
}
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale;
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
let n = lines.len() as f32;
for (j, line) in lines.iter().enumerate() {
let lw = gridcraft_pdf::text_width(font, size, line);
let x = match i {
0 => l,
1 => (pw - lw) / 2.0,
_ => pw - r - lw,
};
let y = if header { hm + size * 0.8 + j as f32 * size * 1.2 } else { ph - fm - size * 0.22 - (n - 1.0 - j as f32) * size * 1.2 };
pg.text(x, y, size, font, [0, 0, 0], line);
}
}
}
}
+364
View File
@@ -0,0 +1,364 @@
use super::header_footer::HF_FIXED_SIDE_MARGIN;
use super::*;
/// Cells in a page block above which only stored cells are visited (not every grid position).
pub(super) const DENSE_BLOCK: u64 = 60_000;
// ---------------------------------------------------------------- paper
/// Paper size in points (portrait) for a paper name; unknown names fall back to Letter.
pub fn paper_size(name: &str) -> (f32, f32) {
known_paper(name).unwrap_or((612.0, 792.0))
}
/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its
/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as
/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too.
pub(super) fn known_paper(name: &str) -> Option<(f32, f32)> {
// Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5`
// are one name.
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], "");
Some(match n.as_str() {
"letter" | "usletter" => (612.0, 792.0),
"legal" | "uslegal" => (612.0, 1008.0),
"a4" => (595.28, 841.89),
"a3" => (841.89, 1190.55),
"a5" => (419.53, 595.28),
"a6" => (297.64, 419.53),
"tabloid" | "11x17" => (792.0, 1224.0),
"ledger" => (1224.0, 792.0),
"executive" => (522.0, 756.0),
"b4" | "b4jis" => (728.50, 1031.81),
"b5" | "b5jis" => (515.91, 728.50),
"b6" | "b6jis" => (362.83, 515.91),
"b4iso" => (708.66, 1000.63),
"b5iso" => (498.90, 708.66),
"b6iso" => (354.33, 498.90),
"japanesepostcard" | "postcard" => (283.46, 419.53),
"japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93),
"japaneseenvelope#3" | "chou#3" => (340.16, 666.14),
// 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out.
"japaneseenvelope#4" | "chou#4" => (255.12, 581.10),
"japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10),
"japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20),
_ => return None,
})
}
// ---------------------------------------------------------------- layout
/// The pagination of one sheet.
#[derive(Clone, Debug)]
pub struct SheetLayout {
pub sheet: usize,
pub settings: PrintSettings,
pub page_w: f32,
pub page_h: f32,
/// Left, right, top, bottom, header, footer margins in points.
pub margins: [f32; 6],
pub scale: f32,
pub area: RangeRef,
pub row_bands: Vec<(u32, u32)>,
pub col_bands: Vec<(u32, u32)>,
/// Pages in print order: (row band, column band).
pub pages: Vec<(usize, usize)>,
pub title_rows: Option<(u32, u32)>,
pub title_cols: Option<(u32, u32)>,
/// Heading sizes in sheet pixels (0 when headings are off).
pub heading_w: f32,
pub heading_h: f32,
/// Manual page breaks apply (they don't when the sheet is fitted to pages).
pub manual_breaks: bool,
}
impl SheetLayout {
pub(super) fn k(&self) -> f32 {
PX_TO_PT * self.scale
}
pub(crate) fn avail(&self) -> (f32, f32) {
let [l, r, t, b, ..] = self.margins;
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
}
/// Left and right margins, in points, that header and footer sections line up with: the
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
/// most a quarter of the page width.
pub(crate) fn hf_sides(&self) -> (f32, f32) {
if self.settings.hf_align_with_margins {
(self.margins[0], self.margins[1])
} else {
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
(side, side)
}
}
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
pub(crate) fn hf_text_scale(&self) -> f32 {
if self.settings.hf_scale_with_doc { self.scale } else { 1.0 }
}
}
/// Options shared by the print commands.
#[derive(Clone, Debug, Default)]
pub struct PrintOptions {
pub all_sheets: bool,
pub range: Option<RangeRef>,
pub fit_to_page: Option<bool>,
}
impl PrintOptions {
pub(super) fn from_params(p: &Json, cmd: &str) -> Result<PrintOptions> {
let all_sheets = match str_param(p, "sheets") {
None | Some("active") => false,
Some("all") | Some("workbook") => true,
Some(other) => return Err(bad(cmd, format!("`sheets` must be active or all, not `{other}`"))),
};
let range = match str_param(p, "range").map(str::trim).filter(|r| !r.is_empty()) {
Some(r) => Some(RangeRef::parse(split_sheet(r).1).ok_or_else(|| bad(cmd, format!("not a range: `{r}`")))?),
None => None,
};
Ok(PrintOptions { all_sheets, range, fit_to_page: bool_param(p, "fitToPage") })
}
}
/// The range a sheet prints: its print area, else the used range grown to cover drawing
/// objects. `None` when there is nothing to print.
pub fn print_range(sh: &Sheet) -> Option<RangeRef> {
if let Some(a) = sh.print.print_area {
return Some(trim_full(sh, a));
}
let mut r = sh.used_range();
let mut grow = |a: &Anchor| {
let x = sh.col_left(a.cell.col) + a.dx as f64;
let y = sh.row_top(a.cell.row) + a.dy as f64;
let end = CellRef::new(sh.row_at(y + a.height.max(0.0) as f64), sh.col_at(x + a.width.max(0.0) as f64));
let ar = RangeRef::new(a.cell, end);
r = Some(r.map_or(ar, |u| u.union(&ar)));
};
for c in &sh.charts {
grow(&c.anchor);
}
for i in &sh.images {
grow(&i.anchor);
}
for s in &sh.shapes {
grow(&s.anchor);
}
// Printing starts at A1 like Excel when the data starts nearby? Excel prints from the first
// used cell; keep the used range as is.
r
}
/// Whole rows/columns are trimmed to the used range.
fn trim_full(sh: &Sheet, r: RangeRef) -> RangeRef {
if !(r.is_full_cols() || r.is_full_rows()) {
return r;
}
match sh.used_range() {
Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
None => RangeRef::cell(r.start),
}
}
/// Splits `start..=end` into bands whose sizes fit `avail(band_start)`; `breaks` force a new
/// band before a line. Stops after `cap` bands.
pub(super) fn bands(start: u32, end: u32, size: impl Fn(u32) -> f32, avail: impl Fn(u32) -> f32, breaks: &[u32], cap: usize) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut cur = start;
let mut used = 0.0f32;
let mut room = avail(start);
let mut line = start;
loop {
let sz = size(line);
let manual = line > cur && breaks.binary_search(&line).is_ok();
if line > cur && (manual || used + sz > room + 0.01) {
out.push((cur, line - 1));
if out.len() >= cap {
return out;
}
cur = line;
used = 0.0;
room = avail(line);
}
used += sz;
if line >= end {
break;
}
line += 1;
}
out.push((cur, end));
out
}
fn title_span(t: Option<(u32, u32)>, max: u32) -> Option<(u32, u32)> {
let (a, b) = t?;
let (a, b) = (a.min(b), a.max(b));
if a >= max {
return None;
}
// Repeated titles are capped (Excel needs them to fit on a page anyway).
Some((a, b.min(max - 1).min(a.saturating_add(499))))
}
/// Paginates one sheet.
pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_override: Option<RangeRef>) -> Option<SheetLayout> {
let sh = wb.sheet(si)?;
let st = sh.print.clone();
let (mut pw, mut ph) = paper_size(&st.paper);
if st.orientation == Orientation::Landscape {
std::mem::swap(&mut pw, &mut ph);
}
let inch = |i: usize| st.margins.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 20.0) * 72.0;
let mut margins = [inch(0), inch(1), inch(2), inch(3), inch(4), inch(5)];
// Keep at least an inch of printable space.
for (a, b, total) in [(0usize, 1usize, pw), (2, 3, ph)] {
let sum = margins[a] + margins[b];
if total - sum < 72.0 {
let f = ((total - 72.0).max(0.0)) / sum.max(1.0);
margins[a] *= f;
margins[b] *= f;
}
}
let area = match range_override {
Some(r) => trim_full(sh, r),
None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))),
};
let title_rows = title_span(st.title_rows, MAX_ROWS);
let title_cols = title_span(st.title_cols, MAX_COLS);
let digits = format!("{}", area.end.row + 1).len() as f32;
let (heading_w, heading_h) = if st.headings { (10.0 + 7.0 * digits, sh.default_row_height.clamp(10.0, 60.0)) } else { (0.0, 0.0) };
let fit = match opts.fit_to_page {
Some(true) => Some((1u16, 1u16)),
Some(false) => None,
None => st.fit_to.filter(|(w, h)| *w > 0 || *h > 0),
};
let mut lay = SheetLayout {
sheet: si,
settings: st.clone(),
page_w: pw,
page_h: ph,
margins,
scale: (st.scale.clamp(10, 400) as f32) / 100.0,
area,
row_bands: vec![],
col_bands: vec![],
pages: vec![],
title_rows,
title_cols,
heading_w,
heading_h,
manual_breaks: fit.is_none(),
};
let paginate = |lay: &mut SheetLayout, manual: bool| {
lay.manual_breaks = manual;
(lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES);
lay.pages.clear();
'outer: for ci in 0..lay.col_bands.len() {
for ri in 0..lay.row_bands.len() {
if lay.pages.len() >= MAX_PAGES {
break 'outer;
}
lay.pages.push((ri, ci));
}
}
};
match fit {
Some((fw, fh)) => {
let total_w: f32 = (area.start.col..=area.end.col).map(|c| sh.col_width(c)).sum::<f32>() + heading_w;
let total_h: f32 = if area.height() > 200_000 {
f32::INFINITY
} else {
(area.start.row..=area.end.row).map(|r| sh.row_height(r)).sum::<f32>() + heading_h
};
let (aw, ah) = lay.avail();
let sw = if fw > 0 { aw * fw as f32 / (total_w * PX_TO_PT).max(1.0) } else { f32::INFINITY };
let sh_ = if fh > 0 { ah * fh as f32 / (total_h * PX_TO_PT).max(1.0) } else { f32::INFINITY };
lay.scale = sw.min(sh_).min(1.0).clamp(0.1, 1.0);
for _ in 0..40 {
paginate(&mut lay, false);
let ok_w = fw == 0 || lay.col_bands.len() <= fw as usize;
let ok_h = fh == 0 || lay.row_bands.len() <= fh as usize;
if (ok_w && ok_h) || lay.scale <= 0.1 {
break;
}
lay.scale = (lay.scale * 0.97).max(0.1);
}
}
None => paginate(&mut lay, true),
}
Some(lay)
}
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
let st = &lay.settings;
let mut rb: Vec<u32> = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] };
let mut cb: Vec<u32> = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] };
rb.sort_unstable();
cb.sort_unstable();
let (title_rows, title_cols) = (lay.title_rows, lay.title_cols);
let title_h = |start: u32| -> f32 {
match title_rows {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(),
_ => 0.0,
}
};
let title_w = |start: u32| -> f32 {
match title_cols {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(),
_ => 0.0,
}
};
let area = lay.area;
let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap);
let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap);
(rows, cols)
}
/// Lays out every sheet a print command covers.
pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
let mut out = Vec::new();
let mut pages = 0usize;
let sheets: Vec<usize> = if opts.all_sheets {
(0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect()
} else {
vec![wb.active_sheet()]
};
for si in sheets {
if pages >= MAX_PAGES {
break;
}
let ovr = if si == wb.active_sheet() { opts.range } else { None };
if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) {
continue;
}
if let Some(mut l) = layout_sheet(wb, si, opts, ovr) {
l.pages.truncate(MAX_PAGES - pages);
pages += l.pages.len();
out.push(l);
}
}
if out.is_empty() && opts.all_sheets {
out.extend(layout_sheet(wb, wb.active_sheet(), opts, None));
}
out
}
/// Segments of a page along one axis: repeated titles (before the band) then the band.
pub(super) fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32, u32)> {
let mut v = Vec::new();
if let Some((a, b)) = titles
&& a < band.0
{
v.push((a, b.min(band.0 - 1)));
}
v.push(band);
v
}
pub(super) fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 {
if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() }
}
+177
View File
@@ -0,0 +1,177 @@
use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of};
use super::text::wrap_lines;
use super::*;
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
let (x, y, w, h) = rect;
let pad = 2.0 * k;
let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0));
if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 {
return;
}
let Some((iw, ih)) = crate::io::image_size(data) else { return };
if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 {
return;
}
let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return };
let mut limits = image::Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let Ok(image) = reader.decode() else { return };
let image =
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
let fit = (w / iw as f32).min(h / ih as f32);
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
}
/// Sheet pixel rectangle of an anchor.
fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) {
(sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64)
}
/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels.
fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u32)> {
let (w, h) = crate::io::image_size(data)?;
if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 {
return None;
}
let img = image::load_from_memory(data).ok()?;
let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img };
let rgba = img.to_rgba8();
let (pw, ph) = (rgba.width(), rgba.height());
Some((rgba.into_raw(), pw, ph))
}
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
/// picture again, but the decode happens once).
pub(super) fn draw_objects(
pg: &mut Page,
wb: &Workbook,
si: usize,
sh: &Sheet,
b: &Block,
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
) {
let block = b.range();
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0));
let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by);
let visible = |r: (f64, f64, f64, f64)| r.2 > 0.0 && r.3 > 0.0 && r.0 < sx1 && r.0 + r.2 > sx0 && r.1 < sy1 && r.1 + r.3 > sy0;
let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k);
pg.save();
pg.clip_rect(bx, by, bw, bh);
draw_sparklines(pg, wb, si, sh, b, block);
for (imi, im) in sh.images.iter().enumerate() {
let r = anchor_rect(sh, &im.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
// About 150 dpi is plenty for print.
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(x, y, w, h, px, *pw, *ph);
}
}
for shp in &sh.shapes {
let r = anchor_rect(sh, &shp.anchor);
if !visible(r) {
continue;
}
let (x, y, w, h) = place(r);
draw_shape(pg, wb, shp, b, (x, y, w, h));
}
for ch in &sh.charts {
let r = anchor_rect(sh, &ch.anchor);
if !visible(r) {
continue;
}
let (x, y, _, _) = place(r);
let data = gridcraft_chart::resolve(wb, si, ch);
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
}
pg.restore();
}
fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) {
for sp in &sh.sparklines {
if !block.contains(sp.cell) {
continue;
}
let vals: Vec<Option<f64>> = match gridcraft_calc::evaluate(wb, si, sp.cell, &sp.source) {
Value::Array(a) => a.iter().take(10_000).map(Value::as_f64).collect(),
v => vec![v.as_f64()],
};
let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]);
let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell));
let (wpx, hpx) = (w / b.k, h / b.k);
let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx);
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
}
}
fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) {
let fill = rgb_of(shp.fill, wb);
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
match shp.kind {
ShapeKind::Ellipse => {
let pts: Vec<[f32; 2]> = (0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
})
.collect();
pg.polygon(&pts, fill, line);
}
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
ShapeKind::Line | ShapeKind::Arrow => {
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
if shp.kind == ShapeKind::Arrow {
let ang = h.atan2(w);
let al = 8.0 * b.k;
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
}
}
_ => {
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
}
}
if shp.text.is_empty() {
return;
}
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
let fm = &shp.format;
let text_box = shp.kind == ShapeKind::TextBox;
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
let block_h = lines.len().min(500) as f32 * size * 1.2;
let mut yy = match anchor {
ShapeTextAnchor::Top => y + 2.0 * b.k,
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
} + size * 0.8;
for l in lines.iter().take(500) {
let lw = gridcraft_pdf::text_width(font, size, l);
let lx = match align {
ShapeTextAlign::Left => x + 2.0 * b.k,
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
}
+263
View File
@@ -0,0 +1,263 @@
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();
}