From fdc8099ac2b30a025b43c73c12b1f7b482df1d35 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:08:29 +0000 Subject: [PATCH] Keep leading spaces and tabs when wrapping plain text into a PDF Found while testing crates/fonts/src/lib.rs:79 (wrap_with), called from crates/create/src/lib.rs:set_text Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/create/src/tests.rs | 12 ++++++++++++ crates/fonts/src/lib.rs | 27 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f..476316f34 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_keeps_leading_spaces_and_tabs() { + let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); + let p = pages(&doc); + let c = doc.resolve(p[0].get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(content.contains("( indented) Tj"), "{content}"); + assert!(content.contains("( tabbed) Tj"), "{content}"); + assert!(content.contains("(plain) Tj"), "{content}"); +} + /// Text Helvetica can't draw is refused with its line and character, never set as `?`; text /// PdfCraft generates writes such characters as [U+XXXX] and says so. #[test] diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index cd0d8358e..a1f2ff277 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -80,13 +80,19 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) let mut lines = Vec::new(); for para in text.split(['\n', '\r']) { let mut line = String::new(); + // No word is on the line yet; an empty word (a leading space) still starts it. + let mut fresh = true; for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width { + let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; + if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { line = candidate; + fresh = false; continue; } - if !line.is_empty() { + // Indentation alone is not a line: drop it before an over-long word. + if line.trim().is_empty() { + line.clear(); + } else { lines.push(std::mem::take(&mut line)); } for ch in word.chars() { @@ -95,6 +101,7 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } line.push(ch); } + fresh = line.is_empty(); } lines.push(line); } @@ -256,6 +263,20 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn wrap_keeps_leading_spaces() { + assert_eq!(wrap(" indented", 11.0, 500.0), [" indented"]); + assert_eq!(wrap(" a b\n c", 11.0, 500.0), [" a b", " c"]); + assert_eq!(wrap("", 11.0, 500.0), [""]); + // A single leading space before a word that must be broken leaves no blank first line. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.is_empty()), "{broken:?}"); + // So does indentation of two or more spaces: no whitespace-only line, same as one space. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.trim().is_empty()), "{broken:?}"); + assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); + } + #[test] fn win_ansi_encodability_matches_the_encoder() { for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] {