Merge upstream/main

This commit is contained in:
jonathanwxh-cell
2026-10-19 15:21:00 +08:00
30 changed files with 1632 additions and 204 deletions
Generated
+2
View File
@@ -4263,9 +4263,11 @@ dependencies = [
name = "pdfcraft-preflight"
version = "0.9.0"
dependencies = [
"flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"roxmltree",
]
[[package]]
+2 -1
View File
@@ -171,7 +171,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-19 (M12, #3217):** Decorative marking replaces changed content-stream occurrences with fresh objects, preserving other uses of shared streams and leaving untouched entries byte-identical. Regressions cover repeated references, another page sharing a stream, unique references, and full/incremental save-and-reopen. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
@@ -183,6 +183,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
+3 -2
View File
@@ -54,8 +54,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
# and `advapi` for the session id that names the single-instance socket (#3338).
winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
+50 -14
View File
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// `%LOCALAPPDATA%` is unset.
#[cfg(windows)]
fn socket_path() -> Option<std::path::PathBuf> {
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
}
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
/// The Windows session this process runs in, from its access token: the console, each Remote
/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
#[cfg(windows)]
fn session_id() -> Option<u32> {
use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
Ok(TokenInfo::SessionId(id)) => Some(*id),
_ => None,
}
}
/// The socket's file name for a Windows session. The same user signed in twice (at the console
/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
/// window of the session it runs in (#3338), and never one in another session (review on #3340).
/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
/// digits and `-` kept.
#[cfg_attr(not(windows), allow(dead_code))]
fn socket_name(session: Option<&str>) -> String {
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
fn socket_name(session: Option<u32>, session_name: Option<&str>) -> String {
if let Some(id) = session {
return format!("instance-s{id}.sock");
}
let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
}
/// Held while a launch finds out whether it is the first, so launches that start together (several
@@ -359,12 +377,20 @@ mod tests {
#[test]
fn each_windows_session_gets_its_own_socket_name() {
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
assert_eq!(socket_name(None), "instance.sock");
assert_eq!(socket_name(Some("")), "instance.sock");
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
// console's session finds the window opened from Explorer there...
assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
// ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
// Only without an id does SESSIONNAME name it.
assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
assert_eq!(socket_name(None, None), "instance.sock");
assert_eq!(socket_name(None, Some("")), "instance.sock");
}
#[test]
@@ -394,11 +420,21 @@ mod tests {
assert_eq!(display_socket_name(Some("")), "instance.sock");
}
/// The socket is named by the session the process runs in, which Windows always gives.
#[cfg(windows)]
#[test]
fn this_process_has_a_session_id() {
let id = super::session_id();
assert!(id.is_some(), "the access token names the session");
let name = socket_name(id, None);
assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn socket(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir.join(socket_name(None))
dir.join(socket_name(None, None))
}
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
+29 -1
View File
@@ -404,7 +404,16 @@ fn parse_indirect_in(
if let Object::Dict(dict) = &obj
&& lx.eat_keyword(b"stream")
{
// The keyword is followed by CRLF or LF (a lone CR is tolerated).
// The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting
// Services writes a space first (`stream \r\n`); spaces and tabs count only when a line
// break follows, so they are never taken from the data.
let mut eol = lx.pos;
while matches!(data.get(eol), Some(b' ' | b'\t')) {
eol += 1;
}
if matches!(data.get(eol), Some(b'\r' | b'\n')) {
lx.pos = eol;
}
match (data.get(lx.pos), data.get(lx.pos + 1)) {
(Some(b'\r'), Some(b'\n')) => lx.pos += 2,
(Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
@@ -554,6 +563,25 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
// line break, so the /Length is still right and the data keeps no leading space.
for eol in [&b" \r\n"[..], b" \n", b"\t \r"] {
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec();
data.extend_from_slice(eol);
data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n");
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{eol:?}");
}
// Spaces with no line break after them are data, not a keyword terminator.
let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n";
let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b" HELLO");
}
#[test]
fn indirect_length_is_resolved() {
let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
+4
View File
@@ -14,3 +14,7 @@ workspace = true
pdfcraft-content = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
roxmltree = "0.20"
[dev-dependencies]
flate2 = { workspace = true }
+6 -6
View File
@@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option<String> {
let cat = catalog(doc);
let m = doc.resolve(cat.get(b"Metadata")?);
match &*m {
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
@@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option<String> {
pub fn declared(doc: &Document) -> Declared {
let mut d = Declared::default();
if let Some(x) = metadata(doc) {
let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok());
let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default();
let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok());
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default();
d.pdfa = part.map(|p| (p, conf.to_uppercase()));
d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok());
d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok());
}
let cat = catalog(doc);
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
@@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
match metadata(doc) {
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
Some(x) => {
let part = xmp::value(&x, "pdfaid:part");
let conf = xmp::value(&x, "pdfaid:conformance");
let part = xmp::identifier(&x, xmp::PDFA_ID, "part");
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance");
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
}
+46
View File
@@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() {
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
}
}
#[test]
fn standards_identification_uses_namespace_not_prefix() {
fn with_xmp(xmp: &str) -> Document {
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "")
}
let attribute = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:a="http://www.aiim.org/pdfa/ns/id/" xmlns:u="http://www.aiim.org/pdfua/ns/id/" a:part="2" a:conformance="b" u:part="1"/></rdf:RDF>"#;
let element = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description><part xmlns="http://www.aiim.org/pdfa/ns/id/">2</part><x:conformance xmlns:x="http://www.aiim.org/pdfa/ns/id/" >B</x:conformance></rdf:Description></rdf:RDF>"#;
for xml in [attribute, element] {
let doc = with_xmp(xml);
assert_eq!(declared(&doc).pdfa, Some((2, "B".into())));
assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
}
assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1));
let wrong = r#"<root xmlns:pdfaid="urn:wrong" pdfaid:part="2" pdfaid:conformance="B"/>"#;
let doc = with_xmp(wrong);
assert_eq!(declared(&doc).pdfa, None);
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
let nested = format!("{}{}", "<n>".repeat(1100), "</n>".repeat(1100));
assert_eq!(declared(&with_xmp(&nested)).pdfa, None);
}
#[test]
fn oversized_xmp_is_refused_while_decoding() {
let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1);
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
assert!(metadata(&doc).is_none());
assert_eq!(declared(&doc).pdfa, None);
use std::io::Write as _;
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(xmp.as_bytes()).unwrap();
let compressed = z.finish().unwrap();
let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect();
let data = format!("{hex}>");
let stream =
format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len());
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
let cat = catalog(&doc);
let raw = doc.resolve(cat.get(b"Metadata").unwrap());
let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") };
assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
}
+135
View File
@@ -3,6 +3,116 @@
use pdfcraft_cos::Document;
/// The largest XMP packet read; a larger one is treated as missing.
pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
/// Namespace URIs of the PDF/A and PDF/UA identification schemas.
pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/";
pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/";
/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet
/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide
/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before.
pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option<String> {
if xmp.len() > MAX_XMP_BYTES {
return None;
}
match parsed_identifier(xmp, namespace, local) {
Ok(found) => found,
Err(()) => {
let prefix = match namespace {
PDFA_ID => "pdfaid",
PDFUA_ID => "pdfuaid",
_ => return None,
};
value(xmp, &format!("{prefix}:{local}"))
}
}
}
/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result<Option<String>, ()> {
if !xml_limits_ok(xmp) {
return Err(());
}
let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 };
let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?;
for node in doc.descendants().filter(roxmltree::Node::is_element) {
if let Some(value) = node.attribute((namespace, local)) {
return Ok(Some(value.trim().to_owned()));
}
if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local {
return Ok(node.text().map(|value| value.trim().to_owned()));
}
}
Ok(None)
}
/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates.
/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings.
/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`.
fn xml_limits_ok(text: &str) -> bool {
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
(from..hay.len()).find(|&p| hay[p..].starts_with(needle))
}
let b = text.as_bytes();
let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize);
while i < b.len() {
if b[i] != b'<' {
i += 1;
} else if b[i..].starts_with(b"<!--") {
i = find(b, i + 4, b"-->").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<![CDATA[") {
i = find(b, i + 9, b"]]>").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<?") {
i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
} else if b[i..].starts_with(b"<!") {
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else if b[i..].starts_with(b"</") {
depth = depth.saturating_sub(1);
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else {
depth += 1;
if depth > 1024 {
return false;
}
// Skip the rest of the start tag, over any quoted attribute value, and remember its last
// significant byte: a tag that ends in `/` closes itself, so it is one level deep while it
// is read rather than a level left open.
i += 1;
let mut quote = 0u8;
let mut last = 0u8;
let mut attributes = 0usize;
while i < b.len() {
let c = b[i];
if quote != 0 {
if c == quote {
quote = 0;
}
} else if c == b'"' || c == b'\'' {
quote = c;
} else if c == b'>' {
i += 1;
break;
} else if c == b'=' {
attributes += 1;
total_attributes += 1;
if attributes > 128 || total_attributes > 4096 {
return false;
}
last = c;
} else if !c.is_ascii_whitespace() {
last = c;
}
i += 1;
}
if last == b'/' {
depth -= 1;
}
}
}
true
}
/// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or
/// as an element (`<pdfaid:part>2</pdfaid:part>`, or inside an `rdf:Alt`/`rdf:Seq`).
pub fn value(xmp: &str, key: &str) -> Option<String> {
@@ -133,3 +243,28 @@ mod tests {
assert_eq!(iso_date("junk"), None);
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn attribute_and_namespace_budgets_bound_parser_work() {
let prefix = r#"<root xmlns:a="http://www.aiim.org/pdfa/ns/id/" a:part="2" "#;
let attrs = |count| (0..count).map(|i| format!("x{i}=\"v=1\" ")).collect::<String>();
let at_limit = format!("{prefix}{}/>", attrs(126));
assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2"));
let over_limit = format!("{prefix}{}/>", attrs(127));
assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None);
let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::<String>();
assert_eq!(identifier(&format!("<root {namespaces}/>"), PDFA_ID, "part"), None);
let many_elements = format!("<root>{}</root>", r#"<n a="v"/>"#.repeat(4097));
assert!(!xml_limits_ok(&many_elements));
// Not well-formed (the prefix is never declared): read by the conventional prefix, as before.
let undeclared = r#"<rdf:Description pdfaid:part="3" pdfaid:conformance="u"/>"#;
assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3"));
assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u"));
let over_bytes = format!("<root>{}</root>", " ".repeat(8 * 1024 * 1024));
assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None);
}
}
+13 -11
View File
@@ -1,5 +1,5 @@
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
//! whose centre maps into a region is set to zero (no ink for image masks), and the result is
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
//! written as a new Flate image; the original object is untouched (other pages may use it).
//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the
//! caller removes the whole image instead; that is fail-closed.
@@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
})
}
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered,
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
/// `Err(())` when the image can't be cleared (the caller removes it).
#[allow(clippy::result_unit_err)]
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result<Option<Stream>, ()> {
@@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
};
let mut cleared = 0usize;
for j in 0..h {
// Image space: (0,0) is the bottom-left of the unit square; row 0 is the top.
let v = 1.0 - (j as f64 + 0.5) / h as f64;
let (ax, ay) = ctm.apply(0.0, v);
let (bx, by) = ctm.apply(1.0, v);
let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)];
if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) {
// Image space has its origin at the bottom left; row 0 is at the top.
let v0 = 1.0 - (j + 1) as f64 / h as f64;
let v1 = 1.0 - j as f64 / h as f64;
let row_box = ctm.bbox([0.0, v0, 1.0, v1]);
if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) {
continue;
}
for i in 0..w {
let u = (i as f64 + 0.5) / w as f64;
let (x, y) = ctm.apply(u, v);
if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) {
let u0 = i as f64 / w as f64;
let u1 = (i + 1) as f64 / w as f64;
// The bounding box may cover extra pixels for a rotated or skewed image; that
// conservative over-clear prevents a touched sample surviving in the file.
let pixel_box = ctm.bbox([u0, v0, u1, v1]);
if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) {
continue;
}
cleared += 1;
+19
View File
@@ -399,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
}
}
#[test]
fn redaction_clears_pixels_intersected_without_covering_their_centres() {
for (content, rect) in
[(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])]
{
let mut doc = one_page(
content,
"/XObject << /Im 7 0 R >>",
vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])],
);
mark(&mut doc, 0, &[rect], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.images_cleared, 1);
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(named_image(&saved, 0, b"Im"), None);
assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234]));
}
}
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
/// before (inherited from the /Pages node, or shared with another page) still named the removed
/// image and form, so a full-rewrite save kept them.
+216
View File
@@ -2881,6 +2881,33 @@ trailer << /Root 1 0 R >>
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
}
/// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space
/// before the line break. The content stream was then taken to start at the space and the
/// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax
/// patch: spaces and tabs before the line break are skipped, with the right `/Length` and
/// with a wrong one. Spaces with no line break after them never start stream data.
#[test]
fn space_before_the_line_break_after_stream_still_renders() {
let pdf = |eol: &str, length: u32| {
format!(
"%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\
4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\
trailer << /Root 1 0 R >>\r\n%%EOF"
)
.into_bytes()
};
for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] {
let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{eol:?}: {:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square");
assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white");
}
}
/// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has
/// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size)
/// divided by zero, and the infinite or NaN matrix reached the device with everything the
@@ -3462,6 +3489,141 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell
/// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels.
fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec<u8> {
let cell = "0 0 1 rg 0 0 10 10 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
page.rgba.to_vec()
}
/// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with
/// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into
/// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn
/// at full scale, once, filled or stroked: where it is it shows, and around it (left, right,
/// above, below) nothing repeats or smears.
#[test]
fn a_tiling_pattern_drawn_once_shows_its_cell() {
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]);
// The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30.
let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20");
assert_eq!(at(&once, 40, 15, 25), blue, "the cell");
for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] {
assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it");
}
// Stroked: a wide line across the cell shows it too.
let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20");
assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern");
assert_eq!(at(&stroked, 40, 25, 25), white);
// An ordinary step still tiles.
let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0");
assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell");
}
/// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40
/// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN
/// geometry reaches the cell renderer, the page renders and what follows still draws.
#[test]
fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() {
assert!(hayro::usable_cell_scale(2.0, 1.0));
for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] {
assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}");
}
let thin = "0.0000000000000000000000000000000000000001";
let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f";
let cell = "0 0 1 rg 0 0 10 10 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream
{cell}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cell.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws");
assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing");
}
/// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area
/// can't reach another: a page wide (or tall) enough to show the next cell, or the one before,
/// still tiles them.
#[test]
fn a_large_step_still_tiles_where_the_next_cell_shows() {
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
let blue = vec![0, 0, 255];
// Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit,
// by 3000/3100; a pixel 4 points into a cell is blue either way.
let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f";
let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0");
assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right");
let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0");
assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left");
let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f";
let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0");
assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above");
let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100");
assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below");
// A stroke counts with its width: this line ends at x 3080, short of the next cell at
// 3100, but its square cap reaches 3110.
let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0");
assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap");
// A miter reaches further than the line width (review on #3356): this join's vertex is
// at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long
// under the default limit of 10, reaches 3130.
let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0");
// The tip narrows to a point, so this pixel is partly covered: blue, not white.
let tip = at(&mitered, 3200, 3104, 10);
assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}");
// Under a skew, a direction between the axes stretches more than either axis (review on
// #3356). This join's vertex is at device x 2980 and its miter tip, along that direction,
// at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell;
// the most any direction stretches (1.461) puts it at 3126.
let skewed = tiling_page(
3200,
20,
"/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S",
"3100",
"1 0 0 1 0 0",
);
// The tip narrows to a point, so its pixels are partly covered.
let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y));
assert!(
under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240),
"the next cell, under the skewed miter's tip: {:?}",
under_tip.collect::<Vec<_>>()
);
}
/// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile,
/// no decomposition, reversible wavelet, and one empty packet per component. Empty
/// coefficients decode to the midpoint (128) after the unsigned DC level shift.
@@ -3503,6 +3665,60 @@ trailer << /Root 1 0 R >>
pdf
}
/// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header
/// has a colour specification naming enumerated colour space `enumerated`.
fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec<u8> {
let raw = jpx_midpoint_pdf(8, components);
let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap();
let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2;
let codestream = &raw[start..end];
let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat();
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height
ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width
ihdr.extend_from_slice(&components.to_be_bytes());
ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR
let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat();
let jp2 = [
vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a],
jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "),
jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()),
jp2_box(b"jp2c", codestream),
]
.concat();
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
let mut pdf = format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /Filter /JPXDecode /Length {} >> stream\n",
content.len(),
jp2.len()
)
.into_bytes();
pdf.extend_from_slice(&jp2);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
pdf
}
/// #3306: a JP2 image whose colour specification names an enumerated colour space the
/// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is
/// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the
/// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is
/// drawn as before.
#[test]
fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() {
for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] {
let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{enumerated}: {:?}", page.error);
let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3];
assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}");
}
}
#[test]
fn jpx_stream_bit_depth_boundaries_do_not_panic() {
use hayro::hayro_syntax::bit_reader::BitReader;
+8 -1
View File
@@ -2,7 +2,7 @@
//! Private keys remain in CNG; Windows may ask permission to use a key.
//!
//! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't
//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out
//! sign with, a key usage or extended key usage that doesn't allow signing documents) are reported in [`Listing::unusable`] with the reason rather than left out
//! silently, so a missing identity can be explained (issue #179).
use std::sync::Arc;
@@ -161,6 +161,13 @@ fn entries() -> Result<Entries, SignError> {
continue;
}
}
// A smart card holds encryption and logon certificates beside the signing one, all under
// the same name; a signature made with them doesn't validate, so they aren't offered.
// Checked after the key, so a certificate without one stays out of the list entirely.
if let Some(reason) = certificate.signing_problem_clause() {
skip(certificate.display_name(), reason, false);
continue;
}
let public = certificate.public_key.clone();
let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public }));
let friendly_name = Some(certificate.display_name());
+9
View File
@@ -384,6 +384,15 @@ impl Certificate {
}
}
/// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's
/// name ("it is not issued for signing documents (…)"), for lists of digital IDs.
pub fn signing_problem_clause(&self) -> Option<String> {
self.signing_problem().map(|d| match d.message.strip_prefix("The signer's certificate ") {
Some(rest) => format!("it {}", rest.strip_suffix('.').unwrap_or(rest)),
None => d.message,
})
}
/// Issued by itself (subject = issuer and its own key verifies it).
pub fn is_self_signed(&self) -> bool {
self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key)
+15
View File
@@ -281,6 +281,21 @@ fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() {
assert_eq!(status, Status::Valid, "{details:?}");
}
#[test]
fn a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists() {
// A DoD CAC's four certificates, all under the same name; only the signing one may sign.
let r = root();
let clause = |exts: &[Vec<u8>]| signer_with("DOE.JANE.1234567890", &r, exts).cert.signing_problem_clause();
let encryption = clause(&[key_usage(KEY_ENCIPHERMENT), extended_key_usage(&["1.3.6.1.5.5.7.3.4"])]);
assert_eq!(encryption.as_deref(), Some("it does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)"));
let logon = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["1.3.6.1.4.1.311.20.2.2", "1.3.6.1.5.5.7.3.2"])]);
assert_eq!(logon.as_deref(), Some("it is not issued for signing documents (its extended key usage has no document-signing purpose)"));
let card_auth = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.16.840.1.101.3.6.8"])]);
assert_eq!(card_auth, logon);
let signing = clause(&[key_usage(DIGITAL_SIGNATURE | 0x40), extended_key_usage(&["1.3.6.1.5.5.7.3.4", "1.3.6.1.4.1.311.10.3.12"])]);
assert_eq!(signing, None);
}
#[test]
fn unreadable_constraint_extensions_fail_closed() {
// keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign.
+124 -47
View File
@@ -482,6 +482,9 @@ pub struct DocView {
/// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as
/// it decays, instead of adding on top of the 1:1 motion.
precise_scroll_until: f64,
/// Per-axis line-wheel state; horizontal input cannot extend a vertical burst.
wheel_boost_x: crate::wheel_boost::WheelBoost,
wheel_boost_y: crate::wheel_boost::WheelBoost,
/// Touchpad momentum after the fingers lift (#531).
fling: crate::fling::Fling,
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
@@ -756,6 +759,8 @@ impl DocView {
shown: false,
wheel: Default::default(),
precise_scroll_until: 0.0,
wheel_boost_x: Default::default(),
wheel_boost_y: Default::default(),
fling: Default::default(),
auto_scroll: Default::default(),
selected: BTreeSet::new(),
@@ -835,6 +840,7 @@ impl DocView {
self.flash = None;
// A thumbnail drag holds page indexes from before the change.
self.panel_drag = None;
self.reset_coarse_state();
}
/// The document was replaced by a newer version of its file (#431). The reader's place
@@ -1153,6 +1159,7 @@ impl DocView {
.unwrap_or(0.0);
self.goto = Some((page, frac));
self.current = page;
self.reset_coarse_state();
}
/// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its
@@ -1174,6 +1181,14 @@ impl DocView {
self.thumbs.get(&page).map(|t| &t.tex)
}
/// Drops wheel state after a discontinuous jump so the destination cannot
/// inherit momentum from the previous viewport.
fn reset_coarse_state(&mut self) {
self.wheel.clear_motion();
self.wheel_boost_x.reset();
self.wheel_boost_y.reset();
}
pub fn go_to_page(&mut self, page: usize) {
let page = page.min(self.page_count.saturating_sub(1));
if page != self.current {
@@ -1187,7 +1202,7 @@ impl DocView {
self.forward.clear();
}
// Partial wheel motion belongs to the old place.
self.wheel.clear_motion();
self.reset_coarse_state();
self.goto = Some((page, 0.0));
self.current = page;
}
@@ -1322,6 +1337,7 @@ impl DocView {
let page = page.min(self.page_count.saturating_sub(1));
self.goto = Some((page, 0.0));
self.current = page;
self.reset_coarse_state();
true
}
@@ -1346,12 +1362,14 @@ impl DocView {
pub fn rotate_view(&mut self, clockwise: bool) {
self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
/// Switch the zoom mode, keeping the current page in view.
pub fn set_fit(&mut self, fit: Fit) {
self.fit = fit;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
/// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is.
@@ -1363,6 +1381,7 @@ impl DocView {
}
self.layout = layout;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
/// Whether the view scrolls continuously (as opposed to one page or spread at a time).
@@ -1376,6 +1395,7 @@ impl DocView {
if self.continuous != continuous {
self.continuous = continuous;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
}
@@ -1391,6 +1411,7 @@ impl DocView {
if self.cover != cover {
self.cover = cover;
self.goto = Some((self.current, 0.0));
self.reset_coarse_state();
}
}
@@ -2037,6 +2058,14 @@ fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option<String> {
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
}
/// The scroll offset that puts `point` (content coordinates) at `rel` in the viewport, kept within
/// the content as egui keeps it. egui clamps only after drawing, so an offset past the end showed
/// the page misplaced for a frame and then jumped (zooming out beside a page that fits).
fn anchored_scroll(point: Pos2, rel: Vec2, content: Vec2, viewport: Vec2) -> Vec2 {
let max = vec2((content.x - viewport.x).max(0.0), (content.y - viewport.y).max(0.0));
vec2((point.x - rel.x).clamp(0.0, max.x), (point.y - rel.y).clamp(0.0, max.y))
}
/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536).
fn scale_tag(scale: f32) -> u64 {
(f64::from(scale) * 65536.0).round() as u64
@@ -2318,6 +2347,23 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
/// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up
/// keep this much of the previous screen in view.
const KEY_SCROLL_LINE: f32 = 40.0;
/// Suppression window for egui's eased copy of point input.
///
/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s.
const SUPPRESS_SECS: f64 = 0.25;
/// Short rolling extension while egui still has an unprocessed wheel tail.
const SUPPRESS_TAIL_SECS: f64 = 0.05;
// The viewer moves between docking and the central panel as modes change; keep its scroll
// identity tied to the document instead of either parent UI.
fn page_scroll<R>(
ui: &mut egui::Ui,
scroll: egui::ScrollArea,
id: egui::Id,
draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
) -> egui::scroll_area::ScrollAreaOutput<R> {
ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner
}
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -2507,36 +2553,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
}
// Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel`
// events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing
// path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply
// the precise delta ourselves this frame through the un-animated scroll path, and suppress
// egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse
// mouse wheel keeps egui's easing (pleasant for chunky notches).
// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured,
// smoothed base and adds only the accelerated surplus. While point suppression is active,
// reconstruct line and page input here so current coarse events survive without exposing an
// older eased tail.
let now = ui.input(|i| i.time);
let mut precise_scroll = Vec2::ZERO;
if ui.rect_contains_pointer(avail) {
let mut coarse_wheel = false;
ui.input(|i| {
for e in &i.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e {
if modifiers.command || modifiers.ctrl {
continue; // Zooming, not scrolling.
}
match unit {
// Shift scrolls sideways, matching egui's own wheel handling.
egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y,
egui::MouseWheelUnit::Point => precise_scroll += *delta,
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true,
}
}
}
});
if coarse_wheel {
view.precise_scroll_until = 0.0;
} else if precise_scroll != Vec2::ZERO {
view.precise_scroll_until = now + 0.25;
}
let input = if ui.rect_contains_pointer(avail) {
// Match the line distance used by egui's other scroll areas.
let live = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS };
ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging))
} else {
crate::wheel_boost::FrameInput::default()
};
let mut precise_scroll = input.precise;
let mut line_base = input.line;
let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now);
let has_page = input.page != Vec2::ZERO;
if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page {
view.precise_scroll_until = now + SUPPRESS_SECS;
}
if edge_turned {
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
@@ -2549,11 +2584,40 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned());
if paging_owns_vertical {
precise_scroll.y = 0.0;
line_base.y = 0.0;
boost_extra.y = 0.0;
}
if now < view.precise_scroll_until {
if ui.input(|i| i.is_scrolling()) {
// Keep hiding egui's unprocessed copy until it has drained. The short rolling grace
// avoids a delayed duplicate without prolonging suppression after the tail is gone.
view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS);
}
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
// The point path suppresses every eased wheel delta. Restore the line base through the
// same un-eased path as its surplus; paging-owned axes were cleared above.
boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base);
if has_page {
// Page units normally stay on egui's viewport-sized path. During suppression only,
// reproduce the current event directly so an older hidden tail stays hidden.
let page_scale = ui.input(|i| i.viewport_rect().height());
let mut page_delta =
if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO };
if paging_owns_vertical {
page_delta.y = 0.0;
}
boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta);
}
} else {
precise_scroll = Vec2::ZERO;
if line_base != Vec2::ZERO || has_page {
// Selection handling below uses zero to distinguish an inactive window.
view.precise_scroll_until = 0.0;
}
}
if has_page && paging_owns_vertical {
// Page input belongs to egui on the free axis; the pager still owns vertical motion.
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
}
// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a
// stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or
@@ -2631,7 +2695,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() {
let r = rects[page.min(rects.len() - 1)];
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0)));
scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size()));
} else if let Some((page, frac)) = view.goto.take() {
let page = page.min(rects.len() - 1);
let r = rects[page];
@@ -2766,7 +2830,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y));
}
}
let out = scroll.show_viewport(ui, |ui, viewport| {
let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail);
let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| {
// egui's drag responses accept every pointer button. Keep a wheel gesture from also
// selecting text, drawing a mark, or panning with the Hand tool.
if middle_gesture {
@@ -2776,10 +2841,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.set_opacity(opacity);
}
// Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide
// (#531), and a drawing's edge scroll; positive y moves the content down. All scroll
// without easing, unlike egui's wheel default.
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + precise_scroll + glide;
if delta != Vec2::ZERO {
// (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y
// moves the content down. All scroll without easing, unlike egui's wheel default.
// ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution
// until after the text widgets when a selection owns that drag.
let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra };
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
@@ -3512,17 +3580,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
// pointer leaves the selection's page for the gap or the next page; over the whole
// document area, like #759. A Line/Page event clears precise_scroll_until; a Point
// gesture's eased tail after its suppression deadline is not picked up.
if view.selection_drag
&& navigation_enabled
&& unobstructed
&& !single_paging
&& view.precise_scroll_until == 0.0
&& ui.rect_contains_pointer(avail)
{
let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta));
if delta != Vec2::ZERO {
// document area, like #759. Combine direct and smoothed motion in one request so mixed
// Point and Line events retain their full contribution.
if selection_wheel {
let smooth =
if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO };
let delta = precise_scroll + boost_extra + smooth;
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
}
@@ -5240,6 +5304,19 @@ fn grayscale(mut img: egui::ColorImage) -> egui::ColorImage {
#[cfg(test)]
mod tests {
#[test]
fn zoom_anchor_scroll_stays_within_the_content() {
let (content, viewport) = (vec2(1000.0, 2000.0), vec2(800.0, 600.0));
// Past the end: clamped to the maximum (200, 1400), not left for egui to snap back.
assert_eq!(anchored_scroll(pos2(900.0, 1900.0), vec2(0.0, 0.0), content, viewport), vec2(200.0, 1400.0));
// Before the start.
assert_eq!(anchored_scroll(pos2(10.0, 10.0), vec2(300.0, 300.0), content, viewport), Vec2::ZERO);
// Inside: unchanged.
assert_eq!(anchored_scroll(pos2(150.0, 800.0), vec2(100.0, 100.0), content, viewport), vec2(50.0, 700.0));
// Content smaller than the viewport: no scrolling.
assert_eq!(anchored_scroll(pos2(400.0, 400.0), Vec2::ZERO, vec2(500.0, 500.0), viewport), vec2(0.0, 0.0));
}
#[test]
fn page_size_hint_zone_is_the_bottom_left_quadrant() {
let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 450.0));
+88 -76
View File
@@ -533,88 +533,100 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
/// Keep menus usable when their contents are taller than the window.
pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
}
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| crate::commands::registry_menu(app, ui, "File"));
ui.menu_button(tl!("Edit"), |ui| crate::commands::registry_menu(app, ui, "Edit"));
ui.menu_button(tl!("Pages"), |ui| crate::commands::registry_menu(app, ui, "Pages"));
ui.menu_button(tl!("View"), |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
scroll_menu(ui, |ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
ui.menu_button(tl!("View"), |ui| {
scroll_menu(ui, |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
scroll_menu(ui, |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
}
}
})
});
})
});
});
ui.menu_button(tl!("Help"), |ui| crate::commands::registry_menu(app, ui, "Help"));
ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
})
});
}
+11 -9
View File
@@ -779,17 +779,19 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
let mut open: Option<String> = None;
let mut clear = false;
ui.menu_button(label, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
crate::chrome::scroll_menu(ui, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
})
});
if let Some(p) = open {
app.open_recent(&p);
+1
View File
@@ -139,6 +139,7 @@ mod tab_context;
pub mod theme;
pub mod ui_scale;
pub mod updates;
mod wheel_boost;
mod wheel_pager;
mod widgets;
pub mod window_controls;
+1 -1
View File
@@ -18,7 +18,7 @@ pub enum ScrollSpeed {
}
/// egui's own `line_scroll_speed`, which its wheel zoom rate is tuned against.
const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 };
pub(crate) const EGUI_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") { 8.0 } else { 40.0 };
/// Points one wheel line scrolls at Normal speed.
const NORMAL_LINE_POINTS: f32 = if cfg!(target_arch = "wasm32") {
+395
View File
@@ -0,0 +1,395 @@
//! Bounded acceleration for coarse wheel input in the document viewport.
//!
//! Line-unit events describe notches, not motion over time. A fixed distance per
//! notch makes a fast spin feel slow, so this module derives a bounded multiplier
//! from arrival time and direction. The state is constant-size and leaves isolated
//! notches unchanged.
use egui::Vec2;
/// Idle time after which a burst is forgotten, in seconds.
const RESET_GAP_SECS: f64 = 0.3;
/// Notch spacing at or below which input counts as a continuous spin, in seconds.
const FAST_GAP_SECS: f64 = 0.08;
/// Multiplier gained per fast consecutive notch.
const BOOST_PER_TICK: f32 = 0.4;
/// Hard limit for accelerated motion.
const MAX_BOOST: f32 = 6.0;
/// Ticks that affect the output.
const MAX_TICKS: u32 = 13;
/// Extra ticks retained beyond the ceiling. They absorb a few slow events
/// before the visible multiplier starts to fall.
const HYSTERESIS_TICKS: u32 = 4;
/// Maximum scroll applied from one frame of coarse input, in points per axis.
/// Each event is clamped first; this caps the sum.
pub(crate) const MAX_FRAME_POINTS: f32 = 8192.0;
/// Per-event clamp, in native units (lines or pages). Keeps one hostile event
/// bounded before points conversion.
pub(crate) const MAX_EVENT_UNITS: f32 = 10.0;
/// Constant-size arrival-rate state for line wheel events.
#[derive(Debug, Default)]
pub(crate) struct WheelBoost {
last: f64,
burst: u32,
dir: i8,
}
impl WheelBoost {
/// Returns the multiplier for one bucket of motion carrying `motion`.
///
/// `motion` is signed in native units; only its sign matters. Zero means
/// no motion. Non-finite time returns 1.0 and leaves state unchanged;
/// backwards time resets the burst and returns 1.0.
pub(crate) fn multiplier(&mut self, now: f64, motion: f32) -> f32 {
if !now.is_finite() || !motion.is_finite() || motion == 0.0 {
return 1.0;
}
let dir = if motion > 0.0 { 1 } else { -1 };
let idle = now - self.last;
if !idle.is_finite() || idle < 0.0 {
self.last = now;
self.burst = 0;
self.dir = dir;
return 1.0;
}
if idle > RESET_GAP_SECS || dir != self.dir {
self.burst = 0;
} else if idle <= FAST_GAP_SECS {
self.burst = self.burst.saturating_add(1).min(MAX_TICKS + HYSTERESIS_TICKS);
} else {
// Slow input in the same direction decays rather than extending the burst.
self.burst = self.burst.saturating_sub(1);
}
self.last = now;
self.dir = dir;
let ticks = self.burst.min(MAX_TICKS);
// MAX_TICKS is well within f32's exact integer range.
(1.0 + ticks as f32 * BOOST_PER_TICK).min(MAX_BOOST)
}
/// Drops burst state. Call after a programmatic scroll jump.
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
}
/// Caps one frame of accumulated coarse scroll, per axis. A non-finite axis
/// maps to zero; the other axis is preserved.
pub(crate) fn cap_frame(delta: Vec2) -> Vec2 {
Vec2::new(
if delta.x.is_finite() { delta.x.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 },
if delta.y.is_finite() { delta.y.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 },
)
}
/// One frame of wheel input, normalized in a single pass. Line motion is in
/// points; page motion remains in native units until the viewport is known.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct FrameInput {
pub precise: Vec2,
pub line: Vec2,
pub page: Vec2,
}
/// Folds raw wheel events into frame totals without consulting time or UI state.
/// Invalid scales contribute no line motion.
pub(crate) fn accumulate(events: &[egui::Event], line_points: f32, single_paging: bool) -> FrameInput {
let line_points = sanitize_scale(line_points);
let mut out = FrameInput::default();
for event in events {
let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event else { continue };
if modifiers.command || modifiers.ctrl {
continue; // Zooming, not scrolling.
}
if !delta.is_finite() {
continue;
}
match unit {
egui::MouseWheelUnit::Point if modifiers.shift => {
// Shift scrolls sideways, matching egui's own wheel handling.
let sum = delta.x + delta.y;
if sum.is_finite() {
out.precise.x += sum;
}
}
egui::MouseWheelUnit::Point => out.precise += *delta,
egui::MouseWheelUnit::Line if modifiers.shift => {
let sum = delta.x + delta.y;
if sum.is_finite() {
out.line.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points;
}
}
egui::MouseWheelUnit::Line => {
out.line.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points;
if !single_paging {
out.line.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points;
}
}
egui::MouseWheelUnit::Page if modifiers.shift => {
let sum = delta.x + delta.y;
if sum.is_finite() {
out.page.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS);
}
}
egui::MouseWheelUnit::Page => {
out.page.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS);
out.page.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS);
}
}
}
// Bound batched events before acceleration is applied.
out.precise = cap_frame(out.precise);
out.line = cap_frame(out.line);
out.page = cap_frame(out.page);
out
}
/// Converts the frame total to the extra scroll delta beyond egui's share.
///
/// egui applies the base distance through its own smoothing. This function
/// returns only the accelerated surplus, so an isolated notch adds nothing.
/// Each axis advances once per frame; events batched into one frame cannot
/// accelerate one another. Output is frame-capped.
pub(crate) fn boost_extra(input: FrameInput, boost_x: &mut WheelBoost, boost_y: &mut WheelBoost, now: f64) -> Vec2 {
let mut extra = Vec2::ZERO;
if input.line.x != 0.0 && input.line.x.is_finite() {
extra.x += input.line.x * (boost_x.multiplier(now, input.line.x) - 1.0);
}
if input.line.y != 0.0 && input.line.y.is_finite() {
extra.y += input.line.y * (boost_y.multiplier(now, input.line.y) - 1.0);
}
cap_frame(extra)
}
/// Maps a non-finite or non-positive points-per-unit scale to zero.
fn sanitize_scale(points: f32) -> f32 {
if points.is_finite() && points > 0.0 { points } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_and_slow_notches_stay_unboosted() {
let mut b = WheelBoost::default();
assert_eq!(b.multiplier(0.0, -1.0), 1.0);
assert_eq!(b.multiplier(0.5, -1.0), 1.0);
assert_eq!(b.multiplier(1.0, -1.0), 1.0);
}
#[test]
fn fast_spin_accelerates_then_caps() {
let mut b = WheelBoost::default();
assert_eq!(b.multiplier(1.0, -1.0), 1.0);
let mut last = 1.0;
for (i, t) in (1..=20).map(|i| 1.0 + f64::from(i) * 0.02).enumerate() {
last = b.multiplier(t, -1.0);
if i < 2 {
assert!(last >= 1.0, "early burst must not jump, got {last}");
}
}
assert!(last > 2.0, "a sustained fast spin must accelerate, got {last}");
assert!(last <= MAX_BOOST, "boost must be capped, got {last}");
assert_eq!(last, MAX_BOOST, "a long spin should reach the ceiling");
for i in 1..=20 {
let m = b.multiplier(1.5 + f64::from(i) * 0.02, -1.0);
assert!(m <= MAX_BOOST, "boost must stay capped, got {m}");
}
}
#[test]
fn reversal_and_idle_reset_the_burst() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
for i in 1..=6 {
b.multiplier(1.0 + f64::from(i) * 0.02, -1.0);
}
let boosted = b.multiplier(1.0 + 7.0 * 0.02, -1.0);
assert!(boosted > 1.0, "precondition: burst is boosted, got {boosted}");
assert_eq!(b.multiplier(1.0 + 8.0 * 0.02, 1.0), 1.0);
for i in 9..=14 {
b.multiplier(1.0 + f64::from(i) * 0.02, 1.0);
}
assert_eq!(b.multiplier(5.0, 1.0), 1.0);
}
#[test]
fn invalid_input_never_boosts_or_corrupts() {
let mut b = WheelBoost::default();
for bad_now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(b.multiplier(bad_now, -1.0), 1.0);
}
for bad_motion in [0.0, -0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(b.multiplier(1.0, bad_motion), 1.0);
}
assert_eq!(b.multiplier(2.0, -1.0), 1.0);
b.reset();
assert_eq!(b.multiplier(3.0, -1.0), 1.0);
}
#[test]
fn same_timestamp_events_count_as_a_burst() {
let mut b = WheelBoost::default();
assert_eq!(b.multiplier(1.0, -1.0), 1.0);
for _ in 0..6 {
b.multiplier(1.0, -1.0);
}
assert!(b.multiplier(1.0, -1.0) > 1.0);
}
#[test]
fn fractional_notches_share_the_burst() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -0.25);
for i in 1..=8 {
b.multiplier(1.0 + f64::from(i) * 0.02, -0.25);
}
assert!(b.multiplier(1.0 + 9.0 * 0.02, -0.25) > 1.0);
}
#[test]
fn sleep_wake_resets_the_burst() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
for i in 1..=6 {
b.multiplier(1.0 + f64::from(i) * 0.02, -1.0);
}
assert_eq!(b.multiplier(3600.0, -1.0), 1.0);
}
#[test]
fn backwards_time_resets_without_corrupting() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
b.multiplier(1.02, -1.0);
assert_eq!(b.multiplier(0.5, -1.0), 1.0);
assert_eq!(b.multiplier(0.52, -1.0), 1.4);
}
#[test]
fn unhurried_spin_decays() {
let mut b = WheelBoost::default();
b.multiplier(1.0, -1.0);
for i in 1..=8 {
b.multiplier(1.0 + f64::from(i) * 0.02, -1.0);
}
let peak = b.multiplier(1.0 + 9.0 * 0.02, -1.0);
assert!(peak > 1.0);
// Same direction but slower than the fast gap: each event sheds one tick.
let mut prev = peak;
for i in 10..=13 {
let m = b.multiplier(1.0 + f64::from(i) * 0.15, -1.0);
assert!(m <= prev, "decay must not grow: {m} > {prev}");
prev = m;
}
}
#[test]
fn hysteresis_holds_the_ceiling_briefly() {
let mut b = WheelBoost::default();
let mut t = 1.0;
b.multiplier(t, -1.0);
for _ in 0..20 {
t += 0.02;
b.multiplier(t, -1.0);
}
assert_eq!(b.multiplier(t + 0.02, -1.0), MAX_BOOST);
// A couple of unhurried events consume hysteresis before output falls.
t += 0.15;
assert_eq!(b.multiplier(t, -1.0), MAX_BOOST);
t += 0.15;
assert_eq!(b.multiplier(t, -1.0), MAX_BOOST);
}
#[test]
fn frame_cap_bounds_each_axis() {
use egui::vec2;
assert_eq!(cap_frame(vec2(40.0, -40.0)), vec2(40.0, -40.0));
assert_eq!(cap_frame(vec2(1e9, -1e9)), vec2(MAX_FRAME_POINTS, -MAX_FRAME_POINTS));
assert_eq!(cap_frame(vec2(f32::NAN, 40.0)), vec2(0.0, 40.0));
assert_eq!(cap_frame(vec2(40.0, f32::INFINITY)), vec2(40.0, 0.0));
}
fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event {
egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }
}
fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event {
egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT }
}
#[test]
fn isolated_notch_adds_no_extra() {
use egui::MouseWheelUnit::Line;
for base in [50.0, 100.0, 200.0, 40.0, 8.0] {
let input = accumulate(&[wheel(Line, 0.0, -1.0)], base, false);
assert_eq!(input.line, egui::vec2(0.0, -base));
let mut bx = WheelBoost::default();
let mut by = WheelBoost::default();
assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO);
}
}
#[test]
fn fast_spin_adds_a_bounded_extra() {
use egui::MouseWheelUnit::Line;
let mut bx = WheelBoost::default();
let mut by = WheelBoost::default();
let mut peak = 0.0f32;
for i in 0..20 {
let input = accumulate(&[wheel(Line, 0.0, -1.0)], 100.0, false);
let extra = boost_extra(input, &mut bx, &mut by, 1.0 + f64::from(i) * 0.02);
assert!(extra.y <= 0.0, "extra follows the spin direction, got {extra:?}");
peak = peak.min(extra.y);
}
assert!(peak < 0.0, "a sustained spin must add an extra");
assert!(peak >= -MAX_FRAME_POINTS, "extra stays frame-capped, got {peak}");
}
#[test]
fn point_and_page_events_stay_out_of_the_line_boost() {
use egui::{MouseWheelUnit::Line, MouseWheelUnit::Page, MouseWheelUnit::Point};
let input = accumulate(&[wheel(Page, 0.0, -1.0), wheel(Point, 0.0, -30.0)], 100.0, false);
assert_eq!(input.precise, egui::vec2(0.0, -30.0));
assert_eq!(input.page, egui::vec2(0.0, -1.0));
assert_eq!(input.line, egui::Vec2::ZERO);
let input = accumulate(&[wheel(Line, 0.0, -1.0), wheel(Page, 0.0, -1.0)], 100.0, false);
assert_eq!(input.line, egui::vec2(0.0, -100.0));
assert_eq!(input.page, egui::vec2(0.0, -1.0));
let mut bx = WheelBoost::default();
let mut by = WheelBoost::default();
assert_eq!(boost_extra(input, &mut bx, &mut by, 1.0), egui::Vec2::ZERO);
}
#[test]
fn single_paging_drops_vertical_keeps_horizontal() {
use egui::MouseWheelUnit::Line;
let input = accumulate(&[wheel(Line, 5.0, -1.0)], 100.0, true);
assert_eq!(input.line, egui::vec2(500.0, 0.0));
}
#[test]
fn shift_merges_into_horizontal() {
use egui::MouseWheelUnit::{Line, Page};
let input = accumulate(&[shifted(Line, 1.0, -2.0)], 100.0, false);
assert_eq!(input.line, egui::vec2(-100.0, 0.0));
let input = accumulate(&[shifted(Page, 1.0, -2.0)], 100.0, false);
assert_eq!(input.page, egui::vec2(-1.0, 0.0));
}
#[test]
fn invalid_scales_and_events_contribute_nothing() {
use egui::MouseWheelUnit::Line;
for bad in [f32::NAN, 0.0, -10.0, f32::INFINITY] {
let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false);
assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}");
}
let zoom =
egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND };
let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false);
assert_eq!(input, FrameInput::default());
}
}
+3
View File
@@ -254,6 +254,9 @@ fn options_tab_sets_the_check_box_style() {
fn button_label_is_editable_without_renaming_the_field() {
use pdfcraft_engine::{Edit, NewField};
let mut h = harness();
// The byte comparisons below must not vary with the save's /ModDate.
let session = &mut h.state_mut().session;
*session = std::mem::take(session).with_clock(|| 1_700_000_000);
h.state_mut().apply_edit(Edit::AddField {
page: 0,
rect: [40.0, 300.0, 190.0, 330.0],
+149 -2
View File
@@ -392,10 +392,78 @@ fn precise_touchpad_wheel_scrolls_one_to_one() {
assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}");
}
#[test]
fn rapid_wheel_notches_accelerate_and_follow_touchpad_input() {
use pdfcraft_ui_egui::scroll_speed::ScrollSpeed;
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]);
h.state_mut().set_option("animation_effects", "off").unwrap();
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(2);
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
let single = scroll_over(&mut h, 2);
let base = ScrollSpeed::default().line_points();
assert!((single - base).abs() < 1.0, "one notch keeps the configured base: moved {single}, base {base}");
// Let the isolated notch expire, then spin five notches one frame apart. The burst must move
// farther than five base steps without adding work on idle frames.
h.run_steps(30);
let mut burst = 0.0;
for _ in 0..5 {
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
burst += scroll_over(&mut h, 2);
}
assert!(burst > 7.0 * base, "a rapid five-notch spin accelerates: moved {burst}, base {base}");
// A line notch inside the precise path's suppression window keeps its base share (#1072):
// suppressing egui's touchpad tail must not swallow mixed-device input.
h.run_steps(30);
h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move));
scroll_over(&mut h, 2);
// Arrive near the old suppression deadline: the line's new easing tail must extend it.
h.run_steps(11);
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
let after_touchpad = scroll_over(&mut h, 2);
assert!(after_touchpad >= base, "a notch after point input is not dropped: moved {after_touchpad}, base {base}");
let delayed = scroll_over(&mut h, 20);
assert!(delayed.abs() < 2.0, "the reconstructed notch has no delayed easing copy: moved {delayed}");
h.event(wheel(MouseWheelUnit::Point, -20.0, TouchPhase::Move));
scroll_over(&mut h, 2);
h.event(wheel(MouseWheelUnit::Page, -1.0, TouchPhase::Move));
let page_after_touchpad = scroll_over(&mut h, 2);
assert!(page_after_touchpad > base, "a page event after point input is preserved: moved {page_after_touchpad}");
let delayed_page = scroll_over(&mut h, 20);
assert!(delayed_page.abs() < 2.0, "the reconstructed page has no delayed easing copy: moved {delayed_page}");
}
#[test]
fn changing_continuous_layout_resets_wheel_acceleration() {
use pdfcraft_ui_egui::scroll_speed::ScrollSpeed;
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]);
h.state_mut().set_option("animation_effects", "off").unwrap();
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(2);
for _ in 0..5 {
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
scroll_over(&mut h, 2);
}
h.state_mut().views[0].set_continuous(false);
h.run_steps(3);
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
let moved = scroll_over(&mut h, 2);
let base = ScrollSpeed::default().line_points();
assert!((moved - base).abs() < 1.0, "a layout jump clears the old burst: moved {moved}, base {base}");
}
/// Steps `frames` frames and returns how far the document scrolled down over them, from the
/// pages on screen in each pair of consecutive frames (a page can leave the screen on the way).
/// Only the non-macOS glide tests below use it, hence the matching gate.
#[cfg(not(target_os = "macos"))]
/// Used by wheel and non-macOS glide tests below.
fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 {
let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::<Vec<_>>();
let mut moved = 0.0;
@@ -1498,3 +1566,82 @@ fn two_up_scroll_end_reaches_an_unpaired_final_page() {
fn two_up_scroll_end_with_cover_reaches_a_taller_left_page() {
assert_final_spread_bottom_reachable(&[400, 400, 400, 400, 100], true);
}
#[test]
fn switching_modes_keeps_the_current_page_at_fixed_zoom() {
use egui_kittest::kittest::Queryable;
let kids = (3..33).map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{kids}] /Count 30 /MediaBox [0 0 300 400] >> endobj\n"
);
for i in 3..33 {
pdf.push_str(&format!("{i} 0 obj << /Type /Page /Parent 2 0 R >> endobj\n"));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
let other = pdf.clone().into_bytes();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, pdf.into_bytes()).unwrap();
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(8);
h.state_mut().views[0].go_to_page(17);
h.run_steps(8);
assert_eq!(h.state().views[0].current, 17);
h.hover_at(h.state().views[0].viewport_rect().center());
h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move));
h.run_steps(8);
let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
h.get_by_label("Read").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page");
let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
assert!((before - after).abs() < 1.0, "the position within the page moved: {before} to {after}");
h.state_mut().views[0].go_to_page(5);
h.run_steps(8);
assert_eq!(h.state().views[0].current, 5);
h.get_by_label("All tools").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 5, "switching to All tools should keep the current page");
h.state_mut().open_bytes("other.pdf", None, other).unwrap();
h.state_mut().set_option("zoom", "100").unwrap();
h.run_steps(8);
h.state_mut().views[1].go_to_page(9);
h.run_steps(8);
assert_eq!(h.state().views[1].current, 9);
h.get_by_label("pages.pdf").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 5, "the first tab keeps its own position");
h.get_by_label("other.pdf").click();
h.run_steps(8);
assert_eq!(h.state().views[1].current, 9, "the second tab keeps its own position");
}
#[test]
fn long_view_menu_fits_a_short_window() {
use egui_kittest::kittest::Queryable;
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap();
app
});
h.run_steps(8);
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("View ⏵").hover();
h.run_steps(4);
let before = h.get_by_label("Side panels ⏵").rect().bottom();
h.get_by_label("Fit to height ").hover();
h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move));
h.run_steps(6);
let after = h.get_by_label("Side panels ⏵").rect().bottom();
assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}");
h.get_by_label("Side panels ⏵").hover();
h.run_steps(4);
h.get_all_by_label("Bookmarks").last().unwrap().click();
h.run_steps(4);
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
}
+3 -2
View File
@@ -146,14 +146,14 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only changed stream occurrences and preserves other uses of shared streams through full/incremental saves (#1324, #3217). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |
## Upcoming milestones, ranked
@@ -200,6 +200,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. Estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-17 | minor | M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the dragged pages into the drop gap (a "+" badge says so; the copies land selected, one undo step); `page_duplicate` takes `at`, and the UI control channel's `ui.drag` modifiers reach the input state. Estimates unchanged. |
+9 -9
View File
@@ -854,8 +854,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.continuous"]
tools = []
evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"]
notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)"
evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide", "crates/ui-egui/tests/view.rs::rapid_wheel_notches_accelerate_and_follow_touchpad_input", "crates/ui-egui/tests/view.rs::changing_continuous_layout_resets_wheel_acceleration"]
notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. Rapid line-wheel notches gain a bounded, constant-space arrival-rate boost (#1072); isolated notches still use the chosen Scroll speed, and page units stay unboosted. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)"
[[feature]]
id = "view.layout-single-page"
@@ -1094,8 +1094,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.read_mode"]
tools = []
evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"]
notes = ""
evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"]
notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position"
[[feature]]
id = "view.full-screen"
@@ -5704,8 +5704,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.apply"]
tools = ["redact_apply"]
evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"]
notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole"
evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres"]
notes = "intersected pixel footprints are cleared conservatively in a copy; DCT/JPX/JBIG2/CCITT images removed whole"
[[feature]]
id = "protect.redact-vectors"
@@ -5912,8 +5912,8 @@ milestone = "M9"
status = "shipped"
commands = ["sign.digital", "sign.certify"]
tools = ["sign_windows_ids", "sign_document"]
evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen"]
notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows:<fingerprint|common name> for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11."
evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password", "crates/ui-egui/tests/signing.rs::an_unusable_id_is_shown_with_its_reason_and_cannot_be_chosen", "crates/sign/tests/chain.rs::a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists"]
notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows:<fingerprint|common name> for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Store certificates that can't sign (no private key, another key type, a key CNG can't open, or a key usage or extended key usage that doesn't allow signing documents, like a CAC's encryption and logon certificates) are reported with the reason and Windows error code (greyed in the picker, `unusable` in sign_windows_ids) rather than left out; keys that need a prompt to open are listed and prompt at signing. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates and a key-less certificate (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); still missing: the Local Machine store, legacy CryptoAPI-only providers, P-521/Brainpool/Ed25519 keys, direct PKCS #11."
[[feature]]
id = "sign.encrypted-documents"
@@ -7776,7 +7776,7 @@ milestone = "M11"
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_verify"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
[[feature]]
+3 -3
View File
File diff suppressed because one or more lines are too long
+172 -2
View File
@@ -26,7 +26,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
target_resolution: params.target_dimension,
};
let image = hayro_jpeg2000::Image::new(data, &settings).ok()?;
// PdfCraft patch (21): a JP2 colour specification naming an enumerated colour space the
// decoder refuses (CIEJab, enum 19, in a veraPDF fixture; YCbCr, PhotoYCC, …) made the whole
// image fail, so the page drew blank (#3306). Then it is decoded as if it had no colour
// specification, as MuPDF and Poppler draw it: grey, RGB or CMYK by its component count.
let repaired;
let image = match hayro_jpeg2000::Image::new(data, &settings) {
Ok(image) => image,
Err(_) => {
repaired = without_refused_enumerated_colour(data)?;
hayro_jpeg2000::Image::new(&repaired, &settings).ok()?
}
};
let width = image.width();
let height = image.height();
@@ -87,6 +98,82 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
})
}
/// The enumerated colour spaces (`EnumCS`, ISO 15444-1 I.5.3.3 and 15444-2 M.11.7.2) the decoder
/// handles: CMYK, CIELab, sRGB, greyscale, sYCC, e-sRGB and ROMM-RGB.
const DECODED_ENUMERATED: [u32; 7] = [12, 14, 16, 17, 18, 20, 21];
/// PdfCraft patch (21): a copy of JP2 file `data` whose colour specification boxes (`colr`, in
/// the `jp2h` header box) that name an enumerated colour space outside [`DECODED_ENUMERATED`]
/// name the space the decoder assumes without one instead: greyscale, sRGB or CMYK for 1, 3 or
/// 4 components (`ihdr`). `None` when there is nothing to change or the boxes can't be read.
pub(crate) fn without_refused_enumerated_colour(data: &[u8]) -> Option<Vec<u8>> {
let header = boxes(data).find(|&(kind, _, _)| kind == *b"jp2h")?;
let (_, start, end) = header;
let inner = data.get(start..end)?;
let (_, ih_start, _) = boxes(inner).find(|&(kind, _, _)| kind == *b"ihdr")?;
// ihdr: height (4), width (4), number of components (2).
let components = u16::from_be_bytes(inner.get(ih_start + 8..ih_start + 10)?.try_into().ok()?);
let replacement: u32 = match components {
1 => 17,
3 => 16,
4 => 12,
_ => return None,
};
// Where each refused enum is, found before anything is copied: a stream the decoder refused
// for another reason isn't copied for nothing.
let mut refused = Vec::new();
for (kind, body, body_end) in boxes(inner) {
if kind != *b"colr" {
continue;
}
// colr: METH (1), PREC (1), APPROX (1), then EnumCS (4) when METH is 1.
let colr = inner.get(body..body_end)?;
if colr.first() != Some(&1) {
continue;
}
let enumerated = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?);
if DECODED_ENUMERATED.contains(&enumerated) {
continue;
}
refused.push(start.checked_add(body)?.checked_add(3)?);
}
if refused.is_empty() {
return None;
}
let mut out = data.to_vec();
for at in refused {
out.get_mut(at..at.checked_add(4)?)?.copy_from_slice(&replacement.to_be_bytes());
}
Some(out)
}
/// The boxes in `data` (ISO 15444-1 I.4): each one's type and the start and end of its contents.
/// Stops at the first box whose length doesn't fit.
fn boxes(data: &[u8]) -> impl Iterator<Item = ([u8; 4], usize, usize)> + '_ {
let mut at = 0usize;
core::iter::from_fn(move || {
let length = u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?);
let kind: [u8; 4] = data.get(at + 4..at.checked_add(8)?)?.try_into().ok()?;
let (header, size) = match length {
// The rest of the data.
0 => (8, data.len().checked_sub(at)?),
// An 8-byte length follows the type.
1 => {
let large = u64::from_be_bytes(data.get(at + 8..at.checked_add(16)?)?.try_into().ok()?);
(16, usize::try_from(large).ok()?)
}
n => (8, usize::try_from(n).ok()?),
};
let end = at.checked_add(size)?;
if size < header || end > data.len() {
return None;
}
let item = (kind, at + header, end);
at = end;
Some(item)
})
}
fn scale(
data: &[u8],
bit_per_component: u8,
@@ -134,7 +221,7 @@ fn scale(
#[cfg(test)]
mod tests {
use super::scale;
use super::{scale, without_refused_enumerated_colour};
use crate::bit_reader::{BitReader, bit_mask};
use alloc::vec::Vec;
@@ -188,4 +275,87 @@ mod tests {
}
}
}
/// A JP2 header (`jp2h`) holding `ihdr` for `components` and the given `colr` bodies, with
/// a codestream box after it.
fn jp2(components: u16, colrs: &[&[u8]]) -> Vec<u8> {
let jp2_box = |kind: &[u8], body: &[u8]| {
let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec();
b.extend_from_slice(kind);
b.extend_from_slice(body);
b
};
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 1]);
ihdr.extend_from_slice(&components.to_be_bytes());
ihdr.extend_from_slice(&[7, 7, 0, 0]);
let mut header = jp2_box(b"ihdr", &ihdr);
for c in colrs {
header.extend(jp2_box(b"colr", c));
}
let mut out = jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 ");
out.extend(jp2_box(b"jp2h", &header));
out.extend(jp2_box(b"jp2c", &[0xff, 0x4f]));
out
}
fn enumerated(e: u32) -> Vec<u8> {
let mut c = alloc::vec![1, 0, 0];
c.extend_from_slice(&e.to_be_bytes());
c
}
/// The enumerated colour spaces found in the copy's `colr` boxes, in order.
fn enums_in(data: &[u8]) -> Vec<u32> {
data.windows(4)
.enumerate()
.filter(|(_, w)| *w == b"colr")
.filter(|(i, _)| data.get(i + 4) == Some(&1))
.map(|(i, _)| u32::from_be_bytes(data[i + 7..i + 11].try_into().unwrap()))
.collect()
}
#[test]
fn refused_enumerated_colour_spaces_become_the_component_counts_default() {
// CIEJab (19) and other refused spaces, by component count.
for (components, refused, expected) in [(3, 19, 16), (3, 22, 16), (1, 15, 17), (4, 13, 12), (3, 1, 16)] {
let data = jp2(components, &[&enumerated(refused)]);
let out = without_refused_enumerated_colour(&data).unwrap();
assert_eq!(enums_in(&out), [expected], "{refused} with {components} component(s)");
assert_eq!(out.len(), data.len(), "only the enum changes");
}
// Several colour specifications: each refused one, and only those.
let out = without_refused_enumerated_colour(&jp2(3, &[&enumerated(19), &enumerated(14), &enumerated(24)])).unwrap();
assert_eq!(enums_in(&out), [16, 14, 16]);
// An 8-byte box length (LBox 1) is read too.
let mut long = jp2(3, &[&enumerated(19)]);
let at = long.windows(4).position(|w| w == b"jp2h").unwrap() - 4;
let len = u32::from_be_bytes(long[at..at + 4].try_into().unwrap()) as u64 + 8;
long.splice(at..at + 4, [0, 0, 0, 1]);
long.splice(at + 8..at + 8, (len).to_be_bytes());
assert_eq!(enums_in(&without_refused_enumerated_colour(&long).unwrap()), [16]);
}
#[test]
fn what_the_decoder_handles_or_cant_be_read_is_left_alone() {
for known in [12, 14, 16, 17, 18, 20, 21] {
assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&enumerated(known)])), None, "{known}");
}
// An ICC profile (METH 2), a component count with no default, no header at all.
// (Its bytes where an enum would be read say 19: only METH 1 is an enum.)
assert_eq!(without_refused_enumerated_colour(&jp2(3, &[&[2, 0, 0, 0, 0, 0, 19, 0, 0]])), None);
assert_eq!(without_refused_enumerated_colour(&jp2(2, &[&enumerated(19)])), None);
assert_eq!(without_refused_enumerated_colour(&[0xff, 0x4f, 0xff, 0x51]), None);
// Damaged lengths: none of these may panic, and none is changed.
let good = jp2(3, &[&enumerated(19)]);
for cut in 0..good.len() {
let _ = without_refused_enumerated_colour(&good[..cut]);
}
for bad in [0x0000_0004_u32, 0x7fff_ffff, 0xffff_ffff] {
let mut data = good.clone();
let at = data.windows(4).position(|w| w == b"colr").unwrap() - 4;
data[at..at + 4].copy_from_slice(&bad.to_be_bytes());
assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}");
}
}
}
+15 -8
View File
@@ -353,14 +353,25 @@ impl FilterResult<'_> {
}
}
/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services
/// writes `stream \r\n`, a space before the line break, which the specification does not allow.
/// Without this the content stream was taken to start at the space, failed to inflate, and the
/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and
/// tabs are skipped, and only when a line break follows, so stream data is never swallowed.
fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> {
r.forward_while(|b| b == b' ' || b == b'\t');
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))
}
fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>> {
let length = dict.get::<u32>(LENGTH)?;
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let data = r.read_bytes(length as usize)?;
r.skip_white_spaces();
r.forward_tag(b"endstream")?;
@@ -376,11 +387,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option<Stream<'a>>
// A wrong `/Length` still recovers, because that keyword sits directly after the dict.
r.skip_white_spaces_and_comments();
r.forward_tag(b"stream")?;
r.forward_tag(b"\n")
.or_else(|| r.forward_tag(b"\r\n"))
// Technically not allowed, but no reason to not try it.
.or_else(|| r.forward_tag(b"\r"))?;
forward_stream_eol(r)?;
let tail = r.tail()?;
let endstream_offset = find_needle(tail, b"endstream")?;
+1 -1
View File
@@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode};
mod renderer;
/// PdfCraft patch: exported for its regression test.
pub use renderer::{image_resampling_size, tiling_cell_scale};
pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale};
/// PdfCraft patch: exported for its regression test.
#[doc(hidden)]
pub use renderer::set_skip_offscreen_paths;
+100 -8
View File
@@ -526,14 +526,7 @@ impl Renderer {
},
};
// A miter limit that isn't finite leaves miters unbounded: never skipped.
let miter = stroke.miter_limit.abs();
let reach = stroke.width / 2.0
* if miter.is_finite() {
miter.max(std::f64::consts::SQRT_2)
} else {
f64::INFINITY
};
let reach = stroke_reach(&stroke);
self.ctx.set_stroke(stroke);
reach
}
@@ -1142,6 +1135,65 @@ impl Renderer {
};
xs = xs.max(min_x_scale).min(max_x_scale);
ys = ys.max(min_y_scale).min(max_y_scale);
// PdfCraft patch (20): a step too large for a cell pixmap, where what is
// painted can only show the first cell (PowerPoint writes `/XStep
// 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is
// drawn at full scale with a transparent border and padded, not tiled.
// Shrinking the cell to fit the step left a picture under one pixel.
// How far a stroke's paint reaches past the path, miters included, in
// device pixels: the most the transform stretches any direction (a skew
// stretches some further than either axis), times the reach.
let reach = if is_stroke {
path_transform.spectral_norm() * stroke_reach(self.ctx.stroke())
} else {
0.0
};
if usable_cell_scale(xs, ys)
&& (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE)
&& shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step)
{
const BORDER: f64 = 1.0;
let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2);
let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2);
let mut renderer = Self {
ctx: RenderContext::new_with(
pix_width,
pix_height,
derive_settings(self.ctx.render_settings()),
),
color_replacement: None,
in_annotations: false,
cur_mask: None,
inside_pattern: true,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
in_type3_glyph: false,
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
};
let initial_transform = Affine::translate((BORDER, BORDER))
* Affine::scale_non_uniform(xs as f64, ys as f64)
* Affine::translate((-bbox.x0, -bbox.y0));
t.interpret(&mut renderer, initial_transform, is_stroke);
let mut pix = Pixmap::new(pix_width, pix_height);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse();
let image = Image {
image: ImageSource::Pixmap(Arc::new(pix)),
sampler: ImageSampler {
x_extend: peniko::Extend::Pad,
y_extend: peniko::Extend::Pad,
quality: ImageQuality::Medium,
alpha: 1.0,
},
};
self.ctx.set_paint_transform(paint_transform);
self.ctx.set_paint(PaintType::Image(image));
return clip_path;
}
// PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the
// u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells).
xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE);
@@ -1894,6 +1946,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode {
peniko::BlendMode::new(mix, Compose::SrcOver)
}
/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width
/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that
/// isn't finite leaves miters unbounded, so the reach is too.
fn stroke_reach(stroke: &kurbo::Stroke) -> f64 {
let miter = stroke.miter_limit.abs();
stroke.width / 2.0
* if miter.is_finite() {
miter.max(std::f64::consts::SQRT_2)
} else {
f64::INFINITY
}
}
/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A
/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale,
/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path.
pub fn usable_cell_scale(xs: f32, ys: f32) -> bool {
xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0
}
/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach`
/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can
/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more
/// away. False when the matrix can't be inverted or the bounds aren't finite.
pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool {
if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() {
return false;
}
let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach);
let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box();
let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs()));
let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite());
// Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other.
finite
&& bounds.x1 < bbox.x0 + sx
&& bounds.x0 > bbox.x1 - sx
&& bounds.y1 < bbox.y0 + sy
&& bounds.y0 > bbox.y1 - sy
}
/// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| ×
/// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform
/// is derived from the scale); an over-large step only renders at lower resolution.