From cc6773c95f1f23800c82329ec404f68868c8b22b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:02:59 +0200 Subject: [PATCH 1/4] Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153) --- crates/render/src/placed.rs | 21 ++++++++++++++++++--- crates/render/src/tests.rs | 12 ++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 0164f3f07..f83af46d8 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -2,9 +2,9 @@ //! 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::text::{Hyperlink, RunKind, TextBody}; use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; -use deckcraft_text::{Opts, TextLayout}; +use deckcraft_text::{Fields, Opts, TextLayout}; use kurbo::{Affine, Rect}; use crate::{SlideFields, shape_geometry, table}; @@ -162,7 +162,22 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId 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() }); + // Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that + // goes with it (character offsets, text layer) must hold the same values. + let mut body = body.clone(); + for para in &mut body.paragraphs { + let src = para.clone(); + for run in &mut para.runs { + if let RunKind::Field { field } = &run.kind { + let lang = resolve::run(rctx, s, &src, &run.props).lang; + if let Some(v) = fields.field_in(field, lang.as_deref()) { + run.text = v; + run.kind = RunKind::Text; + } + } + } + } + out.texts.push(PlacedText { layout, transform: m, body }); } /// The hyperlink of the model run containing character `at` of paragraph `para`. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4ee1c889d..9987bd7c9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2595,3 +2595,15 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179)); assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } + +#[test] +fn placed_text_holds_the_field_value_not_the_saved_placeholder() { + let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]); + let second = p.slides[0].clone(); + p.slides.push(second); + let placed = place_slide(&p, 1); + assert_eq!(placed.texts.len(), 1); + assert_eq!(placed.texts[0].body.text(), "2"); + // The layout was drawn from the same value, so the offsets agree with the body. + assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1)); +} From 813378cc20aa0d94228bb3f700645e7d546b795d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:08:53 +0200 Subject: [PATCH 2/4] Keep the PDF text layer aligned with line breaks, use one clock reading for raster and text layer, and test the page text (#1153) --- crates/pdf/src/lib.rs | 9 +++--- crates/pdf/src/tests.rs | 61 ++++++++++++++++++++++++++++++++++--- crates/render/src/lib.rs | 2 +- crates/render/src/placed.rs | 30 ++++++++++-------- crates/render/src/tests.rs | 9 ++++++ 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 22dc42d36..cc5d5e56a 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -299,7 +299,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape .body .paragraphs .get(run.para) - .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) + .map(|p| p.runs.iter().map(|r| r.content()).collect::()) .unwrap_or_default() .chars() .skip(run.chars.0) @@ -440,7 +440,8 @@ impl Exporter<'_> { let k = b.width() / sw; // Raster at the requested dpi relative to the box size on paper. let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; - let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; + // One clock reading for the raster and the text layer, so a date field cannot differ between them. + let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() }; // Two rasters per slide: the backdrop (background, master and layout graphics) is the same // on every slide that shares its layout, so the writer stores it once, and the slide's own // shapes draw over it (#845). @@ -476,7 +477,7 @@ impl Exporter<'_> { } if self.opts.text_layer { let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); - for pt in deckcraft_render::place_slide(pres, i).texts { + for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts { self.text_layer(s, &pt, view, false)?; } let _ = sh; @@ -494,7 +495,7 @@ impl Exporter<'_> { .body .paragraphs .get(run.para) - .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) + .map(|p| p.runs.iter().map(|r| r.content()).collect::()) .unwrap_or_default() .chars() .skip(run.chars.0) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2bc5c41ac..fd1839377 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -200,8 +200,6 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() { #[cfg_attr(not(target_arch = "wasm32"), test)] fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { use deckcraft_model::{Shape, ShapeId, Xfrm}; - use hayro_syntax::content::ops::TypedInstruction; - use hayro_syntax::object::{Dict, Object, Stream}; let text = "Café Ελληνικά Привет"; let face = FontDb::global().face("Ubuntu", "Regular"); @@ -223,8 +221,17 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0))); let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); - let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); - let page = &pdf.pages()[0]; + assert_eq!(page_text(&bytes, 0), text); +} + +/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing +/// operators decoded through the page font's ToUnicode map. +fn page_text(bytes: &[u8], index: usize) -> String { + use hayro_syntax::content::ops::TypedInstruction; + use hayro_syntax::object::{Dict, Object, Stream}; + + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + let page = &pdf.pages()[index]; let resources = page.raw().get::>(b"Resources").unwrap(); let fonts = resources.get::>(b"Font").unwrap(); assert_eq!(fonts.len(), 1); @@ -275,7 +282,51 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { _ => {} } } - assert_eq!(extracted, text); + extracted +} + +fn ubuntu_runs(runs: Vec) -> TextBody { + let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() }; + let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() }; + for run in &mut body.paragraphs[0].runs { + run.props.font = Some("Ubuntu".into()); + run.props.size = Some(20.0); + } + body +} + +fn deck_with_body(n: usize, body: TextBody) -> Presentation { + use deckcraft_model::{Shape, ShapeId, Xfrm}; + let mut p = deck(n); + for slide in &mut p.slides { + Arc::make_mut(slide).shapes = + vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }]; + } + p +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn slide_number_field_is_recovered_from_the_page_text_layer() { + use deckcraft_model::text::{Run, RunKind}; + let mut field = Run::new("<#>"); + field.kind = RunKind::Field { field: "slidenum".into() }; + let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field])); + let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); + for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() { + assert_eq!(page_text(&bytes, i), want); + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn line_break_keeps_the_text_layer_aligned_with_the_layout() { + use deckcraft_model::text::{Run, RunKind}; + let mut brk = Run::new(""); + brk.kind = RunKind::Break; + let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")])); + let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); + assert_eq!(page_text(&bytes, 0), "onetwo"); } /// Notes pages and handouts print on the chosen paper, landscape included, with every slide box diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cf124a9f6..722162721 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,7 +53,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; -pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline}; +pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline}; pub use table::cell_body as table_cell_body; pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index f83af46d8..2081b2400 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -52,10 +52,16 @@ pub struct Placed { /// Text and links of slide `index` (master and layout graphics first, as rendered). pub fn place_slide(pres: &Presentation, index: usize) -> Placed { + place_slide_with(pres, index, &crate::RenderOpts::default()) +} + +/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same +/// date and time as the raster (`opts.now`). +pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> 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 fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts); let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); if show_master { let mctx = Ctx::for_master(pres, rctx.master); @@ -164,20 +170,20 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId } // Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that // goes with it (character offsets, text layer) must hold the same values. - let mut body = body.clone(); - for para in &mut body.paragraphs { - let src = para.clone(); - for run in &mut para.runs { - if let RunKind::Field { field } = &run.kind { - let lang = resolve::run(rctx, s, &src, &run.props).lang; - if let Some(v) = fields.field_in(field, lang.as_deref()) { - run.text = v; - run.kind = RunKind::Text; - } + let mut placed = body.clone(); + for (pi, para) in body.paragraphs.iter().enumerate() { + for (ri, run) in para.runs.iter().enumerate() { + let RunKind::Field { field } = &run.kind else { continue }; + let lang = resolve::run(rctx, s, para, &run.props).lang; + if let Some(v) = fields.field_in(field, lang.as_deref()) + && let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri)) + { + out_run.text = v; + out_run.kind = RunKind::Text; } } } - out.texts.push(PlacedText { layout, transform: m, body }); + out.texts.push(PlacedText { layout, transform: m, body: placed }); } /// The hyperlink of the model run containing character `at` of paragraph `para`. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9987bd7c9..b9011f999 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2607,3 +2607,12 @@ fn placed_text_holds_the_field_value_not_the_saved_placeholder() { // The layout was drawn from the same value, so the offsets agree with the body. assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1)); } + +#[test] +fn placed_text_takes_its_date_from_the_given_render_options() { + use deckcraft_text::datetime::DateTime; + let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]); + let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() }); + assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026"); + assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027"); +} From 50187449c20d58d8b014a3eb04b967ef3cc687b2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:52:46 +0100 Subject: [PATCH 3/4] Render tests: import place_slide_with so the date test compiles (#1153) --- crates/render/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 05306f66c..540a8500a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,7 +1,7 @@ use super::{ Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph, crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide, - render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, + place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, }; use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; From 65ddd8ad517e15032195059c73f54c690e5d2dbf Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:55:43 +0000 Subject: [PATCH 4/4] Apply review fixes - crates/render/src/placed.rs --- crates/render/src/placed.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index bde0fa157..483db41bc 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -160,16 +160,6 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: 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 }); - } // Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that // goes with it (character offsets, text layer) must hold the same values. let mut placed = body.clone(); @@ -185,6 +175,16 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId } } } + // 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(&placed, 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: placed }); }