mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
* 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>
207 lines
12 KiB
Rust
207 lines
12 KiB
Rust
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:?}");
|
||
}
|