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
@@ -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 |@@ -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)@@ -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 box@@ -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`.@@ -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 ¶.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()));