From 3ef63cb4e7124688e7850a19d1bd46c6ac503659 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 23:14:03 -0600 Subject: [PATCH 1/3] M2: retain open regressions and deterministic test fixtures --- crates/automation/tests/batch.rs | 12 ++++- crates/print/src/tests.rs | 3 +- crates/ui-egui/src/open_worker_tests.rs | 62 +++++++++++++++++++++++++ crates/ui-egui/tests/document_links.rs | 3 ++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/batch.rs index 20a335917b..6c1c551629 100644 --- a/crates/automation/tests/batch.rs +++ b/crates/automation/tests/batch.rs @@ -10,7 +10,17 @@ fn result(auto: &mut Automation, args: Value) -> Value { } fn pdf() -> Vec { - Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() + // Reuse one synthetic document so byte comparisons cannot straddle the + // writer's modification-time clock. Each caller gets an independent buffer. + static PDF: std::sync::OnceLock> = std::sync::OnceLock::new(); + PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone() +} + +#[test] +fn pdf_fixture_remains_identical_across_metadata_clock_tick() { + let before = pdf(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + assert_eq!(before, pdf()); } #[test] diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index ee21c2706c..be0d641aca 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -506,7 +506,8 @@ mod cups_options { let Some(record) = std::env::var_os(RECORD) else { return }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); - println!("{}", std::env::var(ANSWER).unwrap()); + // Serial libtest prints its test label without a newline before this helper's stdout. + println!("\n{}", std::env::var(ANSWER).unwrap()); } fn command(record: &std::path::Path, answer: &str) -> std::process::Command { diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs index 306d8e9ace..7da567dc83 100644 --- a/crates/ui-egui/src/open_worker_tests.rs +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -406,3 +406,65 @@ fn a_startup_document_waits_for_an_active_document_to_prepare() { assert_eq!(harness.state().active, Some(0), "the chosen document keeps focus"); let _ = std::fs::remove_file(path); } + +#[test] +#[allow(clippy::expect_used, clippy::unwrap_used)] +fn inline_tool_open_keeps_its_command_for_pdf_conversion_and_password_retry() { + for (name, bytes, encrypted) in + [("plain.pdf", fixture(1), false), ("converted.txt", b"Text to export".to_vec(), false), ("locked.pdf", encrypted_tool_fixture(), true)] + { + let path = std::env::temp_dir().join(format!("pdfcraft-inline-tool-{}-{name}", std::process::id())); + std::fs::write(&path, bytes).expect("write selected file"); + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").expect("language preference"); + assert!(app.ctx.is_none(), "this exercises the no-context inline path"); + app.open_path_then(&path.to_string_lossy(), Some("export.text")); + if encrypted { + assert!(app.dialog.is_none()); + assert_eq!(app.password_prompt.as_ref().expect("password prompt").then, Some("export.text")); + app.submit_password(Some("wrong".into())); + assert!(app.password_prompt.as_ref().expect("wrong-password prompt").error.is_some()); + assert!(app.dialog.is_none()); + app.submit_password(Some("tool-password".into())); + } + assert_eq!(app.views.len(), 1); + assert_eq!(app.active, Some(0)); + assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the inline {name} retains its export command"); + let _ = std::fs::remove_file(path); + } +} + +#[test] +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn startup_files_wait_for_absolute_link_consent_and_preparation() { + let (mut h, _) = harness(); + let source = h.state().views[0].id; + let path = std::env::temp_dir().join(format!("pdfcraft-open-consented-startup-{}.pdf", std::process::id())); + std::fs::write(&path, fixture(2)).expect("write linked PDF"); + h.state_mut().open_remote_link(source, path.to_string_lossy().into_owned(), pdfcraft_render::RemoteDestination::Default); + h.state().startup_inbox.lock().expect("startup queue").push(("startup.pdf".into(), fixture(1))); + h.step(); + assert!(h.state().pending_remote_link.is_some()); + assert_eq!(h.state().startup_inbox.lock().expect("startup queue").len(), 1); + assert_eq!(h.state().views.len(), 1, "startup cannot replace the consent interaction"); + + let (gate, started, release, finished) = open_worker::prepare_gate(); + h.state_mut().open_prepare_gate = Some(gate); + h.state_mut().resolve_pending_remote_link(true); + started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier"); + h.step(); + let queued = h.state().startup_inbox.lock().expect("startup queue").len(); + let visible = h.state().views.len(); + // Always release before asserting: a failure must not strand the worker at the barrier. + release.send(()).expect("release prepare barrier"); + finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation"); + assert_eq!(queued, 1, "startup bytes wait while the approved linked PDF prepares"); + assert_eq!(visible, 1); + h.step(); + let app = h.state(); + assert_eq!(app.views.len(), 3); + assert_eq!(app.session.get(app.views[1].id).expect("linked document").path.as_deref(), path.to_str()); + assert_eq!(app.session.get(app.views[2].id).expect("startup document").name, "startup.pdf"); + assert_eq!(app.active, Some(1), "the user's approved navigation keeps focus"); + let _ = std::fs::remove_file(path); +} diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs index c9bb64a3df..2d6346c83d 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/document_links.rs @@ -235,6 +235,9 @@ fn a_gotor_link_to_a_directory_or_device_is_refused_without_reading_it() { for target in targets { let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), target, "[0 /Fit]"); click_remote_link(&mut h); + support::wait_until(&mut h, "non-regular remote-file refusal", |app| { + app.toast.as_ref().is_some_and(|(message, _)| message.contains("not a regular file")) + }); assert_eq!(h.state().views.len(), 1, "{target} opens no tab"); let toast = &h.state().toast.as_ref().unwrap().0; assert!(toast.contains("not a regular file"), "{target}: {toast}"); From 9dfc91e0e87e40ebc6b71803049e84c95bfac6ef Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Fri, 16 Oct 2026 06:25:30 +0800 Subject: [PATCH 2/3] M8: the redaction audit finds a cover over one word of a long line (#2662) A cover counted only when it hid 40% of the line's words or box, so a black box over one number in a long line, and an unapplied redaction mark over it, audited clean while the number was still extractable. A cover that hides half of any one word now counts as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/audit/README.md | 10 +++---- crates/audit/src/lib.rs | 11 ++++++- crates/audit/src/tests.rs | 57 +++++++++++++++++++++++++++++++----- parity/acrobat-features.toml | 4 +-- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index 5dd15de3af..bda5cc977e 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -16,9 +16,9 @@ It reports two kinds of finding: A properly applied redaction removes the text, so it never flags. Page backgrounds, table shading and highlights don't either: they're painted before the text, or light against it. -A rectangle covers a text line when it hides at least 40% of the area of the words the line -draws, or at least 40% of the line's box together with a positive-area intersection with one -of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but +A rectangle covers a text line when it hides at least half of one of the line's words, at +least 40% of the area of the words the line draws, or at least 40% of the line's box together +with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but holds no word, so a redaction box over it alone does not count. Limits, by design and documented rather than hidden: @@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden: - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; - annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign; -- a cover that hides less than 40% of a line's box and less than 40% of its words (a short - value in the middle of a long line) is not flagged. +- a cover that hides less than half of every word it touches, less than 40% of a line's box and + less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 204d237b3d..723d086d63 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -87,6 +87,9 @@ pub enum AuditError { /// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a /// cover to count as hidden (see [`covers`]). const COVER_FRACTION: f64 = 0.4; +/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however +/// long the line around it (see [`covers`]). +const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; @@ -138,7 +141,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } -/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough: +/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: +/// +/// - Half of one word lies under the cover. A box over one number in a long line hides a few +/// percent of the line, and the number all the same (#2662). /// /// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words, /// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where @@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { let total: f64 = boxes.iter().map(area).sum(); if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 } }; + if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) { + return true; + } let inked = fraction(&line.ink) >= COVER_FRACTION; let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION; let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0); diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index d7d83a7db3..ed36a8d5ee 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() { assert!(findings[0].covered_text.contains("DO NOT SHARE")); } +/// #2662: a box over one number of a long line hides a few percent of the line, and all of the +/// number. Found as a cover, and as an unapplied mark over the same word. +#[test] +fn a_cover_over_one_word_of_a_long_line_is_found() { + // Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163. + let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET +"; + let mut content = line.to_vec(); + content.extend_from_slice( + b"0 g +131 96 33 14 re f +", + ); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert!(findings[0].covered_text.contains("98765"), "{findings:?}"); + + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(line); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); +} + +/// A box that only grazes the words beside it (less than half of each) is not a cover. +#[test] +fn a_box_grazing_a_word_is_not_flagged() { + let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET +0 g +131 96 6 14 re f +"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + #[test] fn background_before_text_is_not_flagged() { // An opaque dark rectangle painted *before* the text is a background, not a cover. @@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) { /// Keep the historic detections with several covers, lines and text-spacing states. #[test] fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() { - let cases: [&[u8]; 4] = [ - b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", - b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", - b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", - b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", + // The second box of the first case hides most of the number (less than 40% of the line): a + // finding of its own since covers are also measured per word (#2662). + let cases: [(&[u8], usize); 4] = [ + (b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2), + (b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1), + (b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1), + (b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1), ]; - for content in cases { + for (content, expected) in cases { let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); - assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content)); + assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content)); } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..b12cb3726c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5812,8 +5812,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" From 8fa6fecf70e1d927e6b8b0ca5b5a18e977e7b966 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 21:17:37 -0400 Subject: [PATCH 3/3] ci: add nix check workflow --- .github/workflows/nix.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..18392587a8 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: | + if [ -f flake.nix ]; then + nix flake check --no-build --all-systems + else + echo "No flake.nix present in this repository; skipping check." + fi