From 0348d2990fb42c7b50b29abe97bffc13a493a1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:35:49 +0000 Subject: [PATCH 1/5] print: give the job ticket the driver's own prefix for its vendor namespace Windows' PTMergeAndValidatePrintTicket names a vendor namespace with a generated prefix (ns0000) as soon as the merged ticket uses a name from it. driver_ticket merges the driver's own paper option from its capabilities, which carries vendor properties, so a Brother HL-L2390DW's ticket came back with ns0000 where the driver's own default ticket says brpsk. XML-wise the two are the same; the driver's print pipeline reads the prefix as text. The job was accepted and then ID2D1PrintControl::AddPage (or Close, in older builds) failed with 0x80040003, reported by winprint as "Render error" / "Failed to close print control: No such interface supported". The same ticket sent raw with brpsk prints; with only the prefix changed to ns0000 it fails the same way, so the prefix is the whole difference. driver_ticket now re-spells the validated ticket with the prefixes of the driver's own default ticket (an empty ticket, validated), through ticket::respell: pure XML, tested on every platform, and a no-op unless a vendor namespace is spelled differently. It rewrites only the root declarations and the vendor names in name / constrained attributes and QName values, handles two namespaces trading prefixes, and leaves the ticket as it is when a rename could be ambiguous or leave a prefix undeclared. Verified on a Brother HL-L2390DW: the job spools, and pdfcraft head's doc_print that failed at AddPage prints. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FQ5PzHVUcntEHU97znim5u --- crates/print/README.md | 7 +- crates/print/src/ticket.rs | 197 ++++++++++++++++++++++++++++++++++++ crates/print/src/windows.rs | 22 +++- 3 files changed, 223 insertions(+), 3 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index 00b12689..5ccfce05 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -23,8 +23,11 @@ opens Windows printer preferences and submits jobs with copies, collation, duple orientation and "no scaling" travel in a PrintTicket that the driver validates first; a driver that would change the paper, orientation or scaling is refused before anything is sent. Paper is the driver's own option for the sheet's size (to 0.25 mm), preferring the one - named like the standard size ("ARCH D" over a custom 24 × 36 in entry). This backend comes - from storytold/pdfcraft#599 and #1132. + named like the standard size ("ARCH D" over a custom 24 × 36 in entry). Windows' ticket merge + renames a driver's vendor namespace prefix (`brpsk` to `ns0000`) once the ticket uses one of + its names, and a Brother driver then fails every page with `0x80040003`; the validated ticket + is given the prefixes of the driver's own default ticket back (`ticket::respell`). This backend + comes from storytold/pdfcraft#599 and #1132. A job has one ticket, so one paper and orientation. Sheets of the other orientation are turned back with `/Rotate`, so portrait and landscape sheets still print as one job. Exact size refuses sheets of different sizes and paper the driver lacks. Shrink-to-fit uses the diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 5f8c914b..955ed343 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -206,6 +206,152 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( Ok(()) } +const NS_XSI: &str = "http://www.w3.org/2001/XMLSchema-instance"; +const NS_XSD: &str = "http://www.w3.org/2001/XMLSchema"; +const NS_XML: &str = "http://www.w3.org/XML/1998/namespace"; + +/// A PrintTicket that sets nothing. Merged and validated by a driver it comes back as the +/// driver's own default ticket, which spells the driver's vendor namespaces the way the driver does. +pub(crate) fn empty_ticket() -> String { + format!(r#""#) +} + +/// The (prefix, namespace) pairs the root element declares for namespaces other than the Print +/// Schema's: the driver's vendor namespaces. +fn vendor_prefixes(document: &roxmltree::Document<'_>) -> Vec<(String, String)> { + document + .root_element() + .namespaces() + .filter_map(|n| n.name().map(|prefix| (prefix.to_string(), n.uri().to_string()))) + .filter(|(_, uri)| ![NS_PSF, NS_PSK, NS_XSI, NS_XSD, NS_XML].contains(&uri.as_str())) + .collect() +} + +/// Whether every prefix in a `name` or `constrained` attribute or a QName value of the ticket is +/// declared: the XML parser does not check a prefix inside an attribute value. +fn prefixes_resolve(document: &roxmltree::Document<'_>) -> bool { + let unresolved = |element: roxmltree::Node<'_, '_>, value: &str| { + value.split_once(':').is_some_and(|(prefix, _)| element.lookup_namespace_uri(Some(prefix)).is_none()) + }; + !document.descendants().any(|node| { + if node.is_element() { + node.attributes().any(|a| a.namespace().is_none() && matches!(a.name(), "name" | "constrained") && unresolved(node, a.value())) + } else { + node.is_text() && node.parent().is_some_and(|p| is_qname_value(p) && node.text().is_some_and(|t| unresolved(p, t.trim()))) + } + }) +} + +/// A `Value` whose text is a qualified name (`xsi:type="xsd:QName"`): its prefix is a namespace's. +fn is_qname_value(node: roxmltree::Node<'_, '_>) -> bool { + node.attribute((NS_XSI, "type")).is_some_and(|t| t.rsplit(':').next() == Some("QName")) +} + +/// The start of every `xmlns:prefix` declaration's prefix in the start tag that begins at +/// `start`, with the prefix. +fn declared_prefixes(xml: &str, start: usize) -> Vec<(usize, String)> { + let mut found = Vec::new(); + let mut quote = None; + let mut at = start; + let mut previous = None; + for c in xml.get(start..).unwrap_or_default().chars() { + match quote { + Some(q) if c == q => quote = None, + Some(_) => {} + None if c == '"' || c == '\'' => quote = Some(c), + None if c == '>' => break, + None => {} + } + if quote.is_none() + && c == 'x' + && previous.is_some_and(char::is_whitespace) + && let Some(name) = xml.get(at..).and_then(|rest| rest.strip_prefix("xmlns:")) + { + let prefix: String = name.chars().take_while(|c| !c.is_whitespace() && *c != '=').collect(); + found.push((at + "xmlns:".len(), prefix)); + } + previous = Some(c); + at += c.len_utf8(); + } + found +} + +/// `ticket` with the driver's own prefix for each of the driver's vendor namespaces, where the +/// ticket spells one differently; `None` when nothing needs changing, or when it can't be done +/// safely (then the ticket stays as it is). `driver` is the driver's own ticket +/// ([`empty_ticket`] validated). +/// +/// Windows' ticket merge names a vendor namespace with a prefix of its own (`ns0000`) as soon as the +/// merged ticket uses a name from it, such as the driver's own paper option from its capabilities. +/// The prefix means nothing in XML, but some drivers' print pipelines read it as text: the job is +/// accepted, then every page fails in the XPS print API (`0x80040003`, "No such interface +/// supported"). A Brother driver names its namespace `brpsk` and fails with `ns0000`. Only the +/// declarations and the vendor names in `name`/`constrained` attributes and QName values change. +pub(crate) fn respell(ticket: &str, driver: &str) -> Option { + let document = roxmltree::Document::parse(ticket).ok()?; + let own = vendor_prefixes(&roxmltree::Document::parse(driver).ok()?); + let declared = vendor_prefixes(&document); + let renames: Vec<(&str, &str)> = declared + .iter() + .filter_map(|(old, uri)| own.iter().find(|(_, u)| u == uri).map(|(new, _)| (old.as_str(), new.as_str()))) + .filter(|(old, new)| old != new) + .collect(); + if renames.is_empty() { + return None; + } + let renamed = |old: &str| renames.iter().find(|(o, _)| *o == old).map(|(_, new)| *new); + // Each prefix must still name one namespace; a swap of two (ns0000 and ns0001) is fine. + let mut after: Vec<&str> = document.root_element().namespaces().filter_map(|n| n.name()).map(|p| renamed(p).unwrap_or(p)).collect(); + let declared_count = after.len(); + after.sort_unstable(); + after.dedup(); + if after.len() != declared_count { + return None; + } + let respelled = |at: usize| { + let rest = ticket.get(at..)?; + renames.iter().find(|(old, _)| rest.strip_prefix(old).is_some_and(|r| r.starts_with(':'))).map(|(old, new)| (at, at + old.len(), *new)) + }; + let mut edits: Vec<(usize, usize, &str)> = Vec::new(); + for (at, prefix) in declared_prefixes(ticket, document.root_element().range().start) { + if let Some(new) = renamed(&prefix) { + edits.push((at, at + prefix.len(), new)); + } + } + let uses_renamed = |ns: Option<&str>| ns.is_some_and(|ns| declared.iter().any(|(old, uri)| uri == ns && renamed(old).is_some())); + for node in document.descendants() { + if node.is_element() { + // A vendor namespace used for an element or attribute is not re-spelled here. + if uses_renamed(node.tag_name().namespace()) || node.attributes().any(|a| uses_renamed(a.namespace())) { + return None; + } + for a in + node.attributes().filter(|a| (a.namespace().is_none() && matches!(a.name(), "name" | "constrained")) || a.namespace() == Some(NS_XSI)) + { + edits.extend(respelled(a.range_value().start)); + } + } else if node.is_text() && node.parent().is_some_and(is_qname_value) { + edits.extend(respelled(node.range().start)); + } + } + edits.sort_unstable_by_key(|(start, ..)| *start); + let mut out = String::with_capacity(ticket.len()); + let mut copied = 0; + for (start, end, new) in edits { + out.push_str(ticket.get(copied..start)?); + out.push_str(new); + copied = end; + } + out.push_str(ticket.get(copied..)?); + // The result must declare each namespace under the prefix wanted and resolve every prefix. + let check = roxmltree::Document::parse(&out).ok()?; + let mut now = vendor_prefixes(&check); + let mut wanted: Vec<(String, String)> = declared.iter().map(|(old, uri)| (renamed(old).unwrap_or(old).to_string(), uri.clone())).collect(); + now.sort_unstable(); + wanted.sort_unstable(); + (now == wanted && prefixes_resolve(&check)).then_some(out) +} + /// The widest unprintable margin of the paper, in micrometres, from PrintCapabilities computed /// for the job's ticket (`PageImageableSize`). The largest of the four, so content centred inside /// it is clear of every edge whichever way the driver measures. `None` when the driver doesn't say. @@ -297,6 +443,57 @@ mod tests { assert!(verify_ticket(" String { + format!( + r#"215900279400mm{prefix}:Fastns0000:text, not a name"# + ) + } + + #[test] + fn a_vendor_namespace_gets_the_prefix_the_driver_declares_it_with() { + let merged = brother("ns0000"); + assert_eq!(respell(&merged, &brother("brpsk")), Some(brother("brpsk")), "declaration, names and QName values; not the text"); + assert_eq!(respell(&brother("brpsk"), &brother("brpsk")), None, "already the driver's"); + assert_eq!(respell(&brother("brpsk"), &merged), Some(merged.clone()), "whatever the driver calls it"); + // What the job's checks read is the same either way. + let letter = (215_900, 279_400); + assert_eq!(verify_ticket(&merged, Some(letter), false), Ok(letter)); + assert_eq!(verify_ticket(&respell(&merged, &brother("brpsk")).unwrap(), Some(letter), false), Ok(letter)); + } + + #[test] + fn two_vendor_namespaces_can_trade_prefixes() { + let other = "http://example.com/other"; + let ticket = |a: &str, b: &str| { + format!( + r#""# + ) + }; + assert_eq!(respell(&ticket("ns0000", "ns0001"), &ticket("ns0001", "ns0000")), Some(ticket("ns0001", "ns0000"))); + } + + #[test] + fn a_ticket_that_cannot_be_respelled_safely_is_left_alone() { + let merged = brother("ns0000"); + assert_eq!(respell(&merged, &empty_ticket()), None, "the driver has no such namespace"); + assert_eq!(respell("", ""); + assert_eq!(respell(&element, &brother("brpsk")), None); + // A name whose prefix is not declared stays refused after the change. + let dangling = merged.replace("name=\"ns0000:Mode\"", "name=\"ns0001:Mode\""); + assert_eq!(respell(&dangling, &brother("brpsk")), None); + } + #[test] fn a_driver_that_drops_explicit_job_options_is_refused() { let job = Job { copies: 2, duplex: Some(Duplex::LongEdge), grayscale: true, ..Job::default() }; diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 45a23aa7..a4fad893 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -103,12 +103,27 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape } let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?; builder.merge(PrintTicket::from(document)).map_err(spool_error)?; - let validated = builder.build().map_err(spool_error)?; + let validated = in_drivers_spelling(device, builder.build().map_err(spool_error)?); let xml = std::str::from_utf8(validated.get_xml()).map_err(spool_error)?; let paper = ticket::verify_ticket(xml, wanted, landscape)?; Ok((validated, paper)) } +/// The ticket with the driver's vendor namespaces under the prefixes the driver declares them with +/// ([`ticket::respell`]). Windows' merge renames them (`brpsk` to `ns0000`) when the merged ticket +/// uses one of the driver's names, such as its own paper option, and a Brother driver then fails +/// every page of the job with 0x80040003. The driver's prefixes are those of its own default ticket +/// (an empty one, validated). The ticket as it is when they can't be read. +fn in_drivers_spelling(device: &PrinterDevice, ticket: PrintTicket) -> PrintTicket { + let spelled = (|| { + let mut builder = PrintTicketBuilder::new(device).ok()?; + builder.merge(PrintTicket::from_xml(ticket::empty_ticket())).ok()?; + let own = builder.build().ok()?; + ticket::respell(std::str::from_utf8(ticket.get_xml()).ok()?, std::str::from_utf8(own.get_xml()).ok()?) + })(); + spelled.map_or(ticket, PrintTicket::from_xml) +} + /// The widest unprintable margin of the ticket's paper, in points; 0 when the driver doesn't say. fn imageable_margin(device: &PrinterDevice, ticket: &PrintTicket) -> f64 { PrintCapabilities::fetch_xml_for_ticket(device, Some(ticket)) @@ -201,6 +216,11 @@ mod tests { let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap(); ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1).unwrap(); assert_eq!(paper, (215_900, 279_400)); + // The ticket names the driver's vendor namespaces as the driver does (the merge renamed them). + let mut own = PrintTicketBuilder::new(&device).unwrap(); + own.merge(PrintTicket::from_xml(ticket::empty_ticket())).unwrap(); + let own = own.build().unwrap(); + assert_eq!(ticket::respell(std::str::from_utf8(ticket.get_xml()).unwrap(), std::str::from_utf8(own.get_xml()).unwrap()), None); eprintln!("unprintable margin: {} pt", imageable_margin(&device, &ticket)); if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") { std::fs::write(path, ticket.get_xml()).unwrap(); From fb59c587dfb1e26f385ca385cd6acf5f8b8d572c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:06:11 +0000 Subject: [PATCH 2/5] print: respell refuses a nested redeclaration; test the vendor-element case for real Review of #307: - respell rewrites the root's namespace declarations, so an element below the root that declares either prefix of a rename again (ns0000 or brpsk) would make a name resolve to another namespace once the prefixes change. It now returns None for such a ticket, and a test covers both prefixes. - The vendor-element test replaced only an opening tag, left the closing tag behind and so never parsed: it passed because respell refused the broken XML, not because of what it was meant to check. Every refusal fixture is now asserted well-formed, and the element is replaced whole. Doing so showed the element guard was redundant: an element or attribute name in a renamed namespace leaves an undeclared prefix, which the final parse of the result already refuses. The guard is removed and the test now exercises that check. - Doc comments on the new constants and tests. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FQ5PzHVUcntEHU97znim5u --- crates/print/src/ticket.rs | 58 +++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 955ed343..4f385a20 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -206,8 +206,11 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( Ok(()) } +/// XML Schema instance namespace (`xsi:type`). const NS_XSI: &str = "http://www.w3.org/2001/XMLSchema-instance"; +/// XML Schema datatypes namespace (`xsd:integer`, `xsd:QName`). const NS_XSD: &str = "http://www.w3.org/2001/XMLSchema"; +/// The namespace XML itself binds to the `xml` prefix. const NS_XML: &str = "http://www.w3.org/XML/1998/namespace"; /// A PrintTicket that sets nothing. Merged and validated by a driver it comes back as the @@ -286,7 +289,8 @@ fn declared_prefixes(xml: &str, start: usize) -> Vec<(usize, String)> { /// The prefix means nothing in XML, but some drivers' print pipelines read it as text: the job is /// accepted, then every page fails in the XPS print API (`0x80040003`, "No such interface /// supported"). A Brother driver names its namespace `brpsk` and fails with `ns0000`. Only the -/// declarations and the vendor names in `name`/`constrained` attributes and QName values change. +/// declarations and the vendor names in `name`/`constrained` attributes and QName values change; +/// a ticket that redeclares one of the prefixes below its root is left as it is. pub(crate) fn respell(ticket: &str, driver: &str) -> Option { let document = roxmltree::Document::parse(ticket).ok()?; let own = vendor_prefixes(&roxmltree::Document::parse(driver).ok()?); @@ -308,6 +312,16 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { if after.len() != declared_count { return None; } + // Only the root's declarations are rewritten: an element below it that declares either prefix + // again would make a name mean something else once the prefixes change. + let touched = |prefix: &str| renames.iter().any(|(old, new)| prefix == *old || prefix == *new); + let redeclared = document + .descendants() + .filter(|n| n.is_element() && *n != document.root_element()) + .any(|n| declared_prefixes(ticket, n.range().start).iter().any(|(_, prefix)| touched(prefix))); + if redeclared { + return None; + } let respelled = |at: usize| { let rest = ticket.get(at..)?; renames.iter().find(|(old, _)| rest.strip_prefix(old).is_some_and(|r| r.starts_with(':'))).map(|(old, new)| (at, at + old.len(), *new)) @@ -318,13 +332,8 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { edits.push((at, at + prefix.len(), new)); } } - let uses_renamed = |ns: Option<&str>| ns.is_some_and(|ns| declared.iter().any(|(old, uri)| uri == ns && renamed(old).is_some())); for node in document.descendants() { if node.is_element() { - // A vendor namespace used for an element or attribute is not re-spelled here. - if uses_renamed(node.tag_name().namespace()) || node.attributes().any(|a| uses_renamed(a.namespace())) { - return None; - } for a in node.attributes().filter(|a| (a.namespace().is_none() && matches!(a.name(), "name" | "constrained")) || a.namespace() == Some(NS_XSI)) { @@ -343,7 +352,8 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { copied = end; } out.push_str(ticket.get(copied..)?); - // The result must declare each namespace under the prefix wanted and resolve every prefix. + // The result must parse (a vendor element or attribute name would be left with an undeclared + // prefix), declare each namespace under the prefix wanted and resolve every prefix. let check = roxmltree::Document::parse(&out).ok()?; let mut now = vendor_prefixes(&check); let mut wanted: Vec<(String, String)> = declared.iter().map(|(old, uri)| (renamed(old).unwrap_or(old).to_string(), uri.clone())).collect(); @@ -443,6 +453,7 @@ mod tests { assert!(verify_ticket("", ""); - assert_eq!(respell(&element, &brother("brpsk")), None); + let feature = ""; + assert!(merged.contains(feature)); + assert_eq!(refused(&merged.replace(feature, "")), None); // A name whose prefix is not declared stays refused after the change. - let dangling = merged.replace("name=\"ns0000:Mode\"", "name=\"ns0001:Mode\""); - assert_eq!(respell(&dangling, &brother("brpsk")), None); + assert_eq!(refused(&merged.replace("name=\"ns0000:Mode\"", "name=\"ns0001:Mode\"")), None); + // A nested element that declares either prefix again would change what a name resolves to. + for redeclared in ["ns0000", "brpsk"] { + let nested = merged.replace( + "", + &format!(""), + ); + assert_ne!(nested, merged); + assert_eq!(refused(&nested), None, "{redeclared} declared again below the root"); + } + // Control: the same ticket without the nested declaration is respelled. + assert!(refused(&merged).is_some()); } #[test] From 766f1c6f2d1b7e7b543442fe81d7a1e86f53884a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:58:18 +0000 Subject: [PATCH 3/5] print: respell leaves a ticket alone when an element or attribute name would move Review follow-up. When two vendor namespaces trade prefixes, both prefixes stay declared, so a name written with one of them as an element or attribute name (``, `ns0000:flag="1"`) still parsed and resolved after the root declarations changed, but to the other namespace. respell only rewrites `name`/`constrained` values and QName values, so such a name would not follow. respell now returns None when any element or attribute is named in a namespace whose prefix changes. Names in a namespace that keeps its prefix do not block the respelling, and the real Brother tickets (vendor names only in `name` attributes and QName values) are respelled as before. Tests: regression cases for element and attribute names in a swap and in a prefix chain, and a control for names in an untouched namespace. Each ticket is asserted well-formed with every prefix resolving, so the case is refused for the name, not for a parse failure. The refusal tests fail with the guard disabled. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FQ5PzHVUcntEHU97zniM5U --- crates/print/src/ticket.rs | 90 ++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 4f385a20..269623de 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -289,8 +289,9 @@ fn declared_prefixes(xml: &str, start: usize) -> Vec<(usize, String)> { /// The prefix means nothing in XML, but some drivers' print pipelines read it as text: the job is /// accepted, then every page fails in the XPS print API (`0x80040003`, "No such interface /// supported"). A Brother driver names its namespace `brpsk` and fails with `ns0000`. Only the -/// declarations and the vendor names in `name`/`constrained` attributes and QName values change; -/// a ticket that redeclares one of the prefixes below its root is left as it is. +/// declarations and the vendor names in `name`/`constrained` attributes and QName values change. +/// A ticket is left as it is when it redeclares one of the prefixes below its root, or has an +/// element or attribute named in a namespace whose prefix would change. pub(crate) fn respell(ticket: &str, driver: &str) -> Option { let document = roxmltree::Document::parse(ticket).ok()?; let own = vendor_prefixes(&roxmltree::Document::parse(driver).ok()?); @@ -322,6 +323,19 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { if redeclared { return None; } + // Changing the root's declarations moves every name written with one of these prefixes, and only + // the `name`/`constrained` values and QName values are rewritten to follow. An element or attribute + // name (``, `ns0000:flag="1"`) would not follow: it would be left with an undeclared + // prefix, or, when two namespaces trade prefixes, quietly resolve to the other namespace. Names + // in a namespace whose prefix stays as it is are not affected. + let moved: Vec<&str> = document.root_element().namespaces().filter(|n| n.name().is_some_and(touched)).map(|n| n.uri()).collect(); + let names_moved = document.descendants().filter(|n| n.is_element()).any(|n| { + n.tag_name().namespace().is_some_and(|uri| moved.contains(&uri)) + || n.attributes().any(|a| a.namespace().is_some_and(|uri| moved.contains(&uri))) + }); + if names_moved { + return None; + } let respelled = |at: usize| { let rest = ticket.get(at..)?; renames.iter().find(|(old, _)| rest.strip_prefix(old).is_some_and(|r| r.starts_with(':'))).map(|(old, new)| (at, at + old.len(), *new)) @@ -352,8 +366,8 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { copied = end; } out.push_str(ticket.get(copied..)?); - // The result must parse (a vendor element or attribute name would be left with an undeclared - // prefix), declare each namespace under the prefix wanted and resolve every prefix. + // A last check on the result: it must parse, declare each namespace under the prefix wanted and + // resolve every prefix. let check = roxmltree::Document::parse(&out).ok()?; let mut now = vendor_prefixes(&check); let mut wanted: Vec<(String, String)> = declared.iter().map(|(old, uri)| (renamed(old).unwrap_or(old).to_string(), uri.clone())).collect(); @@ -478,16 +492,76 @@ mod tests { assert_eq!(verify_ticket(&respell(&merged, &brother("brpsk")).unwrap(), Some(letter), false), Ok(letter)); } + /// A second vendor namespace, next to [`BROTHER`]. + const OTHER: &str = "http://example.com/other"; + + /// A ticket that names [`BROTHER`] with the prefix `a` and [`OTHER`] with `b` in its root, uses + /// each in a `name`, and ends with `extra`. + fn two_namespaces(a: &str, b: &str, extra: &str) -> String { + format!( + r#"{extra}"# + ) + } + /// Prefixes are renamed together, so two namespaces that swap prefixes still end up distinct. #[test] fn two_vendor_namespaces_can_trade_prefixes() { - let other = "http://example.com/other"; - let ticket = |a: &str, b: &str| { + let ticket = |a: &str, b: &str| two_namespaces(a, b, ""); + assert_eq!(respell(&ticket("ns0000", "ns0001"), &ticket("ns0001", "ns0000")), Some(ticket("ns0001", "ns0000"))); + } + + /// When two namespaces trade prefixes both prefixes stay declared, so a name that is only + /// written with a prefix (an element or an attribute, unlike a `name` value) would still parse and + /// resolve, but to the other namespace. Each ticket here is well-formed and has every prefix + /// declared, so `respell` leaves it alone for the name, not because the result didn't parse. + #[test] + fn a_swap_does_not_move_an_element_or_attribute_name_to_the_other_namespace() { + let driver = two_namespaces("ns0001", "ns0000", ""); + let refused = |extra: &str| { + let ticket = two_namespaces("ns0000", "ns0001", extra); + let document = roxmltree::Document::parse(&ticket).expect("the fixture must stay well-formed"); + assert!(prefixes_resolve(&document)); + respell(&ticket, &driver) + }; + assert!(refused("").is_some(), "control: the same ticket without such a name is respelled"); + + // An element: `ns0000:Feature` is BROTHER's until the declarations swap, then OTHER's. + assert_eq!(refused(""), None, "element in the first namespace"); + assert_eq!(refused(""), None, "element in the second namespace"); + assert_eq!(refused(""), None, "nested element"); + + // An attribute, whichever element carries it. + assert_eq!(refused(""), None, "attribute in the first namespace"); + assert_eq!(refused(""), None, "attribute in the second namespace"); + assert_eq!(refused(""), None, "both at once"); + } + + /// The same when the prefixes form a chain rather than a swap: `brpsk` is OTHER's, is about to + /// become BROTHER's, and OTHER moves on to `ns0005`. + #[test] + fn a_name_in_a_namespace_whose_prefix_is_taken_over_is_not_respelled() { + let driver = two_namespaces("brpsk", "ns0005", ""); + let refused = |extra: &str| { + let ticket = two_namespaces("ns0000", "brpsk", extra); + let document = roxmltree::Document::parse(&ticket).expect("the fixture must stay well-formed"); + assert!(prefixes_resolve(&document)); + respell(&ticket, &driver) + }; + assert!(refused("").is_some(), "control"); + assert_eq!(refused(""), None, "element: OTHER's now, BROTHER's after"); + assert_eq!(refused(""), None, "attribute"); + } + + /// Only names in a namespace whose prefix changes block the respelling. + #[test] + fn names_in_a_namespace_that_keeps_its_prefix_do_not_block_the_respelling() { + let keep = "http://example.com/keep"; + let ticket = |prefix: &str| { format!( - r#""# + r#""# ) }; - assert_eq!(respell(&ticket("ns0000", "ns0001"), &ticket("ns0001", "ns0000")), Some(ticket("ns0001", "ns0000"))); + assert_eq!(respell(&ticket("ns0000"), &ticket("brpsk")), Some(ticket("brpsk"))); } /// Each ticket here is well-formed XML, so `respell` leaves it alone for the reason named, not From 23bd96375a547f3205d9c5083cb2feacf20d3a92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 06:34:58 +0000 Subject: [PATCH 4/5] print: the live driver test compares the vendor prefixes instead of re-running respell Review follow-up. The test asserted `respell(validated, driver_default) == None`. respell also returns None when it refuses a ticket, and in_drivers_spelling then keeps the ticket as it is, so a ticket still spelled `ns0000` passed. The test now compares the vendor namespace declarations of the ticket driver_ticket returns with those of the driver's own default ticket and fails when a namespace the driver declares is under another prefix. The comparison is `ticket::prefixes_unlike_the_drivers` (test-only), unit-tested here: it flags the ticket as the merge returns it, passes the respelled one and the driver's own, and flags a ticket respell refused, where `respell(..) == None` can't tell it from a correct one. Checked against a Brother HL-L2390DW: the test passes, and fails with `(brother namespace, "ns0000", "brpsk")` when in_drivers_spelling is made to return the merged ticket. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FQ5PzHVUcntEHU97zniM5U --- crates/print/src/ticket.rs | 58 +++++++++++++++++++++++++++++++++++++ crates/print/src/windows.rs | 5 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 269623de..9b76290e 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -376,6 +376,29 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { (now == wanted && prefixes_resolve(&check)).then_some(out) } +/// Each vendor namespace that `ticket` declares under a prefix the driver does not declare it with, +/// as (namespace, prefix in the ticket, prefix of the driver); empty when the ticket is spelled the +/// way the driver spells it. `driver` is the driver's own ticket ([`empty_ticket`] validated). +/// `None` when either does not parse. +/// +/// For checking the ticket a job ends up with. [`respell`] returning `None` for it does not: that +/// is also what it returns when it refused to change a ticket, which then stays as it is. +#[cfg(test)] +pub(crate) fn prefixes_unlike_the_drivers(ticket: &str, driver: &str) -> Option> { + let own = vendor_prefixes(&roxmltree::Document::parse(driver).ok()?); + let declared = vendor_prefixes(&roxmltree::Document::parse(ticket).ok()?); + Some( + declared + .into_iter() + .filter(|pair| !own.contains(pair)) + .filter_map(|(prefix, uri)| { + let (theirs, _) = own.iter().find(|(_, u)| *u == uri)?; + Some((uri, prefix, theirs.clone())) + }) + .collect(), + ) +} + /// The widest unprintable margin of the paper, in micrometres, from PrintCapabilities computed /// for the job's ticket (`PageImageableSize`). The largest of the four, so content centred inside /// it is clear of every edge whichever way the driver measures. `None` when the driver doesn't say. @@ -552,6 +575,41 @@ mod tests { assert_eq!(refused(""), None, "attribute"); } + /// What the live driver test checks. A ticket `respell` refused keeps the prefix the merge gave + /// it, but `respell` says `None` for it, as it does for a ticket that is already right: only the + /// prefixes tell the two apart. + #[test] + fn the_prefixes_tell_a_refused_ticket_from_one_in_the_drivers_spelling() { + let driver = brother("brpsk"); + let unlike = |ticket: &str| prefixes_unlike_the_drivers(ticket, &driver).expect("both tickets parse"); + let merged = brother("ns0000"); + let mismatch = (BROTHER.to_string(), "ns0000".to_string(), "brpsk".to_string()); + + assert_eq!(unlike(&merged), std::slice::from_ref(&mismatch), "as the merge returns it"); + assert_eq!(unlike(&respell(&merged, &driver).unwrap()), [], "respelled"); + assert_eq!(unlike(&driver), [], "already the driver's"); + + // `respell` refuses this one (`ns0000` is declared again below the root), so it stays as the + // merge left it, and `respell` says `None` for it just as it does for the driver's own. + let refused = merged.replace( + "", + "", + ); + assert_ne!(refused, merged); + assert_eq!(respell(&refused, &driver), None); + assert_eq!(respell(&driver, &driver), None); + assert_eq!(unlike(&refused), [mismatch], "refused, and still wrong"); + + // Two namespaces that trade prefixes are both reported. + let traded = prefixes_unlike_the_drivers(&two_namespaces("ns0000", "ns0001", ""), &two_namespaces("ns0001", "ns0000", "")).unwrap(); + assert_eq!(traded.len(), 2, "{traded:?}"); + // A namespace the driver doesn't declare has no prefix of the driver's to differ from. + assert_eq!(unlike(&empty_ticket()), [], "no vendor namespace to compare"); + assert_eq!(unlike(&two_namespaces("ns0000", "ns0001", "")).len(), 1, "only BROTHER is the driver's"); + assert_eq!(prefixes_unlike_the_drivers(" Date: Wed, 28 Oct 2026 04:28:55 +0000 Subject: [PATCH 5/5] print: respell a QName value written after whitespace A pretty-printed ticket puts whitespace before the value, so the prefix starts after it; without this the ticket fell back to Windows' spelling. --- crates/print/src/ticket.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 9b76290e..e1ba8ce7 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -354,7 +354,10 @@ pub(crate) fn respell(ticket: &str, driver: &str) -> Option { edits.extend(respelled(a.range_value().start)); } } else if node.is_text() && node.parent().is_some_and(is_qname_value) { - edits.extend(respelled(node.range().start)); + // A pretty-printed ticket puts whitespace before the value; the prefix starts after it. + let start = node.range().start; + let lead = ticket.get(start..).map_or(0, |t| t.len() - t.trim_start().len()); + edits.extend(respelled(start + lead)); } } edits.sort_unstable_by_key(|(start, ..)| *start); @@ -502,6 +505,13 @@ mod tests { ) } + /// A QName value written after whitespace (a pretty-printed ticket) is respelled too. + #[test] + fn a_qname_value_after_whitespace_is_respelled() { + let spaced = |prefix: &str| brother(prefix).replace(&format!(">{prefix}:Fast<"), &format!(">\n {prefix}:Fast\n<")); + assert_eq!(respell(&spaced("ns0000"), &brother("brpsk")), Some(spaced("brpsk"))); + } + /// The ticket Windows returns (`ns0000`) gets the driver's own `brpsk` back, in the declaration, the names and the QName values, but not in text. #[test] fn a_vendor_namespace_gets_the_prefix_the_driver_declares_it_with() {