collective

Browse / github.com/storytold/deckcraft / patch

PDF export: a slide number is searchable as its number, not the saved field text (#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
Zetenypapp (imported from pull request #1197, open)
Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8
Size
+93 −17 in 5 files
Review
pending
Shared
2026-10-28
Id
69d9ef935bca744666431624

How it went

No other operator has reported using it yet.

Diff

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

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-22 | PDF export: the text layer holds what a field shows, so a slide number is searchable and copies as its number instead of the saved `‹#›` (#1153) | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |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@@ -303,11 +303,11 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape     for pt in deckcraft_render::place_master_texts(pres, m, n) {         for run in &pt.layout.runs {             let Some(g) = run.glyphs.first() else { continue };+            // The text as drawn: a slide number is its value, not the saved `‹#›` (#1153).             let text: String = pt-                .body-                .paragraphs+                .shown                 .get(run.para)-                .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())+                .map(String::as_str)                 .unwrap_or_default()                 .chars()                 .skip(run.chars.0)@@ -508,11 +508,11 @@ impl Exporter<'_> {             if run.glyphs.is_empty() || run.size <= 0.0 {                 continue;             }+            // The text as drawn: a slide number is its value, not the saved `‹#›` (#1153).             let text: String = pt-                .body-                .paragraphs+                .shown                 .get(run.para)-                .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())+                .map(String::as_str)                 .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,18 @@ 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 extracted = page_text(bytes, 0);+    assert_eq!(extracted, text);+}++/// The text a PDF reader extracts from page `index` (one embedded font): the text-showing+/// operators decoded through the font's ToUnicode map, not document metadata.+fn page_text(bytes: Vec<u8>, index: usize) -> String {+    use hayro_syntax::content::ops::TypedInstruction;+    use hayro_syntax::object::{Dict, Object, Stream};+     let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();-    let page = &pdf.pages()[0];+    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 +283,55 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {             _ => {}         }     }-    assert_eq!(extracted, text);+    extracted+}++/// A slide-number field reaches the text layer as the number drawn, not the saved placeholder+/// (#1153).+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn slide_number_fields_are_searchable_as_their_number() {+    use deckcraft_model::text::{Run, RunKind};+    use deckcraft_model::{Shape, ShapeId, Xfrm};++    let mut p = deck(3);+    for (i, slide) in p.slides.iter_mut().enumerate() {+        let mut body = TextBody::from_text("Page ");+        let props = body.paragraphs[0].runs[0].props.clone();+        body.paragraphs[0].runs.push(Run { kind: RunKind::Field { field: "slidenum".into() }, text: "\u{2039}#\u{203a}".into(), props });+        for run in &mut body.paragraphs[0].runs {+            run.props.font = Some("Ubuntu".into());+            run.props.size = Some(20.0);+        }+        Arc::make_mut(slide).shapes =+            vec![Shape { id: ShapeId(9100 + i as u32), xfrm: Some(Xfrm::new(20.0, 20.0, 600.0, 60.0)), text: Some(body), ..Default::default() }];+    }+    let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();+    for i in 0..3 {+        assert_eq!(page_text(bytes.clone(), i), format!("Page {}", i + 1));+    }+}++/// A line break (Shift+Enter) inside a paragraph doesn't shift the text after it in the text layer:+/// the layout counts it as a character, and so does the layer's text now.+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]+#[cfg_attr(not(target_arch = "wasm32"), test)]+fn a_line_break_keeps_the_text_layer_in_step() {+    use deckcraft_model::text::{Run, RunKind};+    use deckcraft_model::{Shape, ShapeId, Xfrm};++    let mut body = TextBody::from_text("one");+    let props = body.paragraphs[0].runs[0].props.clone();+    body.paragraphs[0].runs.push(Run { kind: RunKind::Break, text: String::new(), props: props.clone() });+    body.paragraphs[0].runs.push(Run { kind: RunKind::Text, text: "two".into(), props });+    for run in &mut body.paragraphs[0].runs {+        run.props.font = Some("Ubuntu".into());+        run.props.size = Some(20.0);+    }+    let mut p = deck(1);+    Arc::make_mut(&mut p.slides[0]).shapes =+        vec![Shape { id: ShapeId(9200), xfrm: Some(Xfrm::new(20.0, 20.0, 600.0, 120.0)), text: Some(body), ..Default::default() }];+    assert_eq!(page_text(export(&p, &opts(PageLayout::Slides)).unwrap(), 0), "onetwo"); }  /// Notes pages and handouts print on the chosen paper, landscape included, with every slide boxdiff --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@@ -14,7 +14,11 @@ pub struct PlacedText {     pub layout: TextLayout,     /// Shape-local → slide points (text rotation included, flips not: see [`crate::text_transform`]).     pub transform: Affine,+    /// The text as saved. Its character offsets are not `layout`'s where a field or a line break+    /// is drawn differently: read the text the runs index into from `shown`.     pub body: TextBody,+    /// Each paragraph's text as laid out, fields evaluated: what `layout`'s runs index into.+    pub shown: Vec<String>, }  /// A clickable area: a shape action or a text hyperlink.@@ -162,7 +166,8 @@ 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() });+    let shown = deckcraft_text::shown_paragraphs(rctx, s, body, fields);+    out.texts.push(PlacedText { layout, transform: m, body: body.clone(), shown }); }  /// The hyperlink of the model run containing character `at` of paragraph `para`.diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs--- a/crates/text/src/lib.rs+++ b/crates/text/src/lib.rs@@ -700,6 +700,25 @@ pub fn autonum(scheme: &str, n: u32) -> String {     } } +/// The characters run `run` lays out: a field's current value (in the run's language `lang`), a+/// line break as `\u{b}`, otherwise its text.+fn shown_run(run: &deckcraft_model::text::Run, lang: Option<&str>, fields: &dyn Fields) -> String {+    match &run.kind {+        RunKind::Text | RunKind::Math { .. } => run.text.clone(),+        RunKind::Break => "\u{b}".into(),+        RunKind::Field { field } => fields.field_in(field, lang).unwrap_or_else(|| run.text.clone()),+    }+}++/// Each paragraph's text as [`layout`] lays it out, fields evaluated: the text that+/// [`GlyphRun::chars`] index into (#1153).+pub fn shown_paragraphs(ctx: &Ctx, shape: &Shape, body: &TextBody, fields: &dyn Fields) -> Vec<String> {+    body.paragraphs+        .iter()+        .map(|para| para.runs.iter().map(|run| shown_run(run, resolve::run(ctx, shape, para, &run.props).lang.as_deref(), fields)).collect())+        .collect()+}+ #[allow(clippy::too_many_arguments)] fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>, vertical: Upright) -> ParaShaped {     let db = FontDb::global();@@ -729,12 +748,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,     for run in &para.runs {         let rp = resolve::run(ctx, shape, para, &run.props);         let st = make_style(ctx, &rp, scale, opts.prompt_color);-        let t: String = match &run.kind {-            RunKind::Text => run.text.clone(),-            RunKind::Break => "\u{b}".into(),-            RunKind::Field { field } => opts.fields.field_in(field, rp.lang.as_deref()).unwrap_or_else(|| run.text.clone()),-            RunKind::Math { .. } => run.text.clone(),-        };+        let t = shown_run(run, rp.lang.as_deref(), opts.fields);         let si = styles.len();         styles.push(st);         char_style.extend(std::iter::repeat_n(si, t.chars().count()));