PDF/X: names inside a string or a comment no longer fail the standard check

The check that a PDF/X-1a or PDF/X-3 file has no transparency read names
in literal strings and comments as syntax. The file identifier is a
base64 hash of the file, so a file could now and then hold `/BM` or
`/Lab` there and its export was refused as having blend modes or Lab
colors. The check now leaves out the text of literal strings and
comments as well as stream data.
This commit is contained in:
dotCooCoo
2026-11-11 21:29:50 -08:00
parent aa4657e1ce
commit a07cdf87f0
2 changed files with 55 additions and 12 deletions
+35 -12
View File
@@ -211,23 +211,46 @@ pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> {
Ok(())
}
/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is
/// looked for there isn't found in image or font bytes.
/// `pdf` without its streams' data, the text of its literal strings and its comments (dictionaries
/// and the rest of the syntax), so that what is looked for there isn't found in image or font
/// bytes, or in a string such as the file identifier, whose base64 hash can hold `/BM`.
fn without_streams(pdf: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(pdf.len().min(1 << 20));
let mut at = 0;
while let Some(s) = find(pdf, b"stream", at) {
let data = s + b"stream".len();
// `endstream` itself, or a stream keyword that isn't one (inside a name or string).
if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) {
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
at = data;
continue;
while let Some(&b) = pdf.get(at) {
match b {
// A literal string: parentheses nest, and a backslash escapes the next byte.
b'(' => {
out.extend_from_slice(b"()");
let mut depth = 0usize;
at += 1;
while let Some(&c) = pdf.get(at) {
at += 1;
match c {
b'\\' => at += 1,
b'(' => depth += 1,
b')' if depth == 0 => break,
b')' => depth -= 1,
_ => {}
}
}
}
// A comment, to the end of its line.
b'%' => at += pdf.get(at..).unwrap_or_default().iter().take_while(|c| !matches!(c, b'\r' | b'\n')).count(),
// A stream keyword (not `endstream`, and followed by its end of line): its data is left out.
b's' if pdf.get(at..at + 6) == Some(b"stream")
&& !(at >= 3 && pdf.get(at - 3..at) == Some(b"end"))
&& pdf.get(at + 6).is_some_and(|c| matches!(c, b'\r' | b'\n')) =>
{
out.extend_from_slice(b"stream");
at = find(pdf, b"endstream", at + 6).unwrap_or(pdf.len());
}
_ => {
out.push(b);
at += 1;
}
}
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
at = find(pdf, b"endstream", data).unwrap_or(pdf.len());
}
out.extend_from_slice(pdf.get(at..).unwrap_or_default());
out
}
+20
View File
@@ -224,3 +224,23 @@ fn verify_finds_what_each_standard_forbids() {
assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts")));
assert!(verify(&file("<</Type/FontDescriptor/FontFile2 4 0 R>>"), Standard::PdfX4).is_ok());
}
/// Names inside a string or a comment are text, not syntax: a file identifier, whose base64 hash
/// can hold `/BM` or `/Lab`, does not make a file break its standard.
#[test]
fn verify_skips_names_in_strings_and_comments() {
use crate::pdfx::verify;
let file = |body: &str| format!("%PDF-1.3\n%/BM/Multiply\n1 0 obj\n{body}\nendobj\n").into_bytes();
for ok in [
"<</Size 2/ID[(q/BM+Zt1kR0x/Lab=) (q/BM+Zt1kR0x/Lab=)]>>",
"<</Title(a\\)/SMask 5 0 R)/Subject(a(b)/ca 0.5)>>",
"<</Title(stream\n/BM/Multiply\nendstream)>>",
] {
for standard in [Standard::PdfX1a, Standard::PdfX3] {
assert!(verify(&file(ok), standard).is_ok(), "{standard:?}: {ok}");
}
}
// A name after a string is still read.
let after = "<</Title(a(b)c)/BM/Multiply>>";
assert!(matches!(verify(&file(after), Standard::PdfX3), Err(PdfError::Unsupported(m)) if m.contains("blend modes")));
}