demo-pdf: two fixes so xtask demo-pdf and xtask screenshots work on Windows (#954)

* demo-pdf: build a working file:// URL on Windows

Windows Path::canonicalize returns a verbatim \?\C:\... path. After the
backslash swap that is //?/C:/..., which the Unix/Windows branch then
misreads as an absolute Unix path, producing file:////?/C:/... Chrome
fails to load that URL and prints one blank page, so on Windows
cargo xtask demo-pdf and cargo xtask screenshots always fail with
"Chrome produced 1 pages but the showcase layout expects 12".

Strip the //?/ prefix before classifying, and add a regression test.

* demo-pdf: don't accept Skia's incremental %%EOF writes as a finished PDF

Chrome writes the print-to-pdf output incrementally and every increment
ends with its own %%EOF, so the completion poll accepted the first
~29 KB increment (one page of twelve) as final and killed Chrome
mid-print. Require the file size to stay quiet for 10 consecutive
500 ms polls before treating the file as finished; waiting for process
exit alone is not enough because some Chrome builds linger after
writing.

---------

Co-authored-by: pechnikov2026-star <[email protected]>
This commit is contained in:
pechnikov2026-starandpechnikov2026-star authored and GitHub committed 2026-10-11 13:16:12 -04:00
1 parent 43d16ef315
commit 66edead348
2 files changed
+35 -5

No files matched your search

+13 -4
View File
@@ -94,6 +94,10 @@ pub fn print_to_pdf(chrome: &Path, html: &Path, pdf: &Path, log: &Path) -> Resul
let started = Instant::now();
let mut last_len = None;
// Chrome's Skia back-end writes the PDF incrementally, and every increment ends with its own
// `%%EOF`: a stable file that looks complete can still grow while later pages rasterise. Only
// treat the file as finished after the size has been quiet for several consecutive polls.
let mut quiet_polls = 0;
loop {
if let Some(status) = child.try_wait()? {
if is_complete(pdf) {
@@ -102,10 +106,15 @@ pub fn print_to_pdf(chrome: &Path, html: &Path, pdf: &Path, log: &Path) -> Resul
bail!("Chrome exited with {status} without writing {}; see {}", pdf.display(), log.display());
}
let len = fs::metadata(pdf).map(|m| m.len()).ok();
if len.is_some() && len == last_len && is_complete(pdf) {
let _ = child.kill();
let _ = child.wait();
return Ok(());
if len == last_len && len.is_some() && is_complete(pdf) {
quiet_polls += 1;
if quiet_polls >= 10 {
let _ = child.kill();
let _ = child.wait();
return Ok(());
}
} else {
quiet_polls = 0;
}
last_len = len;
if started.elapsed() > Duration::from_secs(120) {
+22 -1
View File
@@ -97,7 +97,13 @@ fn repo_root() -> PathBuf {
/// A `file://` URL for an absolute path, percent-encoding unsafe bytes.
pub(crate) fn file_url(path: &Path) -> String {
let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let s = abs.to_string_lossy().replace('\\', "/");
let mut s = abs.to_string_lossy().replace('\\', "/");
// Windows `canonicalize` returns a verbatim `\\?\C:\...` path; after the slash swap that is
// `//?/C:/...`, which must not be mistaken for an absolute Unix path, or the URL Chrome is
// handed becomes `file:////?/C:/...` and it prints one blank page instead of the document.
if let Some(rest) = s.strip_prefix("//?/") {
s = rest.to_string();
}
let mut url = String::from(if s.starts_with('/') { "file://" } else { "file:///" });
for byte in s.bytes() {
match byte {
@@ -185,3 +191,18 @@ fn foreign_fonts(doc: &lopdf::Document) -> Vec<String> {
}
bad.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// On Windows `canonicalize` returns a verbatim `\?\C:\...` path; the URL must not carry the
/// `//?/` prefix over (regression test for `file:////?/C:/...`, which Chrome prints as one
/// blank page).
#[test]
fn file_url_has_no_verbatim_prefix() {
let url = file_url(&std::env::temp_dir());
assert!(!url.contains("//?/"), "{url}");
assert!(url.starts_with("file:///"), "{url}");
}
}