mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 03:01:55 +00:00
Merge origin/main into feat/3d-embed
Resolves the ROADMAP.md progress-log conflict by keeping both lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
commit
3d106d7e1e
47 files changed
+1509
-243
No files matched your search
Generated
+36
-36
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4232,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4244,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4255,7 +4255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4306,11 +4306,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4333,7 +4333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
@@ -4352,7 +4352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4364,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4382,7 +4382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4426,14 +4426,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4446,7 +4446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4456,7 +4456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4469,11 +4469,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4481,7 +4481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4494,14 +4494,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4512,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4528,11 +4528,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4543,7 +4543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4553,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4564,7 +4564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4576,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4593,7 +4593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4612,7 +4612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4648,11 +4648,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4706,7 +4706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4718,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4732,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+34
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -26,39 +26,39 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.17.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.17.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.17.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.17.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.17.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.17.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.17.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.17.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.17.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.17.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.17.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.17.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.17.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.17.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.17.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.17.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.17.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.17.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.17.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.17.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.17.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.18.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.18.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.18.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.18.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.18.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.18.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.18.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.18.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.18.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.18.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.18.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.18.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.18.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.18.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.18.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.18.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.18.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.18.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.18.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.18.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.18.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.18.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.18.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.18.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.18.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.18.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.18.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.18.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.18.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.18.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -220,6 +220,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
|
||||
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
|
||||
+78
-92
@@ -125,9 +125,9 @@ pub(crate) fn elements(doc: &Document) -> Vec<Element> {
|
||||
}
|
||||
|
||||
/// The marked-content ids an element owns directly.
|
||||
fn mcids(d: &Dict) -> Vec<i64> {
|
||||
fn mcids(doc: &Document, d: &Dict) -> Vec<i64> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |o: &Object| match o {
|
||||
let mut push = |o: &Object| match &*doc.resolve(o) {
|
||||
Object::Int(n) => out.push(*n),
|
||||
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
|
||||
if let Some(n) = m.get(b"MCID").and_then(Object::as_int) {
|
||||
@@ -323,7 +323,7 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
|
||||
let mut wanted: HashMap<usize, HashSet<i64>> = HashMap::new();
|
||||
for (e, page) in &figures {
|
||||
if let Some(p) = page {
|
||||
wanted.entry(*p).or_default().extend(mcids(&e.dict));
|
||||
wanted.entry(*p).or_default().extend(mcids(doc, &e.dict));
|
||||
}
|
||||
}
|
||||
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
|
||||
@@ -333,7 +333,7 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
|
||||
.map(|(e, page)| {
|
||||
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
|
||||
let on_page = page.and_then(|p| bounds.get(&p));
|
||||
let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
|
||||
let bbox = on_page.and_then(|b| mcids(doc, &e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
|
||||
Figure { obj: e.object, page, alt, bbox }
|
||||
})
|
||||
.collect()
|
||||
@@ -363,52 +363,73 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(),
|
||||
}
|
||||
|
||||
/// The page's content entries: one stream, or the elements of a contents array.
|
||||
fn content_entries(doc: &Document, page: &Dict) -> Option<Vec<Object>> {
|
||||
let c = page.get(b"Contents")?;
|
||||
Some(match &*doc.resolve(c) {
|
||||
fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
Object::Stream(_) => vec![c.clone()],
|
||||
_ => return None,
|
||||
})
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet<i64>) -> bool {
|
||||
op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n))
|
||||
}
|
||||
type DecodedStream = (Object, Option<(Stream, Vec<u8>)>);
|
||||
|
||||
/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that
|
||||
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
|
||||
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
|
||||
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
|
||||
/// Decode the page's content streams within the page decode budget. `None` when a stream is
|
||||
/// rejected because the budget is exhausted.
|
||||
fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>> {
|
||||
let entries = content_entries(doc, page);
|
||||
let mut streams = Vec::with_capacity(entries.len());
|
||||
let mut budget = DecodeBudget::page();
|
||||
for entry in &entries {
|
||||
match &*doc.resolve(entry) {
|
||||
for entry in entries {
|
||||
let decoded = match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => {
|
||||
let data = match budget.decode(s) {
|
||||
Ok(data) => data,
|
||||
// Content past the budget may hold more of the figure: editing the rest
|
||||
// would leave that part marked as a figure the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
|
||||
// Content past the budget may hold more of the tag: editing the rest
|
||||
// would leave that part marked as a tag the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return None,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
streams.push(Some((s.clone(), data)));
|
||||
Some((s.clone(), data))
|
||||
}
|
||||
_ => streams.push(None),
|
||||
_ => None,
|
||||
};
|
||||
streams.push((entry, decoded));
|
||||
}
|
||||
Some(streams)
|
||||
}
|
||||
|
||||
fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet<i64>) -> Option<i64> {
|
||||
(op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
|
||||
}
|
||||
|
||||
/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
|
||||
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
|
||||
/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
|
||||
fn artifact_marked_content(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
streams: Vec<DecodedStream>,
|
||||
ids: &HashSet<i64>,
|
||||
require_all: bool,
|
||||
) -> Result<bool, pdfcraft_cos::CosError> {
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let mut found = HashSet::new();
|
||||
let mut edits = Vec::new();
|
||||
for op in pieces.parse().ops {
|
||||
if let Some(id) = marked_id(doc, &props, &op, ids) {
|
||||
found.insert(id);
|
||||
edits.push((op.span, b"/Artifact BMC".to_vec()));
|
||||
}
|
||||
}
|
||||
let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let edits: Vec<_> =
|
||||
pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
|
||||
if edits.is_empty() {
|
||||
if if require_all { found != *ids } else { found.is_empty() } {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut spliced = pieces.splice(edits).into_iter();
|
||||
let mut next = Vec::with_capacity(entries.len());
|
||||
let mut changed = false;
|
||||
for (entry, stream) in entries.into_iter().zip(streams) {
|
||||
let mut next = Vec::with_capacity(streams.len());
|
||||
for (entry, stream) in streams {
|
||||
let Some((stream, old)) = stream else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
@@ -421,7 +442,6 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let mut dict = stream.dict;
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"Filter");
|
||||
@@ -431,28 +451,27 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
// appear elsewhere in this array or on another page, where it must stay unchanged.
|
||||
next.push(Object::Ref(doc.add(stream)));
|
||||
}
|
||||
if changed {
|
||||
let contents = match next.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => Object::Array(next),
|
||||
};
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
|
||||
}
|
||||
Ok(changed)
|
||||
let contents = match next.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => Object::Array(next),
|
||||
};
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Decorative figure: its marked content on the page becomes an artifact, and the element
|
||||
/// leaves the structure tree (and the parent tree).
|
||||
pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> {
|
||||
let d = check_figure(doc, obj)?;
|
||||
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
|
||||
let ids: HashSet<i64> = mcids(doc, &d).into_iter().collect();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty())
|
||||
&& artifact_marked_content(doc, page, &ids)?
|
||||
{
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
|
||||
let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
|
||||
// The parent tree no longer points those ids at the figure.
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
if artifact_marked_content(doc, page, streams, &ids, false)?
|
||||
&& let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
|
||||
{
|
||||
clear_parent_tree(doc, key, obj)?;
|
||||
}
|
||||
}
|
||||
@@ -484,54 +503,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
|
||||
if has_child_structure(doc, &element.dict) {
|
||||
return Err(ArtifactError::HasChildren);
|
||||
}
|
||||
let ids: HashSet<i64> = mcids(&element.dict).into_iter().collect();
|
||||
let ids: HashSet<i64> = mcids(doc, &element.dict).into_iter().collect();
|
||||
if ids.is_empty() {
|
||||
return Err(ArtifactError::NoMarkedContent);
|
||||
}
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
|
||||
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
|
||||
let mut content = Vec::new();
|
||||
if let Some(c) = page.dict.get(b"Contents") {
|
||||
let mut budget = DecodeBudget::page();
|
||||
for data in budget.decode_contents(doc, c) {
|
||||
match data {
|
||||
Ok(data) => {
|
||||
content.extend_from_slice(&data);
|
||||
content.push(b' ');
|
||||
}
|
||||
// The page's /Contents is replaced with what was decoded: content past the
|
||||
// budget would be dropped from the page.
|
||||
Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = pdfcraft_content::parse(&content).ops;
|
||||
let mut found = HashSet::new();
|
||||
for op in &mut ops {
|
||||
let id = (op.op == b"BDC")
|
||||
.then(|| match op.operands.get(1) {
|
||||
Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|id| ids.contains(id));
|
||||
if let Some(id) = id {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
found.insert(id);
|
||||
}
|
||||
}
|
||||
if found != ids {
|
||||
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
|
||||
let replacement = replacement_kids(doc, parent, obj)?;
|
||||
let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
|
||||
if !artifact_marked_content(doc, page, streams, &ids, true)? {
|
||||
return Err(ArtifactError::ContentNotFound);
|
||||
}
|
||||
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?;
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
|
||||
}
|
||||
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
|
||||
remove_from_parent(doc, parent, obj)?;
|
||||
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
|
||||
// Keep the retired object valid if another object holds an out-of-tree reference to it.
|
||||
doc.update_dict(obj, |d| {
|
||||
d.remove(b"P");
|
||||
@@ -551,20 +539,18 @@ fn has_child_structure(doc: &Document, tag: &Dict) -> bool {
|
||||
tag.get(b"K").is_some_and(|items| child(doc, items))
|
||||
}
|
||||
|
||||
fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> {
|
||||
fn replacement_kids(doc: &Document, parent: ObjRef, obj: ObjRef) -> Result<Object, ArtifactError> {
|
||||
let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?;
|
||||
let replacement = match kids {
|
||||
match kids {
|
||||
Object::Array(items) => {
|
||||
if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 {
|
||||
return Err(ArtifactError::AmbiguousParent);
|
||||
}
|
||||
Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())
|
||||
Ok(Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()))
|
||||
}
|
||||
Object::Ref(r) if r == obj => Object::Array(Vec::new()),
|
||||
_ => return Err(ArtifactError::NoParent),
|
||||
};
|
||||
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
|
||||
Ok(())
|
||||
Object::Ref(r) if r == obj => Ok(Object::Array(Vec::new())),
|
||||
_ => Err(ArtifactError::NoParent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree.
|
||||
|
||||
@@ -1017,6 +1017,76 @@ fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() {
|
||||
assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_tag_artifact_finds_named_property_lists_and_indirect_mcrs() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object};
|
||||
let mut doc = named_figures_doc();
|
||||
let mcr = doc.add(Object::Dict([(b"Type".to_vec(), Object::name("MCR")), (b"MCID".to_vec(), Object::Int(0))].into_iter().collect::<Dict>()));
|
||||
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Ref(mcr))).unwrap();
|
||||
assert_eq!(figures(&doc)[0].bbox, Some([10.0, 150.0, 50.0, 170.0]), "indirect MCR is resolved for figure bounds");
|
||||
|
||||
for (tag, gone) in [(ObjRef::new(7, 0), "/P0"), (ObjRef::new(8, 0), "/P1")] {
|
||||
mark_tag_artifact(&mut doc, tag).unwrap();
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap());
|
||||
let Object::Stream(c) = &*c else { panic!("one content stream") };
|
||||
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains(gone), "{text}");
|
||||
}
|
||||
assert_eq!(tags(&doc).into_iter().map(|t| t.object.num).collect::<Vec<_>>(), [6]);
|
||||
let nums = doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
|
||||
assert_eq!(nums, Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Null])])));
|
||||
let r = check(&doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(failed(&r), Vec::<Rule>::new(), "{:?}", r.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
|
||||
use pdfcraft_cos::{ObjRef, Object};
|
||||
|
||||
// Missing /P on the tag must fail before touching /Contents or /ParentTree.
|
||||
let mut no_parent = figures_doc();
|
||||
let before_contents = no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
|
||||
let before_pt = no_parent.get(ObjRef::new(9, 0)).as_ref().clone();
|
||||
no_parent
|
||||
.update_dict(ObjRef::new(7, 0), |d| {
|
||||
d.remove(b"P");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut no_parent, ObjRef::new(7, 0)), Err(ArtifactError::NoParent));
|
||||
assert_eq!(no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*no_parent.get(ObjRef::new(9, 0)), before_pt);
|
||||
|
||||
// Duplicate entry in parent's /K must fail with AmbiguousParent before touching /Contents or /ParentTree.
|
||||
let mut dup_parent = figures_doc();
|
||||
dup_parent
|
||||
.update_dict(ObjRef::new(6, 0), |d| {
|
||||
d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(7, 0))]));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut dup_parent, ObjRef::new(7, 0)), Err(ArtifactError::AmbiguousParent));
|
||||
assert_eq!(dup_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*dup_parent.get(ObjRef::new(9, 0)), before_pt);
|
||||
|
||||
// Partial MCID match (MCID 0 present, MCID 99 absent) must fail with ContentNotFound in
|
||||
// mark_tag_artifact without mutating /Contents, while mark_decorative still converts MCID 0.
|
||||
let mut partial = figures_doc();
|
||||
partial.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(99)]))).unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut partial, ObjRef::new(7, 0)), Err(ArtifactError::ContentNotFound));
|
||||
assert_eq!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*partial.get(ObjRef::new(9, 0)), before_pt);
|
||||
mark_decorative(&mut partial, ObjRef::new(7, 0)).unwrap();
|
||||
assert_ne!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_ne!(*partial.get(ObjRef::new(9, 0)), before_pt);
|
||||
|
||||
// When none of a figure's MCIDs appear on the page, mark_decorative leaves /Contents and /ParentTree untouched.
|
||||
let mut absent = figures_doc();
|
||||
absent.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(99))).unwrap();
|
||||
mark_decorative(&mut absent, ObjRef::new(7, 0)).unwrap();
|
||||
assert_eq!(absent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
|
||||
}
|
||||
|
||||
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
|
||||
Some(pdfcraft_cos::ObjRef::new(n, 0))
|
||||
}
|
||||
|
||||
@@ -99,14 +99,20 @@ fn shown_strings(ops: &[Op]) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_leaves_the_other_stream_untouched() {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let before = doc.get(ObjRef::new(11, 0)).clone();
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
assert!(figures(&doc).is_empty());
|
||||
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
|
||||
let page = doc.get(ObjRef::new(3, 0));
|
||||
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
|
||||
for use_artifact in [false, true] {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let before = doc.get(ObjRef::new(11, 0)).clone();
|
||||
if use_artifact {
|
||||
mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).expect("mark tag artifact succeeds");
|
||||
} else {
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
}
|
||||
assert!(figures(&doc).is_empty());
|
||||
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
|
||||
let page = doc.get(ObjRef::new(3, 0));
|
||||
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
|
||||
}
|
||||
}
|
||||
|
||||
/// A and B supply different operands to R's BDC. Reusing R must not let the first
|
||||
|
||||
@@ -201,8 +201,8 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = match a.get("categories") {
|
||||
None => Edit::Sanitize,
|
||||
let which = match a.get("categories") {
|
||||
None => None,
|
||||
Some(_) => {
|
||||
let which = a
|
||||
.strs("categories")?
|
||||
@@ -212,10 +212,24 @@ impl Automation {
|
||||
if which.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("categories is empty".into()));
|
||||
}
|
||||
Some(which)
|
||||
}
|
||||
};
|
||||
// One scan, for this check and for what was removed. The removal refuses when it counts
|
||||
// every listed category as 0 (an AcroForm with no fields counts 1), with an error about
|
||||
// redaction marks; this reads the same scan, so it refuses exactly then. A document that
|
||||
// can't be edited has no scan: `apply` says why.
|
||||
let info = self.doc(a)?.hidden_info();
|
||||
let edit = match which {
|
||||
None => Edit::Sanitize,
|
||||
Some(which) => {
|
||||
if !info.is_empty() && which.iter().all(|h| info.iter().find(|c| c.0 == *h).is_none_or(|c| c.1 == 0)) {
|
||||
return Err(failed("nothing to remove: the listed categories hold nothing (see doc_hidden_info)"));
|
||||
}
|
||||
Edit::RemoveHidden { which }
|
||||
}
|
||||
};
|
||||
let before: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
|
||||
let before: usize = info.iter().map(|c| c.1).sum();
|
||||
let mut out = self.apply(a, edit)?;
|
||||
let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
|
||||
out["removed"] = json!(before.saturating_sub(after));
|
||||
|
||||
@@ -576,7 +576,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_remove_hidden",
|
||||
"Remove hidden information / Sanitize",
|
||||
"Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
|
||||
"Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Fails when the listed categories hold nothing. Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
|
||||
)
|
||||
.destructive()
|
||||
.with(schema(
|
||||
|
||||
@@ -4670,12 +4670,23 @@ fn removing_hidden_information_through_tools() {
|
||||
assert_eq!(r["undo"], "Remove hidden information");
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
|
||||
assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went");
|
||||
// With none left, removing them again says so, and Optimize discarding them still runs.
|
||||
let again = a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["comments"] }));
|
||||
assert!(matches!(&again, Err(ToolError::Failed(m)) if m.starts_with("nothing to remove")), "{again:?}");
|
||||
let optimized = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf", "discard": ["comments", "bookmarks"] }));
|
||||
assert_eq!(optimized["discarded"], json!([]), "{optimized}");
|
||||
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc }));
|
||||
assert_eq!(r["undo"], "Sanitize document");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" }));
|
||||
let bytes = std::fs::read(dir.join("clean.pdf")).unwrap();
|
||||
assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone");
|
||||
assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_))));
|
||||
// A document that can't be edited has no scan: the refusal says why, not that nothing is there.
|
||||
let junk = [vec![b'x'; 5000], std::fs::read(dir.join("a.pdf")).unwrap()].concat();
|
||||
std::fs::write(dir.join("junk.pdf"), junk).unwrap();
|
||||
let read_only = ok(&mut a, "doc_open", json!({ "path": "junk.pdf" }))["doc"].as_u64().unwrap();
|
||||
let refused = a.call("doc_remove_hidden", &json!({ "doc": read_only, "categories": ["comments"] }));
|
||||
assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}");
|
||||
}
|
||||
|
||||
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
|
||||
|
||||
+50
-25
@@ -23,29 +23,48 @@ pub const LETTER: (f64, f64) = (612.0, 792.0);
|
||||
/// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units).
|
||||
const MAX_SIDE: f64 = 14_400.0;
|
||||
|
||||
fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) -> Result<ObjRef, CreateError> {
|
||||
// `Document::new_empty` always has a page tree.
|
||||
let pages = doc
|
||||
.root()
|
||||
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")))
|
||||
.ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?;
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)]));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
if let Some(c) = content {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c)));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(s));
|
||||
/// The page tree of a document being made, with the pages added to it so far: its `/Kids` are
|
||||
/// written once, by [`Pages::finish`]. Adding each page to the tree in the document would copy every
|
||||
/// page before it, which a text of a million form feeds makes quadratic.
|
||||
struct Pages {
|
||||
tree: ObjRef,
|
||||
kids: Vec<Object>,
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
/// The page tree of `doc`, with the pages it has.
|
||||
fn of(doc: &Document) -> Result<Self, CreateError> {
|
||||
// `Document::new_empty` always has a page tree.
|
||||
let tree = doc
|
||||
.root()
|
||||
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")))
|
||||
.ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?;
|
||||
let kids = doc.get(tree).as_dict().and_then(|d| d.get(b"Kids")).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
Ok(Self { tree, kids })
|
||||
}
|
||||
|
||||
fn add(&mut self, doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) {
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(self.tree));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)]));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
if let Some(c) = content {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c)));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(s));
|
||||
}
|
||||
self.kids.push(Object::Ref(doc.add(page)));
|
||||
}
|
||||
|
||||
/// Writes the pages added into the tree.
|
||||
fn finish(self, doc: &mut Document) -> Result<(), CreateError> {
|
||||
let count = i64::try_from(self.kids.len()).map_err(|_| CreateError::Invalid("too many pages".into()))?;
|
||||
doc.update_dict(self.tree, |d| {
|
||||
d.set(b"Count".to_vec(), Object::Int(count));
|
||||
d.set(b"Kids".to_vec(), Object::Array(self.kids));
|
||||
})
|
||||
.map_err(|e| CreateError::Invalid(e.to_string()))
|
||||
}
|
||||
let r = doc.add(page);
|
||||
let _ = doc.update_dict(pages, |d| {
|
||||
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
kids.push(Object::Ref(r));
|
||||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
});
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn set_title(doc: &mut Document, title: &str) {
|
||||
@@ -65,9 +84,11 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result<Document, CreateEr
|
||||
return Err(CreateError::Invalid("invalid page size or count".into()));
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
let mut tree = Pages::of(&doc)?;
|
||||
for _ in 0..pages {
|
||||
add_page(&mut doc, width, height, Dict::new(), None)?;
|
||||
tree.add(&mut doc, width, height, Dict::new(), None);
|
||||
}
|
||||
tree.finish(&mut doc)?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -675,6 +696,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
|
||||
return Err(CreateError::Invalid("no images".into()));
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
let mut tree = Pages::of(&doc)?;
|
||||
let mut profiles = Vec::new();
|
||||
for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
|
||||
let dpi = match resolution {
|
||||
@@ -692,8 +714,9 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobj));
|
||||
let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes();
|
||||
add_page(&mut doc, w, h, res, Some(content))?;
|
||||
tree.add(&mut doc, w, h, res, Some(content));
|
||||
}
|
||||
tree.finish(&mut doc)?;
|
||||
if let Some((name, _)) = images.first() {
|
||||
set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s));
|
||||
}
|
||||
@@ -787,6 +810,7 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let fr = doc.add(font);
|
||||
let mut tree = Pages::of(&doc)?;
|
||||
let mut page_lines: Vec<Vec<String>> = vec![Vec::new()];
|
||||
for l in lines.iter().take(lines.len().saturating_sub(1)) {
|
||||
if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) {
|
||||
@@ -810,8 +834,9 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(fr));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
add_page(&mut doc, w, h, res, Some(c))?;
|
||||
tree.add(&mut doc, w, h, res, Some(c));
|
||||
}
|
||||
tree.finish(&mut doc)?;
|
||||
set_title(&mut doc, title);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,27 @@ fn text_is_wrapped_and_paginated() {
|
||||
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_of_many_form_feeds_makes_its_pages_in_linear_time() {
|
||||
// Each form feed starts a page: adding each to the page tree copied all the pages before it,
|
||||
// which made forty thousand of them take over a minute in a debug build, against about two
|
||||
// seconds when the tree is written once.
|
||||
let text = "x\u{c}".repeat(40_000);
|
||||
let start = std::time::Instant::now();
|
||||
let doc = from_text("many", &text, LETTER, 11.0).unwrap();
|
||||
let took = start.elapsed();
|
||||
let tree = doc.get(doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap());
|
||||
let tree = tree.as_dict().unwrap();
|
||||
assert_eq!(tree.get(b"Kids").and_then(|k| k.as_array()).map(|a| a.len()), Some(40_001));
|
||||
assert_eq!(tree.int(b"Count"), Some(40_001));
|
||||
assert!(took < std::time::Duration::from_secs(30), "{took:?}");
|
||||
// Blank documents and pages made from images keep their pages, in order.
|
||||
let doc = reopen(&blank(200.0, 300.0, 3).unwrap());
|
||||
assert_eq!(pages(&doc).len(), 3);
|
||||
let doc = reopen(&from_images(&[("a.png".into(), png_bytes(false)), ("b.png".into(), png_bytes(true))]).unwrap());
|
||||
assert_eq!(pages(&doc).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_leading_spaces_and_tabs() {
|
||||
let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap());
|
||||
|
||||
@@ -584,6 +584,10 @@ fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) -
|
||||
end
|
||||
}
|
||||
|
||||
/// The stacking units of `ops[scan_start..scan_end]` that Arrange moves past one another, in
|
||||
/// paint order: a `q … Q`, `BT … ET` or marked-content sequence up to its matching close (or the
|
||||
/// end of the scan), a single `Do`, `sh` or inline image, or a run of other operators that ends
|
||||
/// at a path-painting operator or before the next unit. Units that paint nothing are left out.
|
||||
pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec<std::ops::Range<usize>> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = scan_start;
|
||||
@@ -598,9 +602,9 @@ pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> V
|
||||
sequence_end(ops, i, scan_end, &["q"], "Q")
|
||||
} else if op.is("BT") {
|
||||
sequence_end(ops, i, scan_end, &[], "ET")
|
||||
} else if op.is("BI") {
|
||||
sequence_end(ops, i, scan_end, &[], "EI")
|
||||
} else if op.is("Do") || op.is("sh") {
|
||||
} else if op.is("BI") || op.is("Do") || op.is("sh") {
|
||||
// The parser emits a whole inline image (`BI` … `ID` … `EI`) as one `BI` op, so it is a
|
||||
// unit of its own (#3146).
|
||||
i.saturating_add(1)
|
||||
} else {
|
||||
let mut j = i;
|
||||
|
||||
@@ -286,7 +286,12 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C
|
||||
let digits = parts.get(1).and_then(|d| d.parse::<usize>().ok()).unwrap_or(6).clamp(1, 15);
|
||||
format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&""))
|
||||
}
|
||||
_ => format!("<<{token}>>"),
|
||||
_ => {
|
||||
// Not a token: emit one `<` and retry, so a later token is still found.
|
||||
out.push('<');
|
||||
rest = &rest[start + 1..];
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push_str(&value);
|
||||
rest = &after[end + 2..];
|
||||
@@ -302,3 +307,16 @@ pub(crate) fn bates_start(template: &str) -> Option<u64> {
|
||||
let t = &t[..t.find(">>")?];
|
||||
t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod expand_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_token_after_extra_angle_brackets() {
|
||||
let cx = Context::default();
|
||||
assert_eq!(expand("<<<1>>>", 3, 9, 1, &cx), "<3>");
|
||||
assert_eq!(expand("<< <<1>>", 3, 9, 1, &cx), "<< 3");
|
||||
assert_eq!(expand("<<x>> <<1>>", 3, 9, 1, &cx), "<<x>> 3");
|
||||
}
|
||||
}
|
||||
@@ -9813,6 +9813,133 @@ fn content_units_does_not_swallow_subsequent_units_on_standalone_gs() {
|
||||
assert_eq!(units.len(), 2);
|
||||
}
|
||||
|
||||
/// A 1 × 1 grey inline image whose one sample is `data`.
|
||||
fn inline_image(data: char) -> String {
|
||||
format!("BI /W 1 /H 1 /CS /G /BPC 8 ID {data} EI")
|
||||
}
|
||||
|
||||
/// What page 0 paints, in order: `BI x` for an inline image with sample `x`, `Do x,y` for an
|
||||
/// XObject placed by a `cm` translating to x,y, and `Tj` for text. A `cm` lasts until its `Q`, so an
|
||||
/// XObject moved out of its `q`/`Q` wrapper shows as a bare `Do `.
|
||||
fn painted(doc: &Document) -> Vec<String> {
|
||||
let mut at = vec![String::new()];
|
||||
pdfcraft_content::parse(&page_content_bytes(doc, 0))
|
||||
.ops
|
||||
.iter()
|
||||
.filter_map(|op| match op.op.as_slice() {
|
||||
b"q" => {
|
||||
at.push(at.last().unwrap().clone());
|
||||
None
|
||||
}
|
||||
b"Q" => {
|
||||
at.pop();
|
||||
None
|
||||
}
|
||||
b"cm" => {
|
||||
let m = op.nums::<6>().unwrap();
|
||||
*at.last_mut().unwrap() = format!("{},{}", m[4], m[5]);
|
||||
None
|
||||
}
|
||||
b"Do" => Some(format!("Do {}", at.last().unwrap())),
|
||||
b"BI" => Some(format!("BI {}", String::from_utf8_lossy(&op.inline.as_ref().unwrap().1))),
|
||||
b"Tj" => Some("Tj".into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The index in [`images::page_images`] of the image whose box starts at `x`,`y`.
|
||||
fn image_at(doc: &Document, x: f64, y: f64) -> usize {
|
||||
images::page_images(doc, 0).unwrap().iter().position(|i| (i.rect[0] - x).abs() < 1e-6 && (i.rect[1] - y).abs() < 1e-6).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_units_keep_each_top_level_inline_image_to_itself() {
|
||||
// The parser emits a whole inline image as one `BI` op; its unit must not run on to the end
|
||||
// (#3146). Inline images at the start, side by side and at the end are one unit each.
|
||||
let content = format!(
|
||||
"{} q 200 0 0 100 100 100 cm /Im0 Do Q {} {} q 10 0 0 10 300 300 cm /Im0 Do Q {}",
|
||||
inline_image('a'),
|
||||
inline_image('b'),
|
||||
inline_image('c'),
|
||||
inline_image('d')
|
||||
);
|
||||
let ops = pdfcraft_content::parse(content.as_bytes()).ops;
|
||||
let units = images::content_units(&ops, 0, ops.len());
|
||||
assert_eq!(units, [0..1, 1..5, 5..6, 6..7, 7..11, 11..12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_units_keep_a_nested_inline_image_inside_its_enclosing_unit() {
|
||||
// A `BI` inside `q`/`Q` or a marked-content sequence stays part of that sequence's unit.
|
||||
let content = format!("q {} Q /Span << /MCID 0 >> BDC {} EMC {}", inline_image('a'), inline_image('b'), inline_image('c'));
|
||||
let ops = pdfcraft_content::parse(content.as_bytes()).ops;
|
||||
let units = images::content_units(&ops, 0, ops.len());
|
||||
assert_eq!(units, [0..3, 3..6, 6..7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_backward_after_a_top_level_inline_image_moves_one_step() {
|
||||
// Before #3146 the inline image's unit swallowed everything after it, so SendBackward on a later
|
||||
// image acted like SendToBack: it must step behind the text only.
|
||||
let content = format!(
|
||||
"{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q",
|
||||
inline_image('x')
|
||||
);
|
||||
let mut doc = image_page_with(&content);
|
||||
let front = image_at(&doc, 200.0, 200.0);
|
||||
arrange(&mut doc, 0, front, images::ArrangeOrder::SendBackward).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["BI x", "Do 100,100", "Do 200,200", "Tj"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_steps_an_image_past_one_inline_image_at_a_time() {
|
||||
// Before #3146 BringForward on B, inside the first inline image's unit, put it in front of
|
||||
// everything. One step forward is past the next inline image only, and one step back again
|
||||
// is behind that inline image only.
|
||||
let content = format!(
|
||||
"q 100 0 0 100 100 100 cm /Im0 Do Q {} q 150 0 0 150 200 200 cm /Im0 Do Q {} BT /F1 12 Tf 72 700 Td (Text) Tj ET q 50 0 0 50 300 300 cm /Im0 Do Q",
|
||||
inline_image('a'),
|
||||
inline_image('b')
|
||||
);
|
||||
let mut doc = image_page_with(&content);
|
||||
let b = image_at(&doc, 200.0, 200.0);
|
||||
arrange(&mut doc, 0, b, images::ArrangeOrder::BringForward).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Do 100,100", "BI a", "BI b", "Do 200,200", "Tj", "Do 300,300"]);
|
||||
let b = image_at(&doc, 200.0, 200.0);
|
||||
arrange(&mut doc, 0, b, images::ArrangeOrder::SendBackward).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Do 100,100", "BI a", "Do 200,200", "BI b", "Tj", "Do 300,300"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_around_inline_images_at_the_ends_of_the_stream() {
|
||||
// Before #3146 BringToFront on the last image after an inline image "succeeded" without moving
|
||||
// it. It is already in front, so it is refused and nothing changes.
|
||||
let content =
|
||||
format!("{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q", inline_image('a'));
|
||||
let mut doc = image_page_with(&content);
|
||||
let before = page_content_bytes(&doc, 0);
|
||||
let front = image_at(&doc, 200.0, 200.0);
|
||||
assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringToFront).is_err());
|
||||
assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringForward).is_err());
|
||||
assert_eq!(page_content_bytes(&doc, 0), before);
|
||||
// Send to back goes behind the inline image that starts the stream.
|
||||
arrange(&mut doc, 0, front, images::ArrangeOrder::SendToBack).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Do 200,200", "BI a", "Do 100,100", "Tj"]);
|
||||
|
||||
// Bring to front goes past the inline image that ends the stream.
|
||||
let content = format!("q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET {}", inline_image('z'));
|
||||
let mut doc = image_page_with(&content);
|
||||
let back = image_at(&doc, 100.0, 100.0);
|
||||
arrange(&mut doc, 0, back, images::ArrangeOrder::BringToFront).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Tj", "BI z", "Do 100,100"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_inline_soft_masks_have_distinct_identities() {
|
||||
let doc = Document::new_empty();
|
||||
|
||||
@@ -92,7 +92,24 @@ impl OptimizeJob {
|
||||
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
|
||||
step(OptimizeStage::Discarding)?;
|
||||
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
|
||||
let discarded = if discard.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
// Every ticked category, as `remove_hidden` decides between them: with form fields
|
||||
// ticked, discarding hidden text also takes the hidden text the flattened appearances
|
||||
// bring onto the page, which no scan before flattening counts. It refuses when its scan
|
||||
// counts every one as 0, which would end the whole optimization, and may have changed
|
||||
// the copy by then (an empty outline root goes with bookmarks), so the copy from before
|
||||
// is kept instead: nothing is discarded.
|
||||
let before = cos.clone();
|
||||
match pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard) {
|
||||
Err(pdfcraft_redact::RedactError::NothingToApply) => {
|
||||
cos = before;
|
||||
Vec::new()
|
||||
}
|
||||
r => r?,
|
||||
}
|
||||
};
|
||||
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
|
||||
step(match s {
|
||||
optimize::Stage::Fragments => OptimizeStage::Fragments,
|
||||
|
||||
@@ -3689,6 +3689,66 @@ fn create_and_reduce() {
|
||||
assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]);
|
||||
}
|
||||
|
||||
/// Discarding a category that holds nothing discards nothing; it used to end the whole
|
||||
/// optimization with an error about redaction marks.
|
||||
#[test]
|
||||
fn optimize_discarding_an_empty_category_still_runs() {
|
||||
let (s, id) = session_with(2);
|
||||
let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
assert!(report.discarded.is_empty(), "{:?}", report.discarded);
|
||||
// An outline root with no entries is no bookmarks: it stays, as Remove Hidden Information,
|
||||
// which refuses then, leaves it.
|
||||
let outline = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /PageMode /UseOutlines >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R >>")
|
||||
.obj("<< /Type /Outlines /Count 0 >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("outline.pdf", None, Arc::new(outline), None).unwrap();
|
||||
let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks]).unwrap();
|
||||
assert!(report.discarded.is_empty(), "{:?}", report.discarded);
|
||||
let saved = pdfcraft_cos::Document::open(bytes).unwrap();
|
||||
let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(catalog.contains(b"Outlines") && catalog.contains(b"PageMode"), "{catalog:?}");
|
||||
// Beside an empty tick, one that holds something is discarded, and only that one.
|
||||
let noted = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>")
|
||||
.obj("<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (internal note) >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("noted.pdf", None, Arc::new(noted), None).unwrap();
|
||||
let (_, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap();
|
||||
assert_eq!(report.discarded, vec![(crate::Hidden::Comments, 1)]);
|
||||
// A field whose appearance draws text invisibly: flattening it brings that text onto the page,
|
||||
// which no scan before counts as hidden, and discarding hidden text takes it too.
|
||||
let form = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/F1 0 Tf 0 g) >> >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /Resources << /Font << /F1 6 0 R >> >> /Contents 7 0 R >>")
|
||||
.obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (shown) /Rect [100 100 300 130] /P 3 0 R /F 4 /AP << /N 5 0 R >> >>")
|
||||
.stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 200 30] /Resources << /Font << /F1 6 0 R >> >>",
|
||||
"/Tx BMC BT /F1 12 Tf 2 10 Td (shown) Tj ET BT /F1 12 Tf 3 Tr 2 20 Td (HIDDENSECRET) Tj ET EMC",
|
||||
)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||
.stream("", "BT /F1 12 Tf 72 700 Td (Page text) Tj ET")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(form), None).unwrap();
|
||||
let (bytes, _) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::FormFields, crate::Hidden::HiddenText]).unwrap();
|
||||
let saved = pdfcraft_cos::Document::open(bytes).unwrap();
|
||||
for (r, object) in saved.scan_objects() {
|
||||
if let pdfcraft_cos::Object::Stream(st) = &*object {
|
||||
let data = st.decoded().unwrap_or_default();
|
||||
assert!(!data.windows(12).any(|w| w == b"HIDDENSECRET"), "the hidden text stays in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
|
||||
use crate::optimizer::OptimizeStage;
|
||||
|
||||
@@ -227,4 +227,22 @@ mod tests {
|
||||
assert_eq!(parse_num("12abc"), Some(12.0));
|
||||
assert_eq!(parse_num("abc"), None);
|
||||
}
|
||||
|
||||
/// A number prints to at most fifteen significant digits, so the binary noise of a value
|
||||
/// with six digits before the point (279893.88 is 279893.880000000004656… as a double) stays
|
||||
/// hidden, while small values keep their twelve decimals.
|
||||
#[test]
|
||||
fn num_text_prints_at_most_fifteen_significant_digits() {
|
||||
assert_eq!(num_text(279_893.88), "279893.88");
|
||||
assert_eq!(num_text(-279_893.88), "-279893.88");
|
||||
assert_eq!(num_text(279_893.88 + 291_774.12 + 304_171.06 + 317_107.44 + 273_970.85), "1466917.35");
|
||||
assert_eq!(num_text(1_000_000.0 / 3.0), "333333.333333333");
|
||||
assert_eq!(num_text(1.0 / 3.0), "0.333333333333");
|
||||
assert_eq!(num_text(0.3 - 0.1 - 0.2), "0", "noise below twelve decimals is zero");
|
||||
// Past fifteen digits before the point no decimals are left, and the integer keeps its zeros.
|
||||
assert_eq!(num_text(1e15 + 0.5), "1000000000000000");
|
||||
assert_eq!(num_text(1e20), "100000000000000000000");
|
||||
assert_eq!(num_text(-1e20), "-100000000000000000000");
|
||||
assert_eq!(num_text(f64::MAX).len(), 309);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ pub(super) enum Val {
|
||||
Sink,
|
||||
}
|
||||
|
||||
/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits.
|
||||
/// A number as FormCalc prints it: integers without a fraction, otherwise to at most 12
|
||||
/// decimals and 15 significant digits, without trailing zeros. 15 digits is the most a double
|
||||
/// holds for every value, so a value typed with up to 15 digits prints back as typed
|
||||
/// (`279893.88 * 1` is `279893.88`, not `279893.880000000005`), and the binary noise past
|
||||
/// them never shows. The 12 decimals hide noise left by sums that should be zero.
|
||||
pub(super) fn num_text(n: f64) -> String {
|
||||
if !n.is_finite() {
|
||||
return if n.is_nan() {
|
||||
@@ -35,8 +39,12 @@ pub(super) fn num_text(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
return format!("{}", n as i64);
|
||||
}
|
||||
let s = format!("{n:.12}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
// Digits before the point (at most 309 for a finite double); 1 for |n| < 1.
|
||||
let int_digits = format!("{:.0}", n.abs().trunc()).len();
|
||||
let decimals = 15usize.saturating_sub(int_digits).min(12);
|
||||
let s = format!("{n:.decimals$}");
|
||||
// Only a fraction loses its trailing zeros: with no decimals left they belong to the integer.
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { s.as_str() };
|
||||
if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }
|
||||
}
|
||||
|
||||
|
||||
@@ -1026,6 +1026,20 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5");
|
||||
}
|
||||
|
||||
/// A calculated amount shows the number it is, not the binary noise past a double's 15
|
||||
/// significant digits: 279893.88 is stored as 279893.880000000004656…, which twelve fixed
|
||||
/// decimals printed as `279893.880000000005`. Small values keep their twelve decimals.
|
||||
#[test]
|
||||
fn calculated_numbers_print_at_most_fifteen_significant_digits() {
|
||||
assert_eq!(calc("1 * 279893.88"), "279893.88");
|
||||
assert_eq!(calc("1 * 291774.12"), "291774.12");
|
||||
assert_eq!(calc("279893.88 + 291774.12 + 304171.06 + 317107.44 + 273970.85"), "1466917.35");
|
||||
assert_eq!(calc("1000000 / 3"), "333333.333333333");
|
||||
assert_eq!(calc("1000 / 3"), "333.333333333333");
|
||||
assert_eq!(calc("1 / 3"), "0.333333333333");
|
||||
assert_eq!(calc("0.1 + 0.2"), "0.3");
|
||||
}
|
||||
|
||||
/// Assigning one long value to many fields at once stops at the run's byte budget and is
|
||||
/// charged per copy, and a reset list names each field once.
|
||||
#[test]
|
||||
|
||||
@@ -28,9 +28,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
If a use can't be
|
||||
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
|
||||
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image.
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A
|
||||
stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is.
|
||||
An image is replaced only if the result is smaller. Left alone by these colour and grey
|
||||
settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000,
|
||||
settings: CMYK and other colour spaces, image masks, colour-key masks, decode arrays, more than 8 bits, JPEG 2000,
|
||||
JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with
|
||||
its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one
|
||||
stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left
|
||||
|
||||
@@ -717,6 +717,11 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `/Mask` value is a stencil mask: an image that says `/ImageMask true`.
|
||||
fn is_stencil(doc: &Document, mask: &Object) -> bool {
|
||||
matches!(&*doc.resolve(mask), Object::Stream(s) if matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))))
|
||||
}
|
||||
|
||||
/// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution.
|
||||
/// Returns the new image and its new soft mask, if the result is smaller.
|
||||
fn process(
|
||||
@@ -728,8 +733,11 @@ fn process(
|
||||
budget: &mut ImageBudget,
|
||||
) -> Option<(Stream, Option<(ObjRef, Stream)>)> {
|
||||
let d = &s.dict;
|
||||
// A colour-key mask names sample values, which resampling and JPEG would change. A stencil mask
|
||||
// is an image of its own, with its own resolution (ISO 32000-2 §8.9.6.3): the picture can change
|
||||
// under it and its edges stay as they are.
|
||||
if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n))
|
||||
|| d.contains(b"Mask")
|
||||
|| d.get(b"Mask").is_some_and(|m| !is_stencil(doc, m))
|
||||
|| d.name(b"ImageMask").is_some()
|
||||
|| matches!(d.get(b"ImageMask"), Some(Object::Bool(true)))
|
||||
{
|
||||
|
||||
@@ -3788,3 +3788,102 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin
|
||||
assert_eq!(render(&saved), before, "local halftone {side}x{side}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle.
|
||||
fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef {
|
||||
let row = (w as usize).div_ceil(8);
|
||||
let mut bits = vec![0u8; row * h as usize];
|
||||
for y in (h as usize / 3)..(2 * h as usize / 3) {
|
||||
for x in (w as usize / 3)..(2 * w as usize / 3) {
|
||||
bits[y * row + x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"ImageMask".to_vec(), Object::Bool(true));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||||
doc.add(Object::Stream(Stream::flate(d, &bits)))
|
||||
}
|
||||
|
||||
/// A picture with a stencil mask is made smaller like any other: the mask has a resolution of its
|
||||
/// own, so it stays exactly as it was and the hole it cuts stays where it was.
|
||||
#[test]
|
||||
fn a_picture_with_a_stencil_mask_is_downsampled_under_its_mask() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mask = stencil(&mut doc, 1200, 1200);
|
||||
let img = image(&mut doc, 1200, 1200, 3, true, None);
|
||||
let mut s = stream(&doc, img);
|
||||
s.dict.set(b"Mask".to_vec(), Object::Ref(mask));
|
||||
doc.set(img, Object::Stream(s));
|
||||
let mask_before = stream(&doc, mask);
|
||||
// 1200 pixels over two inches: 600 ppi.
|
||||
page(&mut doc, &[("Im0", img)], "0 0 1 rg 0 0 612 792 re f q 144 0 0 144 100 100 cm /Im0 Do Q");
|
||||
let before = render(&doc);
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(report.images_resampled, 1, "{report:?}");
|
||||
let after = stream(&doc, img);
|
||||
assert_eq!(after.dict.int(b"Width"), Some(300), "150 ppi");
|
||||
assert_eq!(after.dict.reference(b"Mask"), Some(mask), "the mask stays on it");
|
||||
assert_eq!(stream(&doc, mask), mask_before, "and doesn't change");
|
||||
let rendered = render(&doc);
|
||||
assert_eq!(rendered.len(), before.len());
|
||||
let changed = before.iter().zip(&rendered).filter(|(a, b)| a.abs_diff(**b) > 96).count();
|
||||
assert!(changed * 200 < before.len(), "the masked picture looks the same: {changed} of {} samples far off", before.len());
|
||||
// The hole, the middle third of the picture on the 612 by 792 page, still shows what's behind it.
|
||||
// Its first pixel all round is left out: the coarser picture blends into that a little.
|
||||
for y in 597..643 {
|
||||
for x in 149..195 {
|
||||
let i = (y * 612 + x) * 4;
|
||||
assert_eq!(rendered[i..i + 4], before[i..i + 4], "hole pixel ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour-key mask names sample values: resampling or JPEG would make holes where there were none
|
||||
/// and fill those there were, so the picture stays as it is, however the array is written. A mask
|
||||
/// that can't be read as a stencil is no reason to change the picture either.
|
||||
#[test]
|
||||
fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() {
|
||||
type Mask = fn(&mut Document) -> Object;
|
||||
fn key() -> Object {
|
||||
Object::Array([250, 255, 250, 255, 250, 255].map(Object::Int).to_vec())
|
||||
}
|
||||
fn not_a_stencil(doc: &mut Document, change: fn(&mut Dict)) -> Object {
|
||||
let mask = stencil(doc, 1200, 1200);
|
||||
let mut s = stream(doc, mask);
|
||||
change(&mut s.dict);
|
||||
doc.set(mask, Object::Stream(s));
|
||||
Object::Ref(mask)
|
||||
}
|
||||
let masks: [(&str, Mask); 5] = [
|
||||
("direct array", |_| key()),
|
||||
("array by reference", |doc| Object::Ref(doc.add(key()))),
|
||||
("nothing there", |doc| Object::Ref(doc.add(Object::Null))),
|
||||
("an image that says it isn't a stencil", |doc| {
|
||||
not_a_stencil(doc, |d| {
|
||||
d.set(b"ImageMask".to_vec(), Object::Bool(false));
|
||||
})
|
||||
}),
|
||||
("an image that doesn't say it is one", |doc| {
|
||||
not_a_stencil(doc, |d| {
|
||||
d.remove(b"ImageMask");
|
||||
})
|
||||
}),
|
||||
];
|
||||
for (what, mask) in masks {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, true, None);
|
||||
let mut s = stream(&doc, img);
|
||||
let mask = mask(&mut doc);
|
||||
s.dict.set(b"Mask".to_vec(), mask);
|
||||
doc.set(img, Object::Stream(s));
|
||||
let before = stream(&doc, img);
|
||||
page(&mut doc, &[("Im0", img)], "q 144 0 0 144 100 100 cm /Im0 Do Q");
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(report.images_resampled + report.images_recompressed, 0, "{what}: {report:?}");
|
||||
assert_eq!(stream(&doc, img), before, "{what}");
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,9 @@ Headless `doc_print` defaults are unchanged.
|
||||
`spool` delegates discovery, driver options, preferences and submission to
|
||||
[`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print).
|
||||
PdfCraft retains page selection, imposition, annotations and print-permission checks.
|
||||
CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through
|
||||
CUPS jobs stream through stdin with `fit-to-page=false`; on macOS they also carry the job
|
||||
name and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`) as the system print dialog
|
||||
sends them, which some vendors' filters need (#5743). Windows renders sheets through
|
||||
`Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and
|
||||
orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex
|
||||
use the corresponding Windows binding edges. Random, exclusively created Windows job files
|
||||
|
||||
@@ -143,14 +143,62 @@ pub(crate) fn printer_options_via_within(
|
||||
options
|
||||
}
|
||||
|
||||
/// The keys the macOS print dialog names a job's title and owner under.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName";
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const JOB_OWNER_KEY: &str = "com.apple.print.JobInfo.PMJobOwner";
|
||||
|
||||
/// `text` as a job option's value: `lp_args` passes on only printable ASCII, up to 64 bytes,
|
||||
/// without `/ : = "`, and CUPS would take `'` and `\` as quoting and `{` as the start of a
|
||||
/// collection, so all of these become `_`. Text with nothing left of it, such as a title in
|
||||
/// another script, takes `fallback`: the job must still name something. So does a bare `true` or
|
||||
/// `false`, which CUPS would send as a boolean, not the name a filter reads.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn job_option_value(text: &str, fallback: &str) -> String {
|
||||
let kept = |c: char| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{');
|
||||
let value: String = text.chars().take(64).map(|c| if kept(c) { c } else { '_' }).collect();
|
||||
if !text.chars().any(kept) || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") {
|
||||
return fallback.to_string();
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// `job` naming its title and `owner` as a job from the macOS print dialog does. `lp` sends
|
||||
/// neither, and a driver's filter can depend on them: Kyocera's crashes without them, so nothing
|
||||
/// prints, while the same file sent with both does (#5743). A value the caller set stands: the
|
||||
/// last of a repeated option wins in CUPS, which compares their names without case.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub(crate) fn with_job_info(job: &Job, owner: Option<&str>) -> Job {
|
||||
let mut named = job.clone();
|
||||
for (key, value) in [(JOB_NAME_KEY, job.title.as_str()), (JOB_OWNER_KEY, owner.unwrap_or_default())] {
|
||||
if !job.options.iter().any(|(k, _)| k.eq_ignore_ascii_case(key)) {
|
||||
named.options.push((key.to_string(), job_option_value(value, "PdfCraft")));
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// The job as the system spooler takes it.
|
||||
fn spooled(job: &Job) -> Job {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
with_job_info(job, std::env::var("USER").ok().as_deref())
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
job.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
|
||||
craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the
|
||||
/// whole PDF at once and reports nothing).
|
||||
pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result<String, crate::PrintError> {
|
||||
craft_print::submit_with_progress(pdf, job, progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> {
|
||||
|
||||
@@ -305,6 +305,107 @@ fn spooler_arguments_and_printer_list() {
|
||||
assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer");
|
||||
}
|
||||
|
||||
/// The job name and owner, read from the `lp` arguments, since `lp_args` drops a value it can't
|
||||
/// pass on: the job must reach the spooler named, whatever its title holds (#5743).
|
||||
#[test]
|
||||
fn a_job_names_its_title_and_owner_to_the_spooler() {
|
||||
let args_for = |title: &str, owner: Option<&str>| {
|
||||
let job = Job { title: title.into(), ..Job::default() };
|
||||
lp_args(&spool::with_job_info(&job, owner))
|
||||
};
|
||||
let option = |args: &[String], key: &str| {
|
||||
let prefix = format!("com.apple.print.JobInfo.{key}=");
|
||||
args.iter().find_map(|a| a.strip_prefix(prefix.as_str()).map(str::to_string))
|
||||
};
|
||||
let args = args_for("memo.pdf", Some("sam"));
|
||||
assert_eq!(option(&args, "PMJobName").as_deref(), Some("memo.pdf"), "{args:?}");
|
||||
assert_eq!(option(&args, "PMJobOwner").as_deref(), Some("sam"), "{args:?}");
|
||||
// The title keeps its place as `-t`, in full.
|
||||
assert!(args_for("Q3 report: a/b=\"c\".pdf", None).windows(2).any(|w| w[0] == "-t" && w[1] == "Q3 report: a/b=\"c\".pdf"));
|
||||
|
||||
let named = args_for("Q3 report: a/b=\"c\".pdf", None);
|
||||
assert_eq!(option(&named, "PMJobName").as_deref(), Some("Q3_report__a_b__c_.pdf"), "what lp can't be given becomes _");
|
||||
assert_eq!(option(&named, "PMJobOwner").as_deref(), Some("PdfCraft"), "no owner known: the filter still gets one");
|
||||
|
||||
for nothing_left in ["", " ", "見積書", "/:=\""] {
|
||||
assert_eq!(option(&args_for(nothing_left, Some("")), "PMJobName").as_deref(), Some("PdfCraft"), "{nothing_left:?}");
|
||||
}
|
||||
assert_eq!(option(&args_for("見積書2.pdf", None), "PMJobName").as_deref(), Some("___2.pdf"), "a part of it is kept");
|
||||
let long = option(&args_for(&format!("a{}", "é".repeat(200)), None), "PMJobName").unwrap_or_default();
|
||||
assert_eq!(long.len(), 64, "cut to what lp_args takes, on a multi-byte title too: {long}");
|
||||
|
||||
// What CUPS would quote away or send as another type, as this Mac's libcups does.
|
||||
for (title, name) in [("it's.pdf", "it_s.pdf"), (r"a\b.pdf", "a_b.pdf"), ("{draft}.pdf", "_draft}.pdf"), ("a{b", "a_b")] {
|
||||
assert_eq!(option(&args_for(title, None), "PMJobName").as_deref(), Some(name), "{title:?}");
|
||||
}
|
||||
for boolean in ["true", "TRUE", "false", "False"] {
|
||||
assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobName").as_deref(), Some("PdfCraft"), "{boolean:?}");
|
||||
assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobOwner").as_deref(), Some("PdfCraft"), "{boolean:?}");
|
||||
}
|
||||
assert_eq!(option(&args_for("true.pdf", None), "PMJobName").as_deref(), Some("true.pdf"), "only the bare word is a boolean");
|
||||
}
|
||||
|
||||
/// A job's own values stand: CUPS keeps the last of a repeated option, ignoring the case of its
|
||||
/// name, so a default after them would replace what a `doc_print` caller set (#5743).
|
||||
#[test]
|
||||
fn a_job_keeps_the_title_and_owner_it_already_sets() {
|
||||
let values = |job: &Job, key: &str| -> Vec<String> {
|
||||
let found = spool::with_job_info(job, Some("sam")).options;
|
||||
found.into_iter().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v).collect()
|
||||
};
|
||||
let (name, owner) = ("com.apple.print.JobInfo.PMJobName", "com.apple.print.JobInfo.PMJobOwner");
|
||||
let job = Job { title: "memo.pdf".into(), options: vec![(name.to_string(), "Mine".to_string())], ..Job::default() };
|
||||
assert_eq!(values(&job, name), ["Mine"]);
|
||||
assert_eq!(values(&job, owner), ["sam"], "the one it left out still gets its default");
|
||||
|
||||
let job = Job { title: "memo.pdf".into(), options: vec![(owner.to_ascii_uppercase(), "Theirs".to_string())], ..Job::default() };
|
||||
assert_eq!(values(&job, owner), ["Theirs"]);
|
||||
assert_eq!(values(&job, name), ["memo.pdf"]);
|
||||
}
|
||||
|
||||
/// Both ways to submit reach `lp` with the job named. `lp` comes from `PATH`, which only a child
|
||||
/// process can change here, so the test runs itself again with a stand-in that records its
|
||||
/// arguments; no job reaches a real spooler (#5743).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_submitted_job_reaches_lp_named() {
|
||||
const TEST: &str = "tests::a_submitted_job_reaches_lp_named";
|
||||
if let Ok(way) = std::env::var("PDFCRAFT_SUBMIT_TO_STAND_IN") {
|
||||
let job = Job { title: "memo.pdf".into(), ..Job::default() };
|
||||
let sent = match way.as_str() {
|
||||
"submit" => spool::submit(b"%PDF-1.7", &job),
|
||||
_ => spool::submit_with_progress(b"%PDF-1.7", &job, &mut |_, _| {}),
|
||||
};
|
||||
assert!(sent.is_ok(), "{sent:?}");
|
||||
return;
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-lp-stand-in-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let lp = dir.join("lp");
|
||||
let recorded = dir.join("args");
|
||||
let script =
|
||||
format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n/bin/cat > /dev/null\necho 'request id is stand-in-1 (1 file(s))'\n", recorded.display());
|
||||
std::fs::write(&lp, script).expect("stand-in");
|
||||
std::fs::set_permissions(&lp, std::fs::Permissions::from_mode(0o700)).expect("executable");
|
||||
for way in ["submit", "submit_with_progress"] {
|
||||
let _ = std::fs::remove_file(&recorded);
|
||||
let this = std::env::current_exe().expect("this test binary");
|
||||
let child = std::process::Command::new(this)
|
||||
.args(["--exact", TEST, "--nocapture"])
|
||||
.env("PDFCRAFT_SUBMIT_TO_STAND_IN", way)
|
||||
.env("PATH", &dir)
|
||||
.env("USER", "sam")
|
||||
.output()
|
||||
.expect("run the test again");
|
||||
assert!(child.status.success(), "{way}: {}", String::from_utf8_lossy(&child.stdout));
|
||||
let args = std::fs::read_to_string(&recorded).expect("lp was run");
|
||||
assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobName=memo.pdf"), "{way}: {args}");
|
||||
assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobOwner=sam"), "{way}: {args}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lpstat_output_is_untranslated() {
|
||||
// A localized lpstat (here Polish) is unreadable to parse_lpstat...
|
||||
|
||||
@@ -861,6 +861,11 @@ fn xobject(
|
||||
}
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
if new_data.is_some() {
|
||||
// As for page content streams: the source text of an added item (Edit ▸ Add
|
||||
// content) would keep what the rewrite removed.
|
||||
dict.remove(b"PCAdded");
|
||||
}
|
||||
let mut cleaned_res = res.clone();
|
||||
// Install rewritten nested forms before finding live property names: a new form may
|
||||
// still inherit its caller's resources, and its new Do name must resolve here.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Marked content (MCIDs) that applying redactions changed is found by comparing each MCID's
|
||||
//! operators before and after. Elements owning such content lose their alternate text, actual
|
||||
//! text and expansion (which may spell out the removed words);
|
||||
//! text, title and expansion (which may spell out the removed words), as do their ancestors;
|
||||
//! references to marked content that is now empty are dropped from the element.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -281,7 +281,7 @@ pub(crate) fn clean_within(
|
||||
}
|
||||
doc.update_dict(r, |e| {
|
||||
if sensitive {
|
||||
for key in [&b"Alt"[..], b"ActualText", b"E"] {
|
||||
for key in DESCRIPTIONS {
|
||||
e.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -321,8 +321,10 @@ impl Above {
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of an element that describe its content.
|
||||
const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"];
|
||||
/// The keys of an element that describe its content, and so may repeat what was removed: one
|
||||
/// policy for the element that owned it, the elements above it and elements written in place.
|
||||
/// The title (`/T`) is included, as producers often fill it with the element's text.
|
||||
const DESCRIPTIONS: [&[u8]; 4] = [b"Alt", b"ActualText", b"T", b"E"];
|
||||
|
||||
/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with.
|
||||
struct Parents(HashMap<ObjRef, Vec<Above>>);
|
||||
@@ -539,7 +541,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
|
||||
// An element that lost an annotation keeps no text describing it, even with other
|
||||
// kids left: a link's alternate text often repeats the URL, an annotation's its
|
||||
// comment. Elements above it may describe it too.
|
||||
for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
|
||||
for key in DESCRIPTIONS {
|
||||
e.remove(key);
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -3871,3 +3871,29 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
|
||||
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() {
|
||||
// A form drawing invisible text whose dictionary repeats it in /PCAdded.
|
||||
let form = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (LEAK_FORM_SOURCE) >>",
|
||||
b"BT /F1 10 Tf 3 Tr 10 100 Td (HIDDEN) Tj ET BT /F1 10 Tf 10 200 Td (Shown) Tj ET",
|
||||
);
|
||||
let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]);
|
||||
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(16).any(|w| w == b"LEAK_FORM_SOURCE"), "the form's added-content source text survived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redacted_form_xobject_drops_the_source_text_of_added_content() {
|
||||
let form = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (AB1234CD) >>",
|
||||
b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET",
|
||||
);
|
||||
let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]);
|
||||
mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file");
|
||||
}
|
||||
@@ -62,3 +62,61 @@ fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annot
|
||||
let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap();
|
||||
assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text");
|
||||
}
|
||||
|
||||
/// [`sectioned`], with titles that repeat the secret on the paragraph that owns it, on its section
|
||||
/// and on the document element above both.
|
||||
fn titled() -> Document {
|
||||
let mut doc = sectioned("11 0 R");
|
||||
let title = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||||
for (n, t) in [(8, "LEAK_DOC_TITLE"), (9, "LEAK_OWN_TITLE"), (11, "LEAK_SECT_TITLE"), (10, "Public title")] {
|
||||
doc.update_dict(ObjRef::new(n, 0), |d| d.set(b"T".to_vec(), title(t))).unwrap();
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_repeating_redacted_content_go_from_the_owner_and_its_ancestors() {
|
||||
let mut doc = titled();
|
||||
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the redacted word survived");
|
||||
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
|
||||
assert!(public.contains(b"T"), "an element beside it keeps its own title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_repeating_hidden_text_go_from_the_owner_and_its_ancestors() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let mut doc = titled();
|
||||
let hidden =
|
||||
b"/P <</MCID 0>> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <</MCID 1>> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC";
|
||||
doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), hidden.to_vec())));
|
||||
remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap();
|
||||
assert!(!full_save_holds(&doc, b"SECRET"), "the hidden text survived");
|
||||
assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the hidden text survived");
|
||||
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
|
||||
assert!(public.contains(b"T"), "an element beside it keeps its own title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ancestor_title_describing_a_removed_annotation_goes() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let mut doc = pdf(vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>"
|
||||
.to_vec(),
|
||||
stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"),
|
||||
FONT.replace("95 0 R", "12 0 R").into_bytes(),
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(),
|
||||
b"null".to_vec(),
|
||||
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(),
|
||||
// Only a title above the link element repeats the address.
|
||||
b"<< /Type /StructElem /S /P /P 9 0 R /T (LEAK_PARENT_TITLE) /K 11 0 R >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
|
||||
widths(),
|
||||
]);
|
||||
remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap();
|
||||
assert!(!full_save_holds(&doc, b"LEAK_"), "an ancestor's title describing the removed link survived");
|
||||
}
|
||||
@@ -99,6 +99,7 @@ impl PdfCraftApp {
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_theme_enabled: false,
|
||||
scroll_speed: scroll_speed::ScrollSpeed::default(),
|
||||
invert_middle_drag: false,
|
||||
animation_effects: motion::AnimationEffects::default(),
|
||||
motion: motion::Motion::default(),
|
||||
language: i18n::AUTO.to_string(),
|
||||
|
||||
@@ -50,6 +50,9 @@ impl PdfCraftApp {
|
||||
("scroll_speed", _) => {
|
||||
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
|
||||
}
|
||||
("invert_middle_drag", _) => {
|
||||
self.invert_middle_drag = value.parse::<bool>().map_err(|_| "invert_middle_drag must be true or false")?;
|
||||
}
|
||||
("animation_effects", _) => {
|
||||
self.animation_effects = motion::AnimationEffects::parse(value).ok_or("animation_effects must be system, on, or off")?;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ impl PdfCraftApp {
|
||||
"pinned_folders": self.pinned.folders,
|
||||
"theme": self.theme_preference,
|
||||
"scroll_speed": self.scroll_speed,
|
||||
"invert_middle_drag": self.invert_middle_drag,
|
||||
"animation_effects": self.animation_effects,
|
||||
"default_mode": self.default_mode,
|
||||
"default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous),
|
||||
@@ -237,6 +238,9 @@ impl PdfCraftApp {
|
||||
if let Ok(speed) = serde_json::from_value::<scroll_speed::ScrollSpeed>(v["scroll_speed"].clone()) {
|
||||
self.scroll_speed = speed;
|
||||
}
|
||||
if let Some(invert) = v["invert_middle_drag"].as_bool() {
|
||||
self.invert_middle_drag = invert;
|
||||
}
|
||||
if let Ok(effects) = serde_json::from_value::<motion::AnimationEffects>(v["animation_effects"].clone()) {
|
||||
self.animation_effects = effects;
|
||||
}
|
||||
|
||||
@@ -3338,9 +3338,9 @@ fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rec
|
||||
/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the
|
||||
/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point
|
||||
/// beyond what is shown can be reached without letting go of the tool.
|
||||
fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) {
|
||||
fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool, invert: bool) -> (Vec2, Vec2) {
|
||||
let auto_delta = if unobstructed {
|
||||
view.auto_scroll.update(ui, avail, false)
|
||||
view.auto_scroll.update(ui, avail, false, invert)
|
||||
} else {
|
||||
view.auto_scroll.cancel();
|
||||
Vec2::ZERO
|
||||
@@ -3517,11 +3517,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let modal = app.modal_open();
|
||||
let unobstructed = !modal && !app.palette_open;
|
||||
let organize_zoom = app.organize_zoom;
|
||||
let invert_middle = app.invert_middle_drag;
|
||||
let view = &mut app.views[index];
|
||||
if view.organize {
|
||||
// One remembered grid size for every document, kept in the settings.
|
||||
view.set_grid_zoom(organize_zoom);
|
||||
organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t);
|
||||
organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t);
|
||||
app.organize_zoom = view.grid_zoom();
|
||||
return;
|
||||
}
|
||||
@@ -3541,7 +3542,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select);
|
||||
pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled);
|
||||
view.viewport_screen = avail;
|
||||
let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed);
|
||||
let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed, invert_middle);
|
||||
|
||||
let content_w = view.content_width(info, avail.width());
|
||||
let rects = view.layout(info, content_w, ui.ctx().pixels_per_point());
|
||||
|
||||
@@ -46,15 +46,18 @@ impl AutoScroll {
|
||||
}
|
||||
|
||||
/// Run before the document's widgets. Starting is restricted to the unobstructed viewport;
|
||||
/// once started, moving outside that viewport still controls the speed.
|
||||
pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 {
|
||||
self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux"))
|
||||
/// once started, moving outside that viewport still controls the speed. `invert` is
|
||||
/// Preferences ▸ Invert middle-button drag: the returned delta scrolls the other way.
|
||||
pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, invert: bool) -> Vec2 {
|
||||
self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux"), invert)
|
||||
}
|
||||
|
||||
fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool) -> Vec2 {
|
||||
if !supported {
|
||||
return self.update_drag(ui, viewport);
|
||||
}
|
||||
fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool, invert: bool) -> Vec2 {
|
||||
let delta = if supported { self.update_latched(ui, viewport, organize) } else { self.update_drag(ui, viewport) };
|
||||
oriented(delta, invert)
|
||||
}
|
||||
|
||||
fn update_latched(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 {
|
||||
let ctx = ui.ctx();
|
||||
let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| {
|
||||
let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2]
|
||||
@@ -237,6 +240,11 @@ pub(crate) fn edge_delta(y: f32, viewport: egui::Rect, dt: f32) -> f32 {
|
||||
-distance.clamp(-MAX_DISPLACEMENT, MAX_DISPLACEMENT) * EDGE_SPEED * dt.clamp(0.0, 0.05)
|
||||
}
|
||||
|
||||
/// The middle button's delta, reversed when the user inverted it.
|
||||
fn oriented(delta: Vec2, invert: bool) -> Vec2 {
|
||||
if invert { -delta } else { delta }
|
||||
}
|
||||
|
||||
fn scroll_delta(displacement: f32, dt: f32) -> f32 {
|
||||
if !displacement.is_finite() || !dt.is_finite() {
|
||||
return 0.0;
|
||||
@@ -261,10 +269,15 @@ mod tests {
|
||||
|
||||
/// Run one frame of the non-Linux path; `check` sees the pan delta inside the frame.
|
||||
fn drag_frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec<Event>, focused: bool, check: impl Fn(&AutoScroll, Vec2)) {
|
||||
frame(ctx, scroll, events, focused, (false, false), check);
|
||||
}
|
||||
|
||||
/// Run one frame; `mode` is (latched auto-scroll supported, inverted).
|
||||
fn frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec<Event>, focused: bool, mode: (bool, bool), check: impl Fn(&AutoScroll, Vec2)) {
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused, ..Default::default() },
|
||||
|ui| {
|
||||
let delta = scroll.update_for_platform(ui, ui.max_rect(), false, false);
|
||||
let delta = scroll.update_for_platform(ui, ui.max_rect(), false, mode.0, mode.1);
|
||||
check(scroll, delta);
|
||||
},
|
||||
);
|
||||
@@ -272,6 +285,40 @@ mod tests {
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The pan delta after pressing at `p` and moving the pointer by `by`.
|
||||
fn pan_by(by: Vec2, invert: bool) -> Vec2 {
|
||||
let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0));
|
||||
frame(&ctx, &mut scroll, vec![], true, (false, invert), |_, _| {});
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (false, invert), |_, _| {});
|
||||
let moved = std::cell::Cell::new(Vec2::ZERO);
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + by)], true, (false, invert), |_, d| moved.set(d));
|
||||
moved.get()
|
||||
}
|
||||
|
||||
/// The auto-scroll delta in the frame after latching at `p`, with the pointer `dy` below it.
|
||||
fn latched_by(dy: f32, invert: bool) -> Vec2 {
|
||||
let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0));
|
||||
frame(&ctx, &mut scroll, vec![], true, (true, invert), |_, _| {});
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true), middle(p, false)], true, (true, invert), |_, _| {});
|
||||
let moved = std::cell::Cell::new(Vec2::ZERO);
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, dy))], true, (true, invert), |s, d| {
|
||||
assert!(s.active(), "the click latched auto-scroll");
|
||||
moved.set(d);
|
||||
});
|
||||
moved.get()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverting_the_middle_button_reverses_the_pan_and_the_auto_scroll() {
|
||||
let by = vec2(-30.0, 40.0);
|
||||
assert_eq!(pan_by(by, false), by, "by default the page follows the pointer");
|
||||
assert_eq!(pan_by(by, true), -by, "inverted, the view moves the way the pointer goes");
|
||||
let (normal, inverted) = (latched_by(60.0, false), latched_by(60.0, true));
|
||||
assert!(normal.y < 0.0, "by default, below the anchor scrolls down: {normal:?}");
|
||||
assert_eq!(inverted, -normal, "inverted, the same distance scrolls the other way");
|
||||
assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() {
|
||||
let ctx = Context::default();
|
||||
|
||||
@@ -279,7 +279,11 @@ impl DocView {
|
||||
let Some(forward) = self.wheel.feed(unit, dy, phase, now, can_turn) else { return false };
|
||||
let before = self.current;
|
||||
self.step_page(forward);
|
||||
self.current != before
|
||||
if self.current == before {
|
||||
self.wheel.unturn();
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// One wheel event in a non-continuous view whose page is taller than the window, where the
|
||||
@@ -299,6 +303,7 @@ impl DocView {
|
||||
let before = self.current;
|
||||
self.step_page(forward);
|
||||
if self.current == before {
|
||||
self.wheel.unturn();
|
||||
return false;
|
||||
}
|
||||
if !forward {
|
||||
|
||||
@@ -272,6 +272,7 @@ pub(super) fn organize_grid(
|
||||
doc: &pdfcraft_engine::Document,
|
||||
unobstructed: bool,
|
||||
show_page_edge_shadows: bool,
|
||||
invert_middle: bool,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
) {
|
||||
@@ -288,7 +289,7 @@ pub(super) fn organize_grid(
|
||||
let viewport = ui.available_rect_before_wrap();
|
||||
view.viewport_screen = viewport;
|
||||
let auto_delta = if unobstructed {
|
||||
view.auto_scroll.update(ui, viewport, true)
|
||||
view.auto_scroll.update(ui, viewport, true, invert_middle)
|
||||
} else {
|
||||
view.auto_scroll.cancel();
|
||||
Vec2::ZERO
|
||||
|
||||
@@ -73,6 +73,11 @@ impl WheelPager {
|
||||
self.accum = 0.0;
|
||||
}
|
||||
|
||||
/// Reset any turned state when a turn did not happen (e.g. at document boundary).
|
||||
pub(crate) fn unturn(&mut self) {
|
||||
self.turned = None;
|
||||
}
|
||||
|
||||
/// Whether the current gesture already turned a page: the rest of it must not pan the page
|
||||
/// just shown away from its top (or bottom).
|
||||
pub(crate) fn gesture_turned(&self) -> bool {
|
||||
@@ -144,6 +149,9 @@ impl WheelPager {
|
||||
};
|
||||
if notch {
|
||||
if !can_turn {
|
||||
// A notch is a gesture of its own, so one that only pans ends the gesture a
|
||||
// previous notch turned; otherwise `gesture_turned` keeps swallowing the wheel (#5713).
|
||||
self.turned = None;
|
||||
return None;
|
||||
}
|
||||
// Every notch turns a page; a smooth tail of the same gesture won't add another.
|
||||
|
||||
@@ -759,7 +759,11 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens,
|
||||
});
|
||||
ctx.label_secondary(ui, tl!("For page sizes and margins."), t);
|
||||
ui.add_space(6.0);
|
||||
scrolling(ui, app, t, ctx);
|
||||
}
|
||||
|
||||
/// The scroll speed and the middle button's direction, in Card 3.
|
||||
fn scrolling(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) {
|
||||
ui.horizontal(|ui| {
|
||||
ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t);
|
||||
ctx.pill_choice(
|
||||
@@ -773,6 +777,9 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens,
|
||||
],
|
||||
);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ctx.checkbox(ui, &mut app.invert_middle_drag, tl!("Invert middle-button drag"), t);
|
||||
ctx.label_secondary(ui, tl!("Panning or auto-scrolling with the middle (wheel) button moves the page the other way."), t);
|
||||
}
|
||||
|
||||
/// Content for Card 4: Identity
|
||||
|
||||
@@ -2882,3 +2882,21 @@ certificate Version Version
|
||||
No image on clipboard to paste Aucune image à coller dans le presse-papiers
|
||||
Pinned {tool} to Home {tool} épinglé à l’Accueil
|
||||
Unpinned {tool} from Home {tool} désépinglé de l’Accueil
|
||||
|
||||
# The strings the report listed as missing for fr.
|
||||
Crop image Recadrer l'image
|
||||
Mouse wheel scroll speed Vitesse de défilement à la molette
|
||||
Remove {standard} identification Supprimer l'identification {standard}
|
||||
The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Le certificat de {} comporte des extensions critiques que PdfCraft ne reconnaît pas ; il n'est donc pas utilisé pour attester {}.
|
||||
The password must have at least 8 characters. Le mot de passe doit comporter au moins 8 caractères.
|
||||
The signature's signed attributes have no content type, which CMS requires; it was read anyway. Les attributs signés de la signature n'ont pas de type de contenu, exigé par CMS ; elle a néanmoins été lue.
|
||||
The signature's signed attributes have no message digest, so the signature does not cover the document. Les attributs signés de la signature n'ont pas de condensat de message ; la signature ne couvre donc pas le document.
|
||||
The signature's signed attributes name a content type other than data, so it is not a signature of the document. Les attributs signés de la signature indiquent un type de contenu autre que des données ; ce n'est donc pas une signature du document.
|
||||
The timestamp authority's certificate is not issued for time stamping. Le certificat de l'autorité d'horodatage n'est pas émis pour l'horodatage.
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ce fichier a changé sur le disque. Rechargez-le pour voir la nouvelle version ; vos modifications non enregistrées seront perdues.
|
||||
This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Cette signature utilise une clé RSA de moins de 2048 bits, ce qui n'est plus considéré comme suffisamment robuste pour des signatures. Elle se valide toujours, mais il ne faut pas s'y fier.
|
||||
a table has 1 to {max} columns un tableau a de 1 à {max} colonnes
|
||||
a table has 1 to {max} rows un tableau a de 1 à {max} lignes
|
||||
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 l'aire, l'espacement et les largeurs de colonnes doivent être des nombres, l'espacement 0 ou plus et chaque largeur supérieure à 0
|
||||
the first row number can be at most {max} le numéro de la première ligne peut être au maximum {max}
|
||||
theme Appearance Apparence
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -4,10 +4,6 @@
|
||||
#
|
||||
# Clean-room: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menü
|
||||
File Dosya
|
||||
Edit Düzen
|
||||
@@ -2613,3 +2609,295 @@ animation effects Off Kapalı
|
||||
No image on clipboard to paste Panoda yapıştırılacak görüntü yok
|
||||
Pinned {tool} to Home {tool} Ana Sayfaya sabitlendi
|
||||
Unpinned {tool} from Home {tool} Ana Sayfadan kaldırıldı
|
||||
|
||||
# Strings the translation gaps report listed on 2026-11-03 (AI-assisted; a native Turkish speaker reviewed the judgement calls: "of", hotbar, navigation rail, certificate terms, weekday heads).
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Bu değeri düşürmek en eski dosyaları unutturur. 0'da liste olmaz.
|
||||
Recent files to keep Saklanacak son dosya sayısı
|
||||
on page {label} {label}. sayfada
|
||||
1 line 1 satır
|
||||
A font isn't embedded Bir yazı tipi gömülü değil
|
||||
A {} action isn't allowed {} eylemine izin verilmiyor
|
||||
A {} annotation has additional actions Bir {} açıklamasının ek eylemleri var
|
||||
A {} annotation has no appearance stream Bir {} açıklamasının görünüm akışı yok
|
||||
A {} annotation isn't set to print, or is hidden Bir {} açıklaması yazdırılacak şekilde ayarlanmamış ya da gizli
|
||||
Accessibility Report Erişilebilirlik Raporu
|
||||
Add tools to hotbar Kısayol çubuğuna araç ekle
|
||||
Always Her zaman
|
||||
Automatically Otomatik
|
||||
CRL Signing CRL İmzalama
|
||||
Certificate Signing Sertifika İmzalama
|
||||
Certificate authority Sertifika yetkilisi
|
||||
Change Author Yazarı Değiştir
|
||||
Change Keywords Anahtar Kelimeleri Değiştir
|
||||
Change Subject Konuyu Değiştir
|
||||
Change Title Başlığı Değiştir
|
||||
Choose a digital ID. Bir dijital kimlik seçin.
|
||||
Choose a file… Dosya seç…
|
||||
Choose a folder… Klasör seç…
|
||||
Choose… Seç…
|
||||
Close menu Menüyü kapat
|
||||
Color Renk
|
||||
Comment Yorum
|
||||
Comment and review Yorumla ve incele
|
||||
Copied to clipboard Panoya kopyalandı
|
||||
Copy to clipboard Panoya kopyala
|
||||
Create folder Klasör oluştur
|
||||
Create links Bağlantı oluştur
|
||||
Creator Oluşturan
|
||||
Crop image Resmi kırp
|
||||
Custom scale Özel ölçek
|
||||
Cut and stack needs Two-sided: Off. Print single-sided sheets. Kes ve istifle için Çift taraflı: Kapalı olmalı. Tek taraflı sayfalar yazdırın.
|
||||
Data Encipherment Veri Şifreleme
|
||||
Decipher Only Yalnızca Şifre Çözme
|
||||
Detailed Report Ayrıntılı Rapor
|
||||
DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile DeviceCMYK kullanılıyor ancak PDF/A çıktı amacı bir CMYK profili değil
|
||||
DeviceRGB is used, but the PDF/A output intent isn't an RGB profile DeviceRGB kullanılıyor ancak PDF/A çıktı amacı bir RGB profili değil
|
||||
Digital Signature Dijital İmza
|
||||
Disabled Devre dışı
|
||||
Dock navigation rail on Left Gezinme çubuğunu sola yerleştir
|
||||
Dock navigation rail on Right Gezinme çubuğunu sağa yerleştir
|
||||
Document properties (⌘D) Belge özellikleri (⌘D)
|
||||
Drag hotbar Kısayol çubuğunu sürükle
|
||||
Duplicate Çoğalt
|
||||
Enabled Etkin
|
||||
Encipher Only Yalnızca Şifreleme
|
||||
End entity Son varlık
|
||||
English İngilizce
|
||||
Enter a URL Bir URL girin
|
||||
Enter a name. Bir ad girin.
|
||||
Enter a page number Bir sayfa numarası girin
|
||||
Enter the field names Alan adlarını girin
|
||||
Error Hata
|
||||
Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark. Bir düzey için seçilen stille yazılmış her satır, yeni bir ilk yer işaretinin altında o düzeyin yer işareti olur.
|
||||
Extend the docked toolbar along the entire side. Choose a side above to enable. Yerleştirilmiş araç çubuğunu kenar boyunca uzat. Etkinleştirmek için yukarıdan bir kenar seçin.
|
||||
Field "{}" on page {p} has no description (tooltip) {p}. sayfadaki "{}" alanının açıklaması (araç ipucu) yok
|
||||
Filename Dosya adı
|
||||
Flatten Fill & Sign Doldur ve İmzala'yı düzleştir
|
||||
Float (drag anywhere) Serbest (istediğin yere sürükle)
|
||||
Folder Klasör
|
||||
Font (unnamed) on page {p} doesn't map its characters to Unicode {p}. sayfadaki (adsız) yazı tipi karakterlerini Unicode'a eşlemiyor
|
||||
Font {} on page {p} doesn't map its characters to Unicode {p}. sayfadaki {} yazı tipi karakterlerini Unicode'a eşlemiyor
|
||||
Form field on page {p} isn't tagged {p}. sayfadaki form alanı etiketli değil
|
||||
Freehand-Highlight Serbest Vurgulama
|
||||
Full-side strip Tam kenar şeridi
|
||||
General & Interface Genel ve Arayüz
|
||||
Hand El
|
||||
Help… Yardım…
|
||||
Hotbar settings Kısayol çubuğu ayarları
|
||||
Hotbar settings / add tools Kısayol çubuğu ayarları / araç ekle
|
||||
Hotbar tools Kısayol çubuğu araçları
|
||||
H{n} follows an H{d}; headings should not skip levels H{n}, bir H{d} ardından geliyor; başlıklar düzey atlamamalı
|
||||
Import İçe aktar
|
||||
Information Bilgi
|
||||
Key Agreement Anahtar Anlaşması
|
||||
Key Encipherment Anahtar Şifreleme
|
||||
Level {n} Düzey {n}
|
||||
Loading… Yükleniyor…
|
||||
Mouse wheel scroll speed Fare tekerleği kaydırma hızı
|
||||
Move Taşı
|
||||
Navigation rail side Gezinme çubuğunun kenarı
|
||||
Never Hiçbir zaman
|
||||
New bookmark (⌘B) Yeni yer işareti (⌘B)
|
||||
New bookmarks from text styles Metin stillerinden yeni yer işaretleri
|
||||
New bookmarks from text styles… Metin stillerinden yeni yer işaretleri…
|
||||
Next Sonraki
|
||||
Next (⌘G) Sonraki (⌘G)
|
||||
No document language is set Belge dili ayarlanmamış
|
||||
No page has text: the document appears to be images of pages Hiçbir sayfada metin yok: belge sayfa resimlerinden oluşuyor gibi görünüyor
|
||||
No results Sonuç yok
|
||||
Non-Repudiation İnkâr Edilemezlik
|
||||
On Açık
|
||||
Open a PDF (⌘O) PDF aç (⌘O)
|
||||
Open folder Klasörü aç
|
||||
Organize Düzenle
|
||||
Page Properties Sayfa Özellikleri
|
||||
Page properties… Sayfa özellikleri…
|
||||
Page {n} Sayfa {n}
|
||||
Page {n} of {total} Sayfa {n} / {total}
|
||||
Page {p} doesn't tab in structure order {p}. sayfanın sekme sırası yapı sırasını izlemiyor
|
||||
Page {p}: {n} untagged content item Sayfa {p}: {n} etiketsiz içerik öğesi
|
||||
Page {p}: {n} untagged content items Sayfa {p}: {n} etiketsiz içerik öğesi
|
||||
Page {} has additional actions {}. sayfanın ek eylemleri var
|
||||
Panel layout could not be restored: {error} Panel düzeni geri yüklenemedi: {error}
|
||||
Panel move was not applied: {error} Panel taşıma uygulanmadı: {error}
|
||||
PdfCraft can't check this signature yet: {}. PdfCraft bu imzayı henüz denetleyemiyor: {}.
|
||||
Previous Önceki
|
||||
Previous (⇧⌘G) Önceki (⇧⌘G)
|
||||
Print (⌘P) Yazdır (⌘P)
|
||||
Producer Üretici
|
||||
Refresh Yenile
|
||||
Remove from hotbar Kısayol çubuğundan kaldır
|
||||
Remove {standard} identification {standard} tanımlamasını kaldır
|
||||
Rendering… İşleniyor…
|
||||
Report created Rapor oluşturuldu
|
||||
Reset side panels Yan panelleri sıfırla
|
||||
Rev. {rev}: Certified by an unknown signer Rev. {rev}: Bilinmeyen bir imzalayan tarafından onaylandı
|
||||
Rev. {rev}: Certified by {name} Rev. {rev}: {name} tarafından onaylandı
|
||||
Rev. {rev}: Signed by an unknown signer Rev. {rev}: Bilinmeyen bir imzalayan tarafından imzalandı
|
||||
Rev. {rev}: Signed by {name} Rev. {rev}: {name} tarafından imzalandı
|
||||
Rotate view clockwise (⇧⌘+) Görünümü saat yönünde döndür (⇧⌘+)
|
||||
Rule Name Kural Adı
|
||||
Save as {level} {level} olarak kaydet
|
||||
Save changes? Değişiklikler kaydedilsin mi?
|
||||
Save file Dosyayı kaydet
|
||||
Search all tools… Tüm araçlarda ara…
|
||||
Search results Arama sonuçları
|
||||
Search settings… Ayarlarda ara…
|
||||
Select Seç
|
||||
Select all Tümünü seç
|
||||
Show or hide the Tools panel Araçlar panelini göster veya gizle
|
||||
Show or hide the side panel Yan paneli göster veya gizle
|
||||
Signature is invalid İmza geçersiz
|
||||
Signature is valid İmza geçerli
|
||||
Signature validity is unknown İmzanın geçerliliği bilinmiyor
|
||||
Signing time is from the clock on the signer's computer. İmzalama zamanı, imzalayanın bilgisayarındaki saatten alınmıştır.
|
||||
Stick to a side: Bir kenara yapıştır:
|
||||
Still rendering — this page is unusually complex… Hâlâ işleniyor — bu sayfa alışılmadık derecede karmaşık…
|
||||
Success Başarılı
|
||||
System Sistem
|
||||
Tab Order Sekme Sırası
|
||||
Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Metin tanıma kurulu değil: modelleri eksik (`cargo xtask models` çalıştırın ya da PDFCRAFT_MODELS ayarlayın).
|
||||
The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. ECDSA imzası kurallı DER biçiminde değil (sondaki baytlar, dolgu ya da eksik işaret baytı); yine de okundu.
|
||||
The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. ECDSA imzası bir DER dizisi yerine ham r ve s değerlerinden oluşuyor; yine de okundu.
|
||||
The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. RSA imza değeri, anahtarın modülüyle aynı uzunlukta değil (baştaki sıfır baytlar atılmış ya da eklenmiş); yine de okundu.
|
||||
The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA imzasının DigestInfo alanında NULL parametresi yok (eski imzalayıcılar böyle yazar); yine de okundu.
|
||||
The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. {} sertifikası altında bu kadar çok CA sertifikasına izin vermiyor (yol uzunluğu), bu yüzden {} için güvence olarak kullanılmıyor.
|
||||
The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. {} sertifikasında PdfCraft'ın tanımadığı kritik uzantılar var, bu yüzden {} için güvence olarak kullanılmıyor.
|
||||
The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. {} sertifikası, sertifika verebilen bir CA sertifikası değil, bu yüzden {} için güvence olarak kullanılmıyor.
|
||||
The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. {} sertifikası imzalama sırasında geçerli değildi, bu yüzden {} için güvence olarak kullanılmıyor.
|
||||
The checker found problems which may prevent the document from being fully accessible. Denetleyici, belgenin tam olarak erişilebilir olmasını engelleyebilecek sorunlar buldu.
|
||||
The document has been altered or corrupted since the signature was applied. Belge, imza uygulandıktan sonra değiştirilmiş ya da bozulmuş.
|
||||
The document has been altered or corrupted since the timestamp was applied. Belge, zaman damgası uygulandıktan sonra değiştirilmiş ya da bozulmuş.
|
||||
The document has been altered since this signature was applied in ways it does not permit ({}). Belge, bu imza uygulandıktan sonra imzanın izin vermediği biçimlerde değiştirilmiş ({}).
|
||||
The document has been altered since this timestamp was applied in ways it does not permit ({}). Belge, bu zaman damgası uygulandıktan sonra damganın izin vermediği biçimlerde değiştirilmiş ({}).
|
||||
The document has been modified since this signature was applied, but the changes are permitted ({}). Belge, bu imza uygulandıktan sonra değiştirilmiş ancak değişikliklere izin veriliyor ({}).
|
||||
The document has been modified since this timestamp was applied, but the changes are permitted ({}). Belge, bu zaman damgası uygulandıktan sonra değiştirilmiş ancak değişikliklere izin veriliyor ({}).
|
||||
The document has no tags (structure tree) Belgede etiket (yapı ağacı) yok
|
||||
The document has no title Belgenin başlığı yok
|
||||
The document isn't marked as tagged (MarkInfo Marked) Belge etiketli olarak işaretlenmemiş (MarkInfo Marked)
|
||||
The document isn't tagged, so none of its content is Belge etiketli değil, dolayısıyla içeriğinin hiçbir kısmı etiketli değil
|
||||
The document's embedded revocation information shows that the signer's certificate has not been revoked. Belgeye gömülü iptal bilgileri, imzalayanın sertifikasının iptal edilmediğini gösteriyor.
|
||||
The embedded timestamp token is valid and its authority is trusted; trusted time is {}. Gömülü zaman damgası belirteci geçerli ve yetkilisine güveniliyor; güvenilir zaman {}.
|
||||
The font {} isn't embedded {} yazı tipi gömülü değil
|
||||
The metadata doesn't identify the file as {} Meta veriler dosyayı {} olarak tanımlamıyor
|
||||
The password is incorrect. Parola yanlış.
|
||||
The password must have at least 6 characters. Parola en az 6 karakter olmalıdır.
|
||||
The password must have at least 8 characters. Parola en az 8 karakter olmalıdır.
|
||||
The passwords do not match. Parolalar eşleşmiyor.
|
||||
The security settings don't allow screen readers to read the text Güvenlik ayarları ekran okuyucuların metni okumasına izin vermiyor
|
||||
The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. İmza algoritması ile SignerInfo'nun özet algoritması farklı karma işlevleri belirtiyor; imza SignerInfo'dakiyle oluşturulmuş.
|
||||
The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. İmza doğrulanmamış bir zaman damgası taşıyor ({}): zaman damgası yetkilisi güvenilen sertifikalar listenizde değil, bu yüzden onun yerine imzalayanın bildirdiği imzalama zamanı kullanılıyor.
|
||||
The signature contents are not hexadecimal. İmza içeriği onaltılık değil.
|
||||
The signature could not be checked ({}). İmza denetlenemedi ({}).
|
||||
The signature could not be read ({}). İmza okunamadı ({}).
|
||||
The signature could not be verified ({}). İmza doğrulanamadı ({}).
|
||||
The signature has no valid byte range. İmzanın geçerli bir bayt aralığı yok.
|
||||
The signature includes an embedded timestamp. İmza gömülü bir zaman damgası içeriyor.
|
||||
The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. İmza DER yerine BER ile kodlanmış (belirsiz uzunluklar); esnek biçimde okundu.
|
||||
The signature uses the legacy adbe.x509.rsa_sha1 format. İmza eski adbe.x509.rsa_sha1 biçimini kullanıyor.
|
||||
The signature value does not match the signer's certificate: the signature is corrupt. İmza değeri imzalayanın sertifikasıyla eşleşmiyor: imza bozuk.
|
||||
The signature's byte range does not exclude exactly its contents. İmzanın bayt aralığı tam olarak kendi içeriğini dışarıda bırakmıyor.
|
||||
The signature's byte range does not match the file. İmzanın bayt aralığı dosyayla eşleşmiyor.
|
||||
The signature's signed attributes have no content type, which CMS requires; it was read anyway. İmzanın imzalı özniteliklerinde CMS'in gerektirdiği içerik türü yok; yine de okundu.
|
||||
The signature's signed attributes have no message digest, so the signature does not cover the document. İmzanın imzalı özniteliklerinde ileti özeti yok, bu yüzden imza belgeyi kapsamıyor.
|
||||
The signature's signed attributes name a content type other than data, so it is not a signature of the document. İmzanın imzalı öznitelikleri data dışında bir içerik türü belirtiyor, bu yüzden bu bir belge imzası değil.
|
||||
The signature's timestamp token could not be verified ({}). İmzanın zaman damgası belirteci doğrulanamadı ({}).
|
||||
The signature's timestamp token does not cover the signature value. İmzanın zaman damgası belirteci imza değerini kapsamıyor.
|
||||
The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. İmzalanan içerik, BER'in izin verdiği gibi oluşturulmuş (parçalı) bir OCTET STRING; yine de okundu.
|
||||
The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). İmzalayanın sertifikası dijital imzaya izin vermiyor (anahtar kullanımında ne dijital imza ne de inkâr edilemezlik var).
|
||||
The signer's certificate has been revoked ({}). İmzalayanın sertifikası iptal edilmiş ({}).
|
||||
The signer's certificate has critical extensions PdfCraft does not recognize ({}). İmzalayanın sertifikasında PdfCraft'ın tanımadığı kritik uzantılar var ({}).
|
||||
The signer's certificate is not in the signature. İmzalayanın sertifikası imzada yok.
|
||||
The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). İmzalayanın sertifikası belge imzalamak için verilmemiş (genişletilmiş anahtar kullanımında belge imzalama amacı yok).
|
||||
The signer's certificate was not valid at the time of signing. İmzalayanın sertifikası imzalama sırasında geçerli değildi.
|
||||
The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. İmzalayanın kimliği bilinmiyor; çünkü güvenilen sertifikalar listenize eklenmemiş ve üst sertifikalarının hiçbiri güvenilen sertifika değil.
|
||||
The signer's identity is valid. İmzalayanın kimliği geçerli.
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standart yazı tipleri “{c}” ({code}) karakterini çizemiyor. Şimdilik metin olarak yalnızca Batı Avrupa karakterleri eklenebilir.
|
||||
The timestamp authority's certificate is not issued for time stamping. Zaman damgası yetkilisinin sertifikası zaman damgalama için verilmemiş.
|
||||
The timestamp authority's certificate was not valid at the time of timestamping. Zaman damgası yetkilisinin sertifikası zaman damgalama sırasında geçerli değildi.
|
||||
The timestamp contents are not hexadecimal. Zaman damgası içeriği onaltılık değil.
|
||||
The timestamp has no valid byte range. Zaman damgasının geçerli bir bayt aralığı yok.
|
||||
The timestamp token could not be read ({}). Zaman damgası belirteci okunamadı ({}).
|
||||
The timestamp token is valid and its authority is trusted. Zaman damgası belirteci geçerli ve yetkilisine güveniliyor.
|
||||
The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. Zaman damgası belirteci geçerli ancak zaman damgası yetkilisi güvenilen sertifikalar listenizde değil.
|
||||
The timestamp's byte range does not exclude exactly its contents. Zaman damgasının bayt aralığı tam olarak kendi içeriğini dışarıda bırakmıyor.
|
||||
The timestamp's byte range does not match the file. Zaman damgasının bayt aralığı dosyayla eşleşmiyor.
|
||||
The window shows the file name, not the title (Initial View > Show) Pencere başlığı değil dosya adını gösteriyor (İlk Görünüm > Göster)
|
||||
Theme Tema
|
||||
There is no folder to save the digital ID in. Dijital kimliğin kaydedileceği bir klasör yok.
|
||||
This document has no text to make bookmarks from. Bu belgede yer işareti oluşturulacak metin yok.
|
||||
This document has not been modified since this signature was applied. Bu belge, bu imza uygulandığından beri değiştirilmemiş.
|
||||
This document has not been modified since this timestamp was applied. Bu belge, bu zaman damgası uygulandığından beri değiştirilmemiş.
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Bu dosya diskte değişti. Yeni sürümü görmek için yeniden yükleyin; kaydedilmemiş değişiklikleriniz kaybolacak.
|
||||
This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Bu imza, artık imzalar için önerilmeyen eski bir 160 bitlik karma algoritması olan RIPEMD-160'ı kullanıyor. Hâlâ doğrulanıyor ancak güvenilmemelidir.
|
||||
This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Bu imza, zayıf bir karma algoritması olan SHA-1'i kullanıyor: bu algoritmada çakışmalar gösterilmiştir. Yıllar önce imzalanmış birçok belge onu kullandığı için hâlâ doğrulanıyor ancak güvenilmemelidir.
|
||||
This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Bu imza, artık imzalar için yeterince güçlü sayılmayan, 2048 bitten kısa bir RSA anahtarı kullanıyor. Hâlâ doğrulanıyor ancak güvenilmemelidir.
|
||||
Toolbar size Araç çubuğu boyutu
|
||||
Unsigned signature field İmzalanmamış imza alanı
|
||||
Unsigned signature field: {name} İmzalanmamış imza alanı: {name}
|
||||
Unspecified Belirtilmemiş
|
||||
Use Column Order Sütun Sırasını Kullan
|
||||
Use Document Structure Belge Yapısını Kullan
|
||||
Use Row Order Satır Sırasını Kullan
|
||||
Vertical layout Dikey düzen
|
||||
Warning Uyarı
|
||||
Window Pencere
|
||||
Zoom in (⌘+) Yakınlaştır (⌘+)
|
||||
Zoom in / out (also pinch or ⌘-scroll) Yakınlaştır / uzaklaştır (iki parmakla sıkıştırma ya da ⌘-kaydırma ile de)
|
||||
Zoom out (⌘−) Uzaklaştır (⌘−)
|
||||
a table has 1 to {max} columns bir tabloda 1 ile {max} arası sütun olur
|
||||
a table has 1 to {max} rows bir tabloda 1 ile {max} arası satır olur
|
||||
alternate text hides an annotation inside it alternatif metin içindeki bir açıklamayı gizliyor
|
||||
alternate text on an element with no page content sayfa içeriği olmayan bir öğede alternatif metin
|
||||
bold kalın
|
||||
comments yorumlar
|
||||
document security store belge güvenlik deposu
|
||||
document structure belge yapısı
|
||||
form fill form doldurma
|
||||
italic italik
|
||||
its alternate text is inside another element's and will never be read alternatif metni başka bir öğeninkinin içinde ve hiçbir zaman okunmayacak
|
||||
links bağlantılar
|
||||
metadata meta veriler
|
||||
no alternate text alternatif metin yok
|
||||
no header cells (TH) başlık hücresi (TH) yok
|
||||
no summary özet yok
|
||||
not in a TR bir TR içinde değil
|
||||
not in a Table, THead, TBody or TFoot bir Table, THead, TBody ya da TFoot içinde değil
|
||||
not in an L (list) bir L (liste) içinde değil
|
||||
not in an LI (list item) bir LI (liste öğesi) içinde değil
|
||||
of /
|
||||
other changes diğer değişiklikler
|
||||
page content sayfa içeriği
|
||||
page {n} is out of range (1–{count}) {n}. sayfa aralığın dışında (1–{count})
|
||||
pages added or removed eklenen ya da kaldırılan sayfalar
|
||||
rows have between {min} and {max} columns satırlarda {min} ile {max} arası sütun var
|
||||
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 alan, aralık ve sütun genişlikleri sayı olmalı; aralık 0 veya daha büyük, her genişlik 0'dan büyük olmalı
|
||||
the first heading is an H{n}; headings should not skip levels ilk başlık bir H{n}; başlıklar düzey atlamamalı
|
||||
the first row number can be at most {max} ilk satır numarası en fazla {max} olabilir
|
||||
the signed version could not be read imzalı sürüm okunamadı
|
||||
{font}, {size} pt {font}, {size} pt
|
||||
{n} lines {n} satır
|
||||
{n} pages and no bookmarks {n} sayfa ve hiç yer işareti yok
|
||||
{page} is not a page number or label {page} bir sayfa numarası ya da etiketi değil
|
||||
{ty} element on page {p}: {what} {p}. sayfadaki {ty} öğesi: {what}
|
||||
{ty} element: {what} {ty} öğesi: {what}
|
||||
{who}, certificate issued by an unknown issuer. {who}, sertifikayı bilinmeyen bir yayımcı vermiş.
|
||||
{who}, certificate issued by {issuer}. {who}, sertifikayı {issuer} vermiş.
|
||||
{} annotation on page {p} isn't tagged {p}. sayfadaki {} açıklaması etiketli değil
|
||||
{} annotations aren't allowed {} açıklamalarına izin verilmiyor
|
||||
… and {n} more … ve {n} tane daha
|
||||
@plural Delete {n} field|Delete {n} fields {n} alanı sil
|
||||
@plural {n} byte|{n} bytes {n} bayt
|
||||
@plural {n} comment|{n} comments {n} yorum
|
||||
@plural {n} file|{n} files {n} dosya
|
||||
@plural {n} image optimized|{n} images optimized {n} resim iyileştirildi
|
||||
@plural {n} item discarded|{n} items discarded {n} öğe atıldı
|
||||
@plural {n} pixel|{n} pixels {n} piksel
|
||||
@plural {n} result|{n} results {n} sonuç
|
||||
calendar weekday Fr Cu
|
||||
calendar weekday Mo Pt
|
||||
calendar weekday Sa Ct
|
||||
calendar weekday Su Pz
|
||||
calendar weekday Th Pe
|
||||
calendar weekday Tu Sa
|
||||
calendar weekday We Ça
|
||||
|
Can't render this file because it contains an unexpected character in line 880 and column 9.
|
@@ -311,6 +311,9 @@ pub struct PdfCraftApp {
|
||||
desktop_theme_enabled: bool,
|
||||
/// How far a mouse-wheel notch scrolls.
|
||||
pub scroll_speed: scroll_speed::ScrollSpeed,
|
||||
/// Preferences ▸ Invert middle-button drag: the middle button's pan or auto-scroll moves the
|
||||
/// view the other way. Off by default.
|
||||
pub invert_middle_drag: bool,
|
||||
/// Preferences ▸ Animation effects: on, off, or as the system's animation setting says.
|
||||
pub animation_effects: motion::AnimationEffects,
|
||||
/// Whether the system asked for no animations, and how the desktop app asks it.
|
||||
|
||||
@@ -916,6 +916,25 @@ fn middle_button_pans_while_held_outside_linux() {
|
||||
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// With Invert middle-button drag on, a held middle drag moves the view the way the pointer goes.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() {
|
||||
let (mut h, c) = harness();
|
||||
h.state_mut().set_option("zoom", "400").unwrap();
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "invert_middle_drag", "value": "true" }));
|
||||
h.run_steps(3);
|
||||
h.state_mut().views[0].go_to_page(1);
|
||||
h.run_steps(2);
|
||||
let p = h.state().views[0].viewport_rect().center();
|
||||
let top = h.state().views[0].page_screen_rect(1).unwrap().top();
|
||||
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y - 60.0], "steps": 12, "button": "middle" }));
|
||||
h.run_steps(2);
|
||||
let moved = h.state().views[0].page_screen_rect(1).unwrap().top();
|
||||
assert!((moved - (top + 60.0)).abs() < 1.0, "dragging up scrolls up, so the page moves down: {top} -> {moved}");
|
||||
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_and_view_options() {
|
||||
let (mut h, c) = harness();
|
||||
|
||||
@@ -520,6 +520,98 @@ fn single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() {
|
||||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
|
||||
h.state_mut().views[0].go_to_page(2); // Go to last page of PAGES (3 pages)
|
||||
h.run_steps(20);
|
||||
let at = h.state().views[0].viewport_rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(4);
|
||||
|
||||
// Scroll to the bottom edge of page 3
|
||||
let mut notches = 0;
|
||||
let mut last = rect(&h, 2).expect("page 3 on screen").min.y;
|
||||
let mut at_bottom = false;
|
||||
while notches < 80 {
|
||||
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
notches += 1;
|
||||
if let Some(r) = rect(&h, 2) {
|
||||
if (r.min.y - last).abs() < 0.01 {
|
||||
at_bottom = true;
|
||||
break;
|
||||
}
|
||||
last = r.min.y;
|
||||
}
|
||||
}
|
||||
assert!(at_bottom, "reached bottom edge of last page");
|
||||
assert_eq!(h.state().views[0].current, 2, "remains on page 3");
|
||||
|
||||
// Push the wheel further against the bottom edge (cannot turn because it's the last page)
|
||||
for _ in 0..5 {
|
||||
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
}
|
||||
let bottom_y = rect(&h, 2).expect("page 3 on screen").min.y;
|
||||
|
||||
// Roll the wheel back up: the page must move away from the bottom edge (#5713)
|
||||
h.event(wheel(MouseWheelUnit::Line, 1.0, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
let after_up_y = rect(&h, 2).expect("page 3 on screen").min.y;
|
||||
assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}");
|
||||
}
|
||||
|
||||
/// One notch of a mouse wheel that macOS reports in points (a Logitech mouse sends about 14).
|
||||
const POINT_NOTCH: f32 = 14.0;
|
||||
|
||||
#[test]
|
||||
fn single_page_point_notches_pan_the_page_a_notch_turned_to() {
|
||||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
|
||||
let at = h.state().views[0].viewport_rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(20);
|
||||
for _ in 0..120 {
|
||||
h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
if h.state().views[0].current == 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(h.state().views[0].current, 1, "notches pan page 1, then turn to page 2");
|
||||
h.run_steps(20);
|
||||
let top = rect(&h, 1).expect("page 2 on screen").min.y;
|
||||
for _ in 0..3 {
|
||||
h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
}
|
||||
let after = rect(&h, 1).expect("page 2 on screen").min.y;
|
||||
assert!(after < top, "notches pan page 2 down after the turn (#5713): {after} < {top}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_point_notches_scroll_back_up_from_the_last_page_bottom() {
|
||||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
|
||||
h.state_mut().views[0].go_to_page(2);
|
||||
h.run_steps(20);
|
||||
let at = h.state().views[0].viewport_rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(4);
|
||||
// Past the bottom: the extra notches push against the last page's edge.
|
||||
for _ in 0..200 {
|
||||
h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
}
|
||||
assert_eq!(h.state().views[0].current, 2, "stays on the last page");
|
||||
let bottom = rect(&h, 2).expect("page 3 on screen").min.y;
|
||||
for _ in 0..3 {
|
||||
h.event(wheel(MouseWheelUnit::Point, POINT_NOTCH, TouchPhase::Move));
|
||||
h.run_steps(12);
|
||||
}
|
||||
let after = rect(&h, 2).expect("page 3 on screen").min.y;
|
||||
assert!(after > bottom + 1.0, "notches scroll back up from the last page's bottom (#5713): {after} > {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_past_either_end_leaves_the_view_alone() {
|
||||
// Paging on past the first or last page must not jump the page back to its top.
|
||||
|
||||
@@ -1838,6 +1838,22 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() {
|
||||
assert_eq!(again.scroll_speed, ScrollSpeed::Fast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
assert!(!app.invert_middle_drag, "the middle button keeps its direction unless asked");
|
||||
app.set_option("invert_middle_drag", "true").unwrap();
|
||||
assert!(app.set_option("invert_middle_drag", "yes").unwrap_err().contains("true or false"));
|
||||
let mut again = PdfCraftApp::new();
|
||||
again.restore(&app.persist());
|
||||
assert!(again.invert_middle_drag, "turned on, it survives a restart");
|
||||
again.set_option("invert_middle_drag", "false").unwrap();
|
||||
let mut off = PdfCraftApp::new();
|
||||
off.invert_middle_drag = true;
|
||||
off.restore(&again.persist());
|
||||
assert!(!off.invert_middle_drag, "turned off, that is saved too");
|
||||
}
|
||||
|
||||
/// A 40 × 30 picture (one red corner) and a harness showing a PDF of it.
|
||||
fn picture_harness() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) {
|
||||
picture_harness_sized(egui::vec2(1400.0, 900.0))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (Turkish: 2,867 entries, 0 missing in `cargo xtask i18n-report` on 2026-11-03; AI-assisted)
|
||||
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to
|
||||
@@ -72,7 +73,7 @@ help/docs polish (catalog and script work are already done), not missing UI stri
|
||||
| Polish | `pl` | 2,611 entries | partial | yes |
|
||||
| Slovenian | `sl` | 2,536 entries | partial | no |
|
||||
| Greek | `el` | 2,545 entries | partial | no |
|
||||
| Turkish | `tr` | 2,525 entries | partial | yes |
|
||||
| Turkish | `tr` | 2,867 entries (0 missing in `cargo xtask i18n-report`, 2026-11-03) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes |
|
||||
| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no |
|
||||
| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes |
|
||||
| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no |
|
||||
@@ -93,6 +94,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is
|
||||
| 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed |
|
||||
| 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% |
|
||||
| 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date |
|
||||
| 2026-11-03 | minor | Turkish: 2,867 entries after translating the 288 keys the translation gaps report listed (AI-assisted); measured with the report, not against the 2,798-key union of 2026-10-25, which predates keys added since |
|
||||
| 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates |
|
||||
| 2026-10-16 | minor | Czech: 2,674 entries after adding the keys the other catalogs had (the engine's sentences, weekday heads, certificate fields, shared buttons) and unifying "tag" as *tag* (not *značka*, which is a redaction mark) |
|
||||
| 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` |
|
||||
|
||||
@@ -298,6 +298,7 @@ Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. |
|
||||
| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. |
|
||||
|
||||
@@ -938,8 +938,8 @@ milestone = "M3"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"]
|
||||
notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented."
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing", "crates/ui-egui/tests/it/control.rs::inverted_middle_drag_moves_the_view_the_way_the_pointer_goes", "crates/ui-egui/tests/it/viewer.rs::the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings"]
|
||||
notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Preferences ▸ Invert middle-button drag (off by default, saved; ui.set invert_middle_drag) reverses the direction of both the pan and the auto-scroll (#5790); it changes direction only, not which of the two a platform uses. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented."
|
||||
|
||||
[[feature]]
|
||||
id = "view.zoom-in-out"
|
||||
@@ -7373,8 +7373,8 @@ milestone = "M11"
|
||||
status = "shipped"
|
||||
commands = ["optimize.reduce"]
|
||||
tools = ["doc_reduce"]
|
||||
evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"]
|
||||
notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel"
|
||||
evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"]
|
||||
notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel"
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.compatibility-level"
|
||||
@@ -8085,8 +8085,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["printers", "printer_options", "doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818)"
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer."
|
||||
|
||||
[[feature]]
|
||||
id = "print.copies-collate"
|
||||
|
||||
Reference in new issue
Block a user