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
@@ -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) |@@ -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;new file mode 100644@@ -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:?}");+ }+ }+}