mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 12:07:02 -06:00
* Tables: rows grow to fit their text, cell text in the PDF text layer (#13) A stored row height (a:tr/@h) is a minimum, as in PowerPoint: each row grows so its cells' text fits. Tables from pandoc or python-pptx store h="0" and drew every row on top of each other. Cell editing (click to edit, cell outline) uses the same heights, and the PDF text layer now includes table cell text. * Tables: a grown table's box covers its drawn rows; resize scales from drawn heights Handles, hit tests and marquee used the stored frame, so rows grown to fit their text drew below the selection box (a file with h=0 rows had a 0 pt tall box). Resizing divided by the stored row total, which is ~0 for those files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
9.6 KiB
Rust
245 lines
9.6 KiB
Rust
//! Tables: cell fills from the table style (or overrides), borders, and cell text.
|
|
|
|
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
|
use deckcraft_model::resolve::Ctx;
|
|
use deckcraft_model::style::{ColorRef, Fill};
|
|
use deckcraft_model::table::{Cell, Table};
|
|
use deckcraft_model::text::{Anchor, TextBody, TextDir};
|
|
use deckcraft_model::{Presentation, Shape};
|
|
use deckcraft_text::Fields;
|
|
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
|
use vello_cpu::{RenderContext, peniko};
|
|
|
|
use crate::color;
|
|
|
|
/// Style colours for a cell: (fill, text colour, bold).
|
|
pub struct CellLook {
|
|
pub fill: Option<Rgba>,
|
|
pub text: Option<Rgba>,
|
|
pub bold: bool,
|
|
pub border: Option<(Rgba, f64)>,
|
|
}
|
|
|
|
fn accent(ctx: &Ctx, style: &str) -> Rgba {
|
|
let slot = match style.rsplit('-').next().unwrap_or("accent1") {
|
|
"accent2" => SchemeSlot::Accent2,
|
|
"accent3" => SchemeSlot::Accent3,
|
|
"accent4" => SchemeSlot::Accent4,
|
|
"accent5" => SchemeSlot::Accent5,
|
|
"accent6" => SchemeSlot::Accent6,
|
|
"tx1" => SchemeSlot::Tx1,
|
|
_ => SchemeSlot::Accent1,
|
|
};
|
|
ctx.color(&ColorRef::scheme(slot), None)
|
|
}
|
|
|
|
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
|
let a = accent(ctx, &t.style);
|
|
let kind = t.style.split('-').next().unwrap_or("medium2");
|
|
let header = t.first_row && r == 0;
|
|
let total = t.last_row && r + 1 == t.rows.len();
|
|
let first_col = t.first_col && c == 0;
|
|
let last_col = t.last_col && c + 1 == t.cols.len();
|
|
let body_r = if t.first_row { r.wrapping_sub(1) } else { r };
|
|
let band = t.band_row && !header && !total && body_r % 2 == 0;
|
|
let band_c = t.band_col && c.is_multiple_of(2);
|
|
let tint = |k: i32| deckcraft_color::apply(a, &[ColorTransform::Tint(k)]);
|
|
let white = Rgba::WHITE;
|
|
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
|
|
match kind {
|
|
"none" => CellLook { fill: None, text: None, bold: false, border: None },
|
|
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) },
|
|
"light1" => CellLook {
|
|
fill: if band || band_c { Some(tint(20000)) } else { None },
|
|
text: None,
|
|
bold: header || total || first_col || last_col,
|
|
border: if header { Some((a, 1.0)) } else { None },
|
|
},
|
|
"light2" => CellLook {
|
|
fill: if header { Some(a) } else { None },
|
|
text: if header { Some(white) } else { None },
|
|
bold: header || total || first_col,
|
|
border: Some((a, 1.0)),
|
|
},
|
|
"dark1" => CellLook {
|
|
fill: Some(if header {
|
|
Rgba::BLACK
|
|
} else if band || band_c {
|
|
deckcraft_color::apply(a, &[ColorTransform::Shade(60000)])
|
|
} else {
|
|
deckcraft_color::apply(a, &[ColorTransform::Shade(75000)])
|
|
}),
|
|
text: Some(white),
|
|
bold: header || total || first_col,
|
|
border: Some((white, 1.0)),
|
|
},
|
|
"medium4" => CellLook {
|
|
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
|
|
text: None,
|
|
bold: header || total || first_col || last_col,
|
|
border: Some((a, 0.75)),
|
|
},
|
|
_ => CellLook {
|
|
// medium2: strong header, banded light tints, white grid.
|
|
fill: Some(if header || total || first_col || last_col {
|
|
a
|
|
} else if band || band_c {
|
|
tint(40000)
|
|
} else {
|
|
tint(20000)
|
|
}),
|
|
text: if header || total || first_col || last_col { Some(white) } else { None },
|
|
bold: header || total || first_col || last_col,
|
|
border: Some((white, 1.0)),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Running edges 0, s0, s0+s1, … of a list of sizes (negative sizes count as 0).
|
|
fn edges(sizes: &[f64]) -> Vec<f64> {
|
|
std::iter::once(0.0)
|
|
.chain(sizes.iter().scan(0.0, |a, s| {
|
|
*a += s.max(0.0);
|
|
Some(*a)
|
|
}))
|
|
.collect()
|
|
}
|
|
|
|
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
|
|
pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
|
|
let lk = look(rctx, t, r, c);
|
|
let mut body = cell.text.clone();
|
|
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
|
|
body.body.inset_l = Some(ml);
|
|
body.body.inset_r = Some(mr);
|
|
body.body.inset_t = Some(mt);
|
|
body.body.inset_b = Some(mb);
|
|
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
|
|
if cell.vertical.is_some() {
|
|
body.body.vert = cell.vertical;
|
|
}
|
|
for p in &mut body.paragraphs {
|
|
for run in &mut p.runs {
|
|
if run.props.fill.is_none()
|
|
&& let Some(tc) = lk.text
|
|
{
|
|
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
|
|
}
|
|
if lk.bold && run.props.bold.is_none() {
|
|
run.props.bold = Some(true);
|
|
}
|
|
}
|
|
}
|
|
body
|
|
}
|
|
|
|
/// Heights the rows are drawn at. A stored row height (`a:tr/@h`) is a minimum: the row grows so the
|
|
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
|
|
/// Cells spanning several rows and vertical text keep the stored height.
|
|
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
|
|
let xs = edges(&t.cols);
|
|
let mut out = Vec::with_capacity(t.rows.len());
|
|
for (ri, row) in t.rows.iter().enumerate() {
|
|
let mut h = row.height.max(0.0);
|
|
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
|
|
if cell.is_covered() || cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) {
|
|
continue;
|
|
}
|
|
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
|
|
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
|
|
let body = cell_body(rctx, t, ri, ci, cell);
|
|
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
|
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
|
|
if need.is_finite() {
|
|
h = h.max(need.min(20_000.0));
|
|
}
|
|
}
|
|
out.push(h);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells.
|
|
/// `heights` are the drawn row heights ([`row_heights`]).
|
|
pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
|
|
let mut out = vec![];
|
|
let xs = edges(&t.cols);
|
|
let ys = edges(heights);
|
|
for (ri, row) in t.rows.iter().enumerate() {
|
|
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
|
|
if cell.is_covered() {
|
|
continue;
|
|
}
|
|
let c1 = (ci + cell.grid_span.max(1) as usize).min(t.cols.len());
|
|
let r1 = (ri + cell.row_span.max(1) as usize).min(t.rows.len());
|
|
let (Some(x0), Some(x1), Some(y0), Some(y1)) = (xs.get(ci), xs.get(c1), ys.get(ri), ys.get(r1)) else { continue };
|
|
out.push((ri, ci, Rect::new(*x0, *y0, *x1, *y1)));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
|
|
let _ = pres;
|
|
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
|
for (r, c, rect) in &rects {
|
|
let Some(cell) = t.cell(*r, *c) else { continue };
|
|
let lk = look(rctx, t, *r, *c);
|
|
ctx.set_transform(m);
|
|
match &cell.fill {
|
|
Some(f) => {
|
|
crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
|
|
}
|
|
None => {
|
|
if let Some(fc) = lk.fill {
|
|
ctx.set_paint(color(fc, 1.0));
|
|
ctx.fill_rect(rect);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Borders: explicit cell borders, else the style's grid.
|
|
for (r, c, rect) in &rects {
|
|
let Some(cell) = t.cell(*r, *c) else { continue };
|
|
let lk = look(rctx, t, *r, *c);
|
|
let edges = [
|
|
(rect.x0, rect.y0, rect.x0, rect.y1),
|
|
(rect.x1, rect.y0, rect.x1, rect.y1),
|
|
(rect.x0, rect.y0, rect.x1, rect.y0),
|
|
(rect.x0, rect.y1, rect.x1, rect.y1),
|
|
];
|
|
for (i, e) in edges.iter().enumerate() {
|
|
let line = cell.borders.get(i).and_then(|b| b.as_ref());
|
|
let (col, wd) = match line {
|
|
Some(l) => match &l.fill {
|
|
Some(Fill::Solid { color: cc }) => (rctx.color(cc, None), l.width.unwrap_or(1.0)),
|
|
Some(Fill::None) => continue,
|
|
_ => continue,
|
|
},
|
|
None => match lk.border {
|
|
Some(b) => b,
|
|
None => continue,
|
|
},
|
|
};
|
|
let mut p = BezPath::new();
|
|
p.move_to((e.0, e.1));
|
|
p.line_to((e.2, e.3));
|
|
ctx.set_transform(m);
|
|
ctx.set_paint(color(col, 1.0));
|
|
ctx.set_stroke(kurbo::Stroke::new(wd.max(0.25)));
|
|
ctx.stroke_path(&p);
|
|
}
|
|
}
|
|
// Text.
|
|
for (r, c, rect) in &rects {
|
|
let Some(cell) = t.cell(*r, *c) else { continue };
|
|
if cell.text.is_empty() {
|
|
continue;
|
|
}
|
|
let body = cell_body(rctx, t, *r, *c, cell);
|
|
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
|
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
|
let _ = peniko::Fill::NonZero;
|
|
}
|
|
}
|