//! Laid-out text and click areas of a slide, in slide coordinates, without drawing — for exports //! that put real text over a raster (PDF text layer, links) and for text extraction. use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Hyperlink, TextBody}; use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use deckcraft_text::{Opts, TextLayout}; use kurbo::{Affine, Rect}; use crate::{SlideFields, shape_geometry, table}; /// One shape's text as drawn on the slide. pub struct PlacedText { pub layout: TextLayout, /// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]). pub transform: Affine, pub body: TextBody, } /// A clickable area: a shape action or a text hyperlink. pub struct PlacedLink { /// Slide-space bounding box. pub rect: Rect, pub link: Hyperlink, /// The shape that owns the link (for a text hyperlink, the shape holding the text). pub shape: ShapeId, /// A hyperlink on text, not the shape's own action. pub text: bool, } /// A shape of the slide itself (not of its layout or master) with its slide-space bounding box: /// what a click in a slide show lands on. pub struct PlacedShape { pub id: ShapeId, /// Index into [`Placed::shapes`] of the group this shape is in. pub parent: Option, pub is_group: bool, /// Slide-space bounding box. pub rect: Rect, /// The shape's own action (Insert ▸ Action), not its text hyperlinks. pub link: Option, } #[derive(Default)] pub struct Placed { pub texts: Vec, pub links: Vec, /// The slide's own shapes in drawing order (back to front; a group before its children). Hidden /// shapes are left out. pub shapes: Vec, } /// Text and links of slide `index` (master and layout graphics first, as rendered). pub fn place_slide(pres: &Presentation, index: usize) -> Placed { let mut out = Placed::default(); let Some(slide) = pres.slides.get(index) else { return out }; let Some(rctx) = Ctx::for_slide(pres, slide) else { return out }; let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default()); let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); if show_master { let mctx = Ctx::for_master(pres, rctx.master); for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) { place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0, None, false); } } if show_layout && let Some(l) = rctx.layout { let lctx = Ctx::for_layout(pres, rctx.master, l); for s in l.shapes.iter().filter(|s| s.ph.is_none()) { place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0, None, false); } } for s in &slide.shapes { place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0, None, true); } out } /// `group` is the index in `out.shapes` of the enclosing group; `own` is true for the slide's own /// shapes (recorded in `out.shapes`), false for the layout's and master's. #[allow(clippy::too_many_arguments)] fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize, group: Option, own: bool) { if depth > 32 || s.hidden { return; } let x = resolve::xfrm(rctx, s); if !x.is_finite() { return; } let m = parent * x.affine(); let rect = m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)); if let Some(link) = &s.click { out.links.push(PlacedLink { rect, link: link.clone(), shape: s.id, text: false }); } let me = if own { out.shapes.push(PlacedShape { id: s.id, parent: group, is_group: matches!(s.kind, ShapeKind::Group { .. }), rect, link: s.click.clone() }); Some(out.shapes.len() - 1) } else { None }; match &s.kind { ShapeKind::Group { children, child } => { let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); let gm = parent * deckcraft_geom::group_child_affine(&x, ch); for c in children { place(out, rctx, fields, c, gm, depth + 1, me, own); } } ShapeKind::Table(t) => { // Cell text, laid out exactly as the table draws it. for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) { let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; let body = table::cell_body(rctx, t, r, c, cell); let tmp = Shape { text: Some(body.clone()), ..Default::default() }; place_text(out, rctx, fields, s.id, &tmp, &body, rect, m); } } ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {} _ => { let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return }; let tr = shape_geometry(s, x.w, x.h).text_rect; place_text(out, rctx, fields, s.id, s, body, tr, m); } } } /// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks. #[allow(clippy::too_many_arguments)] fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); let m = crate::text_transform(m, tr, layout.rotation); // Text hyperlinks: one box per line segment of a linked run. for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) { let Some(link) = run_link(body, run.para, run.chars.0) else { continue }; let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue }; // The run's visual extent (right-to-left runs run from right to left). let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first)); let r = Rect::new(x0, line.top, x1, line.bottom); out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); } out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); } /// The hyperlink of the model run containing character `at` of paragraph `para`. fn run_link(body: &TextBody, para: usize, at: usize) -> Option { let p = body.paragraphs.get(para)?; let mut pos = 0; for r in &p.runs { let n = r.char_len(); if at < pos + n.max(1) { return r.props.link.clone(); } pos += n; } None }