collective

Browse / github.com/storytold/deckcraft / patch

PDF export: keep line breaks in the text layer's paragraph text

PDF export: keep line breaks in the text layer's paragraph text ## Problem A paragraph with a line break (Shift+Enter) has its characters scrambled in the PDF text layer. Text after a break is read from one position too early, so search and copy give wrong text. In a table cell I saw `BETA` come out as `ETAG`. ## Cause `text_layer` and `master_text_pieces` in `crates/pdf/src/lib.rs` build each paragraph's string with `p.runs.iter().map(|r| r.text.as_str()).collect::<String>()`, then cut the laid-out run's character range out of it. A `RunKind::Break` run has empty `text` but occupies one character in the layout, because `Run::content()` returns `"\u{b}"

Status
open
Score
0.00
Adopters
0 other operators
Author
narain1 (imported from pull request #1257, open)
Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8
Size
+159 −2 in 3 files
Review
pending
Shared
2026-10-28
Id
cf500697af1c47742c48b430

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/pdf/src/lib.rs crates/pdf/src/tests_line_breaks.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -135,6 +135,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 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 | PDF export: a line break inside a paragraph counts as one character in the text layer, so a paragraph with Shift+Enter breaks no longer shifts the characters after the break (`BETA` came out as `ETAG`) | | 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 | | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |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@@ -307,7 +307,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.text())                 .unwrap_or_default()                 .chars()                 .skip(run.chars.0)@@ -512,7 +512,7 @@ impl Exporter<'_> {                 .body                 .paragraphs                 .get(run.para)-                .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())+                .map(|p| p.text())                 .unwrap_or_default()                 .chars()                 .skip(run.chars.0)@@ -695,3 +695,6 @@ fn to_path(bp: &kurbo::BezPath) -> Option<krilla::geom::Path> {  #[cfg(test)] mod tests;++#[cfg(test)]+mod tests_line_breaks;diff --git a/crates/pdf/src/tests_line_breaks.rs b/crates/pdf/src/tests_line_breaks.rsnew file mode 100644--- /dev/null+++ b/crates/pdf/src/tests_line_breaks.rs@@ -0,0 +1,153 @@+//! Breaks occupy one logical character even though their stored run.text is empty.+use super::*;+use deckcraft_model::text::{Bullet, RunProps, TextBody};+use deckcraft_model::{Master, PhType, Placeholder, Shape, ShapeId, Xfrm};+use hayro_syntax::{+    content::ops::TypedInstruction,+    object::{Dict, Object, Stream},+};+use std::sync::Arc;++fn body(text: &str) -> TextBody {+    let mut body = TextBody::default();+    let props = RunProps { font: Some("Ubuntu".into()), size: Some(20.0), ..Default::default() };+    deckcraft_model::edit::insert(&mut body, (0, 0), text, Some(props));+    for paragraph in &mut body.paragraphs {+        paragraph.props.bullet = Some(Bullet::None);+    }+    body+}++fn presentation(text: &str) -> Presentation {+    let mut pres = Presentation::default();+    Arc::make_mut(&mut pres.slides[0]).shapes =+        vec![Shape { id: ShapeId(124), xfrm: Some(Xfrm::new(20.0, 20.0, 600.0, 400.0)), text: Some(body(text)), ..Default::default() }];+    pres+}++// The same bounded two-byte-CID/bfchar fixture decoding used by tests.rs. The+// actual content strings and emitted ToUnicode map are checked, not PDF metadata.+fn page_text(bytes: Vec<u8>) -> String {+    let pdf = hayro_syntax::Pdf::new(bytes).unwrap();+    assert_eq!(pdf.pages().len(), 1);+    let page = &pdf.pages()[0];+    let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();+    let Some(fonts) = resources.get::<Dict<'_>>(b"Font") else { return String::new() };+    assert!(!fonts.is_empty() && fonts.len() <= 8, "bounded generated fixture");+    eprintln!("line-break PDF fixture emitted {} fonts", fonts.len());+    let mut mappings = HashMap::new();+    for name in fonts.keys() {+        let font = fonts.get::<Dict<'_>>(&name).unwrap();+        let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();+        let cmap = std::str::from_utf8(&cmap).unwrap();+        assert!(cmap.len() < 64 * 1024);+        let mut mapping = HashMap::<u16, String>::new();+        let mut in_char_map = false;+        for line in cmap.lines() {+            if line.ends_with("beginbfchar") {+                in_char_map = true;+            } else if line == "endbfchar" {+                in_char_map = false;+            } else if in_char_map {+                let parts: Vec<_> = line.split_whitespace().collect();+                assert_eq!(parts.len(), 2);+                let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();+                let destination = parts[1].trim_matches(['<', '>']);+                assert!(!destination.is_empty() && destination.len().is_multiple_of(4));+                let units: Vec<_> = destination+                    .as_bytes()+                    .as_chunks::<4>()+                    .0+                    .iter()+                    .map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())+                    .collect();+                assert!(mapping.len() < 1024);+                mapping.insert(cid, String::from_utf16(&units).unwrap());+            }+        }+        mappings.insert(name.to_vec(), mapping);+    }+    let mut extracted = String::new();+    let mut current_font: Option<Vec<u8>> = None;+    let append = |bytes: &[u8], mapping: &HashMap<u16, String>, extracted: &mut String| {+        assert!(bytes.len() < 4096);+        assert!(bytes.len().is_multiple_of(2));+        for pair in bytes.as_chunks::<2>().0 {+            extracted.push_str(mapping.get(&u16::from_be_bytes(*pair)).unwrap());+        }+    };+    let mut operations = page.typed_operations();+    while let Some(operation) = operations.next() {+        match operation {+            TypedInstruction::TextFont(value) => {+                assert!(mappings.contains_key(value.0.as_ref()));+                current_font = Some(value.0.to_vec());+            }+            TypedInstruction::ShowText(value) => append(value.0.as_bytes(), mappings.get(current_font.as_ref().unwrap()).unwrap(), &mut extracted),+            TypedInstruction::ShowTexts(values) => {+                for value in values.0.iter::<Object<'_>>() {+                    if let Object::String(value) = value {+                        append(value.as_bytes(), mappings.get(current_font.as_ref().unwrap()).unwrap(), &mut extracted);+                    }+                }+            }+            _ => (),+        }+    }+    assert!(extracted.len() < 8192);+    extracted+}++#[test]+fn line_breaks_preserve_actual_pdf_text_and_native_character_ranges() {+    for text in ["ALPHABETA", "ALPHA\u{b}BETA", "\u{b}ALPHA\u{b}\u{b}BETA\u{b}", "Café e\u{301}\u{b}Ελληνικά", "\u{b}\u{b}"] {+        let pres = presentation(text);+        let placed = deckcraft_render::place_slide(&pres, 0);+        let placed = &placed.texts[0];+        assert_eq!(placed.body.paragraphs.len(), 1);+        assert_eq!(placed.body.paragraphs[0].text(), text);+        assert_eq!(placed.layout.lines.len(), text.chars().filter(|&c| c == '\u{b}').count() + 1);+        for run in &placed.layout.runs {+            assert!(!run.glyphs.is_empty());+            assert!(run.glyphs.iter().all(|glyph| glyph.0 != 0));+            let logical: String = text.chars().skip(run.chars.0).take(run.chars.1 - run.chars.0).collect();+            assert!(!logical.contains('\u{b}'));+        }+        let before = pres.clone();+        eprintln!("line-break source {text:?}; native faces {:?}", placed.layout.runs.iter().map(|r| r.face.family.as_str()).collect::<Vec<_>>());+        let bytes = export(&pres, &PdfOptions { dpi: 48.0, ..Default::default() }).unwrap();+        assert_eq!(page_text(bytes), text.replace('\u{b}', ""));+        assert_eq!(pres, before, "export keeps source model unchanged");+    }+}++#[test]+fn line_breaks_preserve_master_pieces_and_notes_handout_pdf_text() {+    for header_text in ["HeadA\u{b}HeadB", "Café\u{b}\u{b}Ελληνικά\u{b}"] {+        let mut pres = presentation("SlideA\u{b}SlideB");+        pres.notes_size = deckcraft_model::Size::new(612.0, 792.0);+        let master = Arc::new(Master {+            shapes: vec![Shape {+                id: ShapeId(125),+                xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 150.0)),+                ph: Some(Placeholder { kind: PhType::Header, ..Default::default() }),+                text: Some(body(header_text)),+                ..Default::default()+            }],+            ..Default::default()+        });+        let pieces = master_text_pieces(&pres, &master, (612.0, 792.0), 1);+        let pieces_text: String = pieces.iter().map(|p| p.text.as_str()).collect();+        assert_eq!(pieces_text, header_text.replace('\u{b}', ""));+        assert!(pieces.len() >= 2);+        assert!(pieces.first().unwrap().at.1 < pieces.last().unwrap().at.1);+        pres.notes_master = Some(master.clone());+        pres.handout_master = Some(master);+        for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {+            let bytes = export(&pres, &PdfOptions { layout, dpi: 48.0, ..Default::default() }).unwrap();+            let text = page_text(bytes);+            assert_eq!(text.matches("SlideASlideB").count(), 1, "{layout:?}: {text:?}");+            assert_eq!(text.matches(&header_text.replace('\u{b}', "")).count(), 1, "{layout:?}: {text:?}");+        }+    }+}