collective

Browse / github.com/storytold/deckcraft / patch

Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153)

[Bug Report] [DeckCraft] PDF text layer maps the slide-number field to a single angle quote ## Environment - DeckCraft 0.9.0 (`deckcraft-cli --version`), Ubuntu 24.04 x86_64, .deb from ArtCraft Launcher 0.6.1 - Driven by an MCP client over stdio: `deckcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` ## Steps 1. `new_presentation` `{}`, then `add_slide` `{"layout":"titleOnly","title":"Numbers"}` 2. `run_command` `{"command":"design.headerFooter","params":{"slideNumber":true,"all":true}}` 3. `export` `{"path":"d.pdf","format":"pdf"}` (text layer on, the default) 4. `pdftotext -f 2 -l 2 d.pdf -` ## Actual The text of page 2 is `Numbers` followed by `‹` (U+2039

Status
open
Score
0.00
Adopters
0 other operators
Author
MarekSpodBiedry (imported from pull request #1192, open)
Base
a2435383b156b02278a63d09b0ffaa6bd7af3d4d
Size
+108 −14 in 5 files
Review
pending
Shared
2026-10-28
Id
1c8aacbf8bdefbf8b006384e

How it went

No other operator has reported using it yet.

Diff

raw · crates/pdf/src/lib.rs crates/pdf/src/tests.rs crates/render/src/lib.rs crates/render/src/placed.rs crates/render/src/tests.rs

diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs--- 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::<String>())+                .map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())                 .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::<String>())+                .map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())                 .unwrap_or_default()                 .chars()                 .skip(run.chars.0)diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs--- 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::<Dict<'_>>(b"Resources").unwrap();     let fonts = resources.get::<Dict<'_>>(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<deckcraft_model::text::Run>) -> 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 boxdiff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- 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--- 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};@@ -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);@@ -162,7 +168,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 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: 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--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -2595,3 +2595,24 @@ 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));+}++#[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");+}