mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide on the area around it, at full strength, with their effects, master and layout graphics included; the slide show, exports and thumbnails clip to the slide. DeckCraft clipped everything to the slide in the editor too. - render: `slide_extent`/`layout_extent` estimate how far a slide's drawing reaches (boxes, outlines, shadow, glow, reflection); `render_slide_drawn` also reports what a render actually drew (text spilling out of its box, table rows that grew); `pasteboard_region` picks the pixels the editor renders: the slide plus what reaches off it and is in view, rounded to 256 px tiles so scrolling rarely re-renders. A slide with nothing off it renders exactly as before. - ui-egui: the canvas texture covers that region (slide and master views); it re-renders only when the slide, zoom or the region changes, and a second time only if text drew beyond the estimate. - Hit testing, selection, marquee and moves already worked in slide space; tests now cover them off the slide. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
396 lines
17 KiB
Rust
396 lines
17 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, TableBorder, TablePart, TablePartStyle, TableStyleDef};
|
|
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)>,
|
|
/// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
|
|
/// means no border); `None` falls back to `border`.
|
|
pub sides: [Option<(Rgba, f64)>; 4],
|
|
/// A file-defined style's fill as a paint (gradients, see-through colours), with its
|
|
/// placeholder colour; drawn instead of `fill`.
|
|
pub paint: Option<(Fill, Option<Rgba>)>,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
|
|
/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
|
|
/// fill, text and the borders on its region's edges (its inside lines within it).
|
|
fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
|
|
let (rows, cols) = (t.rows.len(), t.cols.len());
|
|
let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c));
|
|
let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1)));
|
|
let last_r = rows.saturating_sub(1);
|
|
let last_c = cols.saturating_sub(1);
|
|
let header = t.first_row && r == 0;
|
|
let total = t.last_row && r1 == last_r && rows > 1;
|
|
let first_col = t.first_col && c == 0;
|
|
let last_col = t.last_col && c1 == last_c && cols > 1;
|
|
let body_r = r.saturating_sub(usize::from(t.first_row));
|
|
let body_c = c.saturating_sub(usize::from(t.first_col));
|
|
let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None };
|
|
for part in TablePart::ALL {
|
|
// (applies, region rows, region cols)
|
|
let (on, region) = match part {
|
|
TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
|
|
TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)),
|
|
TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)),
|
|
TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)),
|
|
TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)),
|
|
TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
|
|
TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
|
|
TablePart::LastRow => (total, (r, last_r, 0, last_c)),
|
|
TablePart::FirstRow => (header, (0, r1, 0, last_c)),
|
|
TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
|
|
TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
|
|
TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
|
|
TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
|
|
};
|
|
if !on {
|
|
continue;
|
|
}
|
|
let Some(ps) = def.part(part) else { continue };
|
|
if let Some(p) = style_fill(ctx, ps) {
|
|
look.fill = fill_color(ctx, &p.0, p.1);
|
|
look.paint = Some(p);
|
|
}
|
|
if let Some(tc) = &ps.text_color {
|
|
look.text = Some(ctx.color(tc, None));
|
|
}
|
|
if let Some(b) = ps.bold {
|
|
look.bold = b;
|
|
}
|
|
let (r0, rr1, c0, cc1) = region;
|
|
// Left, right, top, bottom of the cell: the part's edge or its inside line.
|
|
let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }];
|
|
for (side, i) in pick.iter().enumerate() {
|
|
if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
|
|
*slot = Some(border_look(ctx, b));
|
|
}
|
|
}
|
|
}
|
|
look
|
|
}
|
|
|
|
/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
|
|
/// the placeholder colour.
|
|
fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
|
|
if let Some((idx, col)) = &ps.fill_ref {
|
|
let ph = ctx.color(col, None);
|
|
return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) {
|
|
Some(f) if *idx > 0 => (f.clone(), Some(ph)),
|
|
_ if *idx == 0 => (Fill::None, None),
|
|
_ => (Fill::solid(ColorRef::rgb(ph)), None),
|
|
});
|
|
}
|
|
ps.fill.clone().map(|f| (f, None))
|
|
}
|
|
|
|
/// A fill's colour for a table cell (gradients and pictures by their first colour).
|
|
fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
|
|
match f {
|
|
Fill::None => Some(Rgba::TRANSPARENT),
|
|
Fill::Solid { color: c } => Some(ctx.color(c, ph)),
|
|
Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
|
|
Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A style border's colour and width (a transparent colour for no line).
|
|
fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
|
|
match b {
|
|
TableBorder::Line { line } => match &line.fill {
|
|
Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
|
|
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)),
|
|
_ => (Rgba::TRANSPARENT, 0.0),
|
|
},
|
|
TableBorder::Ref { idx, color } => {
|
|
let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75);
|
|
if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) }
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
|
|
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
|
|
return look_def(ctx, def, t, r, c);
|
|
}
|
|
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, sides: [None; 4], paint: None },
|
|
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
|
|
"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 },
|
|
sides: [None; 4],
|
|
paint: 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)),
|
|
sides: [None; 4],
|
|
paint: None,
|
|
},
|
|
"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)),
|
|
sides: [None; 4],
|
|
paint: None,
|
|
},
|
|
"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)),
|
|
sides: [None; 4],
|
|
paint: None,
|
|
},
|
|
_ => 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)),
|
|
sides: [None; 4],
|
|
paint: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Draws the table; returns the box its cells cover (table space), which grows with their text.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn draw(
|
|
ctx: &mut RenderContext,
|
|
pres: &Presentation,
|
|
rctx: &Ctx,
|
|
t: &Table,
|
|
m: Affine,
|
|
_w: f64,
|
|
_h: f64,
|
|
fields: &dyn deckcraft_text::Fields,
|
|
) -> Option<kurbo::Rect> {
|
|
let _ = pres;
|
|
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
|
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
|
|
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
|
|
let style_bg = rctx
|
|
.pres
|
|
.table_styles
|
|
.iter()
|
|
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
|
|
.and_then(|d| d.background.as_ref())
|
|
.and_then(|b| style_fill(rctx, b));
|
|
let own_bg = t.background.clone().map(|f| (f, None));
|
|
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
|
|
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
|
|
ctx.set_transform(m);
|
|
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
|
}
|
|
}
|
|
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 => match &lk.paint {
|
|
Some((f, ph)) => {
|
|
crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
|
|
}
|
|
None => {
|
|
if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
|
|
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.sides.get(i).copied().flatten().or(lk.border) {
|
|
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
|
|
_ => 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;
|
|
}
|
|
covered
|
|
}
|