Merge remote-tracking branch 'origin/main' into fix/background-hidden-by-page-backdrop

# Conflicts:
#	ATTRIBUTION.toml
This commit is contained in:
alehel committed 2026-10-17 20:20:34 +02:00
commit 74f3522478
41 files changed
+385 -81

No files matched your search

+22 -22
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "dd8a8b41a1a6de5d2f9f540d0c499366ccaa1ebfe62bb259f202dd4be03fc234"
sha256 = "c70caadef8f52c88dda53acae29525d06eecee2b7ff2f37dcd97fcb02ac5285a"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "392fa715e7a058a6d6f872a9381260e9e1598ef45b616edc71bc6b311d9c4a34"
sha256 = "f14bc3945d397c664c0d46c624568d0525864eb7c7aba001d1d434f571e1d8c6"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "343b669decabca1e05a651b410b34427f2a28bb2dd22240a877bbb57a94d87af"
sha256 = "d1087a3191cf50bb8ca75c2d01b4091fe5d0c0987da4263e69cd81f2f2b3f815"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e56bb7c7ebe676b5cb606f0bff4102ca036a532a2c6afbcc74e8a221b2ae3382"
sha256 = "cb5ac03334bcbc05db290a5a5ed948c0172869bf9e64cc050a621b1a90d880cd"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9e3085c3a3d0c6114df4bd73d380d8efc4bb05ba07ad0285b4c54abd9a901b69"
sha256 = "0592951b23cab160003cf0a4d57764a0beaea8fcdbdb2e30dac016548f80ee2e"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8a450837808cd319c82e39b4833623b64c3e50009beffd732d00b8eb88d32fbc"
sha256 = "c6889458db78e160fe9381549d7b9be9801f42a5bb8fe8367615ebc18df01c2d"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9c6c34ec7f372b3d3db2c88435b497eb4bbf2032859c1bfc3cfb12c47c3b36b6"
sha256 = "bb2bc27d42a857ec7afce94644e5407f5676a31fd1ecceb38a4cf6cfe37d64b5"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9d890f88003febb885e076df172872cfff76cde77cf22b1af57c570229dc4316"
sha256 = "ab5ce70d22ed63a8a1f96c730468fabf6a411283b63120b651e2539dd4cfbdb3"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "617bf25bb48c93a0578e5832f54420fc47b3a97ad31bf9ffd57556a1bdb4048b"
sha256 = "67f95b2a38eb99af3d67833854068fad7bca4cc1214e2dd3becb25e4085a9bbd"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b90ef8f3a4b71e2aca3bc98f8420b25fbc6f86220b75f0c5f47b731551e506f8"
sha256 = "9862c4a53240985a0fdb5acf052aec5f537f71380f8c0d29a5a4fdc6e8b5ef41"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "77069128e2d00b03cedf460915fc00a0cc00880e3b1aecf6de769b06c218f71b"
sha256 = "9716ac3bebc449962eb3f15ce6f9a72a2ed05535e721c04bb3c7dc1ec8577985"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2ee89f763e825609beb78b5f05f3a035cff4f01c81a0fb80dcefbbf209d19f98"
sha256 = "a47c41d1e5559386f9a94444f9ec2e3a48d51d5fa587aa372ec030556c9ecd17"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "19844f166956ac49e6c4c09b57e2024e0d73c5c3e14ee8df994a1799bef9b648"
sha256 = "911c2a48182efcce05af453803b94d6f66f7d2f37ddf1ca9b01e912f47db1a66"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5b31891a0775d5e7a9cfb14a2f767b2fa17c9d948ce92b43cd371846a3d69322"
sha256 = "66887fab11c900680136d528385f8928ad673b63baf6f171acd16c6b0245cc4a"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e7b587ecf87095a1317bf37830ca60e75a16a0285dfa14e0361ddfd04b44800b"
sha256 = "0ba6933fec3aea7528cabcb68901ea5ad81bc6cb93dc9d01767ad46b5bdf00dc"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9413104f8543e8a03dccb014d62bd40a77645de5b279c55b3266c02e907afb89"
sha256 = "432c818f2c78780418237d7d88dec1e268be2bf2c336ac40a11926f841f8af46"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f1490c97273e57b256e234a8a53766318f75f888a05144727efc226c7d67f62d"
sha256 = "5c2f293d7a3c124ad6fbb19ea63b37711ae76ec5e0180bd466586c739a108094"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2216666f2d355aed70b4c60e09bfe07d902036bbad52aee1e287e5f50a887903"
sha256 = "7d12586bb4cb268d4e353dcebf20a4a190737821a34a2e8fab05c1d119b5fc24"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "436777718e77c79ca54a16a629d725e7b480ad9fad5f25f7c249b446208797e4"
sha256 = "74bff0a4ed87b103df409309ba3898403d8cd1d29934583d527ba9327c4edf9c"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d66b81e59728b669ed1a35e4773d0ef75b25f7143109e9a5fc7dd29e6a52ee25"
sha256 = "efe5caf1ef8b313a5728ec9a20596d523c9cebb847ed3f20fa5a81dec9e7a9f2"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a2dde60a333e8c75d8f07190149d28d3f210c0c8662e0493b4e67dd34789e453"
sha256 = "19283a0a5ae0636e3539fa143076f811b6ea602338b14d6214694d61f70a337b"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "73563d2d36a685ef23ed517eb635a654b1a5900abe7471db17b40af62b4cd223"
sha256 = "cd64ce98d111cc3cf5a0daa2874ffbce78f314e8d1cba3b2297983151b600864"
[[asset]]
Generated
+6
View File
@@ -1142,6 +1142,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -4100,6 +4105,7 @@ name = "pdfcraft-engine"
version = "0.8.0"
dependencies = [
"chrono",
"craft-docpath",
"icu_properties",
"idna",
"image",
+2
View File
@@ -18,6 +18,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0",
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
+4 -1
View File
@@ -231,7 +231,10 @@ fn main() -> eframe::Result {
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
let instance = match single_instance::claim(&files, may_hand_off) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
+5 -5
View File
@@ -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".
+10 -1
View File
@@ -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::<f64>() / 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);
+50 -7
View File
@@ -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));
}
}
+11 -1
View File
@@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value {
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
fn pdf() -> Vec<u8> {
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<Vec<u8>> = 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]
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
craft-docpath = { workspace = true }
pdfcraft-measure = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
pdfcraft-render = { workspace = true }
+57 -24
View File
@@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError {
Empty,
TooLong,
Hidden,
UrlOrNetwork,
/// A URL, a network or device path, or a path that climbs above its root.
NotLocal(craft_docpath::Refused),
MissingSourcePath,
/// The target doesn't end in `.pdf`.
NotPdf,
@@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError {
Self::Empty => f.write_str("the file specification is empty"),
Self::TooLong => f.write_str("the file specification is too long"),
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"),
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
Self::NotAFile => f.write_str("the linked path is not a regular file"),
@@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool {
}
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
/// Relative paths are based on the source document, which should be an absolute path; an
/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are
/// refused ([`craft_docpath::resolve`]).
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
if raw.is_empty() || raw.trim().is_empty() {
return Err(RemoteDocumentPathError::Empty);
@@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
if raw.chars().any(hides_text) {
return Err(RemoteDocumentPathError::Hidden);
}
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
let path = Path::new(raw);
let drive_absolute = remote_document_path_is_absolute(raw);
// A GoToR file specification is a path, not a URL: any scheme of two or more characters is
// refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included.
if let Some((scheme, _)) = raw.split_once(':') {
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
let is_scheme = scheme.len() > 1
&& scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
let windows_drive = scheme.len() == 1 && drive_absolute;
if valid_scheme && !windows_drive {
return Err(RemoteDocumentPathError::UrlOrNetwork);
if is_scheme {
return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
}
}
if drive_absolute {
return Ok(path.to_path_buf());
let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
// A relative result would be read against the working directory.
if !path.is_absolute() {
return Err(RemoteDocumentPathError::MissingSourcePath);
}
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
Ok(parent.join(path))
Ok(path)
}
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
@@ -503,23 +503,56 @@ mod tests {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
use std::path::Path;
let source = Path::new("/docs/chapter/source.pdf");
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
let source = std::env::temp_dir().join("chapter").join("source.pdf");
let source = source.as_path();
let folder = source.parent().unwrap();
assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf")));
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path()));
#[cfg(unix)]
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used");
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
}
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
for url in
["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"]
{
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}");
}
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
}
#[test]
fn remote_document_paths_to_other_computers_and_devices_are_refused() {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
let source = std::env::temp_dir().join("chapter").join("source.pdf");
let source = source.as_path();
// Refused before any filesystem access: none of these exist, and a stat of a network path
// would contact its host.
for not_local in [
"//server/share/a.pdf",
"\\\\server\\share\\a.pdf",
"\\\\h\\s\\a.pdf",
"\\\\?\\UNC\\h\\s\\a.pdf",
"\\??\\UNC\\h\\s\\a.pdf",
"/??/x",
"\\??\\C:\\a.pdf",
"\\\\.\\device\\a.pdf",
"/\\server/share/a.pdf",
"NUL.pdf",
] {
assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}");
}
// Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share.
#[cfg(unix)]
{
let doubled = format!("/{}", source.with_file_name("a.pdf").display());
assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}");
}
}
#[test]
fn urls_follow_the_artcraft_scheme() {
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
+2 -1
View File
@@ -506,7 +506,8 @@ mod cups_options {
let Some(record) = std::env::var_os(RECORD) else { return };
let args: Vec<String> = 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 {
+1 -2
View File
@@ -738,8 +738,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
match action {
Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c),
Some(crate::sign_ui::CertAction::Export(c)) => {
let pem = crate::sign_ui::certificate_pem(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
app.export_certificate(&c);
}
None => {}
}
+49 -4
View File
@@ -195,9 +195,9 @@ pub(crate) fn file_stem_from_user(name: &str) -> String {
if reserved { format!("_{stem}") } else { stem.to_string() }
}
/// A safe native Save suggestion for a PDF-supplied attachment name.
/// This changes only the suggestion, never the stored name or the selected destination.
#[cfg(any(test, not(target_arch = "wasm32")))]
/// A plain file name for a PDF-supplied attachment name: the Save suggestion, and the tab name
/// (which later names files written from that tab). It never changes the stored name or a
/// destination the user selected.
pub(crate) fn attachment_file_name(name: &str) -> String {
let cleaned: String = name
.chars()
@@ -217,6 +217,14 @@ pub(crate) fn attachment_file_name(name: &str) -> String {
if reserved { format!("_{name}") } else { name.to_string() }
}
/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or
/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data
/// streams.
pub(crate) fn is_plain_file_name(name: &str) -> bool {
let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic());
!(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':')))
}
/// Pages ▸ Rotate Pages.
#[derive(Clone, Debug, PartialEq)]
pub struct RotateDraft {
@@ -667,7 +675,12 @@ impl PdfCraftApp {
/// [`Self::write_files`], then `after` once every file is written: now, or on a later frame
/// when the user chooses the folder. `after` never runs when writing fails or is cancelled.
/// Every name must be a plain file name ([`is_plain_file_name`]); otherwise nothing is written.
pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc<Vec<u8>>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) {
if let Some((name, _)) = named.iter().find(|(name, _)| !is_plain_file_name(name)) {
self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", tl!("the name is not a plain file name"))]);
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let write = move |app: &mut Self, dir: std::path::PathBuf| {
@@ -996,7 +1009,7 @@ impl PdfCraftApp {
#[cfg(test)]
mod stem_tests {
use super::{attachment_file_name, file_stem_from_user};
use super::{attachment_file_name, file_stem_from_user, is_plain_file_name};
#[test]
fn user_file_names_stay_inside_the_folder_and_writable_everywhere() {
@@ -1049,4 +1062,36 @@ mod stem_tests {
assert_eq!(attachment_file_name(&once), once);
}
}
#[test]
fn write_files_refuses_names_that_leave_the_folder() {
let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join("a").join("out");
std::fs::create_dir_all(&out).unwrap();
let absolute = dir.join("abs.pdf").to_string_lossy().into_owned();
let mut app = crate::PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.export_dir_override = Some(out.to_string_lossy().into_owned());
for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] {
assert!(!is_plain_file_name(bad), "{bad}");
// A plain name first: nothing is written when any name in the batch is refused.
app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t");
assert!(app.toast.as_ref().is_some_and(|t| t.0.contains("not a plain file name")), "{bad}");
}
let names: Vec<_> = walk(&dir);
assert!(names.is_empty(), "{names:?}");
app.write_files(&[("x.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t");
assert_eq!(walk(&dir), vec![out.join("x.pdf")]);
let _ = std::fs::remove_dir_all(&dir);
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
}
out
}
}
+1
View File
@@ -1709,6 +1709,7 @@
Couldn't use {name}: {e} تعذّر استخدام {name}: {e}
Couldn't extract pages: {e} تعذّر استخراج الصفحات: {e}
Couldn't write {name}: {e} تعذّرت كتابة {name}: {e}
the name is not a plain file name الاسم ليس اسم ملف بسيطًا
Wrote 1 file to {dir} كُتب ملف واحد في {dir}
Wrote {n} files to {dir} كُتب {n} ملفات في {dir}
No pages match those choices لا صفحات تطابق هذه الاختيارات
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1670,6 +1670,7 @@
Couldn't use {name}: {e} {name} не можа да бъде използван: {e}
Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e}
Couldn't write {name}: {e} {name} не можа да бъде записан: {e}
the name is not a plain file name името не е обикновено име на файл
Wrote 1 file to {dir} Записан е 1 файл в {dir}
Wrote {n} files to {dir} Записани са {n} файла в {dir}
No pages match those choices Няма страници, отговарящи на този избор
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -1761,6 +1761,7 @@ key Space Mezerník
Couldn't use {name}: {e} {name} nelze použít: {e}
Couldn't extract pages: {e} Stránky nelze extrahovat: {e}
Couldn't write {name}: {e} {name} nelze zapsat: {e}
the name is not a plain file name název není prostý název souboru
Wrote 1 file to {dir} Zapsán 1 soubor do {dir}
Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir})
No pages match those choices Těmto volbám neodpovídají žádné stránky
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1643,6 +1643,7 @@
Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e}
Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e}
Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e}
the name is not a plain file name der Name ist kein einfacher Dateiname
Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben
Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben
No pages match those choices Keine Seiten entsprechen dieser Auswahl
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -1221,6 +1221,7 @@
Couldn't use {name}: {e} Δεν ήταν δυνατή η χρήση του {name}: {e}
Couldn't extract pages: {e} Δεν ήταν δυνατή η εξαγωγή σελίδων: {e}
Couldn't write {name}: {e} Δεν ήταν δυνατή η εγγραφή του {name}: {e}
the name is not a plain file name το όνομα δεν είναι απλό όνομα αρχείου
Wrote 1 file to {dir} Γράφτηκε 1 αρχείο στον φάκελο {dir}
Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir}
No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές
Can't render this file because it contains an unexpected character in line 482 and column 9.
+1
View File
@@ -1720,6 +1720,7 @@ action wizard Start Iniciar
Couldn't use {name}: {e} No se pudo usar {name}: {e}
Couldn't extract pages: {e} No se pudieron extraer las páginas: {e}
Couldn't write {name}: {e} No se pudo escribir {name}: {e}
the name is not a plain file name el nombre no es un nombre de archivo simple
Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir}
Wrote {n} files to {dir} Se han escrito {n} archivos en {dir}
No pages match those choices Ninguna página coincide con esas opciones
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -1638,6 +1638,7 @@
Couldn't use {name}: {e} Impossible d'utiliser {name} : {e}
Couldn't extract pages: {e} Impossible d'extraire les pages : {e}
Couldn't write {name}: {e} Impossible d'écrire {name} : {e}
the name is not a plain file name le nom n'est pas un simple nom de fichier
Wrote 1 file to {dir} 1 fichier écrit dans {dir}
Wrote {n} files to {dir} {n} fichiers écrits dans {dir}
No pages match those choices Aucune page ne correspond à ces choix
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -1628,6 +1628,7 @@
Couldn't use {name}: {e} {name} használata nem sikerült: {e}
Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e}
Couldn't write {name}: {e} {name} írása nem sikerült: {e}
the name is not a plain file name a név nem egyszerű fájlnév
Wrote 1 file to {dir} 1 fájl ide írva: {dir}
Wrote {n} files to {dir} {n} fájl ide írva: {dir}
No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -1715,6 +1715,7 @@
Couldn't use {name}: {e} Impossibile usare {name}: {e}
Couldn't extract pages: {e} Impossibile estrarre le pagine: {e}
Couldn't write {name}: {e} Impossibile scrivere {name}: {e}
the name is not a plain file name il nome non è un semplice nome file
Wrote 1 file to {dir} Scritto 1 file in {dir}
Wrote {n} files to {dir} Scritti {n} file in {dir}
No pages match those choices Nessuna pagina corrisponde a queste scelte
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1719,6 +1719,7 @@
Couldn't use {name}: {e} {name} を使用できませんでした: {e}
Couldn't extract pages: {e} ページを抽出できませんでした: {e}
Couldn't write {name}: {e} {name} に書き込めませんでした: {e}
the name is not a plain file name 名前が単独のファイル名ではありません
Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました
Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました
No pages match those choices 条件に一致するページはありません
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1640,6 +1640,7 @@
Couldn't use {name}: {e} {name} файлын пайдалану мүмкін болмады: {e}
Couldn't extract pages: {e} Беттерді шығарып алу мүмкін болмады: {e}
Couldn't write {name}: {e} {name} файлына жазу мүмкін болмады: {e}
the name is not a plain file name атау қарапайым файл атауы емес
Wrote 1 file to {dir} 1 файл {dir} ішіне жазылды
Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды
No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді
Can't render this file because it contains an unexpected character in line 831 and column 9.
+1
View File
@@ -1971,6 +1971,7 @@ comment menu Edit Redaguoti
Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e}
Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e}
Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e}
the name is not a plain file name vardas nėra paprastas failo vardas
Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e}
Create link Kurti saitą
Created 1 link Sukurtas 1 saitas
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -1702,6 +1702,7 @@
Couldn't use {name}: {e} Nie można użyć {name}: {e}
Couldn't extract pages: {e} Nie można wyodrębnić stron: {e}
Couldn't write {name}: {e} Nie można zapisać {name}: {e}
the name is not a plain file name nazwa nie jest zwykłą nazwą pliku
Wrote 1 file to {dir} Zapisano 1 plik w {dir}
Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n}
No pages match those choices Żadna strona nie pasuje do wybranych opcji
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1544,6 +1544,7 @@
Couldn't use {name}: {e} Não foi possível usar {name}: {e}
Couldn't extract pages: {e} Não foi possível extrair páginas: {e}
Couldn't write {name}: {e} Não foi possível gravar {name}: {e}
the name is not a plain file name o nome não é um nome de arquivo simples
Wrote 1 file to {dir} 1 arquivo gravado em {dir}
Wrote {n} files to {dir} {n} arquivos gravados em {dir}
No pages match those choices Nenhuma página corresponde a essas opções
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1710,6 +1710,7 @@
Couldn't use {name}: {e} Не удалось использовать {name}: {e}
Couldn't extract pages: {e} Не удалось извлечь страницы: {e}
Couldn't write {name}: {e} Не удалось записать {name}: {e}
the name is not a plain file name имя не является простым именем файла
Wrote 1 file to {dir} Записан 1 файл в {dir}
Wrote {n} files to {dir} Записано {n} файлов в {dir}
No pages match those choices Страницы не соответствуют этим выбору
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -479,6 +479,7 @@
Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e}
Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e}
Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e}
the name is not a plain file name ime ni preprosto ime datoteke
Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e}
Counterclockwise 90 degrees 90 stopinj v levo
Country/Region Država/regija
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1629,6 +1629,7 @@ action wizard Start ప్రారంభించు
Couldn't use {name}: {e} {name}ను ఉపయోగించడం సాధ్యం కాలేదు: {e}
Couldn't extract pages: {e} పేజీలను వేరు తీయడం సాధ్యం కాలేదు: {e}
Couldn't write {name}: {e} {name}ను రాయడం సాధ్యం కాలేదు: {e}
the name is not a plain file name పేరు సాధారణ ఫైల్ పేరు కాదు
Wrote 1 file to {dir} {dir}లో 1 ఫైల్ సేవ్ చేయబడింది
Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి
No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -1609,6 +1609,7 @@
Couldn't use {name}: {e} {name} kullanılamadı: {e}
Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e}
Couldn't write {name}: {e} {name} yazılamadı: {e}
the name is not a plain file name ad, basit bir dosya adı değil
Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı
Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı
No pages match those choices Bu seçimlerle eşleşen sayfa yok
Can't render this file because it contains an unexpected character in line 830 and column 9.
+1
View File
@@ -1707,6 +1707,7 @@
Couldn't use {name}: {e} Не вдалося використати {name}: {e}
Couldn't extract pages: {e} Не вдалося видобути сторінки: {e}
Couldn't write {name}: {e} Не вдалося записати {name}: {e}
the name is not a plain file name назва не є простою назвою файлу
Wrote 1 file to {dir} Записано 1 файл у {dir}
Wrote {n} files to {dir} Записано файлів ({n}) у {dir}
No pages match those choices Жодна сторінка не відповідає вибраним параметрам
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1961,6 +1961,7 @@ comment menu Edit Sửa
Couldn't use the photo: {e} Không thể dùng ảnh: {e}
Couldn't use {name}: {e} Không thể dùng {name}: {e}
Couldn't write {name}: {e} Không thể ghi {name}: {e}
the name is not a plain file name tên không phải là tên tệp đơn thuần
Couldn't write {path}: {e} Không thể ghi {path}: {e}
Create link Tạo liên kết
Created 1 link Đã tạo 1 liên kết
Can't render this file because it contains an unexpected character in line 490 and column 12.
+1
View File
@@ -1677,6 +1677,7 @@
Couldn't use {name}: {e} 无法使用{name}:{e}
Couldn't extract pages: {e} 无法提取页面:{e}
Couldn't write {name}: {e} 无法写入{name}:{e}
the name is not a plain file name 名称不是单纯的文件名
Wrote 1 file to {dir} 已将 1 个文件写入{dir}
Wrote {n} files to {dir} 已将{n}个文件写入{dir}
No pages match those choices 所选条件没有匹配的页面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1621,6 +1621,7 @@
Couldn't use {name}: {e} 無法使用{name}:{e}
Couldn't extract pages: {e} 無法提取頁面:{e}
Couldn't write {name}: {e} 無法寫入{name}:{e}
the name is not a plain file name 名稱不是單純的檔案名稱
Wrote 1 file to {dir} 已將 1 個檔案寫入{dir}
Wrote {n} files to {dir} 已將{n}個檔案寫入{dir}
No pages match those choices 所選條件沒有匹配的頁面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2 -1
View File
@@ -1520,7 +1520,8 @@ impl PdfCraftApp {
match (data, open) {
(Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]),
(Ok(bytes), true) => {
if let Err(e) = self.open_bytes(&att.name, None, bytes) {
// The tab name later names files written from the tab (Split, Extract pages).
if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]);
}
}
+62
View File
@@ -608,3 +608,65 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() {
}
}
}
#[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);
}
+1 -2
View File
@@ -784,8 +784,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.start_signing(page, None, Some(field), None);
}
Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => {
let pem = crate::sign_ui::certificate_pem(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
app.export_certificate(&c);
}
None => {}
}
+23
View File
@@ -266,6 +266,12 @@ fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry {
}
impl PdfCraftApp {
/// Export certificate: write `c` as PEM into a chosen folder, named after its subject.
pub(crate) fn export_certificate(&mut self, c: &Certificate) {
let name = format!("{}.cer", crate::files::attachment_file_name(&c.display_name()));
self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate");
}
/// Start signing: the rectangle (or field) is known; show Sign with a Digital ID.
pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>) {
self.refresh_os_key_store_ids();
@@ -1496,6 +1502,23 @@ mod tests {
Ok(PathBuf::from(&app.digital_ids[i].path))
}
#[test]
fn an_exported_certificate_is_written_inside_the_chosen_folder() {
let dir = scratch();
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
let key = PrivateKey::generate_p256().unwrap();
let cert = Certificate::self_signed(&Name::build("../x", "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap();
let mut app = PdfCraftApp::new();
app.export_dir_override = Some(out.to_string_lossy().into_owned());
app.export_certificate(&cert);
let written: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(written, vec![std::ffi::OsString::from("out")]);
let pem = std::fs::read_to_string(out.join(".._x.cer")).unwrap();
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "{pem}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn signature_status_style_gives_each_verdict_its_panel_icon() {
assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check");
+53 -8
View File
@@ -748,20 +748,26 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() {
/// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion.
fn attachment_fixture() -> Vec<u8> {
let name = "invoice\u{202e}fdp.exe";
attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes")
}
/// A one-page PDF with one embedded file whose /UF name is `name`.
fn attachment_pdf(name: &str, data: &[u8]) -> Vec<u8> {
let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::<String>();
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_string(),
"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_string(),
format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>"),
let objs: [Vec<u8>; 5] = [
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(),
[format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(),
format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>").into_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
@@ -824,3 +830,42 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() {
assert_eq!(doc.info.attachments[index].name, raw_name);
assert_eq!(doc.bytes.as_ref(), &original);
}
/// Every file under `dir`, recursively, sorted.
fn files_under(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() { out.extend(files_under(&path)) } else { out.push(path) }
}
out.sort();
out
}
#[test]
fn files_written_from_an_attachment_tab_stay_inside_the_chosen_folder() {
let dir = AttachmentDir::new();
let absolute = dir.0.join("elsewhere").join("x.pdf").to_string_lossy().into_owned();
for name in ["../../x.pdf", absolute.as_str()] {
let out = dir.0.join("a").join("b").join("out");
std::fs::create_dir_all(&out).unwrap();
let mut h = harness(&[]);
h.state_mut().open_bytes("outer.pdf", None, attachment_pdf(name, &fixture(2))).unwrap();
h.run_steps(2);
let id = h.state().views[0].id;
h.state_mut().attachment_action(id, 0, true);
h.run_steps(2);
assert_eq!(h.state().views.len(), 2, "{name}: the attachment opens as a tab");
h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned());
h.state_mut().split_active(&pdfcraft_ui_egui::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(1)));
let written = files_under(&dir.0);
assert_eq!(written.len(), 2, "{name}: two split parts, got {written:?}");
assert!(written.iter().all(|p| p.parent() == Some(out.as_path())), "{name}: {written:?}");
// Extract writes page 1 under the name Split gave it.
h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() };
h.state_mut().extract_selection();
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Wrote 1 file")), "{name}: {:?}", h.state().toast);
assert_eq!(files_under(&dir.0), written, "{name}");
std::fs::remove_dir_all(dir.0.join("a")).unwrap();
}
}
+2 -2
View File
@@ -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"