mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
Merge upstream/main
This commit is contained in:
Generated
+2
@@ -4263,9 +4263,11 @@ dependencies = [
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-5
@@ -170,14 +170,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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.
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
|
||||
- **2026-10-18 (M2, PR #2838 refresh):** Integrated upstream `4db6ac4a` while preserving CropBox text clipping, rotated/UserUnit and ActualText coverage, the CLI regression and the asynchronous OS-open fixture fix. Estimates unchanged.
|
||||
|
||||
- **2026-10-16 (M3, PR #2838 CI):** The OS-open regression keeps its synthetic file until asynchronous preparation opens its tab, then verifies the original one-tab, filename and quit behavior. A controlled worker delay reproduced the old zero-tab failure. CropBox behavior and estimates unchanged.
|
||||
|
||||
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
|
||||
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
|
||||
@@ -185,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))))]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2058,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
|
||||
@@ -2346,6 +2354,17 @@ 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());
|
||||
// Keyboard scrolling applies to this frame's page view only, never later.
|
||||
@@ -2676,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];
|
||||
@@ -2812,7 +2831,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail);
|
||||
let out = scroll.show_viewport(ui, |ui, viewport| {
|
||||
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 {
|
||||
@@ -5285,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));
|
||||
|
||||
@@ -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")));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1566,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));
|
||||
}
|
||||
|
||||
+2
-2
@@ -113,7 +113,6 @@ total matches the parity estimate.
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. OS-open regression waits for asynchronous tab creation before removing its fixture (PR #2838 CI). |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
|
||||
@@ -154,7 +153,7 @@ total matches the parity estimate.
|
||||
| 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
|
||||
@@ -201,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. |
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+172
-2
@@ -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
@@ -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")?;
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
Vendored
+100
-8
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user