Files
pdfcraft/crates/print/src/tests.rs
T
6c89837541 fix: print: read lpstat untranslated so printers show on non-English macOS (#101)
* Print: read lpstat untranslated so printers show on non-English macOS

On a Mac whose interface language isn't English, the Print dialog
listed no printers. `lpstat -p -d` follows the UI language (here
Polish: "drukarka … jest bezczynna", "domyślny cel systemowy: …"), and
parse_lpstat only knows the English "printer " and "system default
destination:" prefixes, so it matched nothing.

- lpstat now runs with LC_ALL=C and LANG=C, which is enough on Linux.
  macOS CUPS ignores both and follows AppleLanguages unless SOFTWARE
  is set, in which case it uses LANG, so SOFTWARE is set too.
- New test: localized output parses to nothing, and the command sets
  all three variables.

Checked on macOS with a Polish UI: only SOFTWARE=1 LANG=C gives English
output, and the dialog now lists every printer with the right default.
lp's error messages stay localized; they are shown, never parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the PdfCraft name in lpstat's SOFTWARE variable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:12:45 -04:00

207 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use super::*;
use crate::spool::{Duplex, Job, lp_args, parse_lpstat};
/// `n` pages of 200×300 (page 3 is landscape 300×200 when n ≥ 3); page i shows "(Page i+1)".
/// Page 1 carries a printable square comment, a non-printing note, and a stamp.
fn fixture(n: usize) -> Document {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 6 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
// 4: an appearance stream; 5: unused.
let ap = b"0 0 1 rg 0 0 10 10 re f";
objs.push(
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length {} >>\nstream\n{}\nendstream", ap.len(), String::from_utf8_lossy(ap))
.into_bytes(),
);
objs.push(b"null".to_vec());
for i in 0..n {
let annots = if i == 0 {
" /Annots [<< /Type /Annot /Subtype /Square /F 4 /Rect [10 10 50 50] /AP << /N 4 0 R >> >> << /Type /Annot /Subtype /Text /F 0 /Rect [60 10 80 30] /AP << /N 4 0 R >> >> << /Type /Annot /Subtype /Stamp /F 4 /Rect [100 10 140 50] /AP << /N 4 0 R >> >>]"
} else {
""
};
let mb = if i == 2 { " /MediaBox [0 0 300 200]" } else { "" };
objs.push(
format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >>{mb}{annots} >>", 7 + 2 * i).into_bytes(),
);
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn settings(pages: Vec<usize>, layout: Layout) -> Settings {
Settings { pages, paper: (612.0, 792.0), layout, ..Settings::default() }
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 0.01
}
#[test]
fn page_selection() {
let labels: Vec<String> = ["i", "ii", "1", "2", "A-1"].iter().map(|s| s.to_string()).collect();
assert_eq!(select_pages(5, None, &labels, Subset::All, false).unwrap(), [0, 1, 2, 3, 4]);
assert_eq!(select_pages(5, Some("1-2, 5"), &[], Subset::All, false).unwrap(), [0, 1, 4]);
assert_eq!(select_pages(5, Some("4-"), &[], Subset::All, false).unwrap(), [3, 4]);
assert_eq!(select_pages(5, Some("-2"), &[], Subset::All, false).unwrap(), [0, 1]);
assert_eq!(select_pages(5, Some("ii-2, A-1"), &labels, Subset::All, false).unwrap(), [1, 2, 3, 4], "labels, even with a dash");
assert_eq!(select_pages(5, None, &[], Subset::Even, true).unwrap(), [3, 1]);
assert_eq!(select_pages(5, None, &[], Subset::Odd, false).unwrap(), [0, 2, 4]);
assert!(matches!(select_pages(5, Some("7"), &[], Subset::All, false), Err(PrintError::Invalid(_))));
assert!(matches!(select_pages(5, Some("x"), &[], Subset::All, false), Err(PrintError::Invalid(_))));
assert_eq!(select_pages(1, None, &[], Subset::Even, false), Err(PrintError::NoPages));
}
#[test]
fn size_modes() {
let sizes = [(200.0, 300.0), (1000.0, 1500.0)];
let fit = layout(&sizes, &settings(vec![0, 1], Layout::Size(SizeMode::Fit))).unwrap();
let s0 = fit[0].placed[0].matrix.0[0];
assert!(close(s0, 2.52), "min(576 / 200, 756 / 300): {s0}");
let actual = layout(&sizes, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap();
assert_eq!(actual[0].placed[0].matrix.0, [1.0, 0.0, 0.0, 1.0, 206.0, 246.0], "centred at 100%");
let shrink = layout(&sizes, &settings(vec![0, 1], Layout::Size(SizeMode::Shrink))).unwrap();
assert_eq!(shrink[0].placed[0].matrix.0[0], 1.0, "small pages stay at 100%");
assert!(shrink[1].placed[0].matrix.0[0] < 1.0, "big pages shrink");
let custom = layout(&sizes, &settings(vec![0], Layout::Size(SizeMode::Custom(50.0)))).unwrap();
assert_eq!(custom[0].placed[0].matrix.0[0], 0.5);
// Auto orientation turns the sheet for landscape pages.
let land = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap();
assert_eq!(land[0].size, (792.0, 612.0));
}
#[test]
fn multiple_pages_per_sheet() {
let sizes = vec![(200.0, 300.0); 5];
let sheets = layout(&sizes, &settings((0..5).collect(), Layout::multiple(4))).unwrap();
assert_eq!(sheet_pages(&sheets), [vec![0, 1, 2, 3], vec![4]]);
// Horizontal order: 0 top-left, 1 top-right, 2 bottom-left.
let o = |k: usize| (sheets[0].placed[k].matrix.0[4], sheets[0].placed[k].matrix.0[5]);
assert!(o(1).0 > o(0).0 && close(o(1).1, o(0).1) && o(2).1 < o(0).1 && close(o(2).0, o(0).0));
// Two per sheet prints side by side on landscape paper.
let two = layout(&sizes, &settings(vec![0, 1], Layout::multiple(2))).unwrap();
assert_eq!(two[0].size, (792.0, 612.0));
assert!(two[0].placed[1].matrix.0[4] > two[0].placed[0].matrix.0[4]);
// Vertical order and borders.
let v =
layout(&sizes, &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, order: PageOrder::Vertical, border: true, auto_rotate: false }))
.unwrap();
assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]);
assert_eq!(v[0].borders.len(), 3);
// Auto-rotate turns a landscape page in a portrait cell.
let r = layout(&[(300.0, 200.0), (200.0, 300.0)], &settings(vec![0, 1], Layout::multiple(4))).unwrap();
// The first (landscape) page sets a landscape sheet; the portrait page is turned to fit.
assert_eq!(r[0].size, (792.0, 612.0));
assert!(r[0].placed[0].matrix.0[0] > 0.0);
assert_eq!(r[0].placed[1].matrix.0[0], 0.0, "rotated");
}
#[test]
fn booklets_pair_pages_for_folding() {
let sizes = vec![(200.0, 300.0); 6];
let sheets = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })).unwrap();
// 6 pages pad to 8: sheet 1 front [8,1] → [blank, 0], back [1, 6]; sheet 2 front [5, 2], back [3, 4].
assert_eq!(sheet_pages(&sheets), [vec![0], vec![1], vec![5, 2], vec![3, 4]]);
assert!(sheets[0].placed[0].matrix.0[4] >= 395.9, "page 1 on the right half");
assert_eq!(sheets[0].size, (792.0, 612.0));
let front = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right })).unwrap();
assert_eq!(sheet_pages(&front), [vec![0], vec![2, 5]], "right binding mirrors the spread");
}
#[test]
fn posters_tile_with_overlap() {
// A 200×300 page at 400% = 800×1200 on Letter with 18 pt margins (576×756 tiles), overlap 36.
let sheets = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Poster { scale: 400.0, overlap: 36.0, cut_marks: true })).unwrap();
assert_eq!(sheets.len(), 4, "2 × 2 tiles");
assert_eq!(sheets[0].lines.len(), 8, "cut marks");
let c0 = sheets[0].placed[0].clip;
let c1 = sheets[1].placed[0].clip;
assert!(close(c0[2] - c1[0], 36.0 / 4.0), "neighbouring tiles share the overlap");
assert!(layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Poster { scale: 400.0, overlap: 400.0, cut_marks: false })).is_err());
}
#[test]
fn imposed_pdf_has_the_sheets_and_honours_comments_and_forms() {
let doc = fixture(3);
let out = impose(&doc, &settings(vec![0, 1, 2], Layout::multiple(2))).unwrap();
let printed = Document::open(Arc::new(out.clone())).unwrap();
let pages = pdfcraft_model::pages(&printed);
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].crop(&printed), [0.0, 0.0, 792.0, 612.0]);
// The source pages are form XObjects holding their content.
let streams = |d: &Document| -> Vec<String> {
d.object_numbers()
.into_iter()
.filter_map(|n| match &*d.get(ObjRef::new(n, d.generation(n))) {
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
})
.collect()
};
let all = streams(&printed).join("\n");
assert!(all.contains("(Page 1)") && all.contains("(Page 3)"));
// Markups: the printable square and the stamp, not the note without the Print flag.
let page1 = streams(&printed).into_iter().find(|s| s.contains("(Page 1)")).unwrap();
assert_eq!(page1.matches(" Do Q").count(), 2, "{page1}");
let doc_only = impose(&doc, &Settings { content: Content::Document, ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap();
let p = streams(&Document::open(Arc::new(doc_only)).unwrap()).into_iter().find(|s| s.contains("(Page 1)")).unwrap();
assert_eq!(p.matches(" Do Q").count(), 0);
let stamps = impose(&doc, &Settings { content: Content::DocumentAndStamps, ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap();
let p = streams(&Document::open(Arc::new(stamps)).unwrap()).into_iter().find(|s| s.contains("(Page 1)")).unwrap();
assert_eq!(p.matches(" Do Q").count(), 1);
let fields = impose(&doc, &Settings { content: Content::FormFieldsOnly, ..settings(vec![1], Layout::Size(SizeMode::Fit)) }).unwrap();
assert!(!streams(&Document::open(Arc::new(fields)).unwrap()).join("").contains("(Page 2)"));
// The output is a fresh file: the unused source pages are gone.
let one = impose(&doc, &settings(vec![1], Layout::Size(SizeMode::Fit))).unwrap();
let s = streams(&Document::open(Arc::new(one)).unwrap()).join("\n");
assert!(s.contains("(Page 2)") && !s.contains("(Page 1)"));
}
#[test]
fn spooler_arguments_and_printer_list() {
let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n";
assert_eq!(
parse_lpstat(out),
[spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Label_Writer".into(), default: false }]
);
assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty());
let job =
Job { printer: Some("Office_Laser".into()), copies: 3, collate: false, duplex: Duplex::LongEdge, grayscale: true, title: "memo.pdf".into() };
assert_eq!(
lp_args(&job, "/tmp/x.pdf").join(" "),
"-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false -- /tmp/x.pdf"
);
assert_eq!(lp_args(&Job::default(), "f.pdf")[0], "-n", "no -d: the default printer");
}
#[test]
fn lpstat_output_is_untranslated() {
// A localized lpstat (here Polish) is unreadable to parse_lpstat...
assert!(parse_lpstat("drukarka Office_Laser jest bezczynna.\ndomyślny cel systemowy: Office_Laser\n").is_empty());
// ...so the command must force the C locale, including the SOFTWARE switch macOS CUPS needs.
let cmd = spool::lpstat_command();
let envs: Vec<_> = cmd.get_envs().map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(|v| v.to_string_lossy().into_owned()))).collect();
for key in ["LC_ALL", "LANG"] {
assert!(envs.contains(&(key.into(), Some("C".into()))), "{key}=C missing: {envs:?}");
}
assert!(envs.iter().any(|(k, v)| k == "SOFTWARE" && v.as_deref().is_some_and(|v| !v.is_empty())), "SOFTWARE missing: {envs:?}");
}