mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M7: open local GoToR PDF links natively (#1265)
* M7: open local GoToR PDF links natively * GoToR links: read only regular .pdf files under a size cap; add missing translations A GoToR link in an untrusted PDF could name /dev/zero (unbounded read), a FIFO (UI thread hangs) or a directory. Both the UI path and the `link_open` tool now go through `pdfcraft_engine::read_remote_document`, which requires a `.pdf` extension and a regular file (metadata checked before opening) of at most 1 GiB, and stops reading if the file grows past the cap. Regression tests cover directories, a /dev/zero symlink and a FIFO (unix) in the engine, the tool and the UI. Adds the four link notifications to all 20 catalogs and "The existing action will be preserved." to el, es, pl, te and zh-hant. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
38 files changed
+959
-51
No files matched your search
+20
-20
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0de16c8768d6407bd49d29991d7a079466b20d6034b4e5dd9e599f616888ec41"
|
||||
sha256 = "a02ef4f663e133d6242bfef555d76827f1b8852e607f29bdf833d7b9499e8e01"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "428b841cb1d15d6a2735f7509d2170b1b0a11a87fa0fec04760c1cf895bbb684"
|
||||
sha256 = "6c387bdd051a3eb032644c361b54219f14b44e2e447651bbe8980120fc925bbf"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "29b50165e96db49599478d46ae9a07af496189e715e4ec063e3768aa919d8aab"
|
||||
sha256 = "febaf4c36584c39364e037014831af6208d2dd13688b131b47f1b93403b2c190"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "941a40cc109e57d33040d9a81eceed8fe50afbfe1be2594c214684b1a89c463a"
|
||||
sha256 = "7fce0858eb1cb48c978742f6f2ffaa70960b9e53c82125c16d19c000a29c4d2e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ef66c8a4e9e992d31ca69d95ee8ff11e1298a4a1045d08ab7b24f250ff7af79e"
|
||||
sha256 = "3ae198601bf7ce20496c43412beac51476454f5ddf2a8c95abc6f49dc12f0347"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c7ea41b09d24f87f1c8ceb5bcfadcd8b2cae348dc3927770c1227180b59fdb13"
|
||||
sha256 = "6a47c0408b555e1fa7e7587dabe89486c1df2f47d8c7caca104cd22758634693"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "56d736cefc2189e8dadf11418a7701a746bbf237c58a628e3c1f0ecb64cf5150"
|
||||
sha256 = "bcac30db22be260efcf240263922fcf8f5b69afd218f211431f18f9f8edf211d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "9777ed471a3b641a278cf3d1c41b6011bcd1c353fe3ef8e84d1eab350e0b5531"
|
||||
sha256 = "f7ab9bdfa5498abe967e49729d1d4ca2202ea01273a28eed1505796e2f0c17eb"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "629b769efd3535f31e73bb3a18e9cef7efc5880e4fabb928cad789474dcbf283"
|
||||
sha256 = "e15e8117be1521fb57c9d69d74afb08ac7c07c53beb9ec08ebc3c764a6d16a14"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "bc0399c21f0420dd50aa38a596b8d165089874ffabee56de587803ff45e4b79d"
|
||||
sha256 = "c0bb0ff06f0a37045ffe74d25e4277ed8e129b40d5c5f9c103b8bded77aeea86"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f0e696bd3e046f82f5c2499f9c41e1c023293de3c65d6d71a3324bcd2ede4a78"
|
||||
sha256 = "eefa762fc8ba26812739996d99e266d9431decf5554b0776f4577cd591cdee1e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a2884a44cb42ec85d363417e999b17e2a6f136cacb2112c5ae7caa5a79db3a13"
|
||||
sha256 = "7b4f5a9f13a21acc9b0b277b2d4dbc3678e8d4ea7eba71928f7310742d156365"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8300d27b697cf724a61425c470675cb47f3aa6f57be0b2e072e1516a52ee9088"
|
||||
sha256 = "7231d68ccffa65b2bfabcfd5e532cb6b6583eb7e46963838ccce49dc7c7c11d7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "d4385634667253f26ccc71924786b826bced04fc04448748e3d9d96254c67a08"
|
||||
sha256 = "9227c30d8efd89abb6a10332dc514254cca0f0387a4eb1be63b62a1b585a5f40"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "571bc6cb212380db31b01f6c7efe9355cba085e4b3d834d7d835565f1eb62550"
|
||||
sha256 = "20f221b7c7f0c7afb7c06fb37689c53a17ab141f4f1402192956154ed247bd93"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "914d25f465fb951025f2ef8351626992993cbf70f4e76b9cf55ac9b164b882ae"
|
||||
sha256 = "87aadc11cba25578d76137291edff1a47fcf8617db84720b92ec4928be27388c"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ae5ec9ce178c91f843773657a609a32be8ed30800addc17e383415c182417215"
|
||||
sha256 = "701c37cbc525ccfaf5df02a1fbf6c31acf43d84f8dd57b0051a0958e66c02b57"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8974a9566ffa987a76ed3be123ea9e7899cea83ee79ad42c2d289982329eadff"
|
||||
sha256 = "48c24aebb813b279e0ea8aed678a9b2adb1da448e3ad8628694d18b49b0fa823"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "20b7d9813a3be3da0b111f0b7ff93a91f0ded704ccacd431a7e6de3a1a0bc9ed"
|
||||
sha256 = "6e9c96dc18bacbeb7b13a912257f07436a19fab7b836e79dfd3f0f12d45a0c7b"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3328,4 +3328,4 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3e9c1bdfd0b2c47da16922669a195e6a06018d5b6701b2cc19c18850d03cfd87"
|
||||
sha256 = "7ef2f55454c34ef6d0c279b9a3e0817b0a8e0b4ff4e13e13bad44ff9a487e992"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -137,6 +138,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
|
||||
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
|
||||
@@ -386,6 +388,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
|
||||
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
|
||||
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
|
||||
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
|
||||
|
||||
@@ -682,6 +682,7 @@ impl Automation {
|
||||
"printers" => self.printers()?,
|
||||
"printer_options" => self.printer_options(&a)?,
|
||||
"link_list" => self.link_list(&a)?,
|
||||
"link_open" => self.link_open(&a)?,
|
||||
"link_add" => self.link_add(&a)?,
|
||||
"link_edit" => self.link_edit(&a)?,
|
||||
"link_delete" => self.link_delete(&a)?,
|
||||
@@ -2128,6 +2129,15 @@ fn info(d: &Document) -> Value {
|
||||
})).collect::<Vec<_>>(),
|
||||
"preserve_rb": preserve_rb,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { file, destination, new_window } => json!({
|
||||
"remote_document": file,
|
||||
"destination": match destination {
|
||||
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
|
||||
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
|
||||
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
|
||||
},
|
||||
"new_window": new_window,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Link tools: list, add, edit and delete links; create links from URLs in the text; remove
|
||||
//! all links. Rectangles are points from the top-left of the displayed page.
|
||||
|
||||
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle};
|
||||
use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, resolve_remote_document_path};
|
||||
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::comments::parse_color;
|
||||
use crate::{Args, Automation, Result, ToolError, failed};
|
||||
@@ -80,6 +82,66 @@ impl Automation {
|
||||
Ok(json!({ "count": items.len(), "links": items }))
|
||||
}
|
||||
|
||||
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
|
||||
let page_number = a.int("page")?;
|
||||
let index_number = a.int("index")?;
|
||||
let page = usize::try_from(page_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("page must be 1 or more"))?)
|
||||
.map_err(|_| bad("page is out of range"))?;
|
||||
let index = usize::try_from(index_number.checked_sub(1).filter(|n| *n >= 0).ok_or_else(|| bad("index must be 1 or more"))?)
|
||||
.map_err(|_| bad("index is out of range"))?;
|
||||
let (source_path, file, destination, new_window) = {
|
||||
let source = self.doc(a)?;
|
||||
if page >= source.info.pages.len() {
|
||||
return Err(bad(format!("page {page_number} is out of range: the document has {} pages", source.info.pages.len())));
|
||||
}
|
||||
let link = source
|
||||
.info
|
||||
.links
|
||||
.iter()
|
||||
.find(|link| link.page == page && link.index == index)
|
||||
.ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?;
|
||||
let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else {
|
||||
return Err(bad("the selected link is not a supported local PDF GoToR link"));
|
||||
};
|
||||
(source.path.clone(), file.clone(), destination.clone(), *new_window)
|
||||
};
|
||||
let path = resolve_remote_document_path(source_path.as_deref().map(Path::new), &file).map_err(failed)?;
|
||||
let path = self.resolve(&path.to_string_lossy(), false)?;
|
||||
let bytes = read_remote_document(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id =
|
||||
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
|
||||
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
|
||||
let (destination_applied, destination_value, warning) = match destination {
|
||||
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
|
||||
RemoteDestination::Named(name) => {
|
||||
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
|
||||
}
|
||||
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
|
||||
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
|
||||
}
|
||||
RemoteDestination::Page { page, view } => (
|
||||
false,
|
||||
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
|
||||
Some("The PDF opened, but the link's page destination is out of range."),
|
||||
),
|
||||
};
|
||||
let mut display_path = path.to_string_lossy().into_owned();
|
||||
if let Some(root) = &self.root
|
||||
&& let Ok(relative) = path.strip_prefix(root)
|
||||
{
|
||||
display_path = relative.to_string_lossy().into_owned();
|
||||
}
|
||||
Ok(json!({
|
||||
"document": crate::summary(opened),
|
||||
"path": display_path,
|
||||
"destination_applied": destination_applied,
|
||||
"destination": destination_value,
|
||||
"warning": warning,
|
||||
"new_window_hint": new_window,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
|
||||
@@ -132,3 +194,14 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn dest_view_value(view: DestView) -> Value {
|
||||
match view {
|
||||
DestView::Top => json!({ "kind": "top" }),
|
||||
DestView::Xyz { left, top, zoom } => json!({ "kind": "xyz", "left": left, "top": top, "zoom": zoom }),
|
||||
DestView::Fit => json!({ "kind": "fit" }),
|
||||
DestView::FitH { top } => json!({ "kind": "fit_h", "top": top }),
|
||||
DestView::FitV { left } => json!({ "kind": "fit_v", "left": left }),
|
||||
DestView::FitR { rect } => json!({ "kind": "fit_r", "rect": rect }),
|
||||
}
|
||||
}
|
||||
@@ -550,6 +550,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." } }),
|
||||
&["doc", "page", "index"],
|
||||
)),
|
||||
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
|
||||
.cmd("edit.link")
|
||||
.with(schema(
|
||||
|
||||
@@ -35,6 +35,39 @@ fn fixture(n: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec<u8> {
|
||||
let destination = destination.map_or_else(String::new, |d| format!("/D {d}"));
|
||||
let objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(),
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F ({file}) {destination} >> >>").into_bytes(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn protected_fixture(password: &str) -> Vec<u8> {
|
||||
use pdfcraft_engine::{Edit, Protection, Session};
|
||||
let mut session = Session::new();
|
||||
let id = session.open("target.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap();
|
||||
session.apply(id, Edit::Protect(Protection { open_password: Some(password.into()), ..Default::default() })).unwrap();
|
||||
session.save_bytes(id).unwrap().as_ref().clone()
|
||||
}
|
||||
|
||||
/// A fresh directory with `a.pdf` (3 pages) and `b.pdf` (2 pages).
|
||||
#[track_caller]
|
||||
fn workdir(test: &str) -> PathBuf {
|
||||
@@ -124,6 +157,10 @@ fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn err(a: &mut Automation, tool: &str, args: Value) -> ToolError {
|
||||
a.call(tool, &args).unwrap_err()
|
||||
}
|
||||
|
||||
fn page_text(a: &mut Automation, doc: u64) -> Vec<String> {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim().to_string()).collect()
|
||||
}
|
||||
@@ -3126,6 +3163,107 @@ fn links_through_tools() {
|
||||
assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
||||
let (_base, root) = sandbox("gotor-not-a-file");
|
||||
std::fs::create_dir_all(root.join("folder.pdf")).unwrap();
|
||||
std::fs::write(root.join("notes.txt"), fixture(1)).unwrap();
|
||||
std::fs::write(root.join("dir-source.pdf"), remote_link_pdf("folder.pdf", None)).unwrap();
|
||||
std::fs::write(root.join("txt-source.pdf"), remote_link_pdf("notes.txt", None)).unwrap();
|
||||
let mut cases = vec![("dir-source.pdf", "not a regular file"), ("txt-source.pdf", "not a .pdf file")];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// A FIFO inside the root would block a plain read forever.
|
||||
if std::process::Command::new("mkfifo").arg(root.join("pipe.pdf")).status().is_ok_and(|s| s.success()) {
|
||||
std::fs::write(root.join("fifo-source.pdf"), remote_link_pdf("pipe.pdf", None)).unwrap();
|
||||
cases.push(("fifo-source.pdf", "not a regular file"));
|
||||
}
|
||||
}
|
||||
let mut a = auto(&root);
|
||||
for (source_path, reason) in cases {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
let e = err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert!(matches!(&e, ToolError::Failed(m) if m.contains(reason)), "{source_path}: {e:?}");
|
||||
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened: {source_path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_gotor_link_open_applies_destinations_and_honors_root() {
|
||||
let (base, root) = sandbox("gotor-open");
|
||||
std::fs::create_dir_all(root.join("sub")).unwrap();
|
||||
std::fs::write(root.join("target.pdf"), fixture(3)).unwrap();
|
||||
std::fs::write(root.join("a.pdf"), fixture(1)).unwrap();
|
||||
std::fs::write(root.join("sub/source.pdf"), remote_link_pdf("../target.pdf", Some("[2 /FitH 180]"))).unwrap();
|
||||
std::fs::write(root.join("sub/escape.pdf"), remote_link_pdf("../../outside/secret.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("named.pdf"), remote_link_pdf("target.pdf", Some("(chapter)"))).unwrap();
|
||||
std::fs::write(root.join("range.pdf"), remote_link_pdf("target.pdf", Some("[99 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("url.pdf"), remote_link_pdf("https://example.org/target.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("missing-source.pdf"), remote_link_pdf("does-not-exist.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("not-pdf.pdf"), b"not a PDF").unwrap();
|
||||
std::fs::write(root.join("non-pdf-source.pdf"), remote_link_pdf("not-pdf.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("encrypted.pdf"), protected_fixture("secret")).unwrap();
|
||||
std::fs::write(root.join("password-source.pdf"), remote_link_pdf("encrypted.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap();
|
||||
link_dir(&root, "outside-link", &base.join("outside"));
|
||||
std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap();
|
||||
|
||||
let mut a = auto(&root);
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let listed = ok(&mut a, "link_list", json!({ "doc": source }));
|
||||
assert_eq!(listed["links"][0]["other"], "GoToR");
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["path"], "target.pdf");
|
||||
assert_eq!(opened["destination_applied"], true);
|
||||
assert_eq!(opened["destination"]["page"], 3);
|
||||
assert_eq!(opened["destination"]["view"]["kind"], "fit_h");
|
||||
assert_eq!(opened["document"]["pages"], 3);
|
||||
let target_id = opened["document"]["doc"].as_u64().unwrap();
|
||||
assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3);
|
||||
|
||||
let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap();
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["path"], "target.pdf");
|
||||
assert_eq!(opened["destination"]["page"], 2);
|
||||
|
||||
for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
|
||||
assert_eq!(opened["destination_applied"], false);
|
||||
assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}");
|
||||
}
|
||||
|
||||
let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), documents_before);
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), documents_before);
|
||||
let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" }));
|
||||
assert_eq!(opened["document"]["encrypted"], true);
|
||||
|
||||
for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_)));
|
||||
assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}");
|
||||
}
|
||||
|
||||
for source_path in ["sub/escape.pdf", "symlink.pdf"] {
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap();
|
||||
let documents_before = a.session().docs().len();
|
||||
assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside")));
|
||||
assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened");
|
||||
}
|
||||
|
||||
let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" }));
|
||||
assert!(
|
||||
matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR"))
|
||||
);
|
||||
}
|
||||
|
||||
/// doc_info reports annotation and link rectangles in the tools' displayed-page convention,
|
||||
/// the same values comment_list and link_list give, so they can be fed back to link_edit (#129).
|
||||
#[test]
|
||||
|
||||
@@ -109,6 +109,7 @@ pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
|
||||
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
|
||||
}
|
||||
|
||||
pub use links::{MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, resolve_remote_document_path};
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! About dialog, the home screen, the CLI and the README agree.
|
||||
|
||||
use icu_properties::props::Script;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The app's name in ArtCraft URLs (`getartcraft.com/apps/{APP}`, `github.com/storytold/{APP}`).
|
||||
pub const APP: &str = "pdfcraft";
|
||||
@@ -128,6 +129,104 @@ pub fn document_url(raw: &str) -> Result<String, BlockedLink> {
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Maximum decoded GoToR file-spec size retained by inspection and accepted by the resolver.
|
||||
pub const MAX_REMOTE_DOCUMENT_PATH_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RemoteDocumentPathError {
|
||||
Empty,
|
||||
TooLong,
|
||||
Hidden,
|
||||
UrlOrNetwork,
|
||||
MissingSourcePath,
|
||||
/// The target doesn't end in `.pdf`.
|
||||
NotPdf,
|
||||
/// The target is a directory, device, FIFO or socket rather than a regular file.
|
||||
NotAFile,
|
||||
/// The target is larger than [`MAX_REMOTE_DOCUMENT_BYTES`].
|
||||
TooLarge,
|
||||
/// The target couldn't be read.
|
||||
Read(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteDocumentPathError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Empty => f.write_str("the file specification is empty"),
|
||||
Self::TooLong => f.write_str("the file specification is too long"),
|
||||
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
|
||||
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
|
||||
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
|
||||
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
|
||||
Self::NotAFile => f.write_str("the linked path is not a regular file"),
|
||||
Self::TooLarge => f.write_str("the linked file is too large to open"),
|
||||
Self::Read(e) => write!(f, "the linked file couldn't be read: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
|
||||
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
|
||||
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
|
||||
if raw.is_empty() || raw.trim().is_empty() {
|
||||
return Err(RemoteDocumentPathError::Empty);
|
||||
}
|
||||
if raw.len() > MAX_REMOTE_DOCUMENT_PATH_BYTES {
|
||||
return Err(RemoteDocumentPathError::TooLong);
|
||||
}
|
||||
if raw.chars().any(hides_text) {
|
||||
return Err(RemoteDocumentPathError::Hidden);
|
||||
}
|
||||
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
|
||||
return Err(RemoteDocumentPathError::UrlOrNetwork);
|
||||
}
|
||||
let path = Path::new(raw);
|
||||
let drive_absolute = path.is_absolute();
|
||||
if let Some((scheme, _)) = raw.split_once(':') {
|
||||
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||
let windows_drive = scheme.len() == 1 && drive_absolute;
|
||||
if valid_scheme && !windows_drive {
|
||||
return Err(RemoteDocumentPathError::UrlOrNetwork);
|
||||
}
|
||||
}
|
||||
if drive_absolute {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
|
||||
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
|
||||
Ok(parent.join(path))
|
||||
}
|
||||
|
||||
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
|
||||
pub const MAX_REMOTE_DOCUMENT_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// Read the PDF a GoToR link resolved to. The path comes from an untrusted document, so only a
|
||||
/// regular `.pdf` file of at most [`MAX_REMOTE_DOCUMENT_BYTES`] is read: a directory, a device such
|
||||
/// as `/dev/zero` or a FIFO is refused before it is opened, and a file that grows past the cap
|
||||
/// while it is read is refused too.
|
||||
pub fn read_remote_document(path: &Path) -> Result<Vec<u8>, RemoteDocumentPathError> {
|
||||
use std::io::Read;
|
||||
let is_pdf = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
|
||||
if !is_pdf {
|
||||
return Err(RemoteDocumentPathError::NotPdf);
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
|
||||
if !meta.is_file() {
|
||||
return Err(RemoteDocumentPathError::NotAFile);
|
||||
}
|
||||
if meta.len() > MAX_REMOTE_DOCUMENT_BYTES {
|
||||
return Err(RemoteDocumentPathError::TooLarge);
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?;
|
||||
if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) {
|
||||
return Err(RemoteDocumentPathError::TooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Email-address parameters that make an email app read a file from the user's computer: Evolution,
|
||||
/// KMail and Claws Mail attach the file named by `attach` (Evolution and KMail also by
|
||||
/// `attachment`), and Claws Mail reads an `insert` file into the message body. Names are matched as
|
||||
@@ -339,6 +438,54 @@ fn mixes_scripts(label: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn remote_documents_must_be_regular_pdf_files() {
|
||||
use super::{RemoteDocumentPathError as E, read_remote_document as read};
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-gotor-read-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
|
||||
std::fs::write(dir.join("doc.PDF"), b"%PDF-1.7\n").unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), b"text").unwrap();
|
||||
assert_eq!(read(&dir.join("doc.PDF")).as_deref(), Ok(&b"%PDF-1.7\n"[..]));
|
||||
assert_eq!(read(&dir.join("folder.pdf")), Err(E::NotAFile), "a directory is refused");
|
||||
assert_eq!(read(&dir.join("notes.txt")), Err(E::NotPdf));
|
||||
assert_eq!(read(&dir), Err(E::NotPdf));
|
||||
assert!(matches!(read(&dir.join("missing.pdf")), Err(E::Read(_))));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// A device (like /dev/zero, which never ends) and a FIFO (which blocks) are never opened.
|
||||
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
|
||||
assert_eq!(read(&dir.join("zero.pdf")), Err(E::NotAFile));
|
||||
let fifo = dir.join("pipe.pdf");
|
||||
if std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
|
||||
assert_eq!(read(&fifo), Err(E::NotAFile));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_document_paths_are_local_and_source_relative() {
|
||||
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
|
||||
use std::path::Path;
|
||||
|
||||
let source = Path::new("/docs/chapter/source.pdf");
|
||||
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
|
||||
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
|
||||
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
|
||||
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
|
||||
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
|
||||
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
|
||||
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
|
||||
}
|
||||
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
|
||||
{
|
||||
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
|
||||
}
|
||||
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
|
||||
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_follow_the_artcraft_scheme() {
|
||||
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
|
||||
|
||||
@@ -2056,6 +2056,42 @@ fn links_from_urls_and_link_edits() {
|
||||
assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_only_edit_preserves_gotor_action_semantics() {
|
||||
let objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F (target.pdf) /D [2 /FitH 180] /NewWindow true >> >>".to_vec(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
|
||||
let mut session = Session::new();
|
||||
let id = session.open("source.pdf", Some("/docs/source.pdf".into()), Arc::new(bytes), None).unwrap();
|
||||
let before = session.get(id).unwrap().info.links[0].target.clone();
|
||||
let mut style = session.get(id).unwrap().links[0].style;
|
||||
style.visible = true;
|
||||
style.width = 2.0;
|
||||
session.apply(id, Edit::SetLink { page: 0, index: 0, rect: None, action: None, style: Some(style) }).unwrap();
|
||||
|
||||
let after = &session.get(id).unwrap().info.links[0].target;
|
||||
assert_eq!(*after, before, "the GoToR file, destination and /NewWindow hint must remain unchanged");
|
||||
assert_eq!(session.get(id).unwrap().links[0].style, style, "the appearance edit is applied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_lock_take_checkmarks_hide_and_summarize() {
|
||||
let (mut s, id) = session_with(2);
|
||||
|
||||
@@ -29,6 +29,10 @@ const LOAD_STREAM_LIMIT: usize = 256 << 20;
|
||||
/// At most this many `/State` entries of a set-layer-visibility action are read.
|
||||
const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
/// File specifications retained from a remote-Go-To action are deliberately small.
|
||||
const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
|
||||
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
|
||||
|
||||
/// At most this many layers are read from all of `/RBGroups` together.
|
||||
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
|
||||
|
||||
@@ -172,6 +176,8 @@ impl PageInfo {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Link {
|
||||
pub page: usize,
|
||||
/// Zero-based position in the page's raw `/Annots` array.
|
||||
pub index: usize,
|
||||
pub rect: [f32; 4],
|
||||
pub target: LinkTarget,
|
||||
}
|
||||
@@ -188,9 +194,22 @@ pub enum LinkTarget {
|
||||
changes: Vec<(LayerOp, (u32, u16))>,
|
||||
preserve_rb: bool,
|
||||
},
|
||||
/// A local-file remote Go-To action. The caller resolves `file` relative to the source path.
|
||||
RemoteGoTo {
|
||||
file: String,
|
||||
destination: RemoteDestination,
|
||||
new_window: Option<bool>,
|
||||
},
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum RemoteDestination {
|
||||
Default,
|
||||
Page { page: usize, view: DestView },
|
||||
Named(String),
|
||||
}
|
||||
|
||||
/// What a set-layer-visibility action does to a layer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LayerOp {
|
||||
@@ -907,7 +926,7 @@ impl<'a> Inspector<'a> {
|
||||
info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }));
|
||||
}
|
||||
if subtype == "Link" {
|
||||
if let Some(link) = self.link(page, d) {
|
||||
if let Some(link) = self.link(page, index, d) {
|
||||
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
|
||||
info.has_javascript = true;
|
||||
}
|
||||
@@ -975,7 +994,7 @@ impl<'a> Inspector<'a> {
|
||||
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
}
|
||||
|
||||
fn link(&self, page: usize, d: &Dictionary) -> Option<Link> {
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option<Link> {
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
|
||||
let target = if let Ok(dest) = d.get(b"Dest") {
|
||||
let (page, view) = self.dest(dest, 0)?;
|
||||
@@ -991,11 +1010,72 @@ impl<'a> Inspector<'a> {
|
||||
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
|
||||
}
|
||||
Some("SetOCGState") => self.layer_state(a),
|
||||
Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
|
||||
Some(other) => LinkTarget::Other(other.to_string()),
|
||||
None => return None,
|
||||
}
|
||||
};
|
||||
Some(Link { page, rect, target })
|
||||
Some(Link { page, index, rect, target })
|
||||
}
|
||||
|
||||
fn remote_go_to(&self, action: &Dictionary) -> Option<LinkTarget> {
|
||||
if self.name(action, b"FS").as_deref() == Some("URL") {
|
||||
return None;
|
||||
}
|
||||
let file = match self.resolve(action.get(b"F").ok()?) {
|
||||
Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?,
|
||||
Object::Dictionary(spec) => {
|
||||
if self.name(spec, b"FS").as_deref() == Some("URL") {
|
||||
return None;
|
||||
}
|
||||
spec.get(b"UF")
|
||||
.ok()
|
||||
.and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES))
|
||||
.or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if file.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let destination = match action.get(b"D") {
|
||||
Err(_) => RemoteDestination::Default,
|
||||
Ok(o) => match self.resolve(o) {
|
||||
Object::Array(items) => {
|
||||
let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?;
|
||||
let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok());
|
||||
let operands: [Option<f32>; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok()));
|
||||
RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) }
|
||||
}
|
||||
Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => {
|
||||
let name = String::from_utf8_lossy(name).into_owned();
|
||||
if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
RemoteDestination::Named(name)
|
||||
}
|
||||
Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => {
|
||||
let name = text_string(bytes);
|
||||
if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
RemoteDestination::Named(name)
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
let new_window = match action.get(b"NewWindow").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Boolean(value)) => Some(*value),
|
||||
_ => None,
|
||||
};
|
||||
Some(LinkTarget::RemoteGoTo { file, destination, new_window })
|
||||
}
|
||||
|
||||
fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option<String> {
|
||||
match self.resolve(o) {
|
||||
Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups
|
||||
@@ -1260,6 +1340,14 @@ fn text_string(bytes: &[u8]) -> String {
|
||||
pdfcraft_cos::PdfString::literal(bytes).to_text()
|
||||
}
|
||||
|
||||
fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option<String> {
|
||||
if bytes.len() > max_bytes {
|
||||
return None;
|
||||
}
|
||||
let text = text_string(bytes);
|
||||
(text.len() <= max_bytes).then_some(text)
|
||||
}
|
||||
|
||||
/// Nests a pre-order list of `(depth, item)` into the outline tree.
|
||||
fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec<OutlineItem> {
|
||||
let mut roots = Vec::new();
|
||||
@@ -1983,6 +2071,72 @@ trailer << /Root 1 0 R >>
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_read_bounded_local_remote_go_to_actions() {
|
||||
use DestView::{Fit, Xyz};
|
||||
let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1);
|
||||
let wide_file = format!("<FEFF{}>", "4E00".repeat(1400));
|
||||
let wide_name = format!("<FEFF{}>", "4E00".repeat(342));
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\
|
||||
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\
|
||||
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF <FEFF0066006F006F002E007000640066> >> /D [0 /Fit] /NewWindow false >> >> endobj\n\
|
||||
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\
|
||||
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\
|
||||
34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\
|
||||
35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\
|
||||
36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\
|
||||
37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\
|
||||
38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\
|
||||
39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\
|
||||
40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\
|
||||
41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
);
|
||||
let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens");
|
||||
let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect();
|
||||
let huge_page_target = usize::try_from(i64::MAX)
|
||||
.map(|page| LinkTarget::RemoteGoTo {
|
||||
file: "chapter.pdf".into(),
|
||||
destination: RemoteDestination::Page { page, view: Fit },
|
||||
new_window: None,
|
||||
})
|
||||
.unwrap_or_else(|_| LinkTarget::Other("GoToR".into()));
|
||||
assert_eq!(
|
||||
targets,
|
||||
[
|
||||
(
|
||||
1,
|
||||
LinkTarget::RemoteGoTo {
|
||||
file: "chapters/remote.pdf".into(),
|
||||
destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } },
|
||||
new_window: Some(true),
|
||||
}
|
||||
),
|
||||
(
|
||||
2,
|
||||
LinkTarget::RemoteGoTo {
|
||||
file: "foo.pdf".into(),
|
||||
destination: RemoteDestination::Page { page: 0, view: Fit },
|
||||
new_window: Some(false),
|
||||
}
|
||||
),
|
||||
(3, LinkTarget::RemoteGoTo { file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }),
|
||||
(4, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }),
|
||||
(5, LinkTarget::Other("GoToR".into())),
|
||||
(6, LinkTarget::Other("GoToR".into())),
|
||||
(7, LinkTarget::RemoteGoTo { file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None }),
|
||||
(8, LinkTarget::Other("GoToR".into())),
|
||||
(9, LinkTarget::Other("GoToR".into())),
|
||||
(10, huge_page_target),
|
||||
(11, LinkTarget::Other("GoToR".into())),
|
||||
(12, LinkTarget::Other("GoToR".into())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_operands_become_views() {
|
||||
use DestView::*;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub mod text;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
|
||||
Xfa, attachment_data, check_pages, inspect, pretty_date,
|
||||
RemoteDestination, Xfa, attachment_data, check_pages, inspect, pretty_date,
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
|
||||
@@ -2300,6 +2300,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
|| app.pending_link.is_some()
|
||||
|| app.updates.open;
|
||||
let mut clicked_link: Option<LinkTarget> = None;
|
||||
let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None;
|
||||
let mut canvas_action: Option<comments::CanvasAction> = None;
|
||||
let mut field_menu: Option<FieldMenu> = None;
|
||||
let mut open_props: Option<(usize, usize)> = None;
|
||||
@@ -2885,6 +2886,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
LinkTarget::Uri(u) => u.clone(),
|
||||
LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(),
|
||||
LinkTarget::RemoteGoTo { file, .. } => file.clone(),
|
||||
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
|
||||
};
|
||||
hover_text = Some((p, label));
|
||||
@@ -3184,6 +3186,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
|
||||
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
|
||||
Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb),
|
||||
Some(LinkTarget::RemoteGoTo { file, destination, .. }) => remote_open = Some((view.id, file, destination)),
|
||||
Some(LinkTarget::Other(s)) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]),
|
||||
None => {}
|
||||
}
|
||||
@@ -3280,6 +3283,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
run_button(app, index, &name, action);
|
||||
}
|
||||
quick_bar(app, avail, ui);
|
||||
if let Some((source, file, destination)) = remote_open {
|
||||
app.open_remote_link(source, file, destination);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
{verb} as "{name}" {verb} باسم "{name}"
|
||||
Certify تصديق
|
||||
The signature will be invisible. سيكون التوقيع غير مرئي.
|
||||
The existing action will be preserved. سيتم الاحتفاظ بالإجراء الحالي.
|
||||
Distinguished name الاسم المميز
|
||||
Labels التسميات
|
||||
Graphic رسم
|
||||
@@ -2323,3 +2324,7 @@ scroll speed Fast سريعة
|
||||
Ink sensitivity حساسية الحبر
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. لم يُعثر على توقيع داخل الإطار. حرّك الإطار فوق التوقيع أو ارفع حساسية الحبر.
|
||||
Couldn't use the photo: {e} تعذّر استخدام الصورة: {e}
|
||||
Couldn't follow linked PDF: {reason} تعذّر فتح ملف PDF المرتبط: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. فُتح ملف PDF المرتبط، لكن لم تُطبَّق وجهته المسماة.
|
||||
The linked PDF opened, but its page destination was out of range. فُتح ملف PDF المرتبط، لكن وجهة الصفحة خارج النطاق.
|
||||
Local cross-PDF links require the desktop app تتطلب الروابط المحلية بين ملفات PDF تطبيق سطح المكتب
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -542,6 +542,7 @@
|
||||
{verb} as "{name}" {verb} като „{name}“
|
||||
Certify Сертифицирай
|
||||
The signature will be invisible. Подписът ще бъде невидим.
|
||||
The existing action will be preserved. Съществуващото действие ще бъде запазено.
|
||||
Distinguished name Отличително име
|
||||
Labels Етикети
|
||||
Graphic Графика
|
||||
@@ -2265,3 +2266,7 @@ scroll speed Fast Висока
|
||||
Ink sensitivity Чувствителност към мастило
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. В рамката не е открит подпис. Преместете рамката върху подписа или увеличете чувствителността към мастило.
|
||||
Couldn't use the photo: {e} Снимката не може да се използва: {e}
|
||||
Couldn't follow linked PDF: {reason} Свързаният PDF не можа да бъде отворен: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Свързаният PDF се отвори, но именуваната му цел не беше приложена.
|
||||
The linked PDF opened, but its page destination was out of range. Свързаният PDF се отвори, но целевата му страница е извън обхвата.
|
||||
Local cross-PDF links require the desktop app Локалните връзки между PDF файлове изискват настолното приложение
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -938,6 +938,7 @@ key Space Mezerník
|
||||
{verb} as "{name}" {verb} jako „{name}“
|
||||
Certify Certifikovat
|
||||
The signature will be invisible. Podpis bude neviditelný.
|
||||
The existing action will be preserved. Stávající akce bude zachována.
|
||||
Distinguished name Rozlišující jméno
|
||||
Labels Popisky
|
||||
Graphic Grafika
|
||||
@@ -2376,3 +2377,7 @@ scroll speed Fast Rychlá
|
||||
Ink sensitivity Citlivost na inkoust
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. V rámečku nebyl nalezen žádný podpis. Přesuňte rámeček na podpis nebo zvyšte citlivost na inkoust.
|
||||
Couldn't use the photo: {e} Fotografii nelze použít: {e}
|
||||
Couldn't follow linked PDF: {reason} Odkazovaný PDF nelze otevřít: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Odkazovaný PDF se otevřel, ale jeho pojmenovaný cíl nebyl použit.
|
||||
The linked PDF opened, but its page destination was out of range. Odkazovaný PDF se otevřel, ale cílová stránka je mimo rozsah.
|
||||
Local cross-PDF links require the desktop app Místní odkazy mezi soubory PDF vyžadují desktopovou aplikaci
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -842,6 +842,7 @@
|
||||
{verb} as "{name}" {verb} als „{name}“
|
||||
Certify Zertifizieren
|
||||
The signature will be invisible. Die Signatur wird unsichtbar sein.
|
||||
The existing action will be preserved. Die vorhandene Aktion bleibt erhalten.
|
||||
Distinguished name Distinguished Name
|
||||
Labels Beschriftungen
|
||||
Graphic Grafik
|
||||
@@ -2303,3 +2304,7 @@ scroll speed Fast Schnell
|
||||
Ink sensitivity Tintenempfindlichkeit
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Im Rahmen wurde keine Unterschrift gefunden. Ziehen Sie den Rahmen über die Unterschrift oder erhöhen Sie die Tintenempfindlichkeit.
|
||||
Couldn't use the photo: {e} Das Foto kann nicht verwendet werden: {e}
|
||||
Couldn't follow linked PDF: {reason} Verknüpfte PDF konnte nicht geöffnet werden: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Die verknüpfte PDF wurde geöffnet, aber ihr benanntes Ziel wurde nicht angewendet.
|
||||
The linked PDF opened, but its page destination was out of range. Die verknüpfte PDF wurde geöffnet, aber ihr Seitenziel liegt außerhalb des Bereichs.
|
||||
Local cross-PDF links require the desktop app Lokale Verknüpfungen zwischen PDFs erfordern die Desktop-App
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2318,3 +2318,8 @@ scroll speed Fast Γρήγορη
|
||||
Ink sensitivity Ευαισθησία μελανιού
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Δεν βρέθηκε υπογραφή στο πλαίσιο. Μετακινήστε το πλαίσιο πάνω από την υπογραφή ή αυξήστε την ευαισθησία μελανιού.
|
||||
Couldn't use the photo: {e} Δεν ήταν δυνατή η χρήση της φωτογραφίας: {e}
|
||||
Couldn't follow linked PDF: {reason} Δεν ήταν δυνατό να ανοίξει το συνδεδεμένο PDF: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Το συνδεδεμένο PDF άνοιξε, αλλά ο ονομασμένος προορισμός του δεν εφαρμόστηκε.
|
||||
The linked PDF opened, but its page destination was out of range. Το συνδεδεμένο PDF άνοιξε, αλλά ο προορισμός σελίδας του είναι εκτός εύρους.
|
||||
Local cross-PDF links require the desktop app Οι τοπικοί σύνδεσμοι μεταξύ PDF απαιτούν την εφαρμογή υπολογιστή
|
||||
The existing action will be preserved. Η υπάρχουσα ενέργεια θα διατηρηθεί.
|
||||
|
Can't render this file because it contains an unexpected character in line 479 and column 12.
|
@@ -2432,3 +2432,8 @@ scroll speed Fast Rápida
|
||||
Ink sensitivity Sensibilidad de la tinta
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. No se encontró ninguna firma en el marco. Mueva el marco sobre la firma o aumente la sensibilidad de la tinta.
|
||||
Couldn't use the photo: {e} No se puede usar la foto: {e}
|
||||
Couldn't follow linked PDF: {reason} No se pudo abrir el PDF vinculado: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. El PDF vinculado se abrió, pero no se aplicó su destino con nombre.
|
||||
The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango.
|
||||
Local cross-PDF links require the desktop app Los enlaces locales entre PDF requieren la aplicación de escritorio
|
||||
The existing action will be preserved. Se conservará la acción existente.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -838,6 +838,7 @@
|
||||
{verb} as "{name}" {verb} en tant que «{name}»
|
||||
Certify Certifier
|
||||
The signature will be invisible. La signature sera invisible.
|
||||
The existing action will be preserved. L’action existante sera conservée.
|
||||
Distinguished name Nom distinctif
|
||||
Labels Étiquettes
|
||||
Graphic Graphique
|
||||
@@ -2302,3 +2303,7 @@ scroll speed Fast Rapide
|
||||
Ink sensitivity Sensibilité à l’encre
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Aucune signature trouvée dans le cadre. Placez le cadre sur la signature ou augmentez la sensibilité à l’encre.
|
||||
Couldn't use the photo: {e} Impossible d’utiliser la photo : {e}
|
||||
Couldn't follow linked PDF: {reason} Impossible de suivre le PDF lié : {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Le PDF lié s'est ouvert, mais sa destination nommée n'a pas été appliquée.
|
||||
The linked PDF opened, but its page destination was out of range. Le PDF lié s'est ouvert, mais sa page de destination est hors limites.
|
||||
Local cross-PDF links require the desktop app Les liens locaux entre PDF nécessitent l'application de bureau
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -835,6 +835,7 @@
|
||||
{verb} as "{name}" {verb} mint „{name}”
|
||||
Certify Hitelesítés
|
||||
The signature will be invisible. Az aláírás láthatatlan lesz.
|
||||
The existing action will be preserved. A meglévő művelet megmarad.
|
||||
Distinguished name Megkülönböztető név
|
||||
Labels Címkék
|
||||
Graphic Grafika
|
||||
@@ -2301,3 +2302,7 @@ scroll speed Fast Gyors
|
||||
Ink sensitivity Tintaérzékenység
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. A keretben nem található aláírás. Húzza a keretet az aláírásra, vagy növelje a tintaérzékenységet.
|
||||
Couldn't use the photo: {e} A fénykép nem használható: {e}
|
||||
Couldn't follow linked PDF: {reason} A hivatkozott PDF nem nyitható meg: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. A hivatkozott PDF megnyílt, de a nevesített cél nem lett alkalmazva.
|
||||
The linked PDF opened, but its page destination was out of range. A hivatkozott PDF megnyílt, de a céloldal a tartományon kívül esik.
|
||||
Local cross-PDF links require the desktop app A PDF-ek közötti helyi hivatkozásokhoz az asztali alkalmazás szükséges
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -861,6 +861,7 @@
|
||||
{verb} as "{name}" {verb} come “{name}”
|
||||
Certify Certifica
|
||||
The signature will be invisible. La firma sarà invisibile.
|
||||
The existing action will be preserved. L’azione esistente verrà mantenuta.
|
||||
Distinguished name Nome distinto
|
||||
Labels Etichette
|
||||
Graphic Grafica
|
||||
@@ -2311,3 +2312,7 @@ scroll speed Fast Veloce
|
||||
Ink sensitivity Sensibilità all’inchiostro
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Nessuna firma trovata nella cornice. Sposta la cornice sulla firma o aumenta la sensibilità all’inchiostro.
|
||||
Couldn't use the photo: {e} Impossibile usare la foto: {e}
|
||||
Couldn't follow linked PDF: {reason} Impossibile aprire il PDF collegato: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Il PDF collegato è stato aperto, ma la sua destinazione con nome non è stata applicata.
|
||||
The linked PDF opened, but its page destination was out of range. Il PDF collegato è stato aperto, ma la pagina di destinazione è fuori intervallo.
|
||||
Local cross-PDF links require the desktop app I collegamenti locali tra PDF richiedono l'app desktop
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -865,6 +865,7 @@
|
||||
{verb} as "{name}" 「{name}」として{verb}
|
||||
Certify 証明
|
||||
The signature will be invisible. 署名は非表示になります。
|
||||
The existing action will be preserved. 既存のアクションは保持されます。
|
||||
Distinguished name 識別名
|
||||
Labels ラベル
|
||||
Graphic グラフィック
|
||||
@@ -2302,3 +2303,7 @@ scroll speed Fast 速い
|
||||
Ink sensitivity インクの感度
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 枠内に署名が見つかりません。枠を署名に合わせるか、インクの感度を上げてください。
|
||||
Couldn't use the photo: {e} 写真を使用できません: {e}
|
||||
Couldn't follow linked PDF: {reason} リンク先の PDF を開けませんでした: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. リンク先の PDF を開きましたが、名前付き移動先は適用されませんでした。
|
||||
The linked PDF opened, but its page destination was out of range. リンク先の PDF を開きましたが、移動先のページが範囲外です。
|
||||
Local cross-PDF links require the desktop app PDF 間のローカルリンクにはデスクトップアプリが必要です
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -820,6 +820,7 @@
|
||||
{verb} as "{name}" «{name}» ретінде {verb}
|
||||
Certify Куәландыру
|
||||
The signature will be invisible. Қолтаңба көрінбейтін болады.
|
||||
The existing action will be preserved. Қолданыстағы әрекет сақталады.
|
||||
Distinguished name Ажырататын атау
|
||||
Labels Белгілер
|
||||
Permitted actions after certifying Куәландырғаннан кейінгі рұқсат етілген әрекеттер
|
||||
@@ -2300,3 +2301,7 @@ scroll speed Fast Жылдам
|
||||
Ink sensitivity Сия сезімталдығы
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Жақтауда қолтаңба табылмады. Жақтауды қолтаңбаның үстіне жылжытыңыз немесе сия сезімталдығын арттырыңыз.
|
||||
Couldn't use the photo: {e} Суретті пайдалану мүмкін болмады: {e}
|
||||
Couldn't follow linked PDF: {reason} Байланысты PDF ашылмады: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады.
|
||||
The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс.
|
||||
Local cross-PDF links require the desktop app PDF файлдары арасындағы жергілікті сілтемелер үшін жұмыс үстелі қолданбасы қажет
|
||||
|
Can't render this file because it contains an unexpected character in line 820 and column 12.
|
@@ -2282,3 +2282,8 @@ scroll speed Fast Szybka
|
||||
Ink sensitivity Czułość na atrament
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. W ramce nie znaleziono podpisu. Przesuń ramkę na podpis lub zwiększ czułość na atrament.
|
||||
Couldn't use the photo: {e} Nie można użyć zdjęcia: {e}
|
||||
Couldn't follow linked PDF: {reason} Nie można otworzyć połączonego pliku PDF: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Połączony plik PDF został otwarty, ale jego nazwany cel nie został zastosowany.
|
||||
The linked PDF opened, but its page destination was out of range. Połączony plik PDF został otwarty, ale jego strona docelowa jest poza zakresem.
|
||||
Local cross-PDF links require the desktop app Lokalne łącza między plikami PDF wymagają aplikacji komputerowej
|
||||
The existing action will be preserved. Istniejąca akcja zostanie zachowana.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -496,6 +496,7 @@
|
||||
{verb} as "{name}" {verb} como "{name}"
|
||||
Certify Certificar
|
||||
The signature will be invisible. A assinatura será invisível.
|
||||
The existing action will be preserved. A ação existente será preservada.
|
||||
Distinguished name Nome distinto
|
||||
Labels Rótulos
|
||||
Graphic Gráfico
|
||||
@@ -2316,3 +2317,7 @@ scroll speed Fast Rápida
|
||||
Ink sensitivity Sensibilidade à tinta
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Nenhuma assinatura encontrada no quadro. Mova o quadro sobre a assinatura ou aumente a sensibilidade à tinta.
|
||||
Couldn't use the photo: {e} Não foi possível usar a foto: {e}
|
||||
Couldn't follow linked PDF: {reason} Não foi possível abrir o PDF vinculado: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. O PDF vinculado foi aberto, mas seu destino nomeado não foi aplicado.
|
||||
The linked PDF opened, but its page destination was out of range. O PDF vinculado foi aberto, mas sua página de destino está fora do intervalo.
|
||||
Local cross-PDF links require the desktop app Links locais entre PDFs exigem o aplicativo para desktop
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -858,6 +858,7 @@
|
||||
{verb} as "{name}" {verb} как «{name}»
|
||||
Certify Сертифицировать
|
||||
The signature will be invisible. Подпись будет невидимой.
|
||||
The existing action will be preserved. Существующее действие будет сохранено.
|
||||
Distinguished name Уникальное имя
|
||||
Labels Метки
|
||||
Graphic Графика
|
||||
@@ -2215,3 +2216,7 @@ scroll speed Fast Высокая
|
||||
Ink sensitivity Чувствительность к чернилам
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. В рамке не найдена подпись. Наведите рамку на подпись или увеличьте чувствительность к чернилам.
|
||||
Couldn't use the photo: {e} Не удалось использовать фото: {e}
|
||||
Couldn't follow linked PDF: {reason} Не удалось открыть связанный PDF: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Связанный PDF открыт, но его именованное назначение не применено.
|
||||
The linked PDF opened, but its page destination was out of range. Связанный PDF открыт, но страница назначения вне допустимого диапазона.
|
||||
Local cross-PDF links require the desktop app Локальные ссылки между PDF работают только в настольном приложении
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1793,6 +1793,7 @@
|
||||
The same password is tried on each file. Isto geslo se poskusi pri vsaki datoteki.
|
||||
The selected area has been copied ({w} × {h} pixels) Izbrano območje je kopirano ({w} × {h} slikovnih pik)
|
||||
The signature will be invisible. Podpis bo nevidni.
|
||||
The existing action will be preserved. Obstoječe dejanje bo ohranjeno.
|
||||
The text is the same Besedilo je enako
|
||||
The title in the metadata and the document information differ Naslov v metapodatkih in podatkih o dokumentu se razlikujeta
|
||||
The value is shown as typed. Vrednost je prikazana, kot je vnesena.
|
||||
@@ -2307,3 +2308,7 @@ scroll speed Fast Hitro
|
||||
Ink sensitivity Občutljivost za črnilo
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. V okvirju ni bilo najdenega podpisa. Premaknite okvir nad podpis ali povečajte občutljivost za črnilo.
|
||||
Couldn't use the photo: {e} Fotografije ni bilo mogoče uporabiti: {e}
|
||||
Couldn't follow linked PDF: {reason} Povezanega PDF-ja ni bilo mogoče odpreti: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Povezani PDF se je odprl, vendar njegov poimenovani cilj ni bil uporabljen.
|
||||
The linked PDF opened, but its page destination was out of range. Povezani PDF se je odprl, vendar je ciljna stran zunaj obsega.
|
||||
Local cross-PDF links require the desktop app Lokalne povezave med PDF-ji zahtevajo namizno aplikacijo
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2215,3 +2215,8 @@ scroll speed Fast వేగంగా
|
||||
Ink sensitivity ఇంక్ సున్నితత్వం
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. ఫ్రేమ్లో సంతకం కనబడలేదు. ఫ్రేమ్ను సంతకం మీదకు జరపండి లేదా ఇంక్ సున్నితత్వాన్ని పెంచండి.
|
||||
Couldn't use the photo: {e} ఫోటోను ఉపయోగించలేకపోయాం: {e}
|
||||
Couldn't follow linked PDF: {reason} లింక్ చేసిన PDFను తెరవలేకపోయాం: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. లింక్ చేసిన PDF తెరుచుకుంది, కానీ దాని పేరున్న గమ్యం వర్తించలేదు.
|
||||
The linked PDF opened, but its page destination was out of range. లింక్ చేసిన PDF తెరుచుకుంది, కానీ దాని పేజీ గమ్యం పరిధికి వెలుపల ఉంది.
|
||||
Local cross-PDF links require the desktop app PDFల మధ్య స్థానిక లింక్లకు డెస్క్టాప్ యాప్ అవసరం
|
||||
The existing action will be preserved. ఉన్న చర్య అలాగే ఉంచబడుతుంది.
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -819,6 +819,7 @@
|
||||
{verb} as "{name}" "{name}" olarak {verb}
|
||||
Certify Sertifikala
|
||||
The signature will be invisible. İmza görünmez olacak.
|
||||
The existing action will be preserved. Mevcut işlem korunacak.
|
||||
Distinguished name Ayırt edici ad
|
||||
Labels Etiketler
|
||||
Permitted actions after certifying Sertifikalandırma sonrası izin verilen eylemler
|
||||
@@ -2298,3 +2299,7 @@ scroll speed Fast Hızlı
|
||||
Ink sensitivity Mürekkep hassasiyeti
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Çerçevede imza bulunamadı. Çerçeveyi imzanın üzerine taşıyın veya mürekkep hassasiyetini artırın.
|
||||
Couldn't use the photo: {e} Fotoğraf kullanılamadı: {e}
|
||||
Couldn't follow linked PDF: {reason} Bağlantılı PDF açılamadı: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı.
|
||||
The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında.
|
||||
Local cross-PDF links require the desktop app PDF'ler arası yerel bağlantılar masaüstü uygulamasını gerektirir
|
||||
|
Can't render this file because it contains an unexpected character in line 819 and column 12.
|
@@ -862,6 +862,7 @@
|
||||
{verb} as "{name}" {verb} як «{name}»
|
||||
Certify Засвідчити
|
||||
The signature will be invisible. Підпис буде невидимим.
|
||||
The existing action will be preserved. Наявну дію буде збережено.
|
||||
Distinguished name Розпізнавальне ім’я
|
||||
Labels Позначки
|
||||
Graphic Графіка
|
||||
@@ -2324,3 +2325,7 @@ scroll speed Fast Швидка
|
||||
Ink sensitivity Чутливість до чорнила
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. У рамці не знайдено підпису. Наведіть рамку на підпис або збільште чутливість до чорнила.
|
||||
Couldn't use the photo: {e} Не вдалося використати фото: {e}
|
||||
Couldn't follow linked PDF: {reason} Не вдалося відкрити пов’язаний PDF: {reason}
|
||||
The linked PDF opened, but its named destination was not applied. Пов’язаний PDF відкрито, але його іменоване призначення не застосовано.
|
||||
The linked PDF opened, but its page destination was out of range. Пов’язаний PDF відкрито, але сторінка призначення поза межами діапазону.
|
||||
Local cross-PDF links require the desktop app Локальні посилання між PDF працюють лише в настільному застосунку
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -549,6 +549,7 @@
|
||||
{verb} as "{name}" {verb}为“{name}”
|
||||
Certify 认证
|
||||
The signature will be invisible. 签名将不可见。
|
||||
The existing action will be preserved. 将保留现有操作。
|
||||
Distinguished name 可分辨名称
|
||||
Labels 标签
|
||||
Graphic 图形
|
||||
@@ -2266,3 +2267,7 @@ scroll speed Fast 快
|
||||
Ink sensitivity 墨迹灵敏度
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框内未找到签名。请将框移到签名上,或提高墨迹灵敏度。
|
||||
Couldn't use the photo: {e} 无法使用该照片:{e}
|
||||
Couldn't follow linked PDF: {reason} 无法打开链接的 PDF:{reason}
|
||||
The linked PDF opened, but its named destination was not applied. 已打开链接的 PDF,但未应用其命名目标。
|
||||
The linked PDF opened, but its page destination was out of range. 已打开链接的 PDF,但其页面目标超出范围。
|
||||
Local cross-PDF links require the desktop app PDF 之间的本地链接需要桌面应用
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2176,3 +2176,8 @@ scroll speed Fast 快
|
||||
Ink sensitivity 墨跡靈敏度
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框內找不到簽名。請將框移到簽名上,或提高墨跡靈敏度。
|
||||
Couldn't use the photo: {e} 無法使用此相片:{e}
|
||||
Couldn't follow linked PDF: {reason} 無法開啟連結的 PDF:{reason}
|
||||
The linked PDF opened, but its named destination was not applied. 已開啟連結的 PDF,但未套用其命名目的地。
|
||||
The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。
|
||||
Local cross-PDF links require the desktop app PDF 之間的本機連結需要桌面應用程式
|
||||
The existing action will be preserved. 將保留現有動作。
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -399,6 +399,8 @@ pub struct PasswordPrompt {
|
||||
/// The tool that asked for this file (a tool chosen with no document open): it runs once
|
||||
/// the document is unlocked.
|
||||
then: Option<&'static str>,
|
||||
/// A link destination to apply after the linked PDF is unlocked.
|
||||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
}
|
||||
|
||||
/// Asks for the permissions password of an open document whose security restricts it, which
|
||||
@@ -1018,8 +1020,17 @@ impl PdfCraftApp {
|
||||
Ok(id) => id,
|
||||
Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => {
|
||||
let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
|
||||
self.password_prompt =
|
||||
Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error, activate, recovered: None, then: None });
|
||||
self.password_prompt = Some(PasswordPrompt {
|
||||
name: name.to_string(),
|
||||
path,
|
||||
bytes,
|
||||
input: String::new(),
|
||||
error,
|
||||
activate,
|
||||
recovered: None,
|
||||
then: None,
|
||||
after_open: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
@@ -1220,10 +1231,14 @@ impl PdfCraftApp {
|
||||
}
|
||||
return;
|
||||
};
|
||||
let after_open = p.after_open.clone();
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) {
|
||||
Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
|
||||
// A recovered encrypted document is open once its prompt is gone.
|
||||
Ok(()) if self.password_prompt.is_none() => {
|
||||
if let Some(destination) = after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
if let Some(meta) = &p.recovered {
|
||||
self.finish_recovery(meta);
|
||||
}
|
||||
@@ -1237,6 +1252,7 @@ impl PdfCraftApp {
|
||||
if let Some(prompt) = self.password_prompt.as_mut() {
|
||||
prompt.recovered = p.recovered;
|
||||
prompt.then = p.then;
|
||||
prompt.after_open = after_open;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1280,6 +1296,62 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn open_remote_link(&mut self, source: DocId, file: String, destination: pdfcraft_render::RemoteDestination) {
|
||||
let source_path = self.session.get(source).and_then(|doc| doc.path.clone());
|
||||
let path = match pdfcraft_engine::resolve_remote_document_path(source_path.as_deref().map(std::path::Path::new), &file) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone());
|
||||
let stamp = file_watch::Stamp::at(&path);
|
||||
let tabs = self.views.len();
|
||||
self.startup_superseded = true;
|
||||
let bytes = match pdfcraft_engine::read_remote_document(std::path::Path::new(&path)) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.try_open(&name, Some(path), std::sync::Arc::new(bytes), None, true) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
} else if let Some(prompt) = self.password_prompt.as_mut() {
|
||||
prompt.after_open = Some(destination);
|
||||
} else if self.views.len() > tabs {
|
||||
if let Some(view) = self.views.last() {
|
||||
self.watch.known_as(view.id, stamp);
|
||||
}
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn open_remote_link(&mut self, _source: DocId, _file: String, _destination: pdfcraft_render::RemoteDestination) {
|
||||
self.notify_tr("Local cross-PDF links require the desktop app");
|
||||
}
|
||||
|
||||
fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) {
|
||||
let Some(index) = self.views.len().checked_sub(1) else { return };
|
||||
let Some(doc) = self.session.get(self.views[index].id) else { return };
|
||||
let warning = match destination {
|
||||
pdfcraft_render::RemoteDestination::Default => None,
|
||||
pdfcraft_render::RemoteDestination::Named(_) => Some("The linked PDF opened, but its named destination was not applied."),
|
||||
pdfcraft_render::RemoteDestination::Page { page, view } if page < doc.info.pages.len() => {
|
||||
self.views[index].go_to_dest(page, view, &doc.info);
|
||||
None
|
||||
}
|
||||
pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."),
|
||||
};
|
||||
if let Some(warning) = warning {
|
||||
self.notify_tr(warning);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct LinkDraft {
|
||||
pub rect: [f64; 4],
|
||||
/// `None` for a new link.
|
||||
pub index: Option<usize>,
|
||||
/// Keep an existing action that Link Properties cannot edit.
|
||||
pub preserve_action: bool,
|
||||
pub web: bool,
|
||||
pub url: String,
|
||||
/// 1-based target page.
|
||||
@@ -142,6 +144,7 @@ impl PdfCraftApp {
|
||||
page,
|
||||
rect: l.rect,
|
||||
index,
|
||||
preserve_action: matches!(l.action, LinkAction::Other(_)),
|
||||
web: matches!(l.action, LinkAction::Uri(_)),
|
||||
url: match &l.action {
|
||||
LinkAction::Uri(u) => u.clone(),
|
||||
@@ -157,6 +160,7 @@ impl PdfCraftApp {
|
||||
page,
|
||||
rect: rect.unwrap_or_default(),
|
||||
index: None,
|
||||
preserve_action: false,
|
||||
web: true,
|
||||
url: "https://".into(),
|
||||
target: 1,
|
||||
@@ -249,23 +253,27 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::section_title(ui, tl!("Link Action"));
|
||||
widgets::radio_value(ui, &mut d.web, true, tl!("Open a web page"));
|
||||
ui.add_enabled_ui(d.web, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org"));
|
||||
if d.preserve_action {
|
||||
ui.label(tl!("The existing action will be preserved."));
|
||||
} else {
|
||||
widgets::radio_value(ui, &mut d.web, true, tl!("Open a web page"));
|
||||
ui.add_enabled_ui(d.web, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org"));
|
||||
});
|
||||
});
|
||||
});
|
||||
widgets::radio_value(ui, &mut d.web, false, tl!("Go to a page view"));
|
||||
ui.add_enabled_ui(!d.web, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.label(tl!("Page"));
|
||||
ui.add(egui::DragValue::new(&mut d.target).range(1..=pages.max(1)));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())]));
|
||||
widgets::radio_value(ui, &mut d.web, false, tl!("Go to a page view"));
|
||||
ui.add_enabled_ui(!d.web, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.label(tl!("Page"));
|
||||
ui.add(egui::DragValue::new(&mut d.target).range(1..=pages.max(1)));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())]));
|
||||
});
|
||||
});
|
||||
});
|
||||
let ok = !d.web || (d.url.trim().len() > 3 && d.url.trim() != "https://");
|
||||
}
|
||||
let ok = d.preserve_action || !d.web || (d.url.trim().len() > 3 && d.url.trim() != "https://");
|
||||
if !ok {
|
||||
ui.label(egui::RichText::new(tl!("Type the web address.")).small().color(t.text_faint));
|
||||
}
|
||||
@@ -284,9 +292,33 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token
|
||||
|
||||
/// The edit for a confirmed draft.
|
||||
pub(crate) fn edit_for(d: &LinkDraft) -> Edit {
|
||||
let action = if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page(d.target.saturating_sub(1)) };
|
||||
match d.index {
|
||||
None => Edit::AddLink { page: d.page, rect: d.rect, action, style: d.style },
|
||||
Some(index) => Edit::SetLink { page: d.page, index, rect: None, action: Some(action), style: Some(d.style) },
|
||||
None => Edit::AddLink {
|
||||
page: d.page,
|
||||
rect: d.rect,
|
||||
action: if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page(d.target.saturating_sub(1)) },
|
||||
style: d.style,
|
||||
},
|
||||
Some(index) => Edit::SetLink {
|
||||
page: d.page,
|
||||
index,
|
||||
rect: None,
|
||||
action: (!d.preserve_action)
|
||||
.then(|| if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page(d.target.saturating_sub(1)) }),
|
||||
style: Some(d.style),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LinkDraft, edit_for};
|
||||
use pdfcraft_engine::{Edit, LinkStyle};
|
||||
|
||||
#[test]
|
||||
fn appearance_edit_preserves_an_existing_unsupported_action() {
|
||||
let style = LinkStyle::default();
|
||||
let draft = LinkDraft { page: 0, rect: [0.0; 4], index: Some(3), preserve_action: true, web: false, url: String::new(), target: 1, style };
|
||||
assert_eq!(edit_for(&draft), Edit::SetLink { page: 0, index: 3, rect: None, action: None, style: Some(style) });
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,55 @@ fn fixture() -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn remote_fixture(file: &str, destination: &str) -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_string(),
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /A << /S /GoToR /F ({file}) /D {destination} /NewWindow true >> >>"),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn remote_harness(source_path: Option<String>, file: &str, destination: &str) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("source.pdf", source_path, remote_fixture(file, destination)).unwrap();
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
fn linked_pages(count: usize, password: Option<&str>) -> Vec<u8> {
|
||||
use pdfcraft_engine::{Edit, Protection, Session};
|
||||
let mut session = Session::new();
|
||||
let id = session.open("target.pdf", None, std::sync::Arc::new(fixture()), None).unwrap();
|
||||
for page in 1..count {
|
||||
session.apply(id, Edit::InsertBlankPage { at: page, width: 300.0, height: 400.0 }).unwrap();
|
||||
}
|
||||
if let Some(password) = password {
|
||||
session.apply(id, Edit::Protect(Protection { open_password: Some(password.into()), ..Default::default() })).unwrap();
|
||||
}
|
||||
session.save_bytes(id).unwrap().as_ref().clone()
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
@@ -77,6 +126,10 @@ fn click_link_at(h: &mut Harness<'static, PdfCraftApp>, y: f32) {
|
||||
click_at(h, at);
|
||||
}
|
||||
|
||||
fn click_remote_link(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
click_link(h);
|
||||
}
|
||||
|
||||
fn click_link(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
click_link_at(h, 325.0);
|
||||
}
|
||||
@@ -109,6 +162,80 @@ fn a_link_asks_first_and_opens_when_allowed() {
|
||||
assert_eq!(h.state().pending_link, None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_local_gotor_link_opens_in_a_tab_and_applies_its_destination() {
|
||||
let _gpu = gpu();
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap();
|
||||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /FitH 200]");
|
||||
click_remote_link(&mut h);
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(h.state().active, Some(1));
|
||||
assert_eq!(h.state().views[1].current, 1, "the remote page destination is applied");
|
||||
assert_eq!(h.state().last_opened_url, None, "no URL or external launcher is involved");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_remote_destination_survives_password_retries_and_cancel_opens_no_tab() {
|
||||
let _gpu = gpu();
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-password-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("target.pdf"), linked_pages(2, Some("test-password"))).unwrap();
|
||||
|
||||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
|
||||
click_remote_link(&mut h);
|
||||
assert!(h.state().password_prompt.is_some());
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||||
assert!(h.state().password_prompt.as_ref().is_some_and(|p| p.error.is_some()));
|
||||
h.state_mut().submit_password(Some("test-password".into()));
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(h.state().views[1].current, 1, "the destination survives a wrong-password retry");
|
||||
|
||||
let mut cancelled = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
|
||||
click_remote_link(&mut cancelled);
|
||||
cancelled.state_mut().submit_password(None);
|
||||
assert_eq!(cancelled.state().views.len(), 1, "cancelling does not leave a half-open target tab");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_gotor_link_to_a_directory_or_device_is_refused_without_reading_it() {
|
||||
let _gpu = gpu();
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-not-a-file-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
|
||||
let mut targets = vec!["folder.pdf"];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// /dev/zero never ends: reading it would exhaust memory.
|
||||
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
|
||||
targets.push("zero.pdf");
|
||||
}
|
||||
for target in targets {
|
||||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), target, "[0 /Fit]");
|
||||
click_remote_link(&mut h);
|
||||
assert_eq!(h.state().views.len(), 1, "{target} opens no tab");
|
||||
let toast = &h.state().toast.as_ref().unwrap().0;
|
||||
assert!(toast.contains("not a regular file"), "{target}: {toast}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn an_in_memory_source_does_not_resolve_a_relative_gotor_against_cwd() {
|
||||
let _gpu = gpu();
|
||||
let mut h = remote_harness(None, "target.pdf", "[0 /Fit]");
|
||||
click_remote_link(&mut h);
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
let toast = &h.state().toast.as_ref().unwrap().0;
|
||||
assert!(toast.contains("relative link needs a source document path"), "{toast}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_launch_action_is_reported_without_claiming_javascript() {
|
||||
let _gpu = gpu();
|
||||
|
||||
@@ -81,7 +81,8 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2
|
||||
| Action | Acrobat | PdfCraft |
|
||||
|---|---|---|
|
||||
| GoTo | yes | yes (page with Fit; named destinations and zoom partial) |
|
||||
| GoToR, GoToE | yes | none (preserved; the Optimizer can remove invalid ones) |
|
||||
| GoToR | yes | partial: local PDF paths open in a PdfCraft tab; `/F` strings and file-spec dictionaries (`/UF`, then `/F`), source-relative paths, and explicit numeric-page destinations are supported. Automation uses the same resolver and `--root` confinement. Named destinations open at the target's default view with a warning; `/NewWindow` is a tab hint. Network/URL file systems are refused. |
|
||||
| GoToE | yes | none (preserved; the Optimizer can remove invalid ones) |
|
||||
| Launch | yes (restricted) | none |
|
||||
| Thread | yes | none |
|
||||
| URI | yes | yes |
|
||||
|
||||
+4
-1
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: local GoToR links and confined automation candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; earlier: M10, printing on the web through the browser's print dialog, gap 4, rank 1 65–120 → 50–90 h) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -83,7 +85,7 @@ total matches the parity estimate.
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
|
||||
@@ -140,6 +142,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
|
||||
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
|
||||
| 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. |
|
||||
| 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. |
|
||||
|
||||
@@ -1165,9 +1165,9 @@ tier = "P0"
|
||||
milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view"]
|
||||
notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6. Destinations keep their position and zoom (/XYZ, /Fit, /FitH, /FitV, /FitR, named); the /FitB* content box is read as the whole page"
|
||||
tools = ["link_open"]
|
||||
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination"]
|
||||
notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. URL/network file systems, GoToE, Launch and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit."
|
||||
|
||||
[[feature]]
|
||||
id = "view.annotation-hover"
|
||||
@@ -2191,7 +2191,7 @@ status = "shipped"
|
||||
commands = ["edit.link"]
|
||||
tools = ["link_edit"]
|
||||
evidence = ["crates/ui-egui/tests/ui_group/pdf_links.rs::drawing_editing_and_deleting_links", "crates/automation/tests/automation.rs::links_through_tools", "crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed"]
|
||||
notes = "visible/invisible rectangle, highlight, thickness, solid/dashed/underline, colour; action"
|
||||
notes = "visible/invisible rectangle, highlight, thickness, solid/dashed/underline and colour; supported page/URI action editing. Appearance-only edits preserve unsupported actions, including GoToR."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.links-from-urls"
|
||||
|
||||
Reference in new issue
Block a user