diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 5d7b303f..52710bca 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -10,8 +10,11 @@ name: Conformance # at the base commit when the cache has none), and `read-comment` posts one comment on the # pull request when a document reads worse or better, updating it on later pushes. # - `conformance` (main and manual runs only): renders the PDFium corpus and compares every -# page with PDFium. The pdf.js corpus is not rendered; only its reads are reported. -# - On main, `issues` files, updates and closes GitHub issues from the PDFium render and the +# page with PDFium. The pdf.js corpus is not rendered; only its reads are reported. The +# `web` renderer renders the PDFium corpus again with the browser stack (pdf-web, Canvas 2D +# plus its annotation layer) in the runner's preinstalled Chrome, and compares it with +# PDFium's render of page content, annotations and form fields. +# - On main, `issues` files, updates and closes GitHub issues from the PDFium renders and the # pdf.js reads together, and the viewers are published to GitHub Pages under /conformance/. # # Pull requests are never failed by these jobs. See tools/conformance/README.md. @@ -238,7 +241,7 @@ jobs: run: .github/scripts/conformance-read-comment.sh diffs conformance: - name: Conformance (${{ matrix.corpus }}) + name: Conformance (${{ matrix.corpus }}, ${{ matrix.renderer }}) if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 150 @@ -249,6 +252,12 @@ jobs: matrix: # pdf.js pages are not rendered; the `reads` job covers that corpus. corpus: [pdfium] + renderer: [skia, web] + env: + # Report name: target/conformance//, baselines/.json, Pages path. + REPORT: ${{ matrix.corpus }}${{ matrix.renderer == 'web' && '-web' || '' }} + # Chrome's sandbox cannot start on the runner. + CONFORMANCE_CHROME_NO_SANDBOX: "1" steps: - name: Checkout repository @@ -256,6 +265,8 @@ jobs: - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.renderer == 'web' && 'wasm32-unknown-unknown' || '' }} - name: Install system dependencies (Skia) run: | @@ -268,10 +279,24 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git + ~/.cargo/bin/wasm-bindgen target/regression - key: ${{ runner.os }}-conformance-${{ hashFiles('**/Cargo.lock') }} + target/release + target/wasm32-unknown-unknown + key: ${{ runner.os }}-conformance-${{ matrix.renderer }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-conformance- + ${{ runner.os }}-conformance-${{ matrix.renderer }}- + + # Must match the `wasm-bindgen = "=0.2.100"` pin in the workspace; the web renderer + # builds examples/web-canvas with `cargo xtask web-canvas`, which refuses other versions. + - name: Install wasm-bindgen-cli + if: matrix.renderer == 'web' + run: | + if ! command -v wasm-bindgen >/dev/null || [ "$(wasm-bindgen --version)" != "wasm-bindgen 0.2.100" ]; then + cargo install wasm-bindgen-cli --version 0.2.100 --locked --force + fi + wasm-bindgen --version + google-chrome --version # Pinned corpus revisions live in corpus.rs; the pdf.js cache also keeps downloaded # linked files so their (often slow or dead) hosts are only contacted on a miss. @@ -307,23 +332,23 @@ jobs: id: run run: | set +e - cargo conformance run --corpus ${{ matrix.corpus }} --reference pdfium --jobs 4 + cargo conformance run --corpus ${{ matrix.corpus }} --renderer ${{ matrix.renderer }} --reference pdfium --jobs 4 status=$? echo "status=$status" >> "$GITHUB_OUTPUT" if [ "$status" != 0 ]; then - echo "::warning ::Conformance regressions against tools/conformance/baselines/${{ matrix.corpus }}.json; see the job summary and the conformance-${{ matrix.corpus }} artifact." + echo "::warning ::Conformance regressions against tools/conformance/baselines/${REPORT}.json; see the job summary and the conformance-${REPORT} artifact." fi - name: Job summary if: always() run: | - triage="target/conformance/${{ matrix.corpus }}/TRIAGE.md" + triage="target/conformance/${REPORT}/TRIAGE.md" if [ -f "$triage" ]; then { sed '/^## Failure clusters/,$d' "$triage" if [ "${{ github.ref }}" = refs/heads/main ]; then owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - echo "Viewer (published when the run finishes): https://${owner}.github.io/${{ github.event.repository.name }}/conformance/${{ matrix.corpus }}/" + echo "Viewer (published when the run finishes): https://${owner}.github.io/${{ github.event.repository.name }}/conformance/${REPORT}/" echo fi echo "## Largest failure clusters" @@ -335,26 +360,26 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: conformance-${{ matrix.corpus }} - path: target/conformance/${{ matrix.corpus }} + name: conformance-${{ env.REPORT }} + path: target/conformance/${{ env.REPORT }} retention-days: 14 if-no-files-found: warn - name: Record baseline if: github.event_name == 'workflow_dispatch' && inputs.accept - run: cargo conformance accept --corpus ${{ matrix.corpus }} + run: cargo conformance accept --corpus ${{ matrix.corpus }} --renderer ${{ matrix.renderer }} - name: Upload baseline if: github.event_name == 'workflow_dispatch' && inputs.accept uses: actions/upload-artifact@v4 with: - name: conformance-baseline-${{ matrix.corpus }} - path: tools/conformance/baselines/${{ matrix.corpus }}.json + name: conformance-baseline-${{ env.REPORT }} + path: tools/conformance/baselines/${{ env.REPORT }}.json - name: Fail on regressions if: steps.run.outputs.status != '0' && env.FAIL_ON_EVENTS != '' && contains(env.FAIL_ON_EVENTS, github.event_name) run: | - echo "::error ::Conformance regressions against tools/conformance/baselines/${{ matrix.corpus }}.json (exit ${{ steps.run.outputs.status }}); see the job summary and the conformance-${{ matrix.corpus }} artifact." + echo "::error ::Conformance regressions against tools/conformance/baselines/${REPORT}.json (exit ${{ steps.run.outputs.status }}); see the job summary and the conformance-${REPORT} artifact." exit 1 # Both corpora are reported together, so one cause found in both is one issue and an issue @@ -402,8 +427,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: | args="" - # The PDFium corpus is rendered; the pdf.js corpus is only read. - for report in conformance-pdfium reads-pdfjs; do + # The PDFium corpus is rendered by both renderers; the pdf.js corpus is only read. + for report in conformance-pdfium reads-pdfjs conformance-pdfium-web; do corpus=${report#*-} if [ -f "reports/$report/index.json" ]; then mkdir -p target/conformance @@ -456,8 +481,8 @@ jobs: run: | mkdir -p site/conformance items="" - # The PDFium corpus is rendered; the pdf.js corpus is only read. - for report in conformance-pdfium reads-pdfjs; do + # The PDFium corpus is rendered by both renderers; the pdf.js corpus is only read. + for report in conformance-pdfium reads-pdfjs conformance-pdfium-web; do corpus=${report#*-} src="reports/$report" if [ ! -f "$src/index.html" ]; then @@ -487,7 +512,7 @@ jobs:

Safe-PDF conformance

-

Safe-PDF compared with PDFium, both rendering page content without annotations or form fields.

+

Safe-PDF compared with PDFium. pdfium and pdfjs render page content without annotations or form fields; pdfium-web renders the browser stack with its annotation layer, compared with PDFium's render of annotations and form fields.

Commit ${GITHUB_SHA::7} · workflow run · $(date -u '+%Y-%m-%d %H:%M UTC')

Safe-PDF web viewer

diff --git a/AGENTS.md b/AGENTS.md index 91cc5991..979d04fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,9 @@ the pull request when a document reads worse or better. On `main` (merged pull r manual runs) it also renders the PDFium corpus against PDFium's annotation-free render (the pdf.js corpus is only read), keeps one GitHub issue per failure cause across both corpora, closes issues whose failure is gone, and publishes the viewers to GitHub Pages under -`/conformance/`. To +`/conformance/`. On `main` the PDFium corpus is also rendered with `--renderer web`: the +browser stack (`pdf-web`, Canvas 2D plus its annotation layer) in headless Chrome, compared +with PDFium's render including annotations and form fields, reported as `pdfium-web`. To fix a conformance issue, follow `.claude/skills/fix-conformance-issue/SKILL.md` (`cargo conformance repro `, then `cargo conformance verify `). See `tools/conformance/README.md`. diff --git a/Cargo.lock b/Cargo.lock index ba9712fb..3ce1e82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.72.1" @@ -752,6 +758,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "digest" version = "0.10.7" @@ -1288,6 +1300,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2148,6 +2176,7 @@ name = "pdf-conformance" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", "image", "md-5", @@ -2168,6 +2197,7 @@ dependencies = [ "serde_json", "sha2", "skia-safe", + "tungstenite", "walkdir", ] @@ -2673,6 +2703,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -2752,6 +2791,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + [[package]] name = "range-alloc" version = "0.1.4" @@ -3024,6 +3092,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3440,6 +3519,23 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3509,6 +3605,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16string" version = "0.2.0" diff --git a/examples/web-canvas/renderer.rs b/examples/web-canvas/renderer.rs index 465fd972..f78354f5 100644 --- a/examples/web-canvas/renderer.rs +++ b/examples/web-canvas/renderer.rs @@ -1,6 +1,7 @@ //! A small document owner demonstrating the browser crate's host-facing APIs. use pdf_annotation_core::pdf_data::AnnotationAction; use pdf_document::{document::PdfDocument, reader::PdfReader}; +use pdf_graphics::{size::Size, transform::Transform}; use pdf_graphics_web::{WebCanvasBackend, WebCanvasOptions}; use pdf_renderer::{DocumentTextSelection, PdfRenderer}; use pdf_web::{ @@ -26,10 +27,13 @@ pub struct WebDocument { #[wasm_bindgen] impl WebDocument { - /// Parses user-provided bytes entirely within WebAssembly. + /// Parses user-provided bytes entirely within WebAssembly, decrypting them with + /// `password` when given. #[wasm_bindgen(constructor)] - pub fn new(bytes: &[u8]) -> Result { - let document = PdfReader.read_from_bytes(bytes, None).map_err(error)?; + pub fn new(bytes: &[u8], password: Option) -> Result { + let document = PdfReader + .read_from_bytes(bytes, password.as_deref().map(str::as_bytes)) + .map_err(error)?; let count = u32::try_from(document.pages.len()).map_err(error)?; let overlay = WebAnnotationOverlay::new(&document).map_err(error)?; Ok(Self { @@ -74,40 +78,35 @@ impl WebDocument { dpr: f32, rotation: u32, ) -> Result { - self.viewport_revision = self - .viewport_revision - .checked_add(1) - .ok_or_else(|| error("viewport revision overflow"))?; + let revision = self.next_viewport_revision()?; let page = page_of(self.renderer.document(), page_index)?; - let viewport = WebViewport::for_page(page, zoom, dpr, rotation, self.viewport_revision) - .map_err(error)?; - let size = viewport.page().device_size(); - let mut backend = - WebCanvasBackend::new(canvas, *viewport.canvas(), WebCanvasOptions::default()) - .map_err(error)?; - let output = self - .renderer - .render( - page_index, - self.content_revision, - &mut backend, - &viewport, - &mut self.overlay, - ) - .map_err(error)?; - if self.layouts.get(&page_index) != Some(&self.content_revision) { - self.selection - .install_layout( - page_index, - self.content_revision, - size, - Arc::clone(&output.text_layout), - ) - .map_err(error)?; - self.layouts.insert(page_index, self.content_revision); - } - self.viewports.insert(page_index, viewport); - Ok(WebAnnotationSnapshot::from_snapshot(output.annotations)) + let viewport = WebViewport::for_page(page, zoom, dpr, rotation, revision).map_err(error)?; + self.render_viewport(page_index, canvas, viewport) + } + + /// Renders the page fitted to exactly `width` x `height` canvas pixels, one CSS pixel + /// each, unrotated; the annotations are placed in the same pixels. + pub fn render_fitted( + &mut self, + page_index: u32, + canvas: web_sys::HtmlCanvasElement, + width: u32, + height: u32, + ) -> Result { + let revision = self.next_viewport_revision()?; + let page = page_of(self.renderer.document(), page_index)?; + let device = Size::new( + num_traits::ToPrimitive::to_f32(&width).ok_or_else(|| error("canvas width"))?, + num_traits::ToPrimitive::to_f32(&height).ok_or_else(|| error("canvas height"))?, + ); + let viewport = WebViewport::new( + page.viewport(None, device).map_err(error)?, + Size::new(width, height), + Transform::identity(), + revision, + ) + .map_err(error)?; + self.render_viewport(page_index, canvas, viewport) } /// Finds a glyph from container-local CSS coordinates. @@ -222,6 +221,53 @@ impl WebDocument { } } +impl WebDocument { + /// Advances the viewport revision that tags annotation snapshots. + fn next_viewport_revision(&mut self) -> Result { + self.viewport_revision = self + .viewport_revision + .checked_add(1) + .ok_or_else(|| error("viewport revision overflow"))?; + Ok(self.viewport_revision) + } + + /// Renders page pixels through `viewport` and returns its prepared annotations. + fn render_viewport( + &mut self, + page_index: u32, + canvas: web_sys::HtmlCanvasElement, + viewport: WebViewport, + ) -> Result { + let size = viewport.page().device_size(); + let mut backend = + WebCanvasBackend::new(canvas, *viewport.canvas(), WebCanvasOptions::default()) + .map_err(error)?; + let output = self + .renderer + .render( + page_index, + self.content_revision, + &mut backend, + &viewport, + &mut self.overlay, + ) + .map_err(error)?; + if self.layouts.get(&page_index) != Some(&self.content_revision) { + self.selection + .install_layout( + page_index, + self.content_revision, + size, + Arc::clone(&output.text_layout), + ) + .map_err(error)?; + self.layouts.insert(page_index, self.content_revision); + } + self.viewports.insert(page_index, viewport); + Ok(WebAnnotationSnapshot::from_snapshot(output.annotations)) + } +} + /// Borrows one page so rendering can hold it alongside mutable overlay state. fn page_of(document: &PdfDocument, page: u32) -> Result<&pdf_document::page::PdfPage, JsValue> { document diff --git a/tools/conformance/Cargo.toml b/tools/conformance/Cargo.toml index b6d8fc55..c8054ced 100644 --- a/tools/conformance/Cargo.toml +++ b/tools/conformance/Cargo.toml @@ -21,6 +21,7 @@ pdf-renderer = { path = "../../crates/pdf-renderer", features = ["error-backtrac pdf-resources = { path = "../../crates/pdf-resources" } pdf-utils = { path = "../../crates/pdf-utils", features = ["error-backtrace"] } anyhow = "1" +base64 = "0.22" clap = { version = "4", features = ["derive", "env"] } image = { version = "0.25", default-features = false, features = ["png"] } md-5 = "0.10" @@ -30,4 +31,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" skia-safe = { version = "0.91.1", default-features = false, features = ["binary-cache"] } +tungstenite = { version = "0.26", default-features = false, features = ["handshake"] } walkdir = "2" diff --git a/tools/conformance/README.md b/tools/conformance/README.md index 6ce30904..f1a1f2e0 100644 --- a/tools/conformance/README.md +++ b/tools/conformance/README.md @@ -46,6 +46,38 @@ when its revision differs from the pinned one. command line tools. - **No prebuilt downloads.** The harness never downloads prebuilt PDFium binaries. +## Web renderer + +`--renderer web` checks the browser stack instead of the Skia renderer: `pdf-web` draws +the page content on a Canvas 2D (`pdf-graphics-web`), and its annotation layer +(`crates/pdf-web/js`) draws annotations and form fields over it, as in the web viewer. The +reference is PDFium's render of page content, annotations and form fields, so annotation areas +are compared too. + +```sh +cargo conformance run --corpus pdfium --renderer web --reference pdfium +cargo conformance render --renderer web # one page +``` + +- **Needs.** A Chrome or Chromium installed on the machine (found in the usual places, or + `--chrome ` / `CHROME_PATH`), the `wasm32-unknown-unknown` target and + `wasm-bindgen-cli 0.2.100`. Nothing is downloaded. Set `CONFORMANCE_CHROME_NO_SANDBOX=1` + where Chrome's sandbox cannot start, such as CI containers. +- **How it runs.** The run builds `examples/web-canvas` with + `cargo xtask web-canvas --profile release`, serves it with a harness page on a loopback + port, and starts one headless Chrome. Each page worker opens its own tab, renders the page + fitted to the reference size (one CSS pixel per reference pixel), and screenshots it over the + DevTools protocol. The worker's time limit covers the tab; a killed worker's tab is closed, and + Chrome is relaunched when it stops answering. +- **Reports.** The web renderer writes its own report to `target/conformance/-web/` + with its own baseline (`baselines/-web.json`), viewer (`/conformance/pdfium-web/` on + Pages) and issues. Its signatures carry `[web]` after the status (`mismatch: [web] …`), so + they never merge with the Skia renderer's clusters. `show`, `accept` and `read-diff` take + `--renderer web`. `issues` and `repro` take report names such as `--corpus pdfium-web`. +- **Evidence.** Draw lists, glyph bounds and content-stream listings come from the Skia + pipeline's trace of the same page content; annotation differences have no draws. +- **CI.** On `main`, the PDFium corpus is rendered by both renderers. + ## Text rasterization PDFium and Safe-PDF rasterize text differently, so glyph edges differ on almost every page @@ -81,14 +113,21 @@ cargo conformance accept --corpus pdfjs # record the last run cargo conformance repro # rerun an issue's cases, no setup needed cargo conformance verify # exits 0 once no case fails that way cargo conformance render [--page N] # one local PDF, no corpus needed +cargo conformance render --all-pages # every page, in parallel (--jobs) +cargo conformance run --corpus pdfium --renderer web # browser stack with annotations ``` -`render` runs one page of any PDF through the same worker as `run`. It writes the Safe-PDF -image, the page's content-stream graph and `summary.md` to -`target/conformance/render//`, prints how long each Safe-PDF step took, and exits -non-zero when the page fails. Pass `--reference ` to compare against an image such as a -PDFium render. Otherwise it compares against `--pdfium `, `PDFIUM_LIBRARY` or the -`setup-pdfium` build when one exists. +`render` runs pages of any PDF through the same worker as `run`: the first page, each +`--page N` (the flag repeats) or every page with `--all-pages`, `--jobs` at a time. It writes +each page's Safe-PDF image and content-stream graph, one `summary.md` covering every page and a +`report.json` array to `target/conformance/render//`. It prints how long each Safe-PDF +step took and exits non-zero when any page fails. Pass `--reference ` to compare a single +page against an image such as a PDFium render. Otherwise it compares against +`--pdfium `, `PDFIUM_LIBRARY` or the `setup-pdfium` build when one exists. + +`run` schedules every page as its own job: it first reads each document, then renders the +selected pages of all documents on `--jobs` workers, so the pages of a long document render +side by side. ## Fixing an issue diff --git a/tools/conformance/src/corpus.rs b/tools/conformance/src/corpus.rs index dd4acb0b..12160bf8 100644 --- a/tools/conformance/src/corpus.rs +++ b/tools/conformance/src/corpus.rs @@ -62,6 +62,63 @@ impl CorpusKind { } } +/// The Safe-PDF stack whose render a run compares with the reference. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum Renderer { + /// Page content through `PdfRenderer` onto a Skia raster; annotations are not drawn. + #[default] + Skia, + /// The browser stack (`pdf-web`): page content on a Canvas 2D plus the annotation layer, + /// rendered in headless Chrome. + Web, +} + +/// One kind of report: a corpus rendered by one renderer. Each has its own output +/// directory, baseline, published viewer and issue label. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Target { + /// Corpus the cases come from. + pub corpus: CorpusKind, + /// Renderer compared with the reference. + pub renderer: Renderer, +} + +impl Target { + /// Every target, in the order reports are listed. + pub const ALL: [Self; 4] = [ + Self::new(CorpusKind::Pdfium, Renderer::Skia), + Self::new(CorpusKind::Pdfjs, Renderer::Skia), + Self::new(CorpusKind::Pdfium, Renderer::Web), + Self::new(CorpusKind::Pdfjs, Renderer::Web), + ]; + + /// Returns the target of `corpus` rendered by `renderer`. + pub const fn new(corpus: CorpusKind, renderer: Renderer) -> Self { + Self { corpus, renderer } + } + + /// Returns the name used in paths, labels and commands: the corpus name, suffixed with + /// `-web` for the web renderer. + pub fn as_str(self) -> &'static str { + match (self.corpus, self.renderer) { + (CorpusKind::Pdfium, Renderer::Skia) => "pdfium", + (CorpusKind::Pdfjs, Renderer::Skia) => "pdfjs", + (CorpusKind::Pdfium, Renderer::Web) => "pdfium-web", + (CorpusKind::Pdfjs, Renderer::Web) => "pdfjs-web", + } + } +} + +impl clap::ValueEnum for Target { + fn value_variants<'a>() -> &'a [Self] { + &Self::ALL + } + + fn to_possible_value(&self) -> Option { + Some(clap::builder::PossibleValue::new(self.as_str())) + } +} + /// Returns the workspace root, fixed at compile time. pub fn workspace_root() -> PathBuf { let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -78,16 +135,16 @@ pub fn default_root(kind: CorpusKind) -> PathBuf { state_dir().join("corpora").join(kind.as_str()) } -/// Returns the run output directory of a corpus. -pub fn output_dir(kind: CorpusKind) -> PathBuf { - state_dir().join(kind.as_str()) +/// Returns the run output directory of a target. +pub fn output_dir(target: Target) -> PathBuf { + state_dir().join(target.as_str()) } -/// Returns the checked-in baseline of a corpus. -pub fn baseline_path(kind: CorpusKind) -> PathBuf { +/// Returns the checked-in baseline of a target. +pub fn baseline_path(target: Target) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("baselines") - .join(format!("{}.json", kind.as_str())) + .join(format!("{}.json", target.as_str())) } /// Keeps cases whose id contains `filter` and, when `ids` is not empty, equals one of `ids`. diff --git a/tools/conformance/src/issue_body.rs b/tools/conformance/src/issue_body.rs index 46367925..4b4e2a2f 100644 --- a/tools/conformance/src/issue_body.rs +++ b/tools/conformance/src/issue_body.rs @@ -7,7 +7,7 @@ use crate::{ baseline::Delta, - corpus::CorpusKind, + corpus::{CorpusKind, Target}, issue_state::IssueState, issues::{Group, Part}, model::{CaseResult, ErrorDetail, PageOutput, ProcessEvidence, Status}, @@ -66,7 +66,7 @@ impl BodyContext { } /// Returns the published report of a corpus on GitHub Pages, ending in `/`. - pub fn pages(&self, kind: CorpusKind) -> String { + pub fn pages(&self, kind: Target) -> String { pages_url(&self.repo, kind) } @@ -80,7 +80,7 @@ impl BodyContext { } /// Returns the published report of a corpus for a repository, ending in `/`. -pub fn pages_url(repo: &str, kind: CorpusKind) -> String { +pub fn pages_url(repo: &str, kind: Target) -> String { let (owner, name) = repo.split_once('/').unwrap_or((repo, "")); format!( "https://{}.github.io/{name}/conformance/{}/", @@ -282,7 +282,7 @@ impl Entry<'_> { .ok()? .to_string_lossy() .replace('\\', "/"); - let kind = self.part.run.kind; + let kind = self.part.run.kind.corpus; let (url, pinned) = kind.source(); let revision = self .part @@ -870,6 +870,7 @@ fn agents( mod tests { use super::*; use crate::{ + corpus::Renderer, issues::{self, CorpusRun}, model::{Case, PageResult}, report_index::{Cluster, Index}, @@ -941,7 +942,7 @@ mod tests { dir: "clippath-1234abcd".to_owned(), }; CorpusRun { - kind: CorpusKind::Pdfjs, + kind: Target::new(CorpusKind::Pdfjs, Renderer::Skia), index: Index { corpus: "pdfjs".to_owned(), corpus_root: "/corpus".to_owned(), @@ -1027,7 +1028,10 @@ mod tests { #[test] fn links_into_pages() { - let base = pages_url("Velli20/safe-pdf", CorpusKind::Pdfjs); + let base = pages_url( + "Velli20/safe-pdf", + Target::new(CorpusKind::Pdfjs, Renderer::Skia), + ); assert_eq!( base, "https://velli20.github.io/safe-pdf/conformance/pdfjs/" diff --git a/tools/conformance/src/issues.rs b/tools/conformance/src/issues.rs index 8452751b..fcdadcf9 100644 --- a/tools/conformance/src/issues.rs +++ b/tools/conformance/src/issues.rs @@ -13,7 +13,7 @@ use crate::{ baseline::Delta, - corpus::{self, CorpusKind}, + corpus::{self, Target}, github::{self, Issue}, issue_body::{self, BodyContext}, issue_state::{self, CorpusState, IssueState}, @@ -35,8 +35,8 @@ const CLOSE_AFTER_MISSING: u32 = 2; /// Settings of an issue run. pub struct IssueOptions<'a> { - /// Corpora whose last runs are reported together. - pub kinds: &'a [CorpusKind], + /// Reports whose last runs are reported together. + pub kinds: &'a [Target], /// `owner/name` of the repository. pub repo: &'a str, /// Issues created at most per run. @@ -47,8 +47,8 @@ pub struct IssueOptions<'a> { /// The last run of one corpus. pub struct CorpusRun { - /// Corpus. - pub kind: CorpusKind, + /// Corpus and renderer of the run. + pub kind: Target, /// Run index. pub index: Index, /// Full case results by id; empty when `results.json` is not available. @@ -57,7 +57,7 @@ pub struct CorpusRun { impl CorpusRun { /// Loads the last run of a corpus from its output directory. - pub fn load(kind: CorpusKind, with_results: bool) -> Result { + pub fn load(kind: Target, with_results: bool) -> Result { let out = corpus::output_dir(kind); let index: Index = serde_json::from_slice( &fs::read(out.join("index.json")) @@ -629,10 +629,12 @@ fn change_note(old: &IssueState, new: &IssueState) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::corpus::{CorpusKind, Renderer}; const CURRENT: &str = "render_error: Shading type 'LatticeFormTriangleMesh' not implemented @ pdf-canvas::UnsupportedFeature"; - fn corpus_run(kind: CorpusKind) -> CorpusRun { + fn corpus_run(corpus: CorpusKind) -> CorpusRun { + let kind = Target::new(corpus, Renderer::Skia); CorpusRun { kind, index: Index { @@ -674,7 +676,10 @@ mod tests { corpus_run(CorpusKind::Pdfjs), ]; let options = IssueOptions { - kinds: &[CorpusKind::Pdfium, CorpusKind::Pdfjs], + kinds: &[ + Target::new(CorpusKind::Pdfium, Renderer::Skia), + Target::new(CorpusKind::Pdfjs, Renderer::Skia), + ], repo: "owner/repo", max_new: 15, dry_run: true, diff --git a/tools/conformance/src/main.rs b/tools/conformance/src/main.rs index 4e16fa0b..7b6a0e05 100644 --- a/tools/conformance/src/main.rs +++ b/tools/conformance/src/main.rs @@ -32,11 +32,12 @@ mod signature; mod source_hints; mod stream_graph; mod trace_backend; +mod web_render; mod worker; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; -use corpus::CorpusKind; +use corpus::{CorpusKind, Renderer, Target}; use std::{fs, path::PathBuf, process::ExitCode, time::Duration}; #[derive(Parser)] @@ -59,10 +60,20 @@ enum Command { #[arg(long)] links: bool, }, - /// Check cases and write reports to target/conformance//. + /// Check cases and write reports to target/conformance// (the web renderer's to + /// target/conformance/-web/). Run { #[arg(long, value_enum)] corpus: CorpusKind, + /// Renderer compared with the reference: `skia` draws page content only, `web` is the + /// browser stack with its annotation layer, compared with PDFium's render of page + /// content, annotations and form fields. + #[arg(long, value_enum, default_value_t)] + renderer: Renderer, + /// Chrome executable for the web renderer (default: the installed Google Chrome or + /// Chromium). + #[arg(long, env = "CHROME_PATH")] + chrome: Option, /// Corpus checkout (default: target/conformance/corpora/). #[arg(long)] root: Option, @@ -76,7 +87,7 @@ enum Command { #[arg(long)] page: Option, /// Parallel workers. - #[arg(short, long, default_value_t = default_jobs())] + #[arg(short, long, default_value_t = run::default_jobs())] jobs: usize, /// Pixels per PDF point. #[arg(long, default_value_t = 1.5)] @@ -115,6 +126,8 @@ enum Command { ReadDiff { #[arg(long, value_enum)] corpus: CorpusKind, + #[arg(long, value_enum, default_value_t)] + renderer: Renderer, /// `reads.json` of the run to compare against, usually the base branch's. #[arg(long)] base: PathBuf, @@ -123,9 +136,10 @@ enum Command { SetupPdfium, /// File, update and close GitHub issues for the failure clusters of the last runs (uses `gh`). Issues { - /// Corpora whose last runs are reported together; repeat the flag. + /// Reports whose last runs are reported together, such as `pdfium` or `pdfium-web`; + /// repeat the flag. #[arg(long, value_enum, required = true)] - corpus: Vec, + corpus: Vec, /// Repository as owner/name. #[arg(long, env = "GITHUB_REPOSITORY")] repo: String, @@ -148,6 +162,8 @@ enum Command { Show { #[arg(long, value_enum)] corpus: CorpusKind, + #[arg(long, value_enum, default_value_t)] + renderer: Renderer, /// Case id. case: String, }, @@ -155,6 +171,8 @@ enum Command { Accept { #[arg(long, value_enum)] corpus: CorpusKind, + #[arg(long, value_enum, default_value_t)] + renderer: Renderer, /// Only record this case. #[arg(long)] case: Option, @@ -196,6 +214,12 @@ enum WorkerCommand { reference_images: Option, #[arg(long)] write_safe: bool, + /// DevTools endpoint of the browser the web renderer runs in. + #[arg(long, requires = "harness")] + devtools: Option, + /// URL of the web renderer's harness page. + #[arg(long, requires = "devtools")] + harness: Option, }, } @@ -208,10 +232,6 @@ enum ReferenceKind { Pdfium, } -fn default_jobs() -> usize { - std::thread::available_parallelism().map_or(4, |count| count.get().saturating_sub(1).max(1)) -} - fn main() -> Result { match Cli::parse().command { Command::Fetch { @@ -221,6 +241,8 @@ fn main() -> Result { } => fetch::run(corpus, root.as_deref(), links)?, Command::Run { corpus, + renderer, + chrome, root, filter, case, @@ -240,9 +262,20 @@ fn main() -> Result { if !(scale.is_finite() && scale > 0.0) { bail!("--scale must be positive"); } - let reference = reference.unwrap_or(match corpus { - CorpusKind::Pdfium => ReferenceKind::Goldens, - CorpusKind::Pdfjs => ReferenceKind::Pdfium, + if renderer == Renderer::Web { + if read_only { + bail!("--read-only only reads documents; run it with the skia renderer"); + } + if reference == Some(ReferenceKind::Goldens) { + bail!( + "the web renderer is compared with PDFium's render of annotations and \ + form fields; use --reference pdfium" + ); + } + } + let reference = reference.unwrap_or(match (corpus, renderer) { + (CorpusKind::Pdfium, Renderer::Skia) => ReferenceKind::Goldens, + (CorpusKind::Pdfjs, _) | (_, Renderer::Web) => ReferenceKind::Pdfium, }); let pdfium = match reference { _ if reference_images.is_some() => None, @@ -254,6 +287,8 @@ fn main() -> Result { }; let options = run::RunOptions { kind: corpus, + renderer, + chrome, explicit_root: root.is_some(), root: { let root = root.unwrap_or_else(|| corpus::default_root(corpus)); @@ -277,8 +312,12 @@ fn main() -> Result { return Ok(ExitCode::FAILURE); } } - Command::Show { corpus, case } => { - let summary = corpus::output_dir(corpus) + Command::Show { + corpus, + renderer, + case, + } => { + let summary = corpus::output_dir(Target::new(corpus, renderer)) .join("cases") .join(run::case_dir(&case)) .join("summary.md"); @@ -290,8 +329,16 @@ fn main() -> Result { ), } } - Command::ReadDiff { corpus, base } => read_diff::run(corpus, &base)?, - Command::Accept { corpus, case } => accept(corpus, case.as_deref())?, + Command::ReadDiff { + corpus, + renderer, + base, + } => read_diff::run(Target::new(corpus, renderer), &base)?, + Command::Accept { + corpus, + renderer, + case, + } => accept(Target::new(corpus, renderer), case.as_deref())?, Command::SetupPdfium => pdfium_build::run()?, Command::Issues { corpus, @@ -339,6 +386,8 @@ fn main() -> Result { out_dir, reference_images, write_safe, + devtools, + harness, }) => worker::page(&worker::PageJob { pdf: &pdf, password: password.as_deref(), @@ -350,6 +399,10 @@ fn main() -> Result { pdfium: pdfium.as_deref(), reference_images: reference_images.as_deref(), write_safe, + web: devtools + .zip(harness) + .map(|(devtools, harness)| web_render::WebEndpoints { devtools, harness }) + .as_ref(), })?, } Ok(ExitCode::SUCCESS) @@ -373,7 +426,7 @@ fn pdfium_library(explicit: Option) -> Result { Ok(library) } -fn accept(kind: CorpusKind, case: Option<&str>) -> Result<()> { +fn accept(kind: Target, case: Option<&str>) -> Result<()> { let results_path = corpus::output_dir(kind).join("results.json"); let Ok(bytes) = fs::read(&results_path) else { bail!( diff --git a/tools/conformance/src/model.rs b/tools/conformance/src/model.rs index b254c333..99c80bd2 100644 --- a/tools/conformance/src/model.rs +++ b/tools/conformance/src/model.rs @@ -193,6 +193,9 @@ pub struct RenderStats { pub nesting: Option, /// Milliseconds spent replaying the recording onto the Skia raster. pub replay_ms: Option, + /// Milliseconds the web renderer spent in the browser, from opening the document to + /// the settled page with its annotation layer. + pub web_ms: Option, } impl RenderStats { @@ -202,11 +205,13 @@ impl RenderStats { self.replay_cost = other.replay_cost.or(self.replay_cost); self.nesting = other.nesting.or(self.nesting); self.replay_ms = other.replay_ms.or(self.replay_ms); + self.web_ms = other.web_ms.or(self.web_ms); } - /// Returns the Safe-PDF render time in milliseconds, when both steps finished. + /// Returns the Safe-PDF render time in milliseconds, when every step finished. pub fn total_ms(&self) -> Option { - self.record_ms?.checked_add(self.replay_ms?) + self.web_ms + .or_else(|| self.record_ms?.checked_add(self.replay_ms?)) } } diff --git a/tools/conformance/src/process.rs b/tools/conformance/src/process.rs index cea7a912..60453d18 100644 --- a/tools/conformance/src/process.rs +++ b/tools/conformance/src/process.rs @@ -18,6 +18,10 @@ pub const STAGE_MARKER: &str = "conformance-stage: "; /// Prefix of the stderr lines carrying a JSON [`RenderStats`] update from a page worker. pub const STATS_MARKER: &str = "conformance-stats: "; +/// Prefix of the stderr line naming the browser target a web page worker opened, so the +/// harness can close it when the worker is killed. +pub const TARGET_MARKER: &str = "conformance-target: "; + /// Keeps the tail of stderr; panics and backtraces end there. const STDERR_LIMIT: usize = 64 * 1024; @@ -115,7 +119,9 @@ fn render_stats(stderr: &str) -> Option { /// Returns true for the progress lines workers print for the harness rather than for readers. pub fn is_marker(line: &str) -> bool { - line.starts_with(STAGE_MARKER) || line.starts_with(STATS_MARKER) + line.starts_with(STAGE_MARKER) + || line.starts_with(STATS_MARKER) + || line.starts_with(TARGET_MARKER) } fn tail(text: &str, limit: usize) -> String { diff --git a/tools/conformance/src/read_diff.rs b/tools/conformance/src/read_diff.rs index c660df3d..2e1f7739 100644 --- a/tools/conformance/src/read_diff.rs +++ b/tools/conformance/src/read_diff.rs @@ -2,7 +2,7 @@ //! report what a pull request changes in how documents are read. use crate::{ - corpus::{self, CorpusKind}, + corpus::{self, Target}, fetch, model::{CaseResult, Status}, }; @@ -102,7 +102,7 @@ pub struct Reads { impl Reads { /// Collects the read outcomes of a run. - pub fn from_results(kind: CorpusKind, results: &[CaseResult]) -> Self { + pub fn from_results(kind: Target, results: &[CaseResult]) -> Self { Self { corpus: kind.as_str().to_owned(), commit: fetch::revision(&corpus::workspace_root()), @@ -242,7 +242,7 @@ fn markdown(base: &Reads, head: &Reads, diff: &Diff<'_>) -> Result { } /// Compares the last run of `kind` with the reads at `base_path` and writes the report. -pub fn run(kind: CorpusKind, base_path: &Path) -> Result<()> { +pub fn run(kind: Target, base_path: &Path) -> Result<()> { let out = corpus::output_dir(kind); let head = Reads::load(&out.join(READS_FILE))?; let base = Reads::load(base_path)?; diff --git a/tools/conformance/src/reference.rs b/tools/conformance/src/reference.rs index 636a3570..8581d521 100644 --- a/tools/conformance/src/reference.rs +++ b/tools/conformance/src/reference.rs @@ -17,12 +17,14 @@ pub fn page_size(page: &PdfPage<'_>) -> [f32; 2] { [page.width().value, page.height().value] } -/// Content-only render settings matching `PdfRenderer::render`, which excludes annotations. -pub fn config(width: u32, height: u32) -> Result { +/// Render settings for a `width` x `height` image. Without `annotations` they match +/// `PdfRenderer::render`, which draws page content only; with them, annotations and form +/// fields are drawn too, as the web renderer's annotation layer does. +pub fn config(width: u32, height: u32, annotations: bool) -> Result { Ok(PdfRenderConfig::new() .set_target_size(i32::try_from(width)?, i32::try_from(height)?) - .render_annotations(false) - .render_form_data(false) + .render_annotations(annotations) + .render_form_data(annotations) .set_clear_color(PdfColor::WHITE)) } diff --git a/tools/conformance/src/render.rs b/tools/conformance/src/render.rs index 3feba226..2b544daf 100644 --- a/tools/conformance/src/render.rs +++ b/tools/conformance/src/render.rs @@ -1,17 +1,20 @@ -//! Renders one page of a local PDF in a worker, without a corpus, and reports what happened. +//! Renders pages of a local PDF in workers, without a corpus, and reports what happened. //! -//! This is the quickest way to look at a single document: it times both Safe-PDF render -//! steps, writes the Safe-PDF image and the page's content-stream graph, and compares against -//! a reference image or PDFium when one is available. +//! This is the quickest way to look at a single document: it times the Safe-PDF render steps +//! of each page, writes the Safe-PDF images and the pages' content-stream graphs, and compares +//! against a reference image or PDFium when one is available. Pages render in parallel. use crate::{ - model::{PageOutput, PageResult, Status}, - pdfium_build, process, report_case, run, + corpus::Renderer, + model::{PageOutput, PageResult, ReadOutput, Status}, + pdfium_build, process, report_case, run, signature, + web_render::WebHost, }; -use anyhow::{Result, bail}; +use anyhow::{Result, anyhow, bail}; use std::{ fs, path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, time::Duration, }; @@ -20,13 +23,20 @@ use std::{ pub struct RenderArgs { /// PDF file. pub pdf: PathBuf, - /// Zero-based page. - #[arg(long, default_value_t = 0)] - pub page: usize, + /// Zero-based page; repeat for several pages (default: the first page). + #[arg(long, conflicts_with = "all_pages")] + pub page: Vec, + /// Render every page of the document. + #[arg(long, conflicts_with = "reference")] + pub all_pages: bool, + /// Pages rendered in parallel. + #[arg(short, long, default_value_t = run::default_jobs())] + pub jobs: usize, /// Document password. #[arg(long)] pub password: Option, - /// Reference image of the page, such as a PDFium render, to compare against. + /// Reference image of the page, such as a PDFium render, to compare against; only with a + /// single page. #[arg(long, conflicts_with = "pdfium")] pub reference: Option, /// PDFium shared library to render the reference with (default: the one built by @@ -45,6 +55,14 @@ pub struct RenderArgs { /// Output directory (default: target/conformance/render/). #[arg(long)] pub out: Option, + /// Renderer: `skia` draws page content only; `web` is the browser stack with its + /// annotation layer, compared with PDFium's render of annotations and form fields. + #[arg(long, value_enum, default_value_t)] + pub renderer: Renderer, + /// Chrome executable for the web renderer (default: the installed Google Chrome or + /// Chromium). + #[arg(long, env = "CHROME_PATH")] + pub chrome: Option, } /// Where the reference image of the page comes from. @@ -80,11 +98,24 @@ impl Reference { } } -/// Renders the page and prints its report; returns true when the page did not fail. +/// One `render` invocation: the document, where its evidence goes, and what it is compared with. +struct Render<'a> { + args: &'a RenderArgs, + pdf: PathBuf, + out: PathBuf, + reference: Reference, + /// The browser shared by every page of the web renderer. + web: Option, +} + +/// Renders the pages and prints their report; returns true when no page failed. pub fn run(args: &RenderArgs) -> Result { if !(args.scale.is_finite() && args.scale > 0.0) { bail!("--scale must be positive"); } + if args.reference.is_some() && args.page.len() > 1 { + bail!("--reference is the image of one page; pass a single --page"); + } let pdf = std::path::absolute(&args.pdf)?; if !pdf.is_file() { bail!("{} not found", pdf.display()); @@ -97,65 +128,218 @@ pub fn run(args: &RenderArgs) -> Result { fs::remove_dir_all(&out)?; } fs::create_dir_all(&out)?; - let reference = Reference::select(args)?; + let mut render = Render { + args, + pdf, + out, + reference: Reference::select(args)?, + web: None, + }; + let pages = render.pages()?; + if let Reference::Image(image) = &render.reference + && let Some(page) = pages.first() + { + fs::copy(image, render.out.join(format!("p{page}-ref.png")))?; + } + render.web = match args.renderer { + Renderer::Web => Some(WebHost::start(args.chrome.clone())?), + Renderer::Skia => None, + }; + let finished = AtomicUsize::new(0); + let results = run::in_parallel(args.jobs, &pages, |_, &page| { + let result = render.page(page); + if pages.len() > 1 { + let done = finished.fetch_add(1, Ordering::SeqCst).saturating_add(1); + match &result { + Ok(result) => println!( + "[{done}/{}] page {page}: {}", + pages.len(), + result.status.as_str() + ), + Err(error) => println!( + "[{done}/{}] page {page}: harness error {error:#}", + pages.len() + ), + } + } + result + }) + .into_iter() + .collect::>>()?; + render.web = None; + fs::write( + render.out.join("report.json"), + serde_json::to_vec_pretty(&results)?, + )?; + let report = render.report(&results); + fs::write(render.out.join("summary.md"), &report)?; + print!("{report}"); + Ok(results.iter().all(|result| !result.status.is_failure())) +} - let mut command = process::worker_command()?; - command - .arg("page") - .arg("--pdf") - .arg(&pdf) - .arg("--page") - .arg(args.page.to_string()) - .arg("--scale") - .arg(args.scale.to_string()) - .arg("--max-side") - .arg("3000") - .arg("--tolerance") - .arg(args.tolerance.to_string()) - .arg("--out-dir") - .arg(&out) - .arg("--write-safe"); - match &reference { - Reference::Image(image) => { - fs::copy(image, out.join(format!("p{}-ref.png", args.page)))?; - command.arg("--reference-images").arg(&out); +impl Render<'_> { + /// Returns the pages to render: `--page`, every page with `--all-pages`, or the first. + fn pages(&self) -> Result> { + if !self.args.all_pages { + return Ok(if self.args.page.is_empty() { + vec![0] + } else { + self.args.page.clone() + }); + } + // Counted in a worker, so a document that crashes the reader cannot take this down. + let mut command = process::worker_command()?; + command.arg("read").arg("--pdf").arg(&self.pdf); + if let Reference::Pdfium(library) = &self.reference { + run::add_pdfium(&mut command, library); + } + if let Some(password) = &self.args.password { + command.arg("--password").arg(password); + } + let (read, process) = process::execute::(command, self.limits())?; + let read = read.ok_or_else(|| { + anyhow!( + "reading the document to count its pages {} after {} ms", + process.ending(), + process.elapsed_ms + ) + })?; + match read.safe_pages.or(read.reference_pages) { + Some(count) if count > 0 => Ok((0..count).collect()), + _ => bail!( + "cannot count the pages: {}", + read.safe_error + .map(|error| error.message) + .or(read.reference_error) + .unwrap_or_else(|| "the document has no pages".to_owned()) + ), } - Reference::Pdfium(library) => run::add_pdfium(&mut command, library), - Reference::None => {} - } - if let Some(password) = &args.password { - command.arg("--password").arg(password); } - let (output, process) = process::execute::( - command, + + fn limits(&self) -> process::Limits { process::Limits::new( - Duration::from_secs(args.timeout), + Duration::from_secs(self.args.timeout), process::DEFAULT_MEMORY_LIMIT_MIB, - ), - )?; - let (status, signature) = match &output { - None => { - let status = if process.timed_out { - Status::Timeout - } else { - Status::Crash - }; - (status, Some(crate::signature::process(status, &process))) + ) + } + + /// Returns the worker command that renders and compares `page`. + fn page_command(&self, page: usize) -> Result { + let mut command = process::worker_command()?; + command + .arg("page") + .arg("--pdf") + .arg(&self.pdf) + .arg("--page") + .arg(page.to_string()) + .arg("--scale") + .arg(self.args.scale.to_string()) + .arg("--max-side") + .arg("3000") + .arg("--tolerance") + .arg(self.args.tolerance.to_string()) + .arg("--out-dir") + .arg(&self.out) + .arg("--write-safe"); + match &self.reference { + Reference::Image(_) => { + command.arg("--reference-images").arg(&self.out); + } + Reference::Pdfium(library) => run::add_pdfium(&mut command, library), + Reference::None => {} } - Some(output) => run::page_verdict(output, args.tolerance, false), - }; - let result = PageResult { - status, - output, - page: args.page, - process, - signature, - }; - fs::write(out.join("report.json"), serde_json::to_vec_pretty(&result)?)?; - let report = report(&pdf, &reference, &result, &out); - fs::write(out.join("summary.md"), &report)?; - print!("{report}"); - Ok(!status.is_failure()) + if let Some(password) = &self.args.password { + command.arg("--password").arg(password); + } + if let Some(web) = &self.web { + run::add_web(&mut command, web)?; + } + Ok(command) + } + + /// Renders `page` in a worker and derives its verdict. + fn page(&self, page: usize) -> Result { + let (output, process) = + process::execute::(self.page_command(page)?, self.limits())?; + if let Some(web) = &self.web + && output.is_none() + { + web.close_tab(&process.stderr); + } + let (status, signature) = match &output { + None => { + let status = if process.timed_out { + Status::Timeout + } else { + Status::Crash + }; + (status, Some(signature::process(status, &process))) + } + Some(output) => run::page_verdict(output, self.args.tolerance, false), + }; + Ok(PageResult { + status, + output, + page, + process, + signature: signature.map(|signature| signature::tagged(signature, self.args.renderer)), + }) + } + + /// Formats the report printed to the terminal and written as `summary.md`. + fn report(&self, results: &[PageResult]) -> String { + let failed = results + .iter() + .filter(|result| result.status.is_failure()) + .count(); + let mut lines = vec![ + match results { + [result] => format!( + "# {} page {}: {}", + self.pdf.display(), + result.page, + result.status.as_str() + ), + _ => format!( + "# {}: {} pages, {failed} failed", + self.pdf.display(), + results.len() + ), + }, + String::new(), + format!("- Reference: {}", self.reference.describe()), + format!("- Renderer: {:?}", self.args.renderer), + ]; + match results { + [result] => lines.extend(page_lines(result)), + _ => { + for result in results { + lines.push(String::new()); + lines.push(format!( + "## Page {}: {}", + result.page, + result.status.as_str() + )); + lines.push(String::new()); + lines.extend(page_lines(result)); + } + lines.push(String::new()); + } + } + let mut files: Vec = fs::read_dir(&self.out) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name != "summary.md") + .collect(); + files.sort(); + lines.push(format!("- Output in {}:", self.out.display())); + lines.extend(files.iter().map(|file| format!(" - {file}"))); + let mut text = lines.join("\n"); + text.push('\n'); + text + } } /// Returns `target/conformance/render/` for a PDF. @@ -168,18 +352,9 @@ fn default_out(pdf: &Path) -> PathBuf { .join(run::case_dir(&name)) } -/// Formats the page report printed to the terminal and written as `summary.md`. -fn report(pdf: &Path, reference: &Reference, result: &PageResult, out: &Path) -> String { - let mut lines = vec![ - format!( - "# {} page {}: {}", - pdf.display(), - result.page, - result.status.as_str() - ), - String::new(), - format!("- Reference: {}", reference.describe()), - ]; +/// Describes how one page went: signature, render times, mismatch and errors. +fn page_lines(result: &PageResult) -> Vec { + let mut lines = Vec::new(); if let Some(signature) = &result.signature { lines.push(format!("- Signature: `{signature}`")); } @@ -216,17 +391,5 @@ fn report(pdf: &Path, reference: &Reference, result: &PageResult, out: &Path) -> } } } - let mut files: Vec = fs::read_dir(out) - .into_iter() - .flatten() - .flatten() - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name != "summary.md") - .collect(); - files.sort(); - lines.push(format!("- Output in {}:", out.display())); - lines.extend(files.iter().map(|file| format!(" - {file}"))); - let mut text = lines.join("\n"); - text.push('\n'); - text + lines } diff --git a/tools/conformance/src/report_case.rs b/tools/conformance/src/report_case.rs index 8beaff63..828328bd 100644 --- a/tools/conformance/src/report_case.rs +++ b/tools/conformance/src/report_case.rs @@ -8,6 +8,7 @@ use crate::{ report_index::{self, Index, IndexCase}, run::RunOptions, signature, source_hints, + web_render::WEB_RENDER_STAGE, }; use anyhow::Result; use std::{fmt::Write as _, fs, path::Path}; @@ -360,6 +361,7 @@ pub fn render_line(process: &ProcessEvidence) -> Option { (Some(stats), _) => Some(render_text(stats)), // Stopped while recording, before reporting anything. (None, Some("safe-record")) => Some(render_text(&RenderStats::default())), + (None, Some(WEB_RENDER_STAGE)) => Some("web render did not finish".to_owned()), (None, _) => None, } } @@ -372,6 +374,9 @@ pub fn render_text(stats: &RenderStats) -> String { Some(ms) => format!("{name} {ms} ms"), None => format!("{name} did not finish"), }; + if stats.web_ms.is_some() { + return step("web render", stats.web_ms); + } let mut parts = vec![step("record", stats.record_ms)]; if stats.record_ms.is_some() { parts.push(step("replay", stats.replay_ms)); diff --git a/tools/conformance/src/report_index.rs b/tools/conformance/src/report_index.rs index fa9c4c7f..4f297d7d 100644 --- a/tools/conformance/src/report_index.rs +++ b/tools/conformance/src/report_index.rs @@ -210,7 +210,7 @@ pub fn build(options: &RunOptions, results: &[CaseResult], baseline: &Baseline) .then_with(|| a.signature.cmp(&b.signature)) }); Index { - corpus: options.kind.as_str().to_owned(), + corpus: options.target().as_str().to_owned(), corpus_root: options.root.display().to_string(), corpus_revision: fetch::revision(&options.root), pdfium: options.pdfium.as_ref().map_or_else( diff --git a/tools/conformance/src/repro.rs b/tools/conformance/src/repro.rs index a303e489..20a4d783 100644 --- a/tools/conformance/src/repro.rs +++ b/tools/conformance/src/repro.rs @@ -10,7 +10,7 @@ //! reference images, which still shows whether a crash, timeout or error is gone. use crate::{ - corpus::{self, CorpusKind}, + corpus::{self, CorpusKind, Target}, fetch, github, issue_body, issue_state, model::Status, process::{DEFAULT_MEMORY_LIMIT_MIB, Limits}, @@ -33,9 +33,9 @@ use std::{ pub struct ReproArgs { /// Issue key (`conf2-…`) or a case id. pub target: String, - /// Corpora to look in (default: all). + /// Reports to look in, such as `pdfium` or `pdfium-web` (default: all). #[arg(long, value_enum)] - pub corpus: Vec, + pub corpus: Vec, /// Cases rerun at most per corpus. #[arg(long, default_value_t = 10)] pub max_cases: usize, @@ -52,7 +52,7 @@ pub struct ReproArgs { /// What the target selects in one corpus. struct Selection { - kind: CorpusKind, + kind: Target, /// Published report the cases come from; `None` when they come from the issue itself. index: Option, /// Corpus revision the cases were checked at. @@ -93,7 +93,7 @@ pub fn run(args: &ReproArgs) -> Result { None => origin_repo()?, }; let kinds = if args.corpus.is_empty() { - vec![CorpusKind::Pdfium, CorpusKind::Pdfjs] + Target::ALL.to_vec() } else { args.corpus.clone() }; @@ -141,11 +141,11 @@ pub fn run(args: &ReproArgs) -> Result { Ok(fixed) } -fn work_dir(kind: CorpusKind) -> PathBuf { +fn work_dir(kind: Target) -> PathBuf { corpus::state_dir().join("repro").join(kind.as_str()) } -fn select(kind: CorpusKind, index: Index, target: &str, limit: usize) -> Option { +fn select(kind: Target, index: Index, target: &str, limit: usize) -> Option { let clusters: Vec<_> = index .clusters .iter() @@ -172,7 +172,7 @@ fn select(kind: CorpusKind, index: Index, target: &str, limit: usize) -> Option< revision: index .corpus_revision .clone() - .unwrap_or_else(|| kind.source().1.to_owned()), + .unwrap_or_else(|| kind.corpus.source().1.to_owned()), scale: index.scale, tolerance: index.tolerance, read_only: index.read_only, @@ -209,7 +209,7 @@ fn issue_selections(repo: &str, key: &str, limit: usize) -> Result = Vec::new(); for case in summary.cases { - let Ok(kind) = CorpusKind::from_str(&case.corpus, true) else { + let Ok(kind) = Target::from_str(&case.corpus, true) else { continue; }; println!( @@ -230,7 +230,7 @@ fn issue_selections(repo: &str, key: &str, limit: usize) -> Result Result { ); let dir = work_dir(kind); let root = dir.join("corpus"); - checkout(kind, &root, &selection.revision, &selection.cases)?; + checkout(kind.corpus, &root, &selection.revision, &selection.cases)?; let references = dir.join("references"); let base = issue_body::pages_url(repo, kind); let published = selection @@ -308,7 +308,9 @@ fn rerun(args: &ReproArgs, repo: &str, selection: &Selection) -> Result { } } let options = RunOptions { - kind, + kind: kind.corpus, + renderer: kind.renderer, + chrome: None, root, explicit_root: true, filter: None, diff --git a/tools/conformance/src/run.rs b/tools/conformance/src/run.rs index cff312ed..8d61e259 100644 --- a/tools/conformance/src/run.rs +++ b/tools/conformance/src/run.rs @@ -2,9 +2,11 @@ use crate::{ baseline::Baseline, - corpus::{self, CorpusKind}, + corpus::{self, CorpusKind, Renderer, Target}, model::{Case, CaseResult, PageOutput, PageResult, ReadOutput, Status}, - process, read_diff, report_case, report_index, signature, worker, + process, read_diff, report_case, report_index, signature, + web_render::WebHost, + worker, }; use anyhow::{Result, anyhow}; use md5::{Digest as _, Md5}; @@ -19,10 +21,61 @@ use std::{ thread, }; +/// A case after its read: the result so far and the pages still to render. +struct CaseRun { + result: CaseResult, + /// Evidence directory of the case. + case_out: PathBuf, + /// Pages to render; empty when the read already decided the case. + pages: Vec, + non_embedded_fonts: bool, +} + +/// Runs `work` over `items` on `jobs` threads, each taking the next item when it is free. +/// Results come back in item order. +pub fn in_parallel( + jobs: usize, + items: &[T], + work: impl Fn(usize, &T) -> R + Sync, +) -> Vec { + let next = AtomicUsize::new(0); + let results = Mutex::new(Vec::with_capacity(items.len())); + thread::scope(|scope| { + for _ in 0..jobs.max(1).min(items.len()) { + scope.spawn(|| { + loop { + let index = next.fetch_add(1, Ordering::SeqCst); + let Some(item) = items.get(index) else { + break; + }; + let result = work(index, item); + if let Ok(mut results) = results.lock() { + results.push((index, result)); + } + } + }); + } + }); + let mut results = results + .into_inner() + .unwrap_or_else(std::sync::PoisonError::into_inner); + results.sort_by_key(|(index, _)| *index); + results.into_iter().map(|(_, result)| result).collect() +} + +/// Returns the default number of parallel workers: one per core, leaving one free. +pub fn default_jobs() -> usize { + thread::available_parallelism().map_or(4, |count| count.get().saturating_sub(1).max(1)) +} + /// Settings of a corpus run. pub struct RunOptions { /// Corpus kind. pub kind: CorpusKind, + /// Renderer compared with the reference. + pub renderer: Renderer, + /// Chrome executable for the web renderer; `None` finds the installed one. + pub chrome: Option, /// Corpus checkout. pub root: PathBuf, /// True when `root` was given explicitly and must appear in reproduction commands. @@ -55,12 +108,22 @@ pub struct RunOptions { } impl RunOptions { + /// Returns the report this run writes. + pub fn target(&self) -> Target { + Target::new(self.kind, self.renderer) + } + /// Returns true when the run checks only part of the corpus, so absent failures say /// nothing about whether they were fixed. pub fn filtered(&self) -> bool { self.filter.is_some() || !self.cases.is_empty() || self.page.is_some() } + /// Marks a signature with this run's renderer. + pub fn tag(&self, signature: String) -> String { + signature::tagged(signature, self.renderer) + } + /// Returns the command that reruns one case, optionally one page. pub fn reproduce(&self, case: &str, page: Option) -> String { let mut args = vec![ @@ -73,6 +136,9 @@ impl RunOptions { if self.explicit_root { args.extend(["--root".to_owned(), self.root.display().to_string()]); } + if self.renderer == Renderer::Web { + args.extend(["--renderer".to_owned(), "web".to_owned()]); + } args.extend(["--case".to_owned(), case.to_owned()]); if self.read_only { args.push("--read-only".to_owned()); @@ -103,50 +169,83 @@ pub fn run(options: &RunOptions) -> Result { options.filter.as_deref(), &options.cases, )?; - let out = corpus::output_dir(options.kind); + let out = corpus::output_dir(options.target()); fs::create_dir_all(out.join("cases"))?; + let web = match options.renderer { + Renderer::Web if !options.read_only => Some(WebHost::start(options.chrome.clone())?), + _ => None, + }; let total = cases.len(); - let next = AtomicUsize::new(0); - let done = AtomicUsize::new(0); - let results = Mutex::new(Vec::with_capacity(total)); - thread::scope(|scope| { - for _ in 0..options.jobs.max(1) { - scope.spawn(|| { - while let Some(case) = cases.get(next.fetch_add(1, Ordering::SeqCst)) { - let result = run_case(options, &out, case); - let finished = done.fetch_add(1, Ordering::SeqCst).saturating_add(1); - match &result { - Ok(result) => println!( - "[{finished}/{total}] {} {}", - result.status.as_str(), - result.case.id - ), - Err(error) => { - println!("[{finished}/{total}] harness error {}: {error:#}", case.id) - } - } - if let Ok(mut results) = results.lock() { - results.push(result); - } - } - }); + let read = AtomicUsize::new(0); + let runs = in_parallel(options.jobs, &cases, |_, case| { + let run = read_case(options, &out, case); + let finished = read.fetch_add(1, Ordering::SeqCst).saturating_add(1); + match &run { + Ok(run) if run.pages.is_empty() => println!( + "[{finished}/{total}] {} {}", + run.result.status.as_str(), + case.id + ), + Ok(_) => {} + Err(error) => println!("[{finished}/{total}] harness error {}: {error:#}", case.id), } - }); - let mut results = results - .into_inner() - .map_err(|_| anyhow!("result collection poisoned"))? + run + }) + .into_iter() + .collect::>>()?; + + // Every page is its own job, so the pages of a long document render side by side. + let jobs: Vec<(usize, usize)> = runs + .iter() + .enumerate() + .flat_map(|(index, run)| run.pages.iter().map(move |page| (index, *page))) + .collect(); + let page_total = jobs.len(); + let rendered = AtomicUsize::new(0); + let pages = in_parallel(options.jobs, &jobs, |_, &(index, page)| { + let run = runs + .get(index) + .ok_or_else(|| anyhow!("page job without its case"))?; + let result = run_page(options, run, page, web.as_ref()); + let finished = rendered.fetch_add(1, Ordering::SeqCst).saturating_add(1); + let id = &run.result.case.id; + match &result { + Ok(result) => println!( + "[{finished}/{page_total} pages] {} {id} page {page}", + result.status.as_str() + ), + Err(error) => { + println!( + "[{finished}/{page_total} pages] harness error {id} page {page}: {error:#}" + ) + } + } + result.map(|result| (index, result)) + }) + .into_iter() + .collect::>>()?; + let mut grouped: Vec> = runs.iter().map(|_| Vec::new()).collect(); + for (index, page) in pages { + if let Some(case_pages) = grouped.get_mut(index) { + case_pages.push(page); + } + } + let mut results: Vec = runs .into_iter() - .collect::>>()?; + .zip(grouped) + .map(|(run, pages)| run.finish(pages)) + .collect(); results.sort_by(|a, b| a.case.id.cmp(&b.case.id)); - let baseline = Baseline::load(&corpus::baseline_path(options.kind))?; + drop(web); + let baseline = Baseline::load(&corpus::baseline_path(options.target()))?; let index = report_index::build(options, &results, &baseline); for result in &results { report_case::write(options, &out, result, &index)?; } report_index::write(&out, &index)?; fs::write(out.join("results.json"), serde_json::to_vec(&results)?)?; - read_diff::Reads::from_results(options.kind, &results) + read_diff::Reads::from_results(options.target(), &results) .save(&out.join(read_diff::READS_FILE))?; let regressions = index.regressions(); println!( @@ -159,17 +258,17 @@ pub fn run(options: &RunOptions) -> Result { println!( "{} regressions against {}:", regressions.len(), - corpus::baseline_path(options.kind).display() + corpus::baseline_path(options.target()).display() ); for line in regressions.iter().take(50) { println!(" {line}"); } } - let has_baseline = corpus::baseline_path(options.kind).is_file(); + let has_baseline = corpus::baseline_path(options.target()).is_file(); if !has_baseline { println!( "No baseline at {}; regressions are reported but do not fail the run. Record one with `cargo conformance accept`.", - corpus::baseline_path(options.kind).display() + corpus::baseline_path(options.target()).display() ); } Ok(has_baseline && !regressions.is_empty()) @@ -192,25 +291,33 @@ pub fn case_dir(id: &str) -> String { format!("{readable}-{}", digest.get(..8).unwrap_or_default()) } -fn run_case(options: &RunOptions, out: &Path, case: &Case) -> Result { +/// Reads a case and picks the pages to render. A case decided by its read (missing file, +/// crash, timeout, `--read-only`) comes back without pages. +fn read_case(options: &RunOptions, out: &Path, case: &Case) -> Result { let dir = case_dir(&case.id); let case_out = out.join("cases").join(&dir); if case_out.exists() { fs::remove_dir_all(&case_out)?; } - let mut result = CaseResult { - case: case.clone(), - status: Status::Unavailable, - sha256: None, - md5_mismatch: false, - read: None, - read_process: None, - signature: None, + let mut run = CaseRun { + result: CaseResult { + case: case.clone(), + status: Status::Unavailable, + sha256: None, + md5_mismatch: false, + read: None, + read_process: None, + signature: None, + pages: Vec::new(), + dir, + }, + case_out, pages: Vec::new(), - dir, + non_embedded_fonts: false, }; + let result = &mut run.result; let Ok(bytes) = fs::read(&case.path) else { - return Ok(result); + return Ok(run); }; result.sha256 = Some(format!("{:x}", Sha256::digest(&bytes))); result.md5_mismatch = case @@ -232,68 +339,74 @@ fn run_case(options: &RunOptions, out: &Path, case: &Case) -> Result } else { Status::Crash }; - result.signature = Some(signature::process(result.status, &evidence)); + result.signature = Some(options.tag(signature::process(result.status, &evidence))); result.read_process = Some(evidence); - return Ok(result); + return Ok(run); }; result.read_process = Some(evidence); if let Some(error) = &read.safe_error { result.status = Status::ReadError; - result.signature = Some(signature::error(Status::ReadError, error)); + result.signature = Some(options.tag(signature::error(Status::ReadError, error))); } if options.read_only { if result.status != Status::ReadError { result.status = Status::Pass; } result.read = Some(read); - return Ok(result); + return Ok(run); } - let pages = selected_pages(options, case, &read); - let non_embedded_fonts = !read.inventory.non_embedded_fonts.is_empty(); - result.read = Some(read); - for page in pages { - let mut page = run_page( - options, - &case_out, - case, - &result.dir, - page, - non_embedded_fonts, - )?; - if result.status == Status::ReadError && page.status == Status::RenderError { - page.status = Status::ReadError; - page.signature.clone_from(&result.signature); - } - result.pages.push(page); - } - if result.status != Status::ReadError { - let worst = result - .pages - .iter() - .max_by_key(|page| page.status.severity()) - .map(|page| (page.status, page.signature.clone())); - (result.status, result.signature) = match worst { - Some((status, signature)) if status.is_failure() => (status, signature), - Some(_) - if result - .pages - .iter() - .all(|page| page.status == Status::NoReference) => - { - (Status::NoReference, None) + run.pages = selected_pages(options, case, &read); + run.non_embedded_fonts = !read.inventory.non_embedded_fonts.is_empty(); + run.result.read = Some(read); + Ok(run) +} + +impl CaseRun { + /// Completes the case with its rendered pages and removes the evidence of pages that + /// did not fail, now that no page of the case is still writing it. + fn finish(self, pages: Vec) -> CaseResult { + let mut result = self.result; + for mut page in pages { + if result.status == Status::ReadError && page.status == Status::RenderError { + page.status = Status::ReadError; + page.signature.clone_from(&result.signature); } - Some(_) - if result - .pages - .iter() - .any(|page| page.status == Status::FontSubstitution) => - { - (Status::FontSubstitution, None) + if !page.status.is_failure() { + let _ = fs::remove_file(self.case_out.join(worker::streams_file(page.page))); } - _ => (Status::Pass, None), - }; + result.pages.push(page); + } + // Only failing pages keep evidence; a case without any gets no bundle. + let _ = fs::remove_dir(&self.case_out); + if result.status != Status::ReadError { + let worst = result + .pages + .iter() + .max_by_key(|page| page.status.severity()) + .map(|page| (page.status, page.signature.clone())); + (result.status, result.signature) = match worst { + Some((status, signature)) if status.is_failure() => (status, signature), + Some(_) + if result + .pages + .iter() + .all(|page| page.status == Status::NoReference) => + { + (Status::NoReference, None) + } + Some(_) + if result + .pages + .iter() + .any(|page| page.status == Status::FontSubstitution) => + { + (Status::FontSubstitution, None) + } + _ => (Status::Pass, None), + }; + } + result } - Ok(result) } /// Pages to check: the manifest range intersected with the document, capped by `max_pages`. @@ -319,14 +432,14 @@ fn selected_pages(options: &RunOptions, case: &Case, read: &ReadOutput) -> Vec, ) -> Result { + let case = &run.result.case; let mut command = process::worker_command()?; command .arg("page") @@ -341,12 +454,20 @@ fn run_page( .arg("--tolerance") .arg(options.tolerance.to_string()) .arg("--out-dir") - .arg(case_out); - add_reference(&mut command, options, case_dir); + .arg(&run.case_out); + add_reference(&mut command, options, &run.result.dir); if let Some(password) = &case.password { command.arg("--password").arg(password); } + if let Some(web) = web { + add_web(&mut command, web)?; + } let (output, process) = process::execute::(command, options.limits)?; + if let Some(web) = web + && output.is_none() + { + web.close_tab(&process.stderr); + } let (status, signature) = match &output { None => { let status = if process.timed_out { @@ -356,13 +477,9 @@ fn run_page( }; (status, Some(signature::process(status, &process))) } - Some(output) => page_verdict(output, options.tolerance, non_embedded_fonts), + Some(output) => page_verdict(output, options.tolerance, run.non_embedded_fonts), }; - if !status.is_failure() { - // Only failing pages keep evidence; passing cases get no bundle. - let _ = fs::remove_file(case_out.join(worker::streams_file(page))); - let _ = fs::remove_dir(case_out); - } + let signature = signature.map(|signature| options.tag(signature)); Ok(PageResult { status, page, @@ -400,6 +517,17 @@ pub fn page_verdict( } } +/// Points a page worker at the browser and harness page of the web renderer. +pub fn add_web(command: &mut std::process::Command, web: &WebHost) -> Result<()> { + let endpoints = web.endpoints()?; + command + .arg("--devtools") + .arg(endpoints.devtools) + .arg("--harness") + .arg(endpoints.harness); + Ok(()) +} + /// Passes the reference to a worker: published images of the case, or the PDFium library. fn add_reference(command: &mut std::process::Command, options: &RunOptions, case_dir: &str) { if let Some(images) = &options.reference_images { diff --git a/tools/conformance/src/signature.rs b/tools/conformance/src/signature.rs index 6900c366..73783181 100644 --- a/tools/conformance/src/signature.rs +++ b/tools/conformance/src/signature.rs @@ -1,6 +1,7 @@ //! Failure signatures that cluster cases sharing a likely root cause. use crate::{ + corpus::Renderer, model::{DrawRef, ErrorDetail, PageOutput, ProcessEvidence, Region, RegionClass, Status}, source_hints, }; @@ -192,6 +193,17 @@ pub fn panic_message(stderr: &str) -> Option<(String, String)> { None } +/// Marks a signature with the renderer that produced it, after its status prefix +/// (`mismatch: [web] …`), so failures of the web renderer cluster apart from the Skia +/// renderer's. Skia signatures are returned unchanged. +pub fn tagged(signature: String, renderer: Renderer) -> String { + match (renderer, signature.split_once(": ")) { + (Renderer::Web, Some((status, rest))) => format!("{status}: [web] {rest}"), + (Renderer::Web, None) => format!("{signature} [web]"), + (Renderer::Skia, _) => signature, + } +} + /// Signature of a crashed or timed-out worker. pub fn process(status: Status, evidence: &ProcessEvidence) -> String { let stage = evidence.stage.as_deref().unwrap_or("startup"); diff --git a/tools/conformance/src/web_harness.html b/tools/conformance/src/web_harness.html new file mode 100644 index 00000000..28fce55d --- /dev/null +++ b/tools/conformance/src/web_harness.html @@ -0,0 +1,61 @@ + + + + +Safe-PDF web renderer conformance + + + + +
+ + + diff --git a/tools/conformance/src/web_render.rs b/tools/conformance/src/web_render.rs new file mode 100644 index 00000000..b377a4bc --- /dev/null +++ b/tools/conformance/src/web_render.rs @@ -0,0 +1,516 @@ +//! The web renderer: a page rendered by the browser stack (`pdf-web` on a Canvas 2D, with its +//! annotation layer) in headless Chrome, driven over the DevTools protocol. +//! +//! A run starts one [`WebHost`]: it builds the `web-canvas` WebAssembly package, serves it with +//! the harness page on a loopback port, and launches the Chrome installed on the machine. +//! Nothing is downloaded. Each page worker then opens its own tab with [`capture`], renders +//! the page there and screenshots it. + +use crate::{ + corpus, + process::{STAGE_MARKER, TARGET_MARKER}, +}; +use anyhow::{Context, Result, anyhow, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use image::RgbaImage; +use serde_json::{Value, json}; +use std::{ + env, fs, + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; +use tungstenite::{Message, WebSocket, protocol::WebSocketConfig, stream::MaybeTlsStream}; + +/// Worker stage while Safe-PDF renders the page in the browser. +pub const WEB_RENDER_STAGE: &str = "safe-web-render"; + +/// The harness page, served at `/`. +const HARNESS: &str = include_str!("web_harness.html"); + +/// Largest DevTools message accepted; screenshots of large pages arrive as one message. +const MESSAGE_LIMIT: usize = 512 * 1024 * 1024; + +/// How long Chrome may take to start listening. +const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30); + +/// Chrome executables looked up on `PATH`. +const CHROME_NAMES: [&str; 4] = [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", +]; + +/// Chrome's location in a macOS application install. +const CHROME_MAC: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +/// Where page workers find the browser and the harness page. +#[derive(Clone, Debug)] +pub struct WebEndpoints { + /// `host:port` of Chrome's DevTools HTTP endpoint. + pub devtools: String, + /// URL of the harness page. + pub harness: String, +} + +/// The browser and harness server shared by the page workers of one run. +pub struct WebHost { + chrome: PathBuf, + harness: String, + browser: Mutex, +} + +impl WebHost { + /// Builds the WebAssembly package, starts the harness server and launches Chrome: + /// `chrome`, else the first Chrome found in the usual places. + pub fn start(chrome: Option) -> Result { + let chrome = find_chrome(chrome)?; + let pkg = build_package()?; + let harness = serve(pkg)?; + let browser = Browser::launch(&chrome)?; + Ok(Self { + chrome, + harness, + browser: Mutex::new(browser), + }) + } + + /// Returns the endpoints for the next page, relaunching Chrome if it stopped answering. + pub fn endpoints(&self) -> Result { + let mut browser = self + .browser + .lock() + .map_err(|_| anyhow!("browser lock poisoned"))?; + if http(&browser.devtools, "GET", "/json/version").is_err() { + println!("Chrome stopped answering; relaunching it"); + *browser = Browser::launch(&self.chrome)?; + } + Ok(WebEndpoints { + devtools: browser.devtools.clone(), + harness: self.harness.clone(), + }) + } + + /// Closes the tab a killed worker named on its stderr, so it stops using the browser. + pub fn close_tab(&self, stderr: &str) { + let Some(id) = stderr + .lines() + .rev() + .find_map(|line| line.strip_prefix(TARGET_MARKER)) + else { + return; + }; + if let Ok(browser) = self.browser.lock() { + // A tab that already closed with its renderer process leaves nothing to do. + let _ = http( + &browser.devtools, + "GET", + &format!("/json/close/{}", id.trim()), + ); + } + } +} + +/// Renders `page` of the document in a new tab at `width` x `height` pixels and returns the +/// screenshot with the milliseconds Safe-PDF took in the browser. +/// +/// A Safe-PDF error inside the browser is returned as the error, with the message the +/// WebAssembly module reported. +pub fn capture( + endpoints: &WebEndpoints, + bytes: &[u8], + password: Option<&str>, + page: usize, + width: u32, + height: u32, +) -> Result<(RgbaImage, u128)> { + eprintln!("{STAGE_MARKER}web-open"); + let tab = Tab::open(&endpoints.devtools)?; + let mut session = Session::connect(&tab.socket)?; + session.call( + "Emulation.setDeviceMetricsOverride", + json!({ "width": width, "height": height, "deviceScaleFactor": 1, "mobile": false }), + )?; + session.call("Page.enable", json!({}))?; + session.call("Page.navigate", json!({ "url": endpoints.harness }))?; + session.wait_for("Page.loadEventFired")?; + session.evaluate("window.conformanceReady.then(() => true)")?; + + eprintln!("{STAGE_MARKER}{WEB_RENDER_STAGE}"); + let started = Instant::now(); + let rendered = session.evaluate(&format!( + "conformanceRender({}, {}, {page}, {width}, {height})", + json!(BASE64.encode(bytes)), + json!(password), + ))?; + let elapsed = started.elapsed().as_millis(); + if let Some(error) = rendered.get("error").and_then(Value::as_str) { + bail!("{error}"); + } + + eprintln!("{STAGE_MARKER}web-capture"); + let shot = session.call( + "Page.captureScreenshot", + json!({ + "format": "png", + "clip": { "x": 0, "y": 0, "width": width, "height": height, "scale": 1 }, + }), + )?; + let data = shot + .get("data") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("screenshot without data"))?; + let image = + image::load_from_memory_with_format(&BASE64.decode(data)?, image::ImageFormat::Png)? + .to_rgba8(); + if image.dimensions() != (width, height) { + bail!( + "browser screenshot is {:?}, expected {:?}", + image.dimensions(), + (width, height) + ); + } + Ok((image, elapsed)) +} + +/// Returns `explicit`, the macOS application install, or the first Chrome on `PATH`. +fn find_chrome(explicit: Option) -> Result { + if let Some(path) = explicit { + if !path.is_file() { + bail!("Chrome {} not found", path.display()); + } + return Ok(path); + } + let mac = Path::new(CHROME_MAC); + if mac.is_file() { + return Ok(mac.to_owned()); + } + let path = env::var_os("PATH").unwrap_or_default(); + env::split_paths(&path) + .flat_map(|dir| CHROME_NAMES.map(|name| dir.join(name))) + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + anyhow!( + "no Chrome found for the web renderer: install Google Chrome or Chromium, or pass \ + --chrome / CHROME_PATH (nothing is downloaded)" + ) + }) +} + +/// Builds the optimized `web-canvas` package and returns its directory. +fn build_package() -> Result { + let root = corpus::workspace_root(); + println!("Building the web-canvas WebAssembly package"); + let status = Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(&root) + .args(["xtask", "web-canvas", "--profile", "release"]) + .status() + .context("running cargo xtask web-canvas")?; + if !status.success() { + bail!("cargo xtask web-canvas failed ({status})"); + } + Ok(root.join("examples").join("web-canvas").join("pkg")) +} + +/// Serves the harness page and the files of `pkg` on a loopback port; returns the page URL. +fn serve(pkg: PathBuf) -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let url = format!("http://{}/", listener.local_addr()?); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let pkg = pkg.clone(); + thread::spawn(move || { + // A browser that drops a connection only loses that response. + let _ = respond(stream, &pkg); + }); + } + }); + Ok(url) +} + +/// Answers one request: `/` with the harness, `/pkg/` with a file of `pkg`. +fn respond(mut stream: TcpStream, pkg: &Path) -> Result<()> { + let mut reader = BufReader::new(&stream); + let mut request = String::new(); + reader.read_line(&mut request)?; + let mut header = String::new(); + while reader.read_line(&mut header)? > 2 { + header.clear(); + } + let path = request.split_whitespace().nth(1).unwrap_or_default(); + let file = path + .strip_prefix("/pkg/") + .filter(|name| !name.is_empty() && !name.starts_with('.') && !name.contains(['/', '\\'])); + let (status, content_type, body) = match (path, file) { + ("/", _) => ("200 OK", "text/html", HARNESS.as_bytes().to_vec()), + (_, Some(name)) => match fs::read(pkg.join(name)) { + Ok(body) => ("200 OK", content_type(name), body), + Err(_) => ("404 Not Found", "text/plain", b"not found".to_vec()), + }, + _ => ("404 Not Found", "text/plain", b"not found".to_vec()), + }; + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Cache-Control: no-store\r\nConnection: close\r\n\r\n", + body.len() + )?; + stream.write_all(&body)?; + Ok(()) +} + +fn content_type(name: &str) -> &'static str { + match Path::new(name) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("wasm") => "application/wasm", + Some("js") => "text/javascript", + Some("css") => "text/css", + Some("html") => "text/html", + _ => "application/octet-stream", + } +} + +/// Sends one HTTP request to `address` and returns the body of a 200 response. +fn http(address: &str, method: &str, path: &str) -> Result { + let mut stream = TcpStream::connect(address)?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n" + )?; + let mut reader = BufReader::new(stream); + let mut status = String::new(); + reader.read_line(&mut status)?; + let mut length = None; + let mut header = String::new(); + while reader.read_line(&mut header)? > 2 { + if let Some((name, value)) = header.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + length = value.trim().parse::().ok(); + } + header.clear(); + } + if !status.contains(" 200") { + bail!("{method} {path}: {}", status.trim()); + } + let mut body = String::new(); + match length { + Some(length) => reader.take(length).read_to_string(&mut body)?, + None => reader.read_to_string(&mut body)?, + }; + Ok(body) +} + +/// A headless Chrome with its own temporary profile; killed when dropped. +struct Browser { + child: Child, + profile: PathBuf, + devtools: String, +} + +impl Browser { + fn launch(chrome: &Path) -> Result { + static LAUNCHES: AtomicUsize = AtomicUsize::new(0); + let profile = env::temp_dir().join(format!( + "safe-pdf-conformance-chrome-{}-{}", + std::process::id(), + LAUNCHES.fetch_add(1, Ordering::SeqCst) + )); + let _ = fs::remove_dir_all(&profile); + fs::create_dir_all(&profile)?; + let mut command = Command::new(chrome); + command + .args([ + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + "--force-color-profile=srgb", + "--font-render-hinting=none", + "--disable-lcd-text", + "--disable-extensions", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + "about:blank", + ]) + .arg(format!("--user-data-dir={}", profile.display())) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // Containers often cannot start Chrome's sandbox; CI opts out explicitly. + if env::var_os("CONFORMANCE_CHROME_NO_SANDBOX").is_some() { + command.arg("--no-sandbox"); + } + let child = command + .spawn() + .with_context(|| format!("starting {}", chrome.display()))?; + let mut browser = Self { + child, + profile, + devtools: String::new(), + }; + browser.devtools = browser.wait_for_port()?; + Ok(browser) + } + + /// Waits for Chrome to write the DevTools port into its profile. + fn wait_for_port(&mut self) -> Result { + let started = Instant::now(); + let file = self.profile.join("DevToolsActivePort"); + while started.elapsed() < LAUNCH_TIMEOUT { + if let Some(status) = self.child.try_wait()? { + bail!("Chrome exited during startup ({status})"); + } + if let Some(port) = fs::read_to_string(&file) + .ok() + .and_then(|text| text.lines().next().map(str::to_owned)) + .filter(|port| !port.is_empty()) + { + return Ok(format!("127.0.0.1:{port}")); + } + thread::sleep(Duration::from_millis(50)); + } + bail!("Chrome did not open its DevTools port within {LAUNCH_TIMEOUT:?}") + } +} + +impl Drop for Browser { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = fs::remove_dir_all(&self.profile); + } +} + +/// A browser tab; closed when dropped. +struct Tab { + devtools: String, + id: String, + socket: String, +} + +impl Tab { + /// Opens a blank tab and names it on stderr for the harness. + fn open(devtools: &str) -> Result { + let created: Value = + serde_json::from_str(&http(devtools, "PUT", "/json/new?about:blank")?)?; + let field = |name: &str| { + created + .get(name) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| anyhow!("new tab without `{name}`")) + }; + let tab = Self { + devtools: devtools.to_owned(), + id: field("id")?, + socket: field("webSocketDebuggerUrl")?, + }; + eprintln!("{TARGET_MARKER}{}", tab.id); + Ok(tab) + } +} + +impl Drop for Tab { + fn drop(&mut self) { + let _ = http(&self.devtools, "GET", &format!("/json/close/{}", self.id)); + } +} + +/// A DevTools protocol connection to one tab. +struct Session { + socket: WebSocket>, + next_id: u64, + /// Events that arrived while waiting for a command's result. + events: Vec, +} + +impl Session { + fn connect(url: &str) -> Result { + let config = WebSocketConfig::default() + .max_message_size(Some(MESSAGE_LIMIT)) + .max_frame_size(Some(MESSAGE_LIMIT)); + let (socket, _) = tungstenite::client::connect_with_config(url, Some(config), 0) + .with_context(|| format!("connecting to {url}"))?; + Ok(Self { + socket, + next_id: 1, + events: Vec::new(), + }) + } + + /// Reads the next protocol message, skipping frames that carry none. + fn read(&mut self) -> Result { + loop { + if let Message::Text(text) = self.socket.read()? { + return Ok(serde_json::from_str(text.as_str())?); + } + } + } + + /// Sends a command and returns its result, skipping events that arrive meanwhile. + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let request = json!({ "id": id, "method": method, "params": params }); + self.socket.send(Message::text(request.to_string()))?; + loop { + let mut message = self.read()?; + if let Some(event) = message.get("method").and_then(Value::as_str) { + self.events.push(event.to_owned()); + continue; + } + if message.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + if let Some(error) = message.get("error") { + bail!("{method}: {error}"); + } + return Ok(message + .get_mut("result") + .map(Value::take) + .unwrap_or_default()); + } + } + + /// Waits for the event `method`, which may have arrived during an earlier command. + fn wait_for(&mut self, method: &str) -> Result<()> { + if self.events.iter().any(|event| event == method) { + self.events.clear(); + return Ok(()); + } + while self.read()?.get("method").and_then(Value::as_str) != Some(method) {} + Ok(()) + } + + /// Evaluates `expression` in the page, awaiting a promise, and returns its value. + fn evaluate(&mut self, expression: &str) -> Result { + let mut result = self.call( + "Runtime.evaluate", + json!({ "expression": expression, "awaitPromise": true, "returnByValue": true }), + )?; + if let Some(details) = result.get("exceptionDetails") { + let message = details + .pointer("/exception/description") + .or_else(|| details.get("text")) + .and_then(Value::as_str) + .unwrap_or("exception"); + bail!("harness script failed: {message}"); + } + Ok(result + .pointer_mut("/result/value") + .map(Value::take) + .unwrap_or_default()) + } +} diff --git a/tools/conformance/src/worker.rs b/tools/conformance/src/worker.rs index ba7adad5..68eab66f 100644 --- a/tools/conformance/src/worker.rs +++ b/tools/conformance/src/worker.rs @@ -6,6 +6,7 @@ use crate::{ process::{STAGE_MARKER, STATS_MARKER}, reference, regions, safe_render, stream_graph::StreamGraph, + web_render::{self, WebEndpoints}, }; use anyhow::{Result, anyhow}; use image::{DynamicImage, GenericImage, Rgba, RgbaImage, imageops::FilterType}; @@ -49,6 +50,8 @@ pub struct PageJob<'a> { pub reference_images: Option<&'a Path>, /// Writes the Safe-PDF image even when the page matches or has no reference. pub write_safe: bool, + /// Renders with the web renderer in this browser, annotations included, instead of Skia. + pub web: Option<&'a WebEndpoints>, } fn stage(name: &str) { @@ -302,7 +305,7 @@ pub fn page(job: &PageJob<'_>) -> Result<()> { output.size = [width, height]; stage("reference-render"); - let config = reference::config(width, height)?; + let config = reference::config(width, height, job.web.is_some())?; let reference_image = match (golden, &reference_page) { (Some(golden), _) => golden, (None, Some(Ok(page))) => reference::render(page, &config) @@ -322,44 +325,28 @@ pub fn page(job: &PageJob<'_>) -> Result<()> { (None, None) => Err("no reference renderer".to_owned()), }; - stage("safe-record"); let renderer = safe_document.map(PdfRenderer::new); - let started = Instant::now(); - let recorded = renderer - .as_ref() - .map_err(|error| anyhow!("{error}")) - .and_then(|renderer| safe_render::record(renderer, job.page, width, height)); - let recording = recorded.as_ref().ok().map(|recorded| recorded.recording()); - report_stats(&RenderStats { - record_ms: Some(started.elapsed().as_millis()), - replay_cost: recording.map(|recording| recording.replay_cost()), - nesting: recording.map(|recording| recording.nesting()), - replay_ms: None, - })?; - - stage("safe-replay"); - let started = Instant::now(); - let safe_image = recorded.and_then(|recorded| safe_render::replay(&recorded, width, height)); - report_stats(&RenderStats { - replay_ms: Some(started.elapsed().as_millis()), - ..RenderStats::default() - })?; + let safe_image = match job.web { + Some(endpoints) => render_web(job, endpoints, &renderer, &bytes, width, height)?, + None => render_skia(&renderer, job.page, width, height)?, + }; let prefix = format!("p{}", job.page); match (&reference_image, &safe_image) { (Ok(reference_image), Ok(safe_image)) => { stage("compare"); - // Golden images draw annotations and form fields; Safe-PDF's render does not. - let ignore = if job.pdfium.is_none() && job.reference_images.is_none() { - renderer - .as_ref() - .ok() - .and_then(|renderer| renderer.document().get_page(job.page)) - .map(|page| annotation_boxes(page, width, height)) - .unwrap_or_default() - } else { - Vec::new() - }; + // Golden images draw annotations and form fields; the Skia render does not. + let ignore = + if job.pdfium.is_none() && job.reference_images.is_none() && job.web.is_none() { + renderer + .as_ref() + .ok() + .and_then(|renderer| renderer.document().get_page(job.page)) + .map(|page| annotation_boxes(page, width, height)) + .unwrap_or_default() + } else { + Vec::new() + }; let mut comparison = compare::compare(reference_image, safe_image, &ignore)?; let mut traced = None; if comparison.metrics.mismatch > job.tolerance { @@ -471,6 +458,62 @@ pub fn page(job: &PageJob<'_>) -> Result<()> { Ok(()) } +/// Records the page and replays it onto a Skia raster, reporting each step's cost as it +/// finishes. The outer error is the harness's; the inner one is Safe-PDF's. +fn render_skia( + renderer: &Result, + page: usize, + width: u32, + height: u32, +) -> Result> { + stage("safe-record"); + let started = Instant::now(); + let recorded = renderer + .as_ref() + .map_err(|error| anyhow!("{error}")) + .and_then(|renderer| safe_render::record(renderer, page, width, height)); + let recording = recorded.as_ref().ok().map(|recorded| recorded.recording()); + report_stats(&RenderStats { + record_ms: Some(started.elapsed().as_millis()), + replay_cost: recording.map(|recording| recording.replay_cost()), + nesting: recording.map(|recording| recording.nesting()), + ..RenderStats::default() + })?; + + stage("safe-replay"); + let started = Instant::now(); + let image = recorded.and_then(|recorded| safe_render::replay(&recorded, width, height)); + report_stats(&RenderStats { + replay_ms: Some(started.elapsed().as_millis()), + ..RenderStats::default() + })?; + Ok(image) +} + +/// Renders the page with the web renderer in the browser, annotations included. A document +/// Safe-PDF could not read keeps its read error. The outer error is the harness's; the inner +/// one is Safe-PDF's. +fn render_web( + job: &PageJob<'_>, + endpoints: &WebEndpoints, + renderer: &Result, + bytes: &[u8], + width: u32, + height: u32, +) -> Result> { + if let Err(error) = renderer { + return Ok(Err(anyhow!("{error}"))); + } + let captured = web_render::capture(endpoints, bytes, job.password, job.page, width, height); + if let Ok((_, web_ms)) = &captured { + report_stats(&RenderStats { + web_ms: Some(*web_ms), + ..RenderStats::default() + })?; + } + Ok(captured.map(|(image, _)| image)) +} + /// Returns the name of the content-stream graph written for a page. pub fn streams_file(page: usize) -> String { format!("p{page}-streams.txt")