Skip to main content

deckcraft_render/
table.rs

1//! Tables: cell fills from the table style (or overrides), borders, and cell text.
2
3use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
4use deckcraft_model::resolve::Ctx;
5use deckcraft_model::style::{ColorRef, Fill};
6use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef};
7use deckcraft_model::text::{Anchor, TextBody, TextDir};
8use deckcraft_model::{Presentation, Shape};
9use deckcraft_text::Fields;
10use kurbo::{Affine, BezPath, Rect, Shape as _};
11use vello_cpu::{RenderContext, peniko};
12
13use crate::color;
14
15/// Style colours for a cell: (fill, text colour, bold).
16pub struct CellLook {
17    pub fill: Option<Rgba>,
18    pub text: Option<Rgba>,
19    pub bold: bool,
20    pub border: Option<(Rgba, f64)>,
21    /// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
22    /// means no border); `None` falls back to `border`.
23    pub sides: [Option<(Rgba, f64)>; 4],
24    /// A file-defined style's fill as a paint (gradients, see-through colours), with its
25    /// placeholder colour; drawn instead of `fill`.
26    pub paint: Option<(Fill, Option<Rgba>)>,
27}
28
29fn accent(ctx: &Ctx, style: &str) -> Rgba {
30    let slot = match style.rsplit('-').next().unwrap_or("accent1") {
31        "accent2" => SchemeSlot::Accent2,
32        "accent3" => SchemeSlot::Accent3,
33        "accent4" => SchemeSlot::Accent4,
34        "accent5" => SchemeSlot::Accent5,
35        "accent6" => SchemeSlot::Accent6,
36        "tx1" => SchemeSlot::Tx1,
37        _ => SchemeSlot::Accent1,
38    };
39    ctx.color(&ColorRef::scheme(slot), None)
40}
41
42/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
43/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
44/// fill, text and the borders on its region's edges (its inside lines within it).
45fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
46    let (rows, cols) = (t.rows.len(), t.cols.len());
47    let (r1, c1) = t
48        .cell(r, c)
49        .map(|cell| {
50            (
51                r + cell.row_span.max(1) as usize - 1,
52                c + cell.grid_span.max(1) as usize - 1,
53            )
54        })
55        .unwrap_or((r, c));
56    let (r1, c1) = (
57        r1.min(rows.saturating_sub(1)),
58        c1.min(cols.saturating_sub(1)),
59    );
60    let last_r = rows.saturating_sub(1);
61    let last_c = cols.saturating_sub(1);
62    let header = t.first_row && r == 0;
63    let total = t.last_row && r1 == last_r && rows > 1;
64    let first_col = t.first_col && c == 0;
65    let last_col = t.last_col && c1 == last_c && cols > 1;
66    let body_r = r.saturating_sub(usize::from(t.first_row));
67    let body_c = c.saturating_sub(usize::from(t.first_col));
68    let mut look = CellLook {
69        fill: None,
70        text: None,
71        bold: false,
72        border: None,
73        sides: [None; 4],
74        paint: None,
75    };
76    for part in TablePart::ALL {
77        // (applies, region rows, region cols)
78        let (on, region) = match part {
79            TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
80            TablePart::Band1H => (
81                t.band_row && !header && !total && body_r % 2 == 0,
82                (r, r1, 0, last_c),
83            ),
84            TablePart::Band2H => (
85                t.band_row && !header && !total && body_r % 2 == 1,
86                (r, r1, 0, last_c),
87            ),
88            TablePart::Band1V => (
89                t.band_col && !first_col && !last_col && body_c % 2 == 0,
90                (0, last_r, c, c1),
91            ),
92            TablePart::Band2V => (
93                t.band_col && !first_col && !last_col && body_c % 2 == 1,
94                (0, last_r, c, c1),
95            ),
96            TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
97            TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
98            TablePart::LastRow => (total, (r, last_r, 0, last_c)),
99            TablePart::FirstRow => (header, (0, r1, 0, last_c)),
100            TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
101            TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
102            TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
103            TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
104        };
105        if !on {
106            continue;
107        }
108        let Some(ps) = def.part(part) else { continue };
109        if let Some(p) = style_fill(ctx, ps) {
110            look.fill = fill_color(ctx, &p.0, p.1);
111            look.paint = Some(p);
112        }
113        if let Some(tc) = &ps.text_color {
114            look.text = Some(ctx.color(tc, None));
115        }
116        if let Some(b) = ps.bold {
117            look.bold = b;
118        }
119        let (r0, rr1, c0, cc1) = region;
120        // Left, right, top, bottom of the cell: the part's edge or its inside line.
121        let pick = [
122            if c == c0 { 0 } else { 5 },
123            if c1 == cc1 { 1 } else { 5 },
124            if r == r0 { 2 } else { 4 },
125            if r1 == rr1 { 3 } else { 4 },
126        ];
127        for (side, i) in pick.iter().enumerate() {
128            if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
129                *slot = Some(border_look(ctx, b));
130            }
131        }
132    }
133    look
134}
135
136/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
137/// the placeholder colour.
138fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
139    if let Some((idx, col)) = &ps.fill_ref {
140        let ph = ctx.color(col, None);
141        return Some(
142            match ctx
143                .master
144                .theme
145                .format
146                .fills
147                .get((*idx as usize).saturating_sub(1))
148            {
149                Some(f) if *idx > 0 => (f.clone(), Some(ph)),
150                _ if *idx == 0 => (Fill::None, None),
151                _ => (Fill::solid(ColorRef::rgb(ph)), None),
152            },
153        );
154    }
155    ps.fill.clone().map(|f| (f, None))
156}
157
158/// A fill's colour for a table cell (gradients and pictures by their first colour).
159fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
160    match f {
161        Fill::None => Some(Rgba::TRANSPARENT),
162        Fill::Solid { color: c } => Some(ctx.color(c, ph)),
163        Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
164        Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
165        _ => None,
166    }
167}
168
169/// A style border's colour and width (a transparent colour for no line).
170fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
171    match b {
172        TableBorder::Line { line } => match &line.fill {
173            Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
174            Some(Fill::Gradient(g)) => (
175                g.stops
176                    .first()
177                    .map(|s| ctx.color(&s.color, None))
178                    .unwrap_or(Rgba::BLACK),
179                line.width.unwrap_or(0.75),
180            ),
181            _ => (Rgba::TRANSPARENT, 0.0),
182        },
183        TableBorder::Ref { idx, color } => {
184            let w = ctx
185                .master
186                .theme
187                .format
188                .lines
189                .get((*idx as usize).saturating_sub(1))
190                .and_then(|l| l.width)
191                .unwrap_or(0.75);
192            if *idx == 0 {
193                (Rgba::TRANSPARENT, 0.0)
194            } else {
195                (ctx.color(color, None), w)
196            }
197        }
198    }
199}
200
201pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
202    if let Some(def) = ctx
203        .pres
204        .table_styles
205        .iter()
206        .find(|d| d.id.eq_ignore_ascii_case(&t.style))
207    {
208        return look_def(ctx, def, t, r, c);
209    }
210    let a = accent(ctx, &t.style);
211    let kind = t.style.split('-').next().unwrap_or("medium2");
212    let header = t.first_row && r == 0;
213    let total = t.last_row && r + 1 == t.rows.len();
214    let first_col = t.first_col && c == 0;
215    let last_col = t.last_col && c + 1 == t.cols.len();
216    let body_r = if t.first_row { r.wrapping_sub(1) } else { r };
217    let band = t.band_row && !header && !total && body_r % 2 == 0;
218    let band_c = t.band_col && c.is_multiple_of(2);
219    let tint = |k: i32| deckcraft_color::apply(a, &[ColorTransform::Tint(k)]);
220    let white = Rgba::WHITE;
221    let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
222    match kind {
223        "none" => CellLook {
224            fill: None,
225            text: None,
226            bold: false,
227            border: None,
228            sides: [None; 4],
229            paint: None,
230        },
231        "grid" => CellLook {
232            fill: None,
233            text: None,
234            bold: false,
235            border: Some((dark_text, 0.75)),
236            sides: [None; 4],
237            paint: None,
238        },
239        "light1" => CellLook {
240            fill: if band || band_c {
241                Some(tint(20000))
242            } else {
243                None
244            },
245            text: None,
246            bold: header || total || first_col || last_col,
247            border: if header { Some((a, 1.0)) } else { None },
248            sides: [None; 4],
249            paint: None,
250        },
251        "light2" => CellLook {
252            fill: if header { Some(a) } else { None },
253            text: if header { Some(white) } else { None },
254            bold: header || total || first_col,
255            border: Some((a, 1.0)),
256            sides: [None; 4],
257            paint: None,
258        },
259        "dark1" => CellLook {
260            fill: Some(if header {
261                Rgba::BLACK
262            } else if band || band_c {
263                deckcraft_color::apply(a, &[ColorTransform::Shade(60000)])
264            } else {
265                deckcraft_color::apply(a, &[ColorTransform::Shade(75000)])
266            }),
267            text: Some(white),
268            bold: header || total || first_col,
269            border: Some((white, 1.0)),
270            sides: [None; 4],
271            paint: None,
272        },
273        "medium4" => CellLook {
274            fill: Some(if header || band || band_c {
275                tint(40000)
276            } else {
277                tint(20000)
278            }),
279            text: None,
280            bold: header || total || first_col || last_col,
281            border: Some((a, 0.75)),
282            sides: [None; 4],
283            paint: None,
284        },
285        _ => CellLook {
286            // medium2: strong header, banded light tints, white grid.
287            fill: Some(if header || total || first_col || last_col {
288                a
289            } else if band || band_c {
290                tint(40000)
291            } else {
292                tint(20000)
293            }),
294            text: if header || total || first_col || last_col {
295                Some(white)
296            } else {
297                None
298            },
299            bold: header || total || first_col || last_col,
300            border: Some((white, 1.0)),
301            sides: [None; 4],
302            paint: None,
303        },
304    }
305}
306
307/// Running edges 0, s0, s0+s1, … of a list of sizes (negative sizes count as 0).
308fn edges(sizes: &[f64]) -> Vec<f64> {
309    std::iter::once(0.0)
310        .chain(sizes.iter().scan(0.0, |a, s| {
311            *a += s.max(0.0);
312            Some(*a)
313        }))
314        .collect()
315}
316
317/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
318pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
319    let lk = look(rctx, t, r, c);
320    let mut body = cell.text.clone();
321    let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
322    body.body.inset_l = Some(ml);
323    body.body.inset_r = Some(mr);
324    body.body.inset_t = Some(mt);
325    body.body.inset_b = Some(mb);
326    body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
327    if cell.vertical.is_some() {
328        body.body.vert = cell.vertical;
329    }
330    for p in &mut body.paragraphs {
331        for run in &mut p.runs {
332            if run.props.fill.is_none()
333                && let Some(tc) = lk.text
334            {
335                run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
336            }
337            if lk.bold && run.props.bold.is_none() {
338                run.props.bold = Some(true);
339            }
340        }
341    }
342    body
343}
344
345/// Heights the rows are drawn at. A stored row height (`a:tr/@h`) is a minimum: the row grows so the
346/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
347/// Cells spanning several rows and vertical text keep the stored height.
348pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
349    let xs = edges(&t.cols);
350    let mut out = Vec::with_capacity(t.rows.len());
351    for (ri, row) in t.rows.iter().enumerate() {
352        let mut h = row.height.max(0.0);
353        for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
354            if cell.is_covered()
355                || cell.row_span > 1
356                || !matches!(cell.vertical, None | Some(TextDir::Horizontal))
357            {
358                continue;
359            }
360            let c1 = ci
361                .saturating_add(cell.grid_span.max(1) as usize)
362                .min(t.cols.len());
363            let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else {
364                continue;
365            };
366            let body = cell_body(rctx, t, ri, ci, cell);
367            let tmp = Shape {
368                text: Some(body.clone()),
369                ..Default::default()
370            };
371            let need = deckcraft_text::fit_height(
372                rctx,
373                &tmp,
374                &body,
375                Rect::new(*x0, 0.0, *x1, h.max(1.0)),
376                fields,
377            );
378            if need.is_finite() {
379                h = h.max(need.min(20_000.0));
380            }
381        }
382        out.push(h);
383    }
384    out
385}
386
387/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells.
388/// `heights` are the drawn row heights ([`row_heights`]).
389pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
390    let mut out = vec![];
391    let xs = edges(&t.cols);
392    let ys = edges(heights);
393    for (ri, row) in t.rows.iter().enumerate() {
394        for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
395            if cell.is_covered() {
396                continue;
397            }
398            let c1 = (ci + cell.grid_span.max(1) as usize).min(t.cols.len());
399            let r1 = (ri + cell.row_span.max(1) as usize).min(t.rows.len());
400            let (Some(x0), Some(x1), Some(y0), Some(y1)) =
401                (xs.get(ci), xs.get(c1), ys.get(ri), ys.get(r1))
402            else {
403                continue;
404            };
405            out.push((ri, ci, Rect::new(*x0, *y0, *x1, *y1)));
406        }
407    }
408    out
409}
410
411/// Draws the table; returns the box its cells cover (table space), which grows with their text.
412#[allow(clippy::too_many_arguments)]
413pub fn draw(
414    ctx: &mut RenderContext,
415    pres: &Presentation,
416    rctx: &Ctx,
417    t: &Table,
418    m: Affine,
419    _w: f64,
420    _h: f64,
421    fields: &dyn deckcraft_text::Fields,
422) -> Option<kurbo::Rect> {
423    let _ = pres;
424    let rects = cell_rects(t, &row_heights(rctx, t, fields));
425    let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
426    // Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
427    let style_bg = rctx
428        .pres
429        .table_styles
430        .iter()
431        .find(|d| d.id.eq_ignore_ascii_case(&t.style))
432        .and_then(|d| d.background.as_ref())
433        .and_then(|b| style_fill(rctx, b));
434    let own_bg = t.background.clone().map(|f| (f, None));
435    if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
436        for (bg, ph) in style_bg.into_iter().chain(own_bg) {
437            ctx.set_transform(m);
438            crate::paint::fill_path(
439                ctx,
440                rctx,
441                &bg,
442                ph,
443                &all.to_path(0.1),
444                all,
445                m,
446                deckcraft_geom::preset::FillMode::Norm,
447                1.0,
448                rctx.pres,
449            );
450        }
451    }
452    for (r, c, rect) in &rects {
453        let Some(cell) = t.cell(*r, *c) else { continue };
454        let lk = look(rctx, t, *r, *c);
455        ctx.set_transform(m);
456        match &cell.fill {
457            Some(f) => crate::paint::fill_path(
458                ctx,
459                rctx,
460                f,
461                None,
462                &rect.to_path(0.1),
463                *rect,
464                m,
465                deckcraft_geom::preset::FillMode::Norm,
466                1.0,
467                rctx.pres,
468            ),
469            None => match &lk.paint {
470                Some((f, ph)) => crate::paint::fill_path(
471                    ctx,
472                    rctx,
473                    f,
474                    *ph,
475                    &rect.to_path(0.1),
476                    *rect,
477                    m,
478                    deckcraft_geom::preset::FillMode::Norm,
479                    1.0,
480                    rctx.pres,
481                ),
482                None => {
483                    if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
484                        ctx.set_paint(color(fc, 1.0));
485                        ctx.fill_rect(rect);
486                    }
487                }
488            },
489        }
490    }
491    // Borders: explicit cell borders, else the style's grid.
492    for (r, c, rect) in &rects {
493        let Some(cell) = t.cell(*r, *c) else { continue };
494        let lk = look(rctx, t, *r, *c);
495        let edges = [
496            (rect.x0, rect.y0, rect.x0, rect.y1),
497            (rect.x1, rect.y0, rect.x1, rect.y1),
498            (rect.x0, rect.y0, rect.x1, rect.y0),
499            (rect.x0, rect.y1, rect.x1, rect.y1),
500        ];
501        for (i, e) in edges.iter().enumerate() {
502            let line = cell.borders.get(i).and_then(|b| b.as_ref());
503            let (col, wd) = match line {
504                Some(l) => match &l.fill {
505                    Some(Fill::Solid { color: cc }) => {
506                        (rctx.color(cc, None), l.width.unwrap_or(1.0))
507                    }
508                    Some(Fill::None) => continue,
509                    _ => continue,
510                },
511                None => match lk.sides.get(i).copied().flatten().or(lk.border) {
512                    Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
513                    _ => continue,
514                },
515            };
516            let mut p = BezPath::new();
517            p.move_to((e.0, e.1));
518            p.line_to((e.2, e.3));
519            ctx.set_transform(m);
520            ctx.set_paint(color(col, 1.0));
521            ctx.set_stroke(kurbo::Stroke::new(wd.max(0.25)));
522            ctx.stroke_path(&p);
523        }
524    }
525    // Text.
526    for (r, c, rect) in &rects {
527        let Some(cell) = t.cell(*r, *c) else { continue };
528        if cell.text.is_empty() {
529            continue;
530        }
531        let body = cell_body(rctx, t, *r, *c, cell);
532        let tmp = Shape {
533            text: Some(body.clone()),
534            ..Default::default()
535        };
536        crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
537        let _ = peniko::Fill::NonZero;
538    }
539    covered
540}