Merge PR #2754: Keep leading spaces and tabs when wrapping plain text into a PDF

This commit is contained in:
aiongg
2026-10-16 14:02:08 +00:00
2 changed files with 36 additions and 3 deletions
+12
View File
@@ -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]
+24 -3
View File
@@ -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<u8> {
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', '?'] {