Skip to main content

deckcraft_render/
placed.rs

1//! Laid-out text and click areas of a slide, in slide coordinates, without drawing — for exports
2//! that put real text over a raster (PDF text layer, links) and for text extraction.
3
4use deckcraft_model::resolve::{self, Ctx};
5use deckcraft_model::text::{Hyperlink, TextBody};
6use deckcraft_model::{Presentation, Shape, ShapeKind};
7use deckcraft_text::{Opts, TextLayout};
8use kurbo::{Affine, Rect};
9
10use crate::{SlideFields, shape_geometry, table};
11
12/// One shape's text as drawn on the slide.
13pub struct PlacedText {
14    pub layout: TextLayout,
15    /// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]).
16    pub transform: Affine,
17    pub body: TextBody,
18}
19
20/// A clickable area: a shape action or a text hyperlink.
21pub struct PlacedLink {
22    /// Slide-space bounding box.
23    pub rect: Rect,
24    pub link: Hyperlink,
25}
26
27#[derive(Default)]
28pub struct Placed {
29    pub texts: Vec<PlacedText>,
30    pub links: Vec<PlacedLink>,
31}
32
33/// Text and links of slide `index` (master and layout graphics first, as rendered).
34pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
35    let mut out = Placed::default();
36    let Some(slide) = pres.slides.get(index) else {
37        return out;
38    };
39    let Some(rctx) = Ctx::for_slide(pres, slide) else {
40        return out;
41    };
42    let fields = SlideFields::new(
43        pres.slide_number(index),
44        pres.header_footer.footer_text.clone(),
45        &crate::RenderOpts::default(),
46    );
47    let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
48    if show_master {
49        let mctx = Ctx::for_master(pres, rctx.master);
50        for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) {
51            place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0);
52        }
53    }
54    if show_layout && let Some(l) = rctx.layout {
55        let lctx = Ctx::for_layout(pres, rctx.master, l);
56        for s in l.shapes.iter().filter(|s| s.ph.is_none()) {
57            place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0);
58        }
59    }
60    for s in &slide.shapes {
61        place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0);
62    }
63    out
64}
65
66fn place(
67    out: &mut Placed,
68    rctx: &Ctx,
69    fields: &SlideFields,
70    s: &Shape,
71    parent: Affine,
72    depth: usize,
73) {
74    if depth > 32 || s.hidden {
75        return;
76    }
77    let x = resolve::xfrm(rctx, s);
78    if !x.is_finite() {
79        return;
80    }
81    let m = parent * x.affine();
82    if let Some(link) = &s.click {
83        out.links.push(PlacedLink {
84            rect: m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)),
85            link: link.clone(),
86        });
87    }
88    match &s.kind {
89        ShapeKind::Group { children, child } => {
90            let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
91            let gm = parent * deckcraft_geom::group_child_affine(&x, ch);
92            for c in children {
93                place(out, rctx, fields, c, gm, depth + 1);
94            }
95        }
96        ShapeKind::Table(t) => {
97            // Cell text, laid out exactly as the table draws it.
98            for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
99                let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else {
100                    continue;
101                };
102                let body = table::cell_body(rctx, t, r, c, cell);
103                let tmp = Shape {
104                    text: Some(body.clone()),
105                    ..Default::default()
106                };
107                place_text(out, rctx, fields, &tmp, &body, rect, m);
108            }
109        }
110        ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
111        _ => {
112            let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else {
113                return;
114            };
115            let tr = shape_geometry(s, x.w, x.h).text_rect;
116            place_text(out, rctx, fields, s, body, tr, m);
117        }
118    }
119}
120
121/// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks.
122fn place_text(
123    out: &mut Placed,
124    rctx: &Ctx,
125    fields: &SlideFields,
126    s: &Shape,
127    body: &TextBody,
128    tr: Rect,
129    m: Affine,
130) {
131    let layout = deckcraft_text::layout(
132        rctx,
133        s,
134        body,
135        &Opts {
136            rect: tr,
137            fields,
138            prompt_color: None,
139            no_shrink: false,
140        },
141    );
142    let m = crate::text_transform(m, tr, layout.rotation);
143    // Text hyperlinks: one box per line segment of a linked run.
144    for run in layout
145        .runs
146        .iter()
147        .filter(|r| r.link && !r.glyphs.is_empty())
148    {
149        let Some(link) = run_link(body, run.para, run.chars.0) else {
150            continue;
151        };
152        let Some(line) = layout
153            .lines
154            .iter()
155            .find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end)
156        else {
157            continue;
158        };
159        // The run's visual extent (right-to-left runs run from right to left).
160        let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
161        let (x0, x1) = line
162            .span(run.chars.0, run.chars.1)
163            .unwrap_or((first, first));
164        let r = Rect::new(x0, line.top, x1, line.bottom);
165        out.links.push(PlacedLink {
166            rect: m.transform_rect_bbox(r),
167            link,
168        });
169    }
170    out.texts.push(PlacedText {
171        layout,
172        transform: m,
173        body: body.clone(),
174    });
175}
176
177/// The hyperlink of the model run containing character `at` of paragraph `para`.
178fn run_link(body: &TextBody, para: usize, at: usize) -> Option<Hyperlink> {
179    let p = body.paragraphs.get(para)?;
180    let mut pos = 0;
181    for r in &p.runs {
182        let n = r.char_len();
183        if at < pos + n.max(1) {
184            return r.props.link.clone();
185        }
186        pos += n;
187    }
188    None
189}