mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 17:46:38 +00:00
Merge upstream/main into pr/print-area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
commit
1c82944e0c
Generated
+16
@@ -4592,6 +4592,7 @@ dependencies = [
|
||||
"pdfcraft-render",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
"thread-priority",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -4698,6 +4699,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-geom",
|
||||
"pdfcraft-testkit",
|
||||
"serde",
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6955,6 +6957,20 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread-priority"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
|
||||
@@ -499,6 +499,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo
|
||||
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
|
||||
overrides are not supported.
|
||||
|
||||
To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the
|
||||
welcome screen (type it, or Browse…) or pass it on the command line:
|
||||
`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1`
|
||||
on the command line for everyone). An upgrade keeps the folder the installed version uses unless
|
||||
the command line gives another one.
|
||||
|
||||
**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
|
||||
(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
|
||||
MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
|
||||
|
||||
+19
-16
@@ -198,27 +198,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
|
||||
|
||||
## Languages
|
||||
|
||||
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
|
||||
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from
|
||||
`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
|
||||
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
|
||||
| French | `fr` | 99.3% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
|
||||
| Indonesian | `id` | 0% | none |
|
||||
| Japanese | `ja` | 99.1% | partial |
|
||||
| German | `de` | 99.3% | partial |
|
||||
| Korean | `ko` | 0% | none |
|
||||
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
|
||||
| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) |
|
||||
| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) |
|
||||
| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
|
||||
| French | `fr` | 98.4% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 98.4% | partial |
|
||||
| Indonesian | `id` | 97.5% | partial |
|
||||
| Japanese | `ja` | 89.6% | partial |
|
||||
| German | `de` | 89.7% | partial |
|
||||
| Korean | `ko` | 97.5% | partial (no Hangul UI face) |
|
||||
| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
|
||||
(24 interface languages including English).
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian,
|
||||
Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs**
|
||||
(35 interface languages including English).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -252,10 +253,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged.
|
||||
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
|
||||
|
||||
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
|
||||
- **2026-11-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored.
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view.
|
||||
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
@@ -444,6 +446,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
|
||||
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
|
||||
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
|
||||
- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
|
||||
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
|
||||
|
||||
@@ -8,6 +8,9 @@ pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
||||
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the
|
||||
/// standards it declares, and the notes about damage repaired while opening it), with none of
|
||||
/// its scripts run.
|
||||
pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("info", args, INFO_OPTIONS)?;
|
||||
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
|
||||
@@ -18,24 +21,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
|
||||
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let pdf_pages = info.pages.len();
|
||||
let acroform_fields = info.fields.len();
|
||||
// The document as the app opens it: the notes about damage repaired while opening, which the
|
||||
// app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields.
|
||||
// Inspection runs none of the document's scripts: an XFA form's initialize and calculate
|
||||
// scripts, which the app runs on opening, are left alone, as with JavaScript turned off.
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
session.set_javascript(false);
|
||||
let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id));
|
||||
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
|
||||
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
|
||||
.then(|| {
|
||||
let mut s = pdfcraft_engine::Session::new();
|
||||
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
|
||||
let d = s.get(id)?;
|
||||
d.xfa.as_ref().map(|x| {
|
||||
info = d.info.clone();
|
||||
// The in-memory layout is an appended revision, not the file being inspected.
|
||||
info.file_size = bytes.len();
|
||||
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
|
||||
})
|
||||
let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| {
|
||||
d.xfa.as_ref().map(|x| {
|
||||
info = d.info.clone();
|
||||
// The in-memory layout is an appended revision, not the file being inspected.
|
||||
info.file_size = bytes.len();
|
||||
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
|
||||
})
|
||||
.flatten();
|
||||
// What the document declares as a standard, read the way the Standards panel reads it, so
|
||||
// `info` and the app agree. A file that declares nothing leaves these null and empty.
|
||||
let declared =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
|
||||
});
|
||||
// What the document declares as a standard, as the Standards panel reads it, and the notes
|
||||
// Document Properties shows, which include what the app repaired after reading the objects (a
|
||||
// page tree rebuilt from the pages found by scanning), both from the session's own reading. A
|
||||
// file the app cannot open is read once more for what the object reader declares and repaired.
|
||||
// A file that declares nothing leaves the standards null and empty.
|
||||
let (declared, repairs) = match opened {
|
||||
Some(d) => (d.standards(), d.repair_log()),
|
||||
None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
|
||||
.map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec()))
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let json = serde_json::json!({
|
||||
"file": path,
|
||||
"pdf_version": info.pdf_version,
|
||||
@@ -56,6 +68,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
|
||||
"custom_properties": info.custom.len(),
|
||||
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||||
"warnings": info.warnings,
|
||||
"repairs": repairs,
|
||||
});
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `info` lists the notes about damage repaired while opening, as the app shows them in Document
|
||||
//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a
|
||||
//! page tree rebuilt from the pages found by scanning (a note the app adds after reading).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and
|
||||
/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`.
|
||||
fn fixture(catalog: &str, pages: usize, bad_startxref: Option<usize>) -> Vec<u8> {
|
||||
let content = "0 0 0 rg 10 10 40 20 re f";
|
||||
let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect();
|
||||
let mut objs = vec![
|
||||
catalog.to_string(),
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string()));
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for off in &offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
let start = bad_startxref.unwrap_or(xref);
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
|
||||
|
||||
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("f.pdf");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
json
|
||||
}
|
||||
|
||||
fn repairs(json: &serde_json::Value) -> Vec<String> {
|
||||
json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_a_rebuilt_cross_reference_index() {
|
||||
// `startxref` points into the middle of an object, so the index is rebuilt from the headers.
|
||||
let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref");
|
||||
assert_eq!(json["pages"], serde_json::json!(1), "{json}");
|
||||
let lines = repairs(&json);
|
||||
assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() {
|
||||
// The catalog names a page tree object the file does not have (#1656).
|
||||
let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree");
|
||||
let lines = repairs(&json);
|
||||
assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_no_repairs_for_a_sound_file() {
|
||||
let json = info_of(&fixture(CATALOG, 1, None), "sound");
|
||||
assert_eq!(json["repairs"], serde_json::json!([]), "{json}");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod check;
|
||||
mod edit;
|
||||
mod extract;
|
||||
mod help;
|
||||
mod info_repairs;
|
||||
mod info_standards;
|
||||
mod mcp_compact;
|
||||
mod options;
|
||||
|
||||
@@ -7,6 +7,11 @@ use std::sync::Arc;
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
fn fixture(test: &str) -> PathBuf {
|
||||
fixture_with(test, "")
|
||||
}
|
||||
|
||||
/// The two-page form, with `event` inside its `name` field.
|
||||
fn fixture_with(test: &str, event: &str) -> PathBuf {
|
||||
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
@@ -15,11 +20,12 @@ fn fixture(test: &str) -> PathBuf {
|
||||
<medium short="200pt" long="300pt"/>
|
||||
</pageArea></pageSet>
|
||||
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
|
||||
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
|
||||
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value>{event}</field>
|
||||
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
|
||||
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
|
||||
</subform>
|
||||
</subform></template></xdp:xdp>"#;
|
||||
</subform></template></xdp:xdp>"#
|
||||
.replace("{event}", event);
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
@@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() {
|
||||
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
|
||||
}
|
||||
|
||||
/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening
|
||||
/// and which here would change the field's value, does not run.
|
||||
#[test]
|
||||
fn info_runs_none_of_the_forms_scripts() {
|
||||
let script = r#"<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Changed";</script></event>"#;
|
||||
let path = fixture_with("info-script", script);
|
||||
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
|
||||
success(&output);
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
|
||||
let warnings = info["warnings"].as_array().unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}");
|
||||
assert_eq!(info["repairs"], serde_json::json!([]), "{info}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
|
||||
let path = fixture("text");
|
||||
@@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() {
|
||||
assert_eq!(info["file_size"], original.len());
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
}
|
||||
|
||||
/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it,
|
||||
/// as Document Properties does after the form is laid out as one more revision.
|
||||
#[test]
|
||||
fn info_lists_the_repairs_of_a_laid_out_form() {
|
||||
let path = fixture("info-damaged");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
|
||||
let mut damaged = bytes[..at].to_vec();
|
||||
damaged.extend_from_slice(b"20\n%%EOF\n");
|
||||
std::fs::write(&path, &damaged).unwrap();
|
||||
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
|
||||
success(&output);
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
|
||||
let repairs = info["repairs"].as_array().unwrap();
|
||||
assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}");
|
||||
}
|
||||
|
||||
@@ -102,25 +102,12 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||
/// is `num_days_from_ce` 719_163.
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_engine::civil_from_days`,
|
||||
/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second
|
||||
/// count over 86_400, at most about 2.1e14, so it always fits an `i64`.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
// The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`.
|
||||
pdfcraft_engine::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, and
|
||||
//! a file manager or `xdg-open` starts `pdfcraft <file>` the same way, so until now every file got
|
||||
//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10
|
||||
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy,
|
||||
//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same
|
||||
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft` (a portable copy's too, as
|
||||
//! `portable-instance-…` and never in its own folder, #889) and is named after the Windows
|
||||
//! session (`instance-s1.sock` for session 1), so two sign-ins of the same
|
||||
//! user never open files in each other's windows. On Linux and the BSDs it is in
|
||||
//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a
|
||||
//! launch on another display or seat opens its own window there. Either way it lives in a folder
|
||||
|
||||
@@ -37,9 +37,16 @@ struct Walker<'a> {
|
||||
visits: usize,
|
||||
/// Per page: the text each MCID shows (read once per page).
|
||||
texts: HashMap<usize, HashMap<i64, String>>,
|
||||
/// What is left of [`MAX_TITLE_SOURCE_BYTES`].
|
||||
title_left: usize,
|
||||
out: Vec<Heading>,
|
||||
}
|
||||
|
||||
/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all.
|
||||
/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and
|
||||
/// each would otherwise be decoded in full first.
|
||||
const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20;
|
||||
|
||||
/// The headings, in document order. Headings without text or a page are left out.
|
||||
pub fn headings(doc: &Document) -> Vec<Heading> {
|
||||
let Some(root) = tree_root(doc) else { return Vec::new() };
|
||||
@@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec<Heading> {
|
||||
seen: HashSet::new(),
|
||||
visits: 0,
|
||||
texts: HashMap::new(),
|
||||
title_left: MAX_TITLE_SOURCE_BYTES,
|
||||
out: Vec::new(),
|
||||
};
|
||||
if let Some(k) = root.get(b"K") {
|
||||
@@ -131,17 +139,34 @@ impl Walker<'_> {
|
||||
self.marks(kids, pg, depth + 1, &mut marks);
|
||||
}
|
||||
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
|
||||
let doc = self.doc;
|
||||
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
|
||||
let title = match attr(b"ActualText") {
|
||||
let title = match self.attr(d, b"ActualText") {
|
||||
Some(t) => Some(t),
|
||||
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
|
||||
None => match self.marked_text(&marks, page) {
|
||||
Some(t) => Some(t),
|
||||
None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")),
|
||||
},
|
||||
};
|
||||
let Some(title) = title else { return };
|
||||
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
|
||||
self.out.push(Heading { obj, level, title, page });
|
||||
}
|
||||
|
||||
/// The non-blank text of the string `key` holds, charged against the title allowance by the
|
||||
/// longer of its raw and decoded size (a string that does not fit is not decoded).
|
||||
fn attr(&mut self, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
let o = self.doc.resolve(d.get(key)?);
|
||||
let s = o.as_string()?;
|
||||
// A text string decodes to at most three bytes per raw byte, so one whose worst case does
|
||||
// not fit the remaining allowance is never decoded: the raw check alone let a string at
|
||||
// the raw limit decode to three times it before the allowance was reduced.
|
||||
if s.bytes.len().saturating_mul(3) > self.title_left {
|
||||
return None;
|
||||
}
|
||||
let text = s.to_text();
|
||||
self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len()));
|
||||
Some(text).filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The text the marked content shows (`page`: where marks without a page of their own are).
|
||||
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
@@ -1559,6 +1559,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() {
|
||||
let count = 100;
|
||||
let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::<Vec<_>>().join(" ");
|
||||
let mut objects = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(),
|
||||
stream("BT ET"),
|
||||
"<< /Type /StructTreeRoot /K 6 0 R >>".to_string(),
|
||||
format!("<< /S /Document /P 5 0 R /K [{kids}] >>"),
|
||||
format!("({})", "a".repeat(512 << 10)),
|
||||
];
|
||||
objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string()));
|
||||
let found = headings(&pdf(&objects, ""));
|
||||
assert!(!found.is_empty() && found.len() < count, "{}", found.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
|
||||
+10
-6
@@ -6,7 +6,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
|
||||
|
||||
- **Builders** for the comment types Acrobat's commenting tools create: sticky note (`Text` +
|
||||
`Popup`), highlight, underline, strikethrough, squiggly, rectangle (`Square`), oval (`Circle`),
|
||||
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`).
|
||||
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`), text box and callout
|
||||
(`FreeText`), polygon (including cloudy borders), polyline, caret, rubber and custom stamp,
|
||||
and file attachment.
|
||||
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
|
||||
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
|
||||
A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as
|
||||
@@ -47,8 +49,8 @@ deterministic; the engine supplies them.
|
||||
|
||||
- Unknown keys are kept. Inline annotation dictionaries are promoted to indirect objects only
|
||||
when an edit needs a reference to them.
|
||||
- `set_style` refuses (and changes nothing) when it cannot redraw the comment, e.g. stamps,
|
||||
cloudy borders or callouts, instead of leaving a stale appearance.
|
||||
- `set_style` refuses when it cannot redraw the comment, e.g. a stamp
|
||||
whose appearance cannot be regenerated, instead of leaving a stale appearance.
|
||||
- Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but
|
||||
never paint a second icon.
|
||||
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
|
||||
@@ -63,7 +65,9 @@ deterministic; the engine supplies them.
|
||||
(vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is
|
||||
drawn.
|
||||
|
||||
## Not yet
|
||||
## Rich text and remaining limits
|
||||
|
||||
Callouts, polygons and polylines, clouds, carets (insert/replace text), stamps, file
|
||||
attachments, rich text (`/RC`, `/DS`), and the shared named-appearance cache Acrobat writes.
|
||||
Text-box text and colour edits regenerate `/RC` (XHTML) and `/DS` from the plain
|
||||
`/Contents`, `/DA` and `/Q`, so rich-text editors show the same text and style. These edits
|
||||
replace imported rich-text formatting. The shared named-appearance cache Acrobat writes
|
||||
is not implemented.
|
||||
|
||||
@@ -205,11 +205,24 @@ fn quads(context: &AppearanceContext<'_>) -> Option<Vec<[f64; 8]>> {
|
||||
(!q.is_empty()).then_some(q)
|
||||
}
|
||||
|
||||
/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK.
|
||||
fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
|
||||
/// `/C`-style colour arrays (ISO 32000-2 Table 166): none (transparent), gray, RGB or CMYK.
|
||||
/// The entries should all be numbers; a four-entry array with a non-number in it, such as
|
||||
/// `[1 0 0 null]`, is still read as CMYK with that entry 0, as PDFium, MuPDF and Poppler read it.
|
||||
/// A reference is not such an entry: it stands for a value this builder can't see (§7.3.10), so
|
||||
/// the colour is unreadable, as the other colour and number readers here treat one.
|
||||
pub(crate) fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
|
||||
let c = match d.get(key) {
|
||||
None => return Some(None),
|
||||
Some(o) => o.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
|
||||
Some(o) => match o.as_array()? {
|
||||
a if a.len() == 4 => a
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Object::Ref(_) => None,
|
||||
o => Some(o.as_f64().unwrap_or(0.0)),
|
||||
})
|
||||
.collect::<Option<Vec<f64>>>()?,
|
||||
a => a.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
|
||||
},
|
||||
};
|
||||
Some(match c.as_slice() {
|
||||
[] => None,
|
||||
|
||||
+29
-11
@@ -105,7 +105,10 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
let r = nums(b"Rect");
|
||||
let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] };
|
||||
let c = nums(b"C");
|
||||
let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
|
||||
let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
|
||||
// Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry
|
||||
// read as 0), like `props`; three numbers, some given by reference, are still read.
|
||||
let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) };
|
||||
if subtype == "FreeText"
|
||||
&& let Some(da) = text_value(doc, d, b"DA")
|
||||
{
|
||||
@@ -244,8 +247,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let obj = doc.resolve(&entry);
|
||||
let d = obj.as_dict()?;
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") };
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
|
||||
if subtype == "FreeText" {
|
||||
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
|
||||
@@ -275,16 +277,32 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
|
||||
/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. Grey and CMYK fills are converted.
|
||||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||||
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
|
||||
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let rgb = match c.as_slice() {
|
||||
[g] => [*g; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
|
||||
_ => return None,
|
||||
};
|
||||
drawn_color(d, key)
|
||||
}
|
||||
|
||||
/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference.
|
||||
/// Only an array of at most four entries is copied out of the reference.
|
||||
fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> {
|
||||
let rgb = match d.get(b"C") {
|
||||
Some(o @ Object::Ref(_)) => {
|
||||
let held = doc.resolve(o);
|
||||
let entries = held.as_array().filter(|a| a.len() <= 4)?;
|
||||
let mut direct = Dict::new();
|
||||
direct.set(b"C".to_vec(), Object::Array(entries.clone()));
|
||||
drawn_color(&direct, b"C")
|
||||
}
|
||||
_ => drawn_color(d, b"C"),
|
||||
}?;
|
||||
Some(rgb.map(|x| x as f32))
|
||||
}
|
||||
|
||||
/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are
|
||||
/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0.
|
||||
fn drawn_color(d: &Dict, key: &[u8]) -> Option<Rgb> {
|
||||
let rgb = appearance::color(d, key).flatten()?;
|
||||
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
|
||||
+118
-13
@@ -8,13 +8,77 @@ use crate::{AnnotError, annots, page_ref, page_refs, set_annots};
|
||||
/// Where a link goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkAction {
|
||||
/// A page of this document (0-based), shown fitting the window (`/Fit`).
|
||||
Page(usize),
|
||||
/// A page of this document (0-based), shown at `zoom`.
|
||||
Page {
|
||||
page: usize,
|
||||
zoom: LinkZoom,
|
||||
},
|
||||
Uri(String),
|
||||
/// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an
|
||||
/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the
|
||||
/// reader keeps its position in that direction, as a new bookmark does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum LinkZoom {
|
||||
/// `/Fit`: the whole page in the window.
|
||||
FitPage,
|
||||
/// `/FitH null`: the page's width across the window.
|
||||
FitWidth,
|
||||
/// `/FitV null`: the page's height down the window.
|
||||
FitHeight,
|
||||
/// `/FitB`: the page's content (its bounding box) in the window.
|
||||
FitVisible,
|
||||
/// `/XYZ null null null`: the reader's zoom is kept.
|
||||
Inherit,
|
||||
/// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written
|
||||
/// within 1–6400%; a link rewritten with the zoom it has keeps its own factor.
|
||||
Percent(f64),
|
||||
}
|
||||
|
||||
impl LinkZoom {
|
||||
/// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The
|
||||
/// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown
|
||||
/// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom
|
||||
/// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its
|
||||
/// zoom is written (`write_action`) but not its position.
|
||||
fn of_dest(doc: &Document, dest: &[Object]) -> Self {
|
||||
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec));
|
||||
match kind.as_deref() {
|
||||
Some(b"FitB") => LinkZoom::FitVisible,
|
||||
Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth,
|
||||
Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight,
|
||||
Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) {
|
||||
// Finite even for a factor near f64::MAX. A link given a new page with this zoom
|
||||
// keeps the factor itself, however far outside 1–6400% (`write_action`).
|
||||
Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)),
|
||||
// A zoom of 0 or null keeps the reader's (Table 149).
|
||||
_ => LinkZoom::Inherit,
|
||||
},
|
||||
_ => LinkZoom::FitPage,
|
||||
}
|
||||
}
|
||||
|
||||
/// The destination's elements after the page reference.
|
||||
fn operands(self) -> Result<Vec<Object>, AnnotError> {
|
||||
Ok(match self {
|
||||
LinkZoom::FitPage => vec![Object::name("Fit")],
|
||||
LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null],
|
||||
LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null],
|
||||
LinkZoom::FitVisible => vec![Object::name("FitB")],
|
||||
LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null],
|
||||
LinkZoom::Percent(p) => {
|
||||
if !(p.is_finite() && p > 0.0) {
|
||||
return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into()));
|
||||
}
|
||||
vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Acrobat's Link Properties ▸ Appearance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LinkStyle {
|
||||
@@ -87,17 +151,18 @@ fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
}
|
||||
|
||||
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
|
||||
let dest_page = |dest: &Object| -> Option<usize> {
|
||||
let page_view = |dest: &Object| -> Option<LinkAction> {
|
||||
let a = doc.resolve(dest);
|
||||
let first = a.as_array()?.first()?.clone();
|
||||
match first {
|
||||
Object::Ref(r) => pages.iter().position(|p| *p == r),
|
||||
Object::Int(i) => usize::try_from(i).ok(),
|
||||
let a = a.as_array()?;
|
||||
let page = match a.first()? {
|
||||
Object::Ref(r) => pages.iter().position(|p| p == r),
|
||||
Object::Int(i) => usize::try_from(*i).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}?;
|
||||
Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) })
|
||||
};
|
||||
if let Some(dest) = d.get(b"Dest") {
|
||||
return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page);
|
||||
return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into()));
|
||||
}
|
||||
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
|
||||
match a.name(b"S") {
|
||||
@@ -115,7 +180,7 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize)
|
||||
None => LinkAction::Other("web address too long".into()),
|
||||
}
|
||||
}
|
||||
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
|
||||
Some(b"GoTo") => a.get(b"D").and_then(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())),
|
||||
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
|
||||
None => LinkAction::Other("no action".into()),
|
||||
}
|
||||
@@ -214,12 +279,52 @@ fn uri_bytes(u: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`.
|
||||
fn current_dest(doc: &Document, d: &Dict) -> Option<Vec<Object>> {
|
||||
let dest = match d.get(b"Dest") {
|
||||
Some(dest) => doc.resolve(dest),
|
||||
None => {
|
||||
let action = doc.resolve(d.get(b"A")?);
|
||||
let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?;
|
||||
doc.resolve(go_to.get(b"D")?)
|
||||
}
|
||||
};
|
||||
dest.as_array().cloned()
|
||||
}
|
||||
|
||||
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
|
||||
match a {
|
||||
LinkAction::Page(p) => {
|
||||
let target = page_ref(doc, *p)?;
|
||||
LinkAction::Page { page, zoom } => {
|
||||
let target = page_ref(doc, *page)?;
|
||||
let mut operands = zoom.operands()?;
|
||||
let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom);
|
||||
if let Some(c) = current {
|
||||
// The page and zoom the link has (`link_edit` naming its own page): the
|
||||
// destination is left as it is, position on the page and GoTo form included.
|
||||
let same_page = match c.first() {
|
||||
Some(Object::Ref(r)) => *r == target,
|
||||
Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page),
|
||||
_ => false,
|
||||
};
|
||||
if same_page {
|
||||
return Ok(());
|
||||
}
|
||||
// A new page with the zoom the link has keeps how that zoom is written: a boxed
|
||||
// fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400%
|
||||
// a new zoom is written within. Positions belong to the old page and are dropped.
|
||||
let (slot, own) = match zoom {
|
||||
LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)),
|
||||
LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let (Some(slot), Some(own)) = (slot, own) {
|
||||
*slot = doc.resolve(own).as_ref().clone();
|
||||
}
|
||||
}
|
||||
let mut dest = vec![Object::Ref(target)];
|
||||
dest.extend(operands);
|
||||
d.remove(b"A");
|
||||
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")]));
|
||||
d.set(b"Dest".to_vec(), Object::Array(dest));
|
||||
}
|
||||
LinkAction::Uri(u) => {
|
||||
let u = u.trim();
|
||||
|
||||
+84
-11
@@ -94,7 +94,7 @@ pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotE
|
||||
if let Some(r) = target.as_ref() {
|
||||
unlocked(doc, r)?;
|
||||
}
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(target));
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
|
||||
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
|
||||
// answers either. Pop-ups follow their comment.
|
||||
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
|
||||
@@ -115,15 +115,28 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<
|
||||
let p = page_ref(doc, page)?;
|
||||
let list = annots(doc, p);
|
||||
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(target));
|
||||
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
|
||||
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
|
||||
Ok(set_annots(doc, p, kept)?)
|
||||
}
|
||||
|
||||
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
|
||||
/// comment (which has none to collect) the pop-up it names, which still goes.
|
||||
fn seed(target: &Object) -> Option<ObjRef> {
|
||||
target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")))
|
||||
/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a
|
||||
/// Replace Text proposal also takes the caret it is grouped under, so the two go together
|
||||
/// whichever is deleted.
|
||||
fn seed(doc: &Document, target: &Object) -> Vec<ObjRef> {
|
||||
let own = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
|
||||
own.into_iter().chain(replaced_by(doc, target)).collect()
|
||||
}
|
||||
|
||||
/// The caret a Replace Text strikeout is grouped under (`/IT /StrikeOutTextEdit`, `/RT /Group`,
|
||||
/// `/IRT` to a caret): the proposal's primary, which holds the replacement.
|
||||
fn replaced_by(doc: &Document, strike: &Object) -> Option<ObjRef> {
|
||||
let obj = doc.resolve(strike);
|
||||
let d = obj.as_dict()?;
|
||||
let edit = d.name(b"Subtype") == Some(b"StrikeOut") && d.name(b"IT") == Some(b"StrikeOutTextEdit") && d.name(b"RT") == Some(b"Group");
|
||||
let caret = d.reference(b"IRT").filter(|_| edit)?;
|
||||
(annot_dict(doc, caret).name(b"Subtype") == Some(b"Caret")).then_some(caret)
|
||||
}
|
||||
|
||||
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
|
||||
@@ -713,9 +726,61 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
|
||||
|
||||
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
|
||||
/// redraw it.
|
||||
///
|
||||
/// A Replace Text proposal is one look: colour and opacity are the group's (`/C` is read from
|
||||
/// the primary, ISO 32000-2 §12.5.6.2), so restyling either the caret or its strikeout restyles
|
||||
/// and redraws the other too, in the same edit. The strikeout takes no line width (a text markup
|
||||
/// annotation has no border) and neither part has line endings. All or nothing: if one member
|
||||
/// can't be restyled, none is.
|
||||
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let others = replace_text_partners(doc, page, r)?;
|
||||
// Check every partner before changing anything.
|
||||
for &o in &others {
|
||||
unlocked(doc, o)?;
|
||||
}
|
||||
let before: Vec<(ObjRef, Dict)> = others.iter().map(|&o| (o, annot_dict(doc, o))).collect();
|
||||
let mut done: Vec<(ObjRef, Dict)> = vec![(r, annot_dict(doc, r))];
|
||||
style_one(doc, r, change.clone(), meta)?;
|
||||
for (o, d) in before {
|
||||
let caret = d.name(b"Subtype") == Some(b"Caret");
|
||||
let part = StyleChange { color: change.color, opacity: change.opacity, width: change.width.filter(|_| caret), endings: None };
|
||||
let unchanged = part.color.is_none() && part.opacity.is_none() && part.width.is_none();
|
||||
if unchanged {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = style_one(doc, o, part, meta) {
|
||||
// Put the members already restyled back, the target included.
|
||||
for (o, d) in done.into_iter().rev() {
|
||||
doc.set(o, Object::Dict(d));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
done.push((o, d));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The other members of the Replace Text proposal `r` belongs to, on `page`: for its caret, the
|
||||
/// strikeouts grouped under it; for a strikeout, its caret and any other strikeout under it.
|
||||
/// Empty for anything else.
|
||||
fn replace_text_partners(doc: &Document, page: usize, r: ObjRef) -> Result<Vec<ObjRef>, AnnotError> {
|
||||
let own = Object::Ref(r);
|
||||
let caret = match replaced_by(doc, &own) {
|
||||
Some(c) => c,
|
||||
None if annot_dict(doc, r).name(b"Subtype") == Some(b"Caret") => r,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
let strikes = list.iter().filter(|e| replaced_by(doc, e) == Some(caret)).filter_map(Object::as_ref);
|
||||
// `/Annots` may list one annotation twice: restyle each once.
|
||||
let mut seen: HashSet<ObjRef> = HashSet::from([r]);
|
||||
Ok(std::iter::once(caret).chain(strikes).filter(|&o| seen.insert(o)).collect())
|
||||
}
|
||||
|
||||
/// [`set_style`] on one annotation.
|
||||
fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
@@ -994,8 +1059,13 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
|
||||
}
|
||||
|
||||
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
|
||||
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
|
||||
/// move, list and delete as one. Returns the strikeout's index.
|
||||
/// struck text holding `replacement`, the two grouped so they move, list and delete as one.
|
||||
/// Returns the strikeout's index.
|
||||
///
|
||||
/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to
|
||||
/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary,
|
||||
/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has
|
||||
/// to be on the primary.
|
||||
///
|
||||
/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
|
||||
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
|
||||
@@ -1016,7 +1086,6 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
|
||||
meta,
|
||||
)?;
|
||||
let (_, sr) = annot_ref(doc, page, s)?;
|
||||
doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?;
|
||||
// The caret sits on the baseline at the end of the last line, its point at the text.
|
||||
let xs = [last[0], last[2], last[4], last[6]];
|
||||
let ys = [last[1], last[3], last[5], last[7]];
|
||||
@@ -1031,10 +1100,14 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
|
||||
)?;
|
||||
let (_, cr) = annot_ref(doc, page, c)?;
|
||||
doc.update_dict(cr, |d| {
|
||||
d.set(b"IRT".to_vec(), Object::Ref(sr));
|
||||
d.set(b"RT".to_vec(), Object::name("Group"));
|
||||
d.set(b"IT".to_vec(), Object::name("Replace"));
|
||||
d.set(b"Subj".to_vec(), PdfString::text("Replace Text"));
|
||||
})?;
|
||||
doc.update_dict(sr, |d| {
|
||||
d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit"));
|
||||
d.set(b"IRT".to_vec(), Object::Ref(cr));
|
||||
d.set(b"RT".to_vec(), Object::name("Group"));
|
||||
})?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
|
||||
+333
-11
@@ -1003,12 +1003,12 @@ fn stamps_draw_their_label_and_by_line() {
|
||||
|
||||
#[test]
|
||||
fn links_are_added_edited_listed_and_removed() {
|
||||
use crate::links::{self, Highlight, LinkAction, LinkStyle};
|
||||
use crate::links::{self, Highlight, LinkAction, LinkStyle, LinkZoom};
|
||||
let mut doc = fixture();
|
||||
let before = links::list(&doc).len();
|
||||
let i = links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap();
|
||||
let style = LinkStyle { visible: true, color: [1.0, 0.0, 0.0], width: 2.0, dashed: true, underline: false, highlight: Highlight::Outline };
|
||||
links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page(0), &style).unwrap();
|
||||
links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &style).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = links::list(&doc);
|
||||
assert_eq!(all.len(), before + 2);
|
||||
@@ -1017,7 +1017,7 @@ fn links_are_added_edited_listed_and_removed() {
|
||||
(web.action.clone(), web.style.visible, web.style.highlight),
|
||||
(LinkAction::Uri("https://example.org".into()), false, Highlight::Invert)
|
||||
);
|
||||
let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page(0)).unwrap().clone();
|
||||
let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }).unwrap().clone();
|
||||
assert_eq!(page.style, style);
|
||||
let mut doc = doc;
|
||||
links::set(&mut doc, 1, page.index, Some([0.0, 0.0, 50.0, 20.0]), Some(&LinkAction::Uri("mailto:ada@example.org".into())), None).unwrap();
|
||||
@@ -1027,7 +1027,201 @@ fn links_are_added_edited_listed_and_removed() {
|
||||
assert!(links::delete(&mut doc, 0, 999).is_err(), "no such annotation");
|
||||
let n = links::remove_all(&mut doc, None).unwrap();
|
||||
assert!(n >= 1 && links::list(&doc).is_empty());
|
||||
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
|
||||
assert!(
|
||||
links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &LinkStyle::default()).is_err(),
|
||||
"too small"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_links_keep_their_zoom() {
|
||||
use crate::links::{self, LinkAction, LinkStyle, LinkZoom};
|
||||
let page1 = page_refs(&fixture()).unwrap()[1];
|
||||
let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned());
|
||||
// Each zoom is written as its destination type (Table 149) and reads back after a save.
|
||||
let null = Object::Null;
|
||||
for (zoom, written) in [
|
||||
(LinkZoom::FitPage, vec![Object::name("Fit")]),
|
||||
(LinkZoom::FitWidth, vec![Object::name("FitH"), null.clone()]),
|
||||
(LinkZoom::FitHeight, vec![Object::name("FitV"), null.clone()]),
|
||||
(LinkZoom::FitVisible, vec![Object::name("FitB")]),
|
||||
(LinkZoom::Inherit, vec![Object::name("XYZ"), null.clone(), null.clone(), null.clone()]),
|
||||
(LinkZoom::Percent(100.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(1.0)]),
|
||||
(LinkZoom::Percent(250.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(2.5)]),
|
||||
] {
|
||||
let mut doc = fixture();
|
||||
let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom }, &LinkStyle::default()).unwrap();
|
||||
let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap();
|
||||
let mut expected = vec![Object::Ref(page1)];
|
||||
expected.extend(written);
|
||||
assert_eq!(dest_of(&doc, link.obj.unwrap()), Some(Object::Array(expected)), "{zoom:?}");
|
||||
let doc = reopen(&doc);
|
||||
let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap();
|
||||
assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{zoom:?} after a save");
|
||||
}
|
||||
// What other writers use reads as the nearest zoom: a zero zoom keeps the reader's, the boxed
|
||||
// fits as the plain ones, a rectangle as Fit Page, and a GoTo action like a /Dest.
|
||||
let mut doc = fixture();
|
||||
let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap();
|
||||
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
|
||||
let dest = |kind: &str, operands: Vec<Object>| {
|
||||
let mut d = vec![Object::Ref(page1), Object::name(kind)];
|
||||
d.extend(operands);
|
||||
Object::Array(d)
|
||||
};
|
||||
for (key, value, zoom) in [
|
||||
("Dest", dest("XYZ", vec![Object::Int(100), Object::Int(700), Object::Int(0)]), LinkZoom::Inherit),
|
||||
("Dest", dest("FitBH", vec![Object::Int(500)]), LinkZoom::FitWidth),
|
||||
("Dest", dest("FitBV", vec![Object::Int(20)]), LinkZoom::FitHeight),
|
||||
("Dest", dest("FitR", vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)]), LinkZoom::FitPage),
|
||||
("A", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1.5)]), LinkZoom::Percent(150.0)),
|
||||
// A factor outside what PdfCraft writes reads as the destination has it, not capped, so
|
||||
// it agrees with the viewer's `destination`; one whose percentage overflows stays finite.
|
||||
("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(100.0)]), LinkZoom::Percent(10000.0)),
|
||||
("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(0.005)]), LinkZoom::Percent(0.5)),
|
||||
("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1e307)]), LinkZoom::Percent(f64::MAX)),
|
||||
] {
|
||||
let mut d = doc.get(obj).as_dict().cloned().unwrap();
|
||||
d.remove(b"Dest");
|
||||
d.remove(b"A");
|
||||
if key == "A" {
|
||||
let mut go_to = pdfcraft_cos::Dict::new();
|
||||
go_to.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
go_to.set(b"D".to_vec(), value);
|
||||
d.set(b"A".to_vec(), Object::Dict(go_to));
|
||||
} else {
|
||||
d.set(b"Dest".to_vec(), value);
|
||||
}
|
||||
doc.set(obj, Object::Dict(d));
|
||||
let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap();
|
||||
assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{key}");
|
||||
}
|
||||
// A zoom that isn't a positive percentage is refused before anything is written.
|
||||
for p in [0.0, -50.0, f64::NAN, f64::INFINITY] {
|
||||
let before = links::list(&doc).len();
|
||||
let refused =
|
||||
links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(p) }, &LinkStyle::default());
|
||||
assert!(refused.is_err(), "{p}");
|
||||
assert_eq!(links::list(&doc).len(), before, "{p}: no link added");
|
||||
}
|
||||
}
|
||||
|
||||
/// A link rewritten with the zoom it has keeps its own `/XYZ` factor, however far outside the
|
||||
/// 1–6400% a new zoom is written within: giving it a new page (Link Properties' OK, `link_edit`
|
||||
/// without `zoom`) must not change its zoom. A zoom chosen anew is still written within 1–6400%.
|
||||
#[test]
|
||||
fn a_rewritten_link_keeps_its_own_zoom_factor() {
|
||||
use crate::links::{self, LinkAction, LinkStyle, LinkZoom};
|
||||
let pages = page_refs(&fixture()).unwrap();
|
||||
let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned());
|
||||
let xyz = |page: ObjRef, factor: Object| Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, factor]);
|
||||
for (key, factor) in [
|
||||
("Dest", Object::Real(0.005)),
|
||||
("Dest", Object::Real(100.0)),
|
||||
("Dest", Object::Int(100)),
|
||||
("Dest", Object::Real(1e307)),
|
||||
("A", Object::Real(0.005)),
|
||||
] {
|
||||
let mut doc = fixture();
|
||||
let i =
|
||||
links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap();
|
||||
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
|
||||
let mut d = doc.get(obj).as_dict().cloned().unwrap();
|
||||
d.remove(b"Dest");
|
||||
if key == "A" {
|
||||
let mut go_to = pdfcraft_cos::Dict::new();
|
||||
go_to.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
go_to.set(b"D".to_vec(), xyz(pages[1], factor.clone()));
|
||||
d.set(b"A".to_vec(), Object::Dict(go_to));
|
||||
} else {
|
||||
d.set(b"Dest".to_vec(), xyz(pages[1], factor.clone()));
|
||||
}
|
||||
doc.set(obj, Object::Dict(d));
|
||||
let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else {
|
||||
panic!("{key} {factor:?}")
|
||||
};
|
||||
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap();
|
||||
assert_eq!(dest_of(&doc, obj), Some(xyz(pages[0], factor.clone())), "{key} {factor:?}: the factor is the link's own");
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(
|
||||
links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action,
|
||||
LinkAction::Page { page: 0, zoom },
|
||||
"{key} {factor:?}"
|
||||
);
|
||||
}
|
||||
// A zoom other than the link's is written within 1–6400%.
|
||||
let mut doc = fixture();
|
||||
let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(50.0) }, &LinkStyle::default())
|
||||
.unwrap();
|
||||
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
|
||||
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 1, zoom: LinkZoom::Percent(10000.0) }), None).unwrap();
|
||||
assert_eq!(dest_of(&doc, obj), Some(xyz(pages[1], Object::Real(64.0))));
|
||||
}
|
||||
|
||||
/// A go-to edited without a change (its own page and zoom, as `link_edit` naming its page does)
|
||||
/// is left as it is: the `/XYZ` position, a fit's position and the GoTo form stay. Given a new
|
||||
/// page with its zoom, a link keeps how that zoom is written, a boxed fit (`/FitBH`, `/FitBV`)
|
||||
/// included, but not its position, which belongs to the old page.
|
||||
#[test]
|
||||
fn an_unchanged_go_to_keeps_its_destination_and_a_new_page_keeps_its_fit() {
|
||||
use crate::links::{self, LinkAction, LinkStyle, LinkZoom};
|
||||
let pages = page_refs(&fixture()).unwrap();
|
||||
let dest = |page: ObjRef, kind: &str, operands: Vec<Object>| {
|
||||
let mut d = vec![Object::Ref(page), Object::name(kind)];
|
||||
d.extend(operands);
|
||||
Object::Array(d)
|
||||
};
|
||||
let link_with = |key: &str, value: Object| {
|
||||
let mut doc = fixture();
|
||||
let i =
|
||||
links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap();
|
||||
let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap();
|
||||
let mut d = doc.get(obj).as_dict().cloned().unwrap();
|
||||
d.remove(b"Dest");
|
||||
if key == "A" {
|
||||
let mut go_to = pdfcraft_cos::Dict::new();
|
||||
go_to.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
go_to.set(b"D".to_vec(), value);
|
||||
d.set(b"A".to_vec(), Object::Dict(go_to));
|
||||
} else {
|
||||
d.set(b"Dest".to_vec(), value);
|
||||
}
|
||||
doc.set(obj, Object::Dict(d));
|
||||
(doc, i, obj)
|
||||
};
|
||||
let (num, null) = (|n: i64| Object::Int(n), Object::Null);
|
||||
// The same page and zoom: nothing is rewritten.
|
||||
for (key, value) in [
|
||||
("Dest", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)])),
|
||||
("Dest", dest(pages[1], "FitBH", vec![num(250)])),
|
||||
("Dest", dest(pages[1], "FitV", vec![num(20)])),
|
||||
("A", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(0.005)])),
|
||||
] {
|
||||
let (mut doc, i, obj) = link_with(key, value);
|
||||
let before = doc.get(obj).as_dict().cloned();
|
||||
let LinkAction::Page { page, zoom } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else {
|
||||
panic!("{key}")
|
||||
};
|
||||
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page, zoom }), None).unwrap();
|
||||
assert_eq!(doc.get(obj).as_dict().cloned(), before, "{key} {zoom:?}: left as it is");
|
||||
}
|
||||
// A new page: the fit's kind and the factor stay, positions go.
|
||||
for (value, written) in [
|
||||
(dest(pages[1], "FitBH", vec![num(250)]), dest(pages[0], "FitBH", vec![null.clone()])),
|
||||
(dest(pages[1], "FitBV", vec![num(20)]), dest(pages[0], "FitBV", vec![null.clone()])),
|
||||
(dest(pages[1], "FitH", vec![num(250)]), dest(pages[0], "FitH", vec![null.clone()])),
|
||||
(
|
||||
dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)]),
|
||||
dest(pages[0], "XYZ", vec![null.clone(), null.clone(), Object::Real(2.0)]),
|
||||
),
|
||||
] {
|
||||
let (mut doc, i, obj) = link_with("Dest", value.clone());
|
||||
let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else {
|
||||
panic!("{value:?}")
|
||||
};
|
||||
links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap();
|
||||
assert_eq!(doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()), Some(written), "{value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1264,22 +1458,36 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let strike = &a[i];
|
||||
assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..])));
|
||||
let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
|
||||
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..])));
|
||||
assert!(caret.reference(b"IRT").is_some());
|
||||
// The caret is the group's primary: a group's text is read from its primary and the
|
||||
// subordinates' Contents are ignored (ISO 32000-2 §12.5.6.2), so the replacement is on it.
|
||||
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"IT")), ("new words", Some(&b"Replace"[..])));
|
||||
assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none(), "a primary has no /IRT: {caret:?}");
|
||||
assert_eq!(strike.name(b"RT"), Some(&b"Group"[..]));
|
||||
assert!(text(strike, b"Contents").is_empty());
|
||||
// #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it
|
||||
// replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the
|
||||
// pair does not survive an export and import.
|
||||
assert_eq!(text(caret, b"NM").as_str(), "r-caret");
|
||||
assert_eq!(text(strike, b"NM").as_str(), "r");
|
||||
assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name");
|
||||
let irt = caret.reference(b"IRT").unwrap();
|
||||
assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout");
|
||||
let irt = strike.reference(b"IRT").unwrap();
|
||||
assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped under the caret");
|
||||
let r = rect(caret);
|
||||
assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}");
|
||||
// Deleting the strikeout takes the caret with it (the group).
|
||||
// Deleting either one takes the other with it (the proposal is one comment).
|
||||
let marks = |doc: &Document| list(doc, 0).iter().filter(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))).count();
|
||||
let mut by_strike = doc.clone();
|
||||
delete_annotation(&mut by_strike, 0, i).unwrap();
|
||||
assert_eq!(marks(&by_strike), 0, "deleting the strikeout deletes the caret");
|
||||
let mut doc = doc;
|
||||
delete_annotation(&mut doc, 0, i).unwrap();
|
||||
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
|
||||
let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
|
||||
// A locked caret keeps the strikeout too: the pair can't be broken by deleting the other.
|
||||
let mut locked = doc.clone();
|
||||
set_locked(&mut locked, 0, c, true).unwrap();
|
||||
assert!(deletable(&locked, 0, i).is_err() && delete_annotation(&mut locked, 0, i).is_err(), "a locked caret refuses its strikeout's deletion");
|
||||
assert_eq!(marks(&locked), 2);
|
||||
delete_annotation(&mut doc, 0, c).unwrap();
|
||||
assert_eq!(marks(&doc), 0, "deleting the caret deletes the strikeout");
|
||||
assert!(
|
||||
add_text_replacement(
|
||||
&mut doc,
|
||||
@@ -1298,6 +1506,55 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Review on #5689 (CodeRabbit): clicking a Replace Text strikeout selects its caret, the
|
||||
/// group's primary, so a restyle from Comment Properties lands on the caret. Colour and opacity
|
||||
/// are the proposal's, so the strikeout is restyled and redrawn in the same edit, whichever
|
||||
/// member is restyled; it takes no border width. A locked member refuses the whole change.
|
||||
#[test]
|
||||
fn restyling_a_replace_text_pair_restyles_both_members() {
|
||||
let mut doc = fixture();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let tr = TextReplacement {
|
||||
page: 0,
|
||||
quads: &quads,
|
||||
replacement: "new words",
|
||||
author: "Ada",
|
||||
strike: &red,
|
||||
caret: &red,
|
||||
strike_meta: &meta("r"),
|
||||
caret_meta: &meta("r-caret"),
|
||||
};
|
||||
let s = add_text_replacement(&mut doc, tr).unwrap();
|
||||
let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
|
||||
let color = |doc: &Document, i: usize| {
|
||||
list(doc, 0)[i].get(b"C").and_then(|o| o.as_array().cloned()).unwrap().iter().filter_map(Object::as_f64).collect::<Vec<_>>()
|
||||
};
|
||||
let green = [0.0, 0.5, 0.0];
|
||||
|
||||
// Restyled through the caret (what a click on either part selects).
|
||||
let mut by_caret = doc.clone();
|
||||
set_style(&mut by_caret, 0, c, StyleChange::new(Some(green), Some(0.5), Some(3.0), None), &meta("")).unwrap();
|
||||
let by_caret = reopen(&by_caret);
|
||||
assert_eq!(color(&by_caret, c), green.to_vec());
|
||||
assert_eq!(color(&by_caret, s), green.to_vec(), "the strikeout takes the proposal's colour");
|
||||
let strike = &list(&by_caret, 0)[s];
|
||||
assert_eq!(strike.get(b"CA").and_then(Object::as_f64), Some(0.5), "and its opacity");
|
||||
assert!(strike.get(b"BS").is_none(), "a strikeout has no border to widen: {strike:?}");
|
||||
assert!(ap_content(&by_caret, strike).contains("0 0.5 0 RG"), "its appearance is redrawn: {}", ap_content(&by_caret, strike));
|
||||
|
||||
// Restyled through the strikeout: the caret follows.
|
||||
let mut by_strike = doc.clone();
|
||||
set_style(&mut by_strike, 0, s, StyleChange::new(Some(green), None, None, None), &meta("")).unwrap();
|
||||
assert_eq!(color(&by_strike, c), green.to_vec(), "the caret takes the proposal's colour");
|
||||
|
||||
// A locked strikeout refuses the change, and nothing moves.
|
||||
let mut locked = doc.clone();
|
||||
set_locked(&mut locked, 0, s, true).unwrap();
|
||||
assert!(set_style(&mut locked, 0, c, StyleChange::new(Some(green), None, None, None), &meta("")).is_err());
|
||||
assert_eq!(color(&locked, c), red.color.to_vec(), "the caret keeps its colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_attach_as_comments() {
|
||||
let mut doc = fixture();
|
||||
@@ -2786,6 +3043,71 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
|
||||
check(&reopen(&doc));
|
||||
}
|
||||
|
||||
/// PDFBOX-2699: a `/C` written `[1 0 0 null]` (Table 166 asks for numbers) is still a four-entry
|
||||
/// colour, CMYK with the null as 0 as PDFium, MuPDF and Poppler read it, so a shape without an
|
||||
/// appearance is drawn instead of dropped. `/IC` is read the same way, and three numbers stay RGB.
|
||||
/// The properties and the comment list report the colours as drawn.
|
||||
#[test]
|
||||
fn a_four_entry_colour_with_a_null_is_cmyk_with_zero() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 null] /IC [0 0 1 null] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("both squares get a fallback appearance");
|
||||
let squares = list(&display, 0);
|
||||
let cmyk = ap_content(&display, &squares[0]);
|
||||
assert!(cmyk.contains("0 1 1 RG") && cmyk.contains("1 1 0 rg"), "CMYK (1,0,0,0) stroke and (0,0,1,0) fill: {cmyk}");
|
||||
let rgb = ap_content(&display, &squares[1]);
|
||||
assert!(rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}");
|
||||
// The properties read the stroke and the fill as they are drawn.
|
||||
let p = props(&doc, 0, 0).expect("the square has properties");
|
||||
assert_eq!((p.color, p.fill), (Some([0.0, 1.0, 1.0]), Some([1.0, 1.0, 0.0])));
|
||||
assert_eq!(props(&doc, 0, 1).and_then(|p| p.color), Some([1.0, 0.0, 0.0]));
|
||||
// The comment list reports the same colours.
|
||||
let colors: Vec<Option<[f32; 3]>> = summaries(&doc).iter().map(|s| s.color).collect();
|
||||
assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([1.0, 0.0, 0.0])]);
|
||||
}
|
||||
|
||||
/// A `/C` given by reference is followed for the comment list, as three numbers were, and read
|
||||
/// like a direct one: `[0 0 1 null]` is CMYK yellow, and a referenced grey level is grey.
|
||||
#[test]
|
||||
fn a_referenced_colour_is_listed_like_a_direct_one() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C 6 0 R >>",
|
||||
b"<< /Type /Annot /Subtype /Circle /Rect [55 5 95 35] /C 7 0 R >>",
|
||||
b"[0 0 1 null]",
|
||||
b"[0.5]",
|
||||
]);
|
||||
let colors: Vec<Option<[f32; 3]>> = summaries(&doc).iter().map(|s| s.color).collect();
|
||||
assert_eq!(colors, [Some([1.0, 1.0, 0.0]), Some([0.5, 0.5, 0.5])]);
|
||||
}
|
||||
|
||||
/// A reference among the four entries is not a non-number read as 0: `/C [1 0 0 6 0 R]` with
|
||||
/// object 6 the number 1 is CMYK black (§7.3.10), so drawing it cyan would be wrong. The builder
|
||||
/// doesn't follow it and draws no appearance, as for any colour it can't read.
|
||||
#[test]
|
||||
fn a_four_entry_colour_with_a_reference_is_not_read_as_zero() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 6 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>",
|
||||
b"1",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("the RGB square gets a fallback appearance");
|
||||
let squares = list(&display, 0);
|
||||
assert!(!squares[0].contains(b"AP"), "no cyan stand-in for CMYK black");
|
||||
assert_eq!(props(&doc, 0, 0).and_then(|p| p.color), None);
|
||||
assert_eq!(summaries(&doc)[0].color, None, "nor in the comment list");
|
||||
}
|
||||
|
||||
/// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits.
|
||||
fn baselines(content: &str) -> Vec<f64> {
|
||||
content.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Layer L4 (may use `edit`): verification tooling, the faux-redaction audit.
|
||||
|
||||
```rust
|
||||
let findings: Vec<Finding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
|
||||
let findings: Vec<AuditFinding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
|
||||
```
|
||||
|
||||
It reports two kinds of finding:
|
||||
|
||||
@@ -61,7 +61,8 @@ do not keep an old PDF byte buffer alive.
|
||||
|
||||
This bounds retained text-cache data, not total process memory: active extraction batches,
|
||||
renderers, document buffers and the requested output have separate lifetimes. Search still reads
|
||||
growing batches (8 to 128 pages), with one lookahead match to determine continuation.
|
||||
growing batches (8 to 128 pages), with one lookahead match to determine continuation, unless
|
||||
`stop_at_limit` is set (below), which starts at one page and looks no further than its limit.
|
||||
|
||||
`text_find` keeps matches from readable pages when another reached page fails extraction. It
|
||||
returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and
|
||||
@@ -75,6 +76,16 @@ that reaches the end with warnings therefore has `complete: false` **without** `
|
||||
should follow `next` when present and separately inspect warnings. A capped search can return
|
||||
both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged.
|
||||
|
||||
That lookahead means a search with exactly `limit` matches reads on to the end of the document to
|
||||
learn that no further match exists. `stop_at_limit: true` skips it: the search ends at the
|
||||
`limit`th match. It reads one page first, then batches of 2, 4, … up to 128 pages, so nothing past
|
||||
the batch holding that match is read or can add warnings (a match on the first page reads only that
|
||||
page). The answer carries
|
||||
`stopped_at_limit`. When that is `true`, `complete` is `false` and `next` is only the position just
|
||||
past the last match, not a promise of another one; following it may return no matches and
|
||||
`complete: true`. When it is `false`, the search ran out of pages first and `complete` means what
|
||||
it always does.
|
||||
|
||||
`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed
|
||||
semantics; they never silently act on partial text.
|
||||
|
||||
@@ -288,7 +299,14 @@ Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
|
||||
reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
|
||||
`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
|
||||
(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
|
||||
The model is framed to fill the image and shaded by a headlight on white. U3D models are refused
|
||||
The model is framed to fill the image and shaded by a headlight on white. `orbit`, instead of
|
||||
`direction`, frames it as a viewer's orbit control would: `{view, up, front, turn, zoom,
|
||||
zoom_at, pan}`, all optional. `view` is `iso` (default), `front`, `right`, `top` or `back`
|
||||
about the `up` axis (default `+z`) and the `front` axis (perpendicular to `up`); `turn` is
|
||||
`[yaw, pitch]` in degrees, pitch down; `zoom` is a positive factor, the result held within 0.05 to 50, at `zoom_at` `[right, up]`
|
||||
in picture heights from the centre, where the point under it stays still; `pan` `[right, up]`
|
||||
moves the target, in model radii, so the model moves the other way. Every view frames the model
|
||||
at the same size, whatever its direction. U3D models are refused
|
||||
with an error, and the annotation's own views, lighting and render modes are not applied yet.
|
||||
|
||||
## Viewer navigation history
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
|
||||
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
|
||||
|
||||
mod documents;
|
||||
mod form_tools;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
|
||||
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
|
||||
@@ -148,18 +148,7 @@ impl Automation {
|
||||
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
|
||||
out.push(match result {
|
||||
Ok(r) => {
|
||||
let target = child(&folder, &name);
|
||||
let target = if target.exists() {
|
||||
let stem =
|
||||
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
|
||||
unused(&folder, &stem)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
if target.exists() {
|
||||
return Err(failed(format!("{}: no unused output name available", target.display())));
|
||||
}
|
||||
write_atomic(&target, &r.bytes)?;
|
||||
let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?;
|
||||
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
|
||||
}
|
||||
Err(e) => json!({ "path": p, "error": e }),
|
||||
|
||||
@@ -281,7 +281,7 @@ impl Automation {
|
||||
}
|
||||
|
||||
/// Publish atomically without replacing any existing name, even if another writer wins a race.
|
||||
fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
|
||||
pub(crate) fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
|
||||
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
|
||||
std::fs::create_dir_all(dir).map_err(failed)?;
|
||||
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
|
||||
|
||||
+243
-25
@@ -8,6 +8,8 @@ use super::json::Args;
|
||||
use super::paths::read_input;
|
||||
use super::{Arc, Automation, Path, PathBuf};
|
||||
|
||||
const MAX_BATES_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
struct FilePlan {
|
||||
input: String,
|
||||
source: PathBuf,
|
||||
@@ -26,19 +28,23 @@ impl Automation {
|
||||
}
|
||||
let start = match args.0.get("start_number") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.filter(|n| *n >= 1)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?,
|
||||
Some(value) => {
|
||||
value.as_u64().filter(|n| *n >= 1).ok_or_else(|| ToolError::InvalidArgs("start_number must be an integer of at least 1".into()))?
|
||||
}
|
||||
};
|
||||
let digits = args.opt_int("digits")?.unwrap_or(6);
|
||||
let digits =
|
||||
u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?;
|
||||
let prefix = args.opt_str("prefix")?.unwrap_or("");
|
||||
let suffix = args.opt_str("suffix")?.unwrap_or("");
|
||||
// `#` separates the token's fields and `>>` ends it: either would cut the token short.
|
||||
if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) {
|
||||
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into()));
|
||||
if prefix.chars().count() > 128
|
||||
|| suffix.chars().count() > 128
|
||||
|| prefix.contains('#')
|
||||
|| suffix.contains('#')
|
||||
|| prefix.contains(">>")
|
||||
|| suffix.contains(">>")
|
||||
{
|
||||
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 characters and cannot contain # or >>".into()));
|
||||
}
|
||||
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
|
||||
|
||||
@@ -70,16 +76,7 @@ impl Automation {
|
||||
targets.push((output.to_string(), target));
|
||||
passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned));
|
||||
}
|
||||
let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect();
|
||||
let mut output_paths = std::collections::HashSet::new();
|
||||
for (name, path) in &targets {
|
||||
if input_paths.contains(path) {
|
||||
return Err(failed(format!("output {name} names an input file; choose a separate output path")));
|
||||
}
|
||||
if !output_paths.insert(path.clone()) {
|
||||
return Err(failed(format!("two files use the same output path: {name}")));
|
||||
}
|
||||
}
|
||||
check_unique_outputs(&targets, dry_run)?;
|
||||
|
||||
let mut plans = Vec::with_capacity(files.len());
|
||||
let mut next = start;
|
||||
@@ -88,13 +85,13 @@ impl Automation {
|
||||
let size = std::fs::metadata(input_path).map_err(failed)?.len();
|
||||
input_bytes = input_bytes
|
||||
.checked_add(size)
|
||||
.filter(|total| *total <= 512 * 1024 * 1024)
|
||||
.filter(|total| *total <= MAX_BATES_BYTES)
|
||||
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
|
||||
let bytes = read_input(input_path)?;
|
||||
input_bytes = input_bytes
|
||||
.saturating_sub(size)
|
||||
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
|
||||
.filter(|total| *total <= 512 * 1024 * 1024)
|
||||
.filter(|total| *total <= MAX_BATES_BYTES)
|
||||
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
|
||||
let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let password = passwords.get(index).and_then(Option::as_deref);
|
||||
@@ -106,7 +103,8 @@ impl Automation {
|
||||
return Err(failed(format!("{input_name} has no pages to number")));
|
||||
}
|
||||
let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?;
|
||||
let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
|
||||
let last_offset = count.checked_sub(1).ok_or_else(|| failed(format!("{input_name} has no pages to number")))?;
|
||||
let last = next.checked_add(last_offset).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
|
||||
plans.push(FilePlan {
|
||||
input: input_name.clone(),
|
||||
source: input_path.clone(),
|
||||
@@ -116,7 +114,7 @@ impl Automation {
|
||||
first: next,
|
||||
last,
|
||||
});
|
||||
if index + 1 < sources.len() {
|
||||
if index + 1 < files.len() {
|
||||
next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?;
|
||||
}
|
||||
}
|
||||
@@ -163,12 +161,14 @@ impl Automation {
|
||||
let result = applied.and_then(|()| self.session.save_full_bytes(id));
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
|
||||
if total > 512 * 1024 * 1024 {
|
||||
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
|
||||
} else {
|
||||
let candidate = output_bytes
|
||||
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
|
||||
.filter(|total| *total <= MAX_BATES_BYTES);
|
||||
if let Some(total) = candidate {
|
||||
output_bytes = total;
|
||||
staged.push((plan.output.clone(), bytes));
|
||||
} else {
|
||||
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
|
||||
}
|
||||
}
|
||||
Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})),
|
||||
@@ -198,6 +198,123 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_unique_outputs(targets: &[(String, PathBuf)], dry_run: bool) -> Result<()> {
|
||||
if targets.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Probe exact basenames in a directory under each canonical parent, so the filesystem applies its own name rules.
|
||||
let mut groups: std::collections::HashMap<PathBuf, (PathBuf, Vec<(String, std::ffi::OsString)>)> = std::collections::HashMap::new();
|
||||
for (name, target) in targets {
|
||||
let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {name}")))?;
|
||||
let identity = parent.canonicalize().map_err(failed)?;
|
||||
let probe_name = output_probe_name(target).map_err(failed)?;
|
||||
groups.entry(identity.clone()).or_insert_with(|| (identity, Vec::new())).1.push((name.clone(), probe_name));
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
// Dry runs cannot probe the volume: Windows uses a conservative case-insensitive comparison,
|
||||
// while Unix preserves case, so unusual case-sensitive Windows or case-insensitive Unix volumes can differ.
|
||||
for (_, (_, names)) in groups {
|
||||
for (index, (name, candidate)) in names.iter().enumerate() {
|
||||
if names.iter().take(index).any(|(_, prior)| same_output_name(prior, candidate)) {
|
||||
return Err(failed(format!("two files use the same output path: {name}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (_, (parent, names)) in groups {
|
||||
match tempfile::tempdir_in(&parent) {
|
||||
Ok(probes) => {
|
||||
for (name, probe_name) in names {
|
||||
let probe = probes.path().join(probe_name);
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
|
||||
Ok(file) => drop(file),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(failed(format!("two files use the same output path: {name}")));
|
||||
}
|
||||
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => probe_files_for_resolved_outputs(&parent, names)?,
|
||||
Err(error) => return Err(failed(error)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn probe_files_for_resolved_outputs(parent: &Path, names: Vec<(String, std::ffi::OsString)>) -> Result<()> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
|
||||
let mut probes = Vec::with_capacity(names.len());
|
||||
for (name, probe_name) in names {
|
||||
let probe = parent.join(probe_name);
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true).share_mode(0).custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
|
||||
match options.open(&probe) {
|
||||
Ok(file) => probes.push(file),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(failed(format!("two files use the same output path: {name}")));
|
||||
}
|
||||
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
|
||||
}
|
||||
}
|
||||
drop(probes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
fn key(name: &std::ffi::OsStr) -> Vec<u32> {
|
||||
std::char::decode_utf16(name.encode_wide())
|
||||
.map(|decoded| match decoded {
|
||||
Ok(character) => {
|
||||
let mut uppercase = character.to_uppercase();
|
||||
match (uppercase.next(), uppercase.next()) {
|
||||
(Some(single), None) => single as u32,
|
||||
_ => character as u32,
|
||||
}
|
||||
}
|
||||
Err(error) => 0x11_0000 + u32::from(error.unpaired_surrogate()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
key(left) == key(right)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
left == right
|
||||
}
|
||||
}
|
||||
|
||||
fn output_probe_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
|
||||
let name = path.file_name().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "output has no file name"))?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
let is_verbatim = path.components().any(|component| matches!(component, std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) | std::path::Prefix::VerbatimDisk(_))));
|
||||
let mut wide: Vec<u16> = name.encode_wide().collect();
|
||||
if !is_verbatim {
|
||||
while wide.last().is_some_and(|unit| *unit == u16::from(b'.') || *unit == u16::from(b' ')) {
|
||||
wide.pop();
|
||||
}
|
||||
}
|
||||
Ok(std::ffi::OsString::from_wide(&wide))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(name.to_os_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a PDF atomically while refusing a target created by another process after preflight.
|
||||
pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
|
||||
@@ -212,3 +329,104 @@ pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::check_unique_outputs;
|
||||
|
||||
#[test]
|
||||
fn dry_run_path_check_creates_no_probe_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("same.pdf");
|
||||
let targets = vec![("first.pdf".into(), path.clone()), ("second.pdf".into(), path)];
|
||||
assert!(check_unique_outputs(&targets, true).is_err());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dry_run_detects_output_parent_aliases_without_writing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alias = dir.path().join("alias");
|
||||
std::os::unix::fs::symlink(dir.path(), &alias).unwrap();
|
||||
let first = dir.path().join("same.pdf");
|
||||
let second = alias.join("same.pdf");
|
||||
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
|
||||
assert!(check_unique_outputs(&targets, true).is_err());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_names_fit_in_probe_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join(format!("{}.pdf", "a".repeat(220)));
|
||||
let second = dir.path().join(format!("{}.pdf", "b".repeat(220)));
|
||||
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
|
||||
assert!(check_unique_outputs(&targets, false).is_ok());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn output_probe_paths_preserve_non_utf8_names() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xff]));
|
||||
let second = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xfe]));
|
||||
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
|
||||
assert!(check_unique_outputs(&targets, false).is_ok());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_probe_names_trim_only_non_verbatim_paths() {
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
let normal = Path::new(r"C:\root\report.pdf. ");
|
||||
assert_eq!(super::output_probe_name(normal).unwrap(), OsString::from("report.pdf"));
|
||||
|
||||
let verbatim = Path::new(r"\\?\C:\root\report.pdf. ");
|
||||
assert_eq!(super::output_probe_name(verbatim).unwrap(), OsString::from("report.pdf. "));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn dry_run_detects_case_only_output_collisions_without_writing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join("Case.pdf");
|
||||
let second = dir.path().join("CASE.pdf");
|
||||
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
|
||||
assert!(check_unique_outputs(&targets, true).is_err());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn permission_denied_child_directory_uses_delete_on_close_file_probes() {
|
||||
use std::process::Command;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let identity = Command::new("whoami.exe").args(["/user", "/fo", "csv", "/nh"]).output().unwrap();
|
||||
assert!(identity.status.success());
|
||||
let identity = String::from_utf8(identity.stdout).unwrap();
|
||||
let sid = identity.split('"').nth(3).unwrap();
|
||||
let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap();
|
||||
assert!(acl.success());
|
||||
|
||||
// Where the deny entry does not bind the test's own account (it did not on CI's Windows image),
|
||||
// there is no denied folder to probe.
|
||||
if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) {
|
||||
return;
|
||||
}
|
||||
let direct_probe = dir.path().join("direct-probe");
|
||||
let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap();
|
||||
drop(file);
|
||||
std::fs::remove_file(&direct_probe).unwrap();
|
||||
|
||||
let targets = vec![("first.pdf".into(), dir.path().join("first.pdf")), ("second.pdf".into(), dir.path().join("second.pdf"))];
|
||||
assert!(check_unique_outputs(&targets, false).is_ok());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,8 +490,12 @@ impl Automation {
|
||||
if contents.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into()));
|
||||
}
|
||||
// `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the
|
||||
// caret carries the colour too. Only the colour: opacity and the rest are the
|
||||
// caret's own.
|
||||
let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
|
||||
caret.color = style.color;
|
||||
let strike = style;
|
||||
let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
|
||||
let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?;
|
||||
out["comment"] = json!({ "page": page + 1, "type": "replace" });
|
||||
return Ok(out);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, nest_rows, security, summary};
|
||||
use super::paths::{child, read_input, write_atomic};
|
||||
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
|
||||
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, TextBlock, Value, comments, json};
|
||||
|
||||
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
|
||||
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
|
||||
@@ -135,18 +135,18 @@ impl Automation {
|
||||
pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
let target = match a.opt_str("path")? {
|
||||
let explicit = a.opt_str("path")?;
|
||||
let target = match explicit {
|
||||
Some(p) => self.resolve(p, true)?,
|
||||
None => {
|
||||
// Saving in place writes where the file was opened from, which the read root
|
||||
// allows and the write root may not.
|
||||
// allows and the write root may not. It also goes through --root again: a
|
||||
// folder on the remembered path may have become a link out since (#1791).
|
||||
let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?;
|
||||
let own_path = PathBuf::from(&own);
|
||||
self.check_roots(&own, &own_path, file_access::Access::Write)?;
|
||||
own_path
|
||||
self.resolve(&own, true)?
|
||||
}
|
||||
};
|
||||
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
|
||||
let same_file = explicit.is_none() || doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
|
||||
// Saving to a new file is a full rewrite unless asked otherwise, like Save As.
|
||||
let full = a.opt_bool("full")?.unwrap_or(!same_file);
|
||||
let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false);
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
|
||||
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The path is made absolute against the working directory, then followed the way the file
|
||||
//! system reads it, one name at a time from the top, its links followed. A name is looked up
|
||||
//! only while the folder it is in is the root, inside it, or one of the root's own parent
|
||||
//! folders: a path that leaves those is refused before anything beyond is touched (looking up
|
||||
//! `/net/<host>` mounts it), and every such refusal reads the same, so it can't tell what exists
|
||||
//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken
|
||||
//! as plain names (`..` below a missing folder is refused, and so is a link that leads nowhere).
|
||||
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
|
||||
//! than the root's is refused by name, before anything is resolved: resolving it would contact
|
||||
//! the host.
|
||||
@@ -20,7 +23,6 @@
|
||||
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
|
||||
//! files it was given; they don't guard against other programs on the computer.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -100,11 +102,10 @@ impl AutomationRoots {
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
||||
};
|
||||
};
|
||||
let absolute = absolute(shown, target)?;
|
||||
// Another network share or a device is refused by name, as `--root` refuses it: even
|
||||
// resolving the path would contact the host (#4003).
|
||||
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
|
||||
return Ok(());
|
||||
match follow(shown, &absolute(shown, target)?, folder) {
|
||||
Toward::Inside => return Ok(()),
|
||||
Toward::Refused(why) => return Err(why),
|
||||
Toward::Outside => {}
|
||||
}
|
||||
let root = root_shown.display();
|
||||
match access {
|
||||
@@ -141,42 +142,66 @@ fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
|
||||
}
|
||||
|
||||
/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing
|
||||
/// folder's links followed and the rest appended.
|
||||
fn resolve(shown: &str, absolute: &Path) -> Result<PathBuf, String> {
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute;
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
/// Where a followed path ends up.
|
||||
pub(crate) enum Toward {
|
||||
/// Inside the root, with its links followed (a missing tail taken as names).
|
||||
Inside,
|
||||
/// Anywhere else. Said the same way whatever is out there.
|
||||
Outside,
|
||||
/// Inside the root, but it can't be followed, and why.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Follow the absolute `path` the way the file system reads it, one name at a time from the top,
|
||||
/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own
|
||||
/// parent folders (all with their links followed already, so `..` means what it means to the file
|
||||
/// system).
|
||||
pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward {
|
||||
// Another network share or a device is refused by name, as `--root` refuses it: even looking
|
||||
// it up would contact the host (#4003).
|
||||
if foreign_share(path, root) {
|
||||
return Toward::Outside;
|
||||
}
|
||||
let near = |at: &Path| inside(at, root) || inside(root, at);
|
||||
let mut parts = path.components().peekable();
|
||||
let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect();
|
||||
let mut at = match std::fs::canonicalize(&top) {
|
||||
Ok(at) if near(&at) => at,
|
||||
_ => return Toward::Outside,
|
||||
};
|
||||
while let Some(part) = parts.next() {
|
||||
let name = match part {
|
||||
Component::Normal(name) => name,
|
||||
Component::CurDir => continue,
|
||||
Component::ParentDir => {
|
||||
at.pop();
|
||||
continue;
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||
Component::Prefix(_) | Component::RootDir => return Toward::Outside,
|
||||
};
|
||||
let next = at.join(name);
|
||||
match std::fs::canonicalize(&next) {
|
||||
Ok(found) if near(&found) => at = found,
|
||||
Ok(_) => return Toward::Outside,
|
||||
// Not even there yet: only inside the root does it matter why.
|
||||
Err(_) if !inside(&at, root) => return Toward::Outside,
|
||||
Err(e) => {
|
||||
return match std::fs::symlink_metadata(&next) {
|
||||
Ok(m) if m.file_type().is_symlink() => Toward::Refused(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) {
|
||||
return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
}
|
||||
Toward::Inside
|
||||
}
|
||||
Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if inside(&at, root) { Toward::Inside } else { Toward::Outside }
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
@@ -306,6 +331,62 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Outside the root, the refusal comes before anything out there is looked up, so it can't
|
||||
/// tell whether a folder out there exists.
|
||||
#[test]
|
||||
fn paths_out_of_the_root_are_refused_alike_without_looking() {
|
||||
let (base, roots) = folders("follow");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let root = s(base.join("in"));
|
||||
let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, "<path>");
|
||||
// Through a folder outside that exists, and through one that doesn't.
|
||||
let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf");
|
||||
let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf");
|
||||
assert_eq!(refusal(&there), refusal(&missing));
|
||||
assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing));
|
||||
// Out through a missing folder and back in. (Windows folds `..` by name when it makes a path
|
||||
// absolute, as it does when it opens one: that path is `in\a.pdf` there.)
|
||||
#[cfg(not(windows))]
|
||||
assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root"));
|
||||
// A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}");
|
||||
assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf")));
|
||||
assert!(
|
||||
refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"),
|
||||
"{}",
|
||||
refusal(&verbatim(r"..\nowhere\x.pdf"))
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on
|
||||
/// macOS, where the temporary folder is reached through a link).
|
||||
#[test]
|
||||
fn a_root_may_be_named_as_given_or_as_resolved() {
|
||||
let (base, roots) = folders("given");
|
||||
let resolved = std::fs::canonicalize(base.join("in")).unwrap();
|
||||
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A link beside the root, in one of its parent folders, is followed: to the root it leads in,
|
||||
/// anywhere else it leads out.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_beside_the_root_are_followed() {
|
||||
let (base, roots) = folders("beside");
|
||||
std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap();
|
||||
assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(()));
|
||||
assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
@@ -393,7 +474,8 @@ mod tests {
|
||||
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
|
||||
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
|
||||
// inputs and out_dir, walks only inside them (skipping links) and resolves every
|
||||
// discovered file again before `process` reads it or `publish` writes it.
|
||||
// discovered file again before `process` reads it or `publish` writes it. `doc_bates_multi`
|
||||
// resolves its paths before checking output collisions and using the probe helpers.
|
||||
const RESOLVED_BY_CALLER: &[&str] = &[
|
||||
"write_atomic",
|
||||
"write_atomic_with",
|
||||
@@ -405,6 +487,8 @@ mod tests {
|
||||
"linked",
|
||||
"walk",
|
||||
"publish",
|
||||
"check_unique_outputs",
|
||||
"probe_files_for_resolved_outputs",
|
||||
];
|
||||
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
||||
let mut files = vec![];
|
||||
|
||||
@@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks,
|
||||
/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs.
|
||||
pub(crate) fn info(d: &Document) -> Value {
|
||||
let i = &d.info;
|
||||
let page1 = |p: usize| p + 1;
|
||||
@@ -294,7 +296,7 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
// Custom document properties (Document Properties ▸ Custom): /Info entries beyond the
|
||||
// ones the spec names, set and cleared with doc_set_info.
|
||||
"custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::<Vec<_>>(),
|
||||
"creator": i.creator, "producer": i.producer,
|
||||
"creator": i.creator, "producer": i.producer, "created": i.created, "modified": i.modified,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
// Where the document opens (/OpenAction, named destinations resolved): 1-based page and
|
||||
@@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
"pages": i.pages.iter().enumerate().map(|(n, p)| json!({
|
||||
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,
|
||||
})).collect::<Vec<_>>(),
|
||||
"outline": outline(&i.outline),
|
||||
"outline": outline(i, &i.outline),
|
||||
"outline_more": i.outline_more,
|
||||
// Rectangles use the tools' convention (top-left of the displayed page, like
|
||||
// comment_list and link_list), not raw PDF user space, so they can be fed back to
|
||||
@@ -329,32 +331,7 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
})).collect::<Vec<_>>(),
|
||||
"links": i.links.iter().map(|l| json!({
|
||||
"page": page1(l.page), "rect": view_rect(i, l.page, l.rect),
|
||||
"target": match &l.target {
|
||||
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
|
||||
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
|
||||
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
|
||||
"layers": changes.iter().map(|(op, ocg)| json!({
|
||||
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
|
||||
"state": match op {
|
||||
pdfcraft_render::LayerOp::On => "on",
|
||||
pdfcraft_render::LayerOp::Off => "off",
|
||||
pdfcraft_render::LayerOp::Toggle => "toggle",
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"preserve_rb": preserve_rb,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
|
||||
"action": if *launch { "Launch" } else { "GoToR" },
|
||||
"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 }),
|
||||
},
|
||||
"target": link_target(i, &l.target),
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
|
||||
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
|
||||
@@ -389,6 +366,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [
|
||||
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
|
||||
}
|
||||
|
||||
fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
|
||||
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
|
||||
/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`).
|
||||
fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value {
|
||||
Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|o| {
|
||||
json!({
|
||||
"title": o.title, "page": o.page.map(|p| p + 1),
|
||||
"action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer
|
||||
/// changes, another PDF, or the name of an action that isn't run.
|
||||
fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value {
|
||||
// A remote page number is the file's, however large.
|
||||
let page1 = |p: usize| p.saturating_add(1);
|
||||
match target {
|
||||
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
|
||||
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
|
||||
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
|
||||
"layers": changes.iter().map(|(op, ocg)| json!({
|
||||
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
|
||||
"state": match op {
|
||||
pdfcraft_render::LayerOp::On => "on",
|
||||
pdfcraft_render::LayerOp::Off => "off",
|
||||
pdfcraft_render::LayerOp::Toggle => "toggle",
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"preserve_rb": preserve_rb,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
|
||||
"action": if *launch { "Launch" } else { "GoToR" },
|
||||
"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 }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ use serde_json::{Value, json};
|
||||
pub use pdfcraft_render::Pixels;
|
||||
pub use tools::{ToolDef, tools};
|
||||
|
||||
use file_access::foreign_share;
|
||||
use text_cache::TextCache;
|
||||
|
||||
mod assemble;
|
||||
@@ -71,7 +70,7 @@ use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content};
|
||||
use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel};
|
||||
#[cfg(test)]
|
||||
pub(crate) use paths::MAX_INPUT;
|
||||
use paths::{child, lexical, unused, write_atomic};
|
||||
use paths::{child, lexical, write_atomic};
|
||||
pub(crate) use paths::{read_input, read_input_bytes};
|
||||
#[cfg(test)]
|
||||
use paths::{write_atomic_synced, write_atomic_with};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! all links. Rectangles are points from the top-left of the displayed page.
|
||||
|
||||
use pdfcraft_engine::{
|
||||
Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
Edit, LinkAction, LinkHighlight, LinkStyle, LinkZoom, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
};
|
||||
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
|
||||
use serde_json::{Value, json};
|
||||
@@ -25,18 +25,30 @@ impl Automation {
|
||||
Ok(Some([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
|
||||
}
|
||||
|
||||
fn link_action(&self, a: &Args) -> Result<Option<LinkAction>> {
|
||||
/// The action `url`, `to_page` and `zoom` ask for, if any. `current` is the link's action now
|
||||
/// (link_edit): a new page keeps the link's zoom, and a zoom alone keeps its page.
|
||||
fn link_action(&self, a: &Args, current: Option<&LinkAction>) -> Result<Option<LinkAction>> {
|
||||
let zoom = link_zoom(a)?;
|
||||
let (current_page, current_zoom) = match current {
|
||||
Some(LinkAction::Page { page, zoom }) => (Some(*page), Some(*zoom)),
|
||||
_ => (None, None),
|
||||
};
|
||||
match (a.opt_str("url")?, a.opt_int("to_page")?) {
|
||||
(Some(_), Some(_)) => Err(bad("pass url or to_page, not both")),
|
||||
(Some(_), None) if zoom.is_some() => Err(bad("zoom goes with to_page, not url")),
|
||||
(Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))),
|
||||
(None, Some(p)) => {
|
||||
let n = self.doc(a)?.info.pages.len() as i64;
|
||||
if p < 1 || p > n {
|
||||
return Err(bad(format!("to_page must be 1–{n}")));
|
||||
}
|
||||
Ok(Some(LinkAction::Page(p as usize - 1)))
|
||||
Ok(Some(LinkAction::Page { page: p as usize - 1, zoom: zoom.or(current_zoom).unwrap_or(LinkZoom::FitPage) }))
|
||||
}
|
||||
(None, None) => Ok(None),
|
||||
(None, None) => match (zoom, current_page) {
|
||||
(None, _) => Ok(None),
|
||||
(Some(zoom), Some(page)) => Ok(Some(LinkAction::Page { page, zoom })),
|
||||
(Some(_), None) => Err(bad("zoom needs to_page: the link doesn't go to a page")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +98,16 @@ impl Automation {
|
||||
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
|
||||
v["destination"] = d;
|
||||
}
|
||||
if matches!(action, LinkAction::Other(_)) {
|
||||
v["named"] = json!(true);
|
||||
match action {
|
||||
LinkAction::Page { zoom, .. } => v["zoom"] = zoom_value(*zoom),
|
||||
LinkAction::Other(_) => v["named"] = json!(true),
|
||||
LinkAction::Uri(_) => {}
|
||||
}
|
||||
}
|
||||
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
|
||||
(LinkAction::Page { page, zoom }, None) => {
|
||||
v["to_page"] = json!(page + 1);
|
||||
v["zoom"] = zoom_value(*zoom);
|
||||
}
|
||||
(LinkAction::Other(o), None) => v["other"] = json!(o),
|
||||
}
|
||||
v
|
||||
@@ -168,12 +185,13 @@ impl Automation {
|
||||
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"))?;
|
||||
let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?;
|
||||
let action = self.link_action(a, None)?.ok_or_else(|| bad("pass url or to_page"))?;
|
||||
let style = self.link_style(a, LinkStyle::default())?;
|
||||
self.apply(a, Edit::AddLink { page, rect, action, style })
|
||||
}
|
||||
|
||||
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> {
|
||||
/// The link `page` and `index` name: its page, index in `/Annots`, appearance and action.
|
||||
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle, LinkAction)> {
|
||||
let page = self.page(a)?;
|
||||
let index = a.int("index")?;
|
||||
let doc = self.doc(a)?;
|
||||
@@ -182,19 +200,19 @@ impl Automation {
|
||||
.iter()
|
||||
.find(|l| l.page == page && l.index as i64 + 1 == index)
|
||||
.ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?;
|
||||
Ok((page, l.index, l.style))
|
||||
Ok((page, l.index, l.style, l.action.clone()))
|
||||
}
|
||||
|
||||
pub(crate) fn link_edit(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index, style) = self.link_index(a)?;
|
||||
let (page, index, style, current) = self.link_index(a)?;
|
||||
let rect = self.view_rect(a, page)?;
|
||||
let action = self.link_action(a)?;
|
||||
let action = self.link_action(a, Some(¤t))?;
|
||||
let new_style = self.link_style(a, style)?;
|
||||
self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) })
|
||||
}
|
||||
|
||||
pub(crate) fn link_delete(&mut self, a: &Args) -> Result<Value> {
|
||||
let (page, index, _) = self.link_index(a)?;
|
||||
let (page, index, ..) = self.link_index(a)?;
|
||||
self.apply(a, Edit::DeleteLink { page, index })
|
||||
}
|
||||
|
||||
@@ -225,6 +243,40 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom a `zoom` argument names: fit_page, fit_width, fit_height, fit_visible, actual
|
||||
/// (100%), inherit (keep the reader's zoom), or a percentage, kept within 1–6400.
|
||||
fn link_zoom(a: &Args) -> Result<Option<LinkZoom>> {
|
||||
match a.get("zoom") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Number(p)) => match p.as_f64() {
|
||||
Some(p) if p.is_finite() && p > 0.0 => Ok(Some(LinkZoom::Percent(p.clamp(1.0, 6400.0)))),
|
||||
_ => Err(bad("zoom must be a positive percentage")),
|
||||
},
|
||||
Some(Value::String(z)) => Ok(Some(match z.as_str() {
|
||||
"fit_page" => LinkZoom::FitPage,
|
||||
"fit_width" => LinkZoom::FitWidth,
|
||||
"fit_height" => LinkZoom::FitHeight,
|
||||
"fit_visible" => LinkZoom::FitVisible,
|
||||
"actual" => LinkZoom::Percent(100.0),
|
||||
"inherit" => LinkZoom::Inherit,
|
||||
o => return Err(bad(format!("unknown zoom {o:?}"))),
|
||||
})),
|
||||
Some(_) => Err(bad("zoom is a name or a percentage")),
|
||||
}
|
||||
}
|
||||
|
||||
/// How `link_list` reports a link's zoom: the names `link_zoom` reads, a percentage as a number.
|
||||
fn zoom_value(zoom: LinkZoom) -> Value {
|
||||
match zoom {
|
||||
LinkZoom::FitPage => json!("fit_page"),
|
||||
LinkZoom::FitWidth => json!("fit_width"),
|
||||
LinkZoom::FitHeight => json!("fit_height"),
|
||||
LinkZoom::FitVisible => json!("fit_visible"),
|
||||
LinkZoom::Inherit => json!("inherit"),
|
||||
LinkZoom::Percent(p) => json!(p),
|
||||
}
|
||||
}
|
||||
|
||||
fn dest_view_value(view: DestView) -> Value {
|
||||
match view {
|
||||
DestView::Top => json!({ "kind": "top" }),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use pdfcraft_engine::model3d::Model3dKind;
|
||||
use pdfcraft_prc::PrcFile;
|
||||
use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render};
|
||||
use pdfcraft_prc_render::nav::{Axis, NamedView, Orbit, Upright, reach};
|
||||
use pdfcraft_prc_render::{Bounds, Camera, RenderOptions, WorldMesh, assemble, render};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::Automation;
|
||||
use super::dispatch::Content;
|
||||
@@ -22,7 +24,6 @@ impl Automation {
|
||||
let page = self.page(a)?;
|
||||
let width = side(a, "width")?;
|
||||
let height = side(a, "height")?;
|
||||
let direction = direction(a)?;
|
||||
let perspective = a.opt_bool("perspective")?.unwrap_or(true);
|
||||
let doc = self.doc(a)?;
|
||||
let found = doc.model_3d_annotations(page).map_err(failed)?;
|
||||
@@ -42,7 +43,14 @@ impl Automation {
|
||||
let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
|
||||
let meshes = assemble(&prc).map_err(failed)?;
|
||||
let aspect = f64::from(width) / f64::from(height);
|
||||
let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?;
|
||||
let camera = match a.get("orbit") {
|
||||
Some(_) if a.get("direction").is_some() => return Err(Args::wrong("orbit", "left out when direction is given")),
|
||||
Some(moves) => orbit_camera(moves, &meshes, perspective, aspect)?,
|
||||
None => {
|
||||
let direction = direction(a)?;
|
||||
Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?
|
||||
}
|
||||
};
|
||||
let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
|
||||
let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
|
||||
Ok(vec![Content::Png { data, width: image.width, height: image.height }])
|
||||
@@ -71,6 +79,65 @@ fn direction(a: &Args) -> Result<[f64; 3]> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The keys `orbit` takes.
|
||||
const ORBIT_KEYS: [&str; 7] = ["view", "up", "front", "turn", "zoom", "zoom_at", "pan"];
|
||||
|
||||
/// The camera `orbit` names: a named view about an up axis, then a turn, a zoom and a pan, as a
|
||||
/// viewer's orbit control would apply them.
|
||||
fn orbit_camera(moves: &Value, meshes: &[WorldMesh], perspective: bool, aspect: f64) -> Result<Camera> {
|
||||
let wrong = |what: &str| Args::wrong("orbit", what);
|
||||
let moves = moves.as_object().ok_or_else(|| wrong("an object"))?;
|
||||
if let Some(key) = moves.keys().find(|k| !ORBIT_KEYS.contains(&k.as_str())) {
|
||||
return Err(wrong(&format!("an object without {key}; its keys are {}", ORBIT_KEYS.join(", "))));
|
||||
}
|
||||
let token = |key: &str| moves.get(key).map(|v| v.as_str().ok_or_else(|| wrong(&format!("{key} as a string"))));
|
||||
let axis = |key: &str| token(key).map(|t| Axis::from_token(t?).ok_or_else(|| wrong(&format!("{key} as one of +x, +y, +z, -x, -y, -z"))));
|
||||
let mut upright = Upright::default();
|
||||
if let Some(up) = axis("up") {
|
||||
upright = upright.with_up(up?);
|
||||
}
|
||||
if let Some(front) = axis("front") {
|
||||
let front = front?;
|
||||
if !upright.up().perpendicular(front) {
|
||||
return Err(wrong("front perpendicular to up"));
|
||||
}
|
||||
upright = upright.with_front(front);
|
||||
}
|
||||
let view = match token("view") {
|
||||
None => NamedView::Isometric,
|
||||
Some(t) => NamedView::from_token(t?).ok_or_else(|| wrong("view as one of iso, front, right, top, back"))?,
|
||||
};
|
||||
let mut orbit = Orbit::named(view, perspective, upright);
|
||||
let bounds = Bounds::of(meshes).ok_or_else(|| failed("the model has no vertex"))?;
|
||||
let reach = reach(meshes, &bounds);
|
||||
if let Some(v) = moves.get("turn") {
|
||||
let [yaw, pitch] = pair(v).ok_or_else(|| wrong("turn as [yaw, pitch] in degrees"))?;
|
||||
orbit.turn(yaw.to_radians(), pitch.to_radians());
|
||||
}
|
||||
if let Some(v) = moves.get("zoom") {
|
||||
let factor = v.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| wrong("zoom as a positive number"))?;
|
||||
let at = match moves.get("zoom_at") {
|
||||
None => [0.0, 0.0],
|
||||
Some(v) => pair(v).ok_or_else(|| wrong("zoom_at as [right, up] in picture heights"))?,
|
||||
};
|
||||
orbit.zoom_at(&bounds, reach, aspect, factor, at);
|
||||
} else if moves.contains_key("zoom_at") {
|
||||
return Err(wrong("an object with zoom when it has zoom_at"));
|
||||
}
|
||||
if let Some(v) = moves.get("pan") {
|
||||
orbit.pan_by(pair(v).ok_or_else(|| wrong("pan as [right, up] in model radii"))?);
|
||||
}
|
||||
orbit.camera(&bounds, reach, aspect).map_err(failed)
|
||||
}
|
||||
|
||||
/// Two finite numbers.
|
||||
fn pair(v: &Value) -> Option<[f64; 2]> {
|
||||
match v.as_array()?.as_slice() {
|
||||
[a, b] => Some([a.as_f64().filter(|f| f.is_finite())?, b.as_f64().filter(|f| f.is_finite())?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Z is up, as CAD models are drawn, unless the view looks along it.
|
||||
fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
|
||||
if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
|
||||
|
||||
@@ -270,6 +270,8 @@ impl Automation {
|
||||
/// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document
|
||||
/// (#971). One match past the limit supplies `next`. Reached extraction failures are reported
|
||||
/// as bounded warnings; `complete` requires both no continuation and no failed pages.
|
||||
/// `stop_at_limit` drops that lookahead: the search ends at the `limit`th match, so a lone match
|
||||
/// never sends it to the end of the document, and `next` is only where to resume (#5328).
|
||||
pub(crate) fn text_find(&mut self, a: &Args) -> Result<Value> {
|
||||
self.text_find_with(a, &mut extract_parallel)
|
||||
}
|
||||
@@ -289,11 +291,15 @@ impl Automation {
|
||||
Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))),
|
||||
};
|
||||
let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX);
|
||||
let stop_at_limit = a.opt_bool("stop_at_limit")?.unwrap_or(false);
|
||||
let mut matches = Vec::new();
|
||||
let mut next = None;
|
||||
let mut stopped = false;
|
||||
let mut warnings = Vec::new();
|
||||
let mut warning_count = 0usize;
|
||||
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
|
||||
// A search that stops at its limit starts with one page, so a match on it reads nothing
|
||||
// more; the batches still double for a match further in.
|
||||
let (mut first, mut batch) = (start, if stop_at_limit { 1 } else { FIND_FIRST_BATCH });
|
||||
'search: while first < n {
|
||||
let end = first.saturating_add(batch).min(n);
|
||||
let pages: Vec<usize> = (first..end).collect();
|
||||
@@ -315,13 +321,19 @@ impl Automation {
|
||||
let skip_here = if p == start { skip } else { 0 };
|
||||
// One extra match proves that the caller needs a continuation. Include skipped
|
||||
// matches on the starting page when bounding the temporary per-page result.
|
||||
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1);
|
||||
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(usize::from(!stop_at_limit));
|
||||
for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) {
|
||||
if matches.len() == limit {
|
||||
next = Some(json!({ "start_page": p + 1, "skip": k }));
|
||||
break 'search;
|
||||
}
|
||||
matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) }));
|
||||
if stop_at_limit && matches.len() == limit {
|
||||
// Resume just past this match; whether another exists isn't looked at.
|
||||
next = Some(json!({ "start_page": p + 1, "skip": k.saturating_add(1) }));
|
||||
stopped = true;
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
}
|
||||
first = end;
|
||||
@@ -329,8 +341,13 @@ impl Automation {
|
||||
}
|
||||
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches,
|
||||
"warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings });
|
||||
if let (Some(next), Some(o)) = (next, out.as_object_mut()) {
|
||||
o.insert("next".into(), next);
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
if let Some(next) = next {
|
||||
o.insert("next".into(), next);
|
||||
}
|
||||
if stop_at_limit {
|
||||
o.insert("stopped_at_limit".into(), stopped.into());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::error::{Result, failed};
|
||||
use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
|
||||
use super::{Automation, Path, PathBuf, StagingName, file_access, staging_suffixes};
|
||||
|
||||
impl Automation {
|
||||
// ---- paths -------------------------------------------------------------------------------
|
||||
@@ -39,7 +39,13 @@ impl Automation {
|
||||
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
|
||||
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
|
||||
let joined = lexical(&root.join(p)).ok_or_else(outside)?;
|
||||
if foreign_share(&joined, root) {
|
||||
// Followed one name at a time first: nothing outside the root or its own parent folders is
|
||||
// looked up, not even to see whether it exists. What can't be followed inside the root is
|
||||
// said below. Made absolute the way Windows reads it first: it drops a folder name's
|
||||
// trailing dot (`in.` is `in`), but `follow` looks names up below `\\?\` folders, where it
|
||||
// doesn't.
|
||||
let as_read = std::path::absolute(&joined).map_err(|_| outside())?;
|
||||
if matches!(file_access::follow(path, &as_read, root), file_access::Toward::Outside) {
|
||||
return Err(outside());
|
||||
}
|
||||
let mut existing = joined.as_path();
|
||||
|
||||
@@ -134,6 +134,18 @@ impl Automation {
|
||||
"shrink" => Layout::Size(SizeMode::Shrink),
|
||||
"custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)),
|
||||
"multiple" => {
|
||||
// Custom pages per sheet: `cols` across by `rows` down, both or neither.
|
||||
let grid = match (a.opt_int("cols")?, a.opt_int("rows")?) {
|
||||
(None, None) => None,
|
||||
(Some(cols), Some(rows)) => {
|
||||
if a.opt_int("per_sheet")?.is_some() {
|
||||
return Err(bad("give per_sheet, or cols and rows, not both"));
|
||||
}
|
||||
let grid = usize::try_from(cols).ok().zip(usize::try_from(rows).ok()).and_then(|(cols, rows)| Layout::grid(cols, rows));
|
||||
Some(grid.ok_or_else(|| bad(format!("cols and rows must each be 1 to {}", print::MAX_GRID)))?)
|
||||
}
|
||||
_ => return Err(bad("a custom grid needs both cols and rows")),
|
||||
};
|
||||
let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize;
|
||||
let order = match a.opt_str("order")?.unwrap_or("horizontal") {
|
||||
"horizontal" => PageOrder::Horizontal,
|
||||
@@ -143,14 +155,15 @@ impl Automation {
|
||||
"cut-stack" => PageOrder::CutStack,
|
||||
o => return Err(bad(format!("unknown order {o:?}"))),
|
||||
};
|
||||
match Layout::multiple(per) {
|
||||
Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple {
|
||||
match grid.unwrap_or_else(|| Layout::multiple(per)) {
|
||||
Layout::Multiple { cols, rows, per_sheet, custom, .. } => Layout::Multiple {
|
||||
cols,
|
||||
rows,
|
||||
per_sheet,
|
||||
order,
|
||||
border: a.opt_bool("border")?.unwrap_or(false),
|
||||
auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true),
|
||||
custom,
|
||||
},
|
||||
l => l,
|
||||
}
|
||||
@@ -172,6 +185,9 @@ impl Automation {
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
};
|
||||
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
|
||||
if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) {
|
||||
return Err(bad("cols and rows need layout multiple"));
|
||||
}
|
||||
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
|
||||
return Err(bad("cut-stack order needs layout multiple"));
|
||||
}
|
||||
|
||||
@@ -146,6 +146,84 @@ fn search_bounds_warning_volume_and_does_not_reach_distant_failures() {
|
||||
assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_at_limit_ends_at_a_lone_match_without_reading_the_distant_tail() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 200);
|
||||
// #5328: "Page 1" is only on page 1 (every other readable page reads "Page 2"), and the last
|
||||
// page fails, so only a search that runs on to the end hears about it.
|
||||
let requested = std::cell::RefCell::new(Vec::new());
|
||||
let mut extract = |bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]| {
|
||||
requested.borrow_mut().extend_from_slice(pages);
|
||||
let filler = extract_parallel(bytes, password.clone(), &[1]).remove(0).1;
|
||||
let first = extract_parallel(bytes, password, &[0]).remove(0).1;
|
||||
pages
|
||||
.iter()
|
||||
.map(|&p| {
|
||||
(
|
||||
p,
|
||||
match p {
|
||||
0 => first.clone(),
|
||||
199 => Err("synthetic page 200 failure".into()),
|
||||
_ => filler.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let looked_ahead = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page 1", "limit": 1 })), &mut extract).unwrap();
|
||||
assert_eq!((looked_ahead["count"].clone(), looked_ahead["warning_count"].clone()), (json!(1), json!(1)), "{looked_ahead}");
|
||||
assert!(looked_ahead.get("next").is_none() && looked_ahead.get("stopped_at_limit").is_none());
|
||||
|
||||
a.texts.remove(&id);
|
||||
requested.borrow_mut().clear();
|
||||
let args = json!({ "doc": id.0, "query": "Page 1", "limit": 1, "stop_at_limit": true });
|
||||
let stopped = a.text_find_with(&Args(&args), &mut extract).unwrap();
|
||||
assert_eq!(stopped["matches"][0]["page"], 1);
|
||||
assert_eq!((stopped["count"].clone(), stopped["warning_count"].clone()), (json!(1), json!(0)), "{stopped}");
|
||||
assert_eq!((stopped["complete"].clone(), stopped["stopped_at_limit"].clone()), (json!(false), json!(true)));
|
||||
assert_eq!(stopped["next"], json!({ "start_page": 1, "skip": 1 }));
|
||||
assert_eq!(*requested.borrow(), [0], "a match on the first page reads only that page");
|
||||
|
||||
// Resuming finds nothing more and reaches the failing page, which it now reports.
|
||||
let mut resume = args.clone();
|
||||
resume["start_page"] = stopped["next"]["start_page"].clone();
|
||||
resume["skip"] = stopped["next"]["skip"].clone();
|
||||
let rest = a.text_find_with(&Args(&resume), &mut extract).unwrap();
|
||||
assert_eq!((rest["count"].clone(), rest["stopped_at_limit"].clone(), rest["complete"].clone()), (json!(0), json!(false), json!(false)));
|
||||
assert_eq!(rest["warnings"][0]["page"], 200);
|
||||
assert!(rest.get("next").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_at_limit_continuations_return_every_match_once_and_end_complete() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 120);
|
||||
let all = json(&mut a, "text_find", json!({ "doc": id.0, "query": "1", "limit": 10_000 }));
|
||||
assert_eq!(all["complete"], true);
|
||||
// "1" appears twice on pages like 11 and 111, so resuming has to skip within a page.
|
||||
for limit in [1, 2, 3] {
|
||||
let mut args = json!({ "doc": id.0, "query": "1", "limit": limit, "stop_at_limit": true });
|
||||
let mut pieced = Vec::new();
|
||||
for _ in 0..=all["count"].as_u64().unwrap() {
|
||||
let part = json(&mut a, "text_find", args.clone());
|
||||
assert_eq!(part["warning_count"], 0);
|
||||
pieced.extend(part["matches"].as_array().unwrap().iter().cloned());
|
||||
if part["stopped_at_limit"] == false {
|
||||
assert_eq!(part["complete"], true);
|
||||
assert!(part.get("next").is_none());
|
||||
break;
|
||||
}
|
||||
assert_eq!((part["count"].clone(), part["complete"].clone()), (json!(limit), json!(false)));
|
||||
args["start_page"] = part["next"]["start_page"].clone();
|
||||
args["skip"] = part["next"]["skip"].clone();
|
||||
}
|
||||
assert_eq!(pieced, *all["matches"].as_array().unwrap(), "limit {limit}: no match lost or repeated");
|
||||
}
|
||||
let none = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent", "limit": 1, "stop_at_limit": true }));
|
||||
assert_eq!((none["count"].clone(), none["complete"].clone(), none["stopped_at_limit"].clone()), (json!(0), json!(true), json!(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id()));
|
||||
@@ -192,6 +270,30 @@ fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_without_a_path_stays_inside_the_root() {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-automation-save-root-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let root = base.join("root");
|
||||
let outside = base.join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let mut a = Automation::new().with_root(&root).unwrap();
|
||||
let bytes = Arc::new(numbered_pages(1));
|
||||
// Remembered path inside the root: still saves.
|
||||
let inside = root.canonicalize().unwrap().join("in.pdf");
|
||||
let id = a.session.open("in.pdf", Some(inside.to_string_lossy().into_owned()), bytes.clone(), None).unwrap();
|
||||
json(&mut a, "doc_save", json!({ "doc": id.0 }));
|
||||
assert!(inside.exists());
|
||||
// Remembered path outside the root: refused, and nothing is written there.
|
||||
let escaped = outside.join("out.pdf");
|
||||
let id = a.session.open("out.pdf", Some(escaped.to_string_lossy().into_owned()), bytes, None).unwrap();
|
||||
let error = a.call("doc_save", &json!({ "doc": id.0 })).unwrap_err();
|
||||
assert!(error.to_string().contains("outside the allowed directory"), "{error}");
|
||||
assert!(!escaped.exists());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() {
|
||||
let base = std::env::temp_dir();
|
||||
@@ -219,6 +321,7 @@ fn names_from_documents_become_one_plain_file_name() {
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn other_shares_and_device_paths_are_refused_by_name() {
|
||||
use crate::file_access::foreign_share;
|
||||
let local = Path::new(r"\\?\C:\work\root");
|
||||
for p in [
|
||||
r"\\host\share\x.pdf",
|
||||
@@ -242,6 +345,28 @@ fn other_shares_and_device_paths_are_refused_by_name() {
|
||||
assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared));
|
||||
}
|
||||
|
||||
/// Windows drops a trailing dot from a folder name when it opens a path (`root.` is `root`), so
|
||||
/// with a root, a path that names the root or a folder above it that way still reaches the file
|
||||
/// inside. A dotted name outside stays outside.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_trailing_dot_on_the_root_or_above_it_names_the_folder_windows_opens() {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-automation-dot-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("root")).unwrap();
|
||||
std::fs::create_dir_all(base.join("outside")).unwrap();
|
||||
std::fs::write(base.join("root").join("a.pdf"), b"x").unwrap();
|
||||
std::fs::write(base.join("outside").join("b.pdf"), b"x").unwrap();
|
||||
let a = Automation::new().with_root(base.join("root")).unwrap();
|
||||
let shown = base.to_string_lossy().into_owned();
|
||||
for p in [format!(r"{shown}\root.\a.pdf"), format!(r"{shown}.\root\a.pdf")] {
|
||||
let resolved = a.resolve(&p, false);
|
||||
assert!(resolved.is_ok(), "{p}: {resolved:?}");
|
||||
}
|
||||
assert!(a.resolve(&format!(r"{shown}\outside.\b.pdf"), false).is_err());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A fresh, empty folder for one staging test.
|
||||
fn staging_dir(test: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id()));
|
||||
|
||||
@@ -140,8 +140,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({
|
||||
"files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}},
|
||||
"start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."},
|
||||
"prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."},
|
||||
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."}
|
||||
"prefix": {"type":"string","maxLength":128,"description":"Bates prefix, up to 128 characters; cannot contain # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Bates suffix, up to 128 characters; cannot contain # or >>."},
|
||||
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false). Output collisions are checked conservatively: Windows compares case-insensitively, while Unix preserves case, so case-sensitive Windows and case-insensitive Unix volumes can differ from normal runs."}
|
||||
}), &["files"])),
|
||||
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
|
||||
.with(schema(json!({
|
||||
@@ -195,12 +195,21 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
|
||||
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
|
||||
}), &["doc", "page"])),
|
||||
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
|
||||
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. orbit, in place of direction, frames the model as a viewer's orbit control does (the same size from every side) and applies, in order: view (iso, front, right, top or back; default iso) about up (+x, +y, +z, -x, -y or -z; default +z) with the Front view looking along front (an axis perpendicular to up; default +y, or -z when y is up), then turn [yaw, pitch] in degrees (pitch looks down and stops just short of the up axis), then zoom (a factor, the result held within 0.05 to 50) about the picture point zoom_at [right, up] in picture heights from the centre (default the centre), then pan [right, up] in model radii (it moves the target, so the model moves the other way). perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 },
|
||||
"width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 },
|
||||
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
|
||||
"perspective": { "type": "boolean" }
|
||||
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3, "description": "The view direction from the eye towards the model (default [-1, 1, -1]). Mutually exclusive with orbit." },
|
||||
"perspective": { "type": "boolean" },
|
||||
"orbit": { "type": "object", "description": "A viewer's orbit moves, applied in the order of these keys; all optional. Mutually exclusive with direction.", "properties": {
|
||||
"view": { "type": "string", "enum": ["iso", "front", "right", "top", "back"], "description": "The view to start from (default iso)." },
|
||||
"up": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The model axis that appears upward (default +z)." },
|
||||
"front": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The axis the Front view looks along, perpendicular to up (default +y, or -z when y is up)." },
|
||||
"turn": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[yaw, pitch] in degrees, pitch looking down (default [0, 0])." },
|
||||
"zoom": { "type": "number", "exclusiveMinimum": 0, "description": "A factor on the fitted view (default 1); the result is held within 0.05 to 50." },
|
||||
"zoom_at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "The picture point the zoom keeps still, [right, up] in picture heights from the centre (default [0, 0]). Requires zoom." },
|
||||
"pan": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "Moves the target [right, up] in model radii, so the model moves the other way (default [0, 0]); each held within 1000." }
|
||||
}, "additionalProperties": false }
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
@@ -237,12 +246,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.")
|
||||
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation. With stop_at_limit the search ends at the limit-th match instead of looking for one more, so a lone early match doesn't read the rest of the document.")
|
||||
.ro()
|
||||
.cmd("edit.find")
|
||||
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." },
|
||||
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
|
||||
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])),
|
||||
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." },
|
||||
"stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])),
|
||||
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
|
||||
.cmd("page.rotate")
|
||||
.with(schema(
|
||||
@@ -698,7 +708,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -709,6 +719,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] },
|
||||
"scale": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 },
|
||||
"cols": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: columns across the sheet; give rows too, and no per_sheet." },
|
||||
"rows": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: rows down the sheet; give cols too, and no per_sheet." },
|
||||
"order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] },
|
||||
"border": { "type": "boolean" },
|
||||
"auto_rotate": { "type": "boolean" },
|
||||
@@ -802,7 +814,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
|
||||
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).")
|
||||
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 with its zoom).")
|
||||
.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. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
|
||||
@@ -810,22 +822,24 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
|
||||
&["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.")
|
||||
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page, shown at zoom: fit_page (default), fit_width, fit_height, fit_visible, actual, inherit (keep the reader's zoom) or a percentage. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
|
||||
.cmd("edit.link")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 },
|
||||
"zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" },
|
||||
"visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] },
|
||||
}),
|
||||
&["doc", "page", "rect"],
|
||||
)),
|
||||
t("link_edit", "Edit a link", "Change a link's rect, destination (url or to_page) or appearance. Undoable.")
|
||||
t("link_edit", "Edit a link", "Change a link's rect, destination (url, or to_page and zoom as in link_add; a new to_page keeps the link's zoom, a zoom alone keeps its page) or appearance. Undoable.")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 },
|
||||
"zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" },
|
||||
"visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
|
||||
@@ -2969,6 +2969,15 @@ fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failu
|
||||
{"input":"a.pdf","output":"a-numbered.pdf"}
|
||||
]);
|
||||
let mut a = auto(&dir);
|
||||
for invalid_start in [json!(0), json!(-1), json!(1.5), json!("17")] {
|
||||
let error = err(&mut a, "doc_bates_multi", json!({"files":files,"start_number":invalid_start}));
|
||||
assert!(matches!(error, ToolError::InvalidArgs(_)));
|
||||
}
|
||||
let unicode = ok(&mut a, "doc_bates_multi", json!({"files":files,"prefix":"é".repeat(65),"dry_run":true}));
|
||||
assert_eq!(unicode["files"].as_array().unwrap().len(), 2);
|
||||
assert!(matches!(err(&mut a, "doc_bates_multi", json!({"files":files,"prefix":"CASE>>"})), ToolError::InvalidArgs(_)));
|
||||
assert!(matches!(err(&mut a, "doc_bates_multi", json!({"files":files,"suffix":"A>>B"})), ToolError::InvalidArgs(_)));
|
||||
|
||||
let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true}));
|
||||
assert_eq!(allocation["files"][0]["first"], 50);
|
||||
assert_eq!(allocation["files"][0]["last"], 51);
|
||||
@@ -2987,15 +2996,24 @@ fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failu
|
||||
let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX}));
|
||||
assert!(overflow.to_string().contains("overflows"));
|
||||
assert!(!dir.join("overflow.pdf").exists());
|
||||
// A range may end at u64::MAX itself.
|
||||
let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true}));
|
||||
assert_eq!(at_max["files"][0]["last"], u64::MAX);
|
||||
for bad in
|
||||
[json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})]
|
||||
std::fs::write(dir.join("one.pdf"), fixture(1)).unwrap();
|
||||
let max_number =
|
||||
ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"one.pdf","output":"one-numbered.pdf"}],"start_number":u64::MAX,"dry_run":true}));
|
||||
assert_eq!(max_number["files"][0]["first"], u64::MAX);
|
||||
assert_eq!(max_number["files"][0]["last"], u64::MAX);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true});
|
||||
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}");
|
||||
let collision = err(
|
||||
&mut a,
|
||||
"doc_bates_multi",
|
||||
json!({"files":[
|
||||
{"input":"b.pdf","output":"Case.pdf"},
|
||||
{"input":"a.pdf","output":"CASE.pdf"}
|
||||
]}),
|
||||
);
|
||||
assert!(collision.to_string().contains("same output path"));
|
||||
assert!(!dir.join("Case.pdf").exists());
|
||||
}
|
||||
|
||||
use pdfcraft_engine::{Changes, Edit, Protection, Session};
|
||||
@@ -6183,6 +6201,80 @@ 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 link_zoom_through_tools() {
|
||||
let dir = workdir("link-zoom");
|
||||
std::fs::write(dir.join("notes.txt"), "Contents").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap();
|
||||
let link = |a: &mut Automation| ok(a, "link_list", json!({ "doc": doc }))["links"][0].clone();
|
||||
// A page link opens at Fit Page unless told otherwise.
|
||||
ok(&mut a, "link_add", json!({ "doc": doc, "page": 1, "rect": [72, 300, 200, 320], "to_page": 1 }));
|
||||
assert_eq!((link(&mut a)["to_page"].clone(), link(&mut a)["zoom"].clone()), (json!(1), json!("fit_page")));
|
||||
let index = link(&mut a)["index"].clone();
|
||||
// A zoom alone keeps the page; a page alone keeps the zoom.
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": 200 }));
|
||||
let l = link(&mut a);
|
||||
assert_eq!((l["to_page"].clone(), l["zoom"].clone()), (json!(1), json!(200.0)));
|
||||
assert_eq!(l["destination"]["zoom"], 2.0, "the viewer reads the zoom the link writes: {l}");
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": "fit_width" }));
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "to_page": 1 }));
|
||||
assert_eq!(link(&mut a)["zoom"], "fit_width");
|
||||
for (name, zoom) in [("actual", json!(100.0)), ("inherit", json!("inherit")), ("fit_visible", json!("fit_visible"))] {
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": name }));
|
||||
assert_eq!(link(&mut a)["zoom"], zoom, "{name}");
|
||||
}
|
||||
// A zoom needs a page to show: refused with a web address, unknown, or not positive.
|
||||
for args in [
|
||||
json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "url": "https://example.org", "zoom": "fit_page" }),
|
||||
json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "zoom": "fit_page" }),
|
||||
json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": "sideways" }),
|
||||
json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": 0 }),
|
||||
json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": [2] }),
|
||||
] {
|
||||
assert!(matches!(a.call("link_add", &args), Err(ToolError::InvalidArgs(_))), "{args}");
|
||||
}
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "url": "https://example.org" }));
|
||||
assert!(matches!(a.call("link_edit", &json!({ "doc": doc, "page": 1, "index": index, "zoom": 150 })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// `link_edit` with a page and no zoom keeps the link's zoom, and with it the link's own `/XYZ`
|
||||
/// factor, even outside the 1–6400% a chosen zoom is written within: 0.5% and 10000% stay so, in
|
||||
/// link_list and in the viewer's destination. Naming the link's own page changes nothing, so its
|
||||
/// position stays too. A zoom chosen anew is still kept within 1–6400%.
|
||||
#[test]
|
||||
fn link_edit_keeps_a_links_own_zoom_outside_the_range_it_writes() {
|
||||
let (_base, root) = sandbox("link-own-zoom");
|
||||
let pdf = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [10 10 100 30] /Border [0 0 0] /Dest [3 0 R /XYZ null null 0.005] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [10 50 100 70] /Border [0 0 0] /Dest [3 0 R /XYZ null null 100] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [10 90 100 110] /Border [0 0 0] /Dest [3 0 R /XYZ 72 300 2] >>",
|
||||
]);
|
||||
std::fs::write(root.join("zooms.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&root);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "zooms.pdf" }))["doc"].as_u64().unwrap();
|
||||
let link = |a: &mut Automation, index: u64| {
|
||||
ok(a, "link_list", json!({ "doc": doc }))["links"].as_array().unwrap().iter().find(|l| l["index"] == index).unwrap().clone()
|
||||
};
|
||||
for (index, percent, factor) in [(1, 0.5, 0.005), (2, 10000.0, 100.0)] {
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "to_page": 2 }));
|
||||
let l = link(&mut a, index);
|
||||
assert_eq!((l["to_page"].clone(), l["zoom"].clone()), (json!(2), json!(percent)), "{l}");
|
||||
let shown = l["destination"]["zoom"].as_f64().unwrap();
|
||||
assert!((shown - factor).abs() <= factor * 1e-6, "the viewer's zoom is the link's own: {l}");
|
||||
}
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": 3, "to_page": 1 }));
|
||||
let l = link(&mut a, 3);
|
||||
// `/XYZ 72 300` on a 400 pt page: 72 from the left and 100 from the top, as the tools measure.
|
||||
assert_eq!((&l["zoom"], &l["destination"]["left"], &l["destination"]["top"]), (&json!(200.0), &json!(72.0), &json!(100.0)), "{l}");
|
||||
ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": 1, "zoom": 10000 }));
|
||||
assert_eq!(link(&mut a, 1)["zoom"], json!(6400.0));
|
||||
}
|
||||
|
||||
/// #4168: a Launch action whose file is a PDF (here with Windows `\` separators, relative to the
|
||||
/// source as a remote link is) opens that PDF in PdfCraft through link_open, within the root; one
|
||||
/// naming a program is listed but never followed.
|
||||
@@ -6210,6 +6302,40 @@ fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() {
|
||||
assert_eq!(a.session().docs().len(), before, "nothing was opened or launched");
|
||||
}
|
||||
|
||||
/// A bookmark's action is reported as a link's target is, and only a go-to names a page here: a
|
||||
/// remote go-to's `[1 /Fit]` is the other file's page 2, so doc_info and bookmark_list give this
|
||||
/// file's no page for it.
|
||||
#[test]
|
||||
fn bookmarks_report_their_actions_and_a_remote_go_to_names_no_page_here() {
|
||||
let (_base, root) = sandbox("bookmark-actions");
|
||||
let pdf = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>",
|
||||
"<< /Title (Web) /Parent 5 0 R /Next 7 0 R /A << /S /URI /URI (https://example.org/manual) >> >>",
|
||||
"<< /Title (Other file) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>",
|
||||
"<< /Title (Here) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>",
|
||||
]);
|
||||
std::fs::write(root.join("bookmarks.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&root);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "bookmarks.pdf" }))["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
let outline = &info["outline"];
|
||||
assert_eq!((&outline[0]["page"], &outline[0]["action"]), (&Value::Null, &json!({ "uri": "https://example.org/manual" })), "{info}");
|
||||
assert_eq!(outline[1]["page"], Value::Null, "{info}");
|
||||
assert_eq!(
|
||||
(&outline[1]["action"]["action"], &outline[1]["action"]["remote_document"], &outline[1]["action"]["destination"]["page"]),
|
||||
(&json!("GoToR"), &json!("other.pdf"), &json!(2)),
|
||||
"{info}"
|
||||
);
|
||||
assert_eq!((&outline[2]["page"], &outline[2]["action"]), (&json!(2), &Value::Null), "{info}");
|
||||
let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();
|
||||
let pages: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].clone(), b["page"].clone())).collect();
|
||||
assert_eq!(pages, [(json!("Web"), Value::Null), (json!("Other file"), Value::Null), (json!("Here"), json!(2))], "{marks}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
|
||||
let (_base, root) = sandbox("gotor-not-a-file");
|
||||
@@ -6783,11 +6909,26 @@ fn drawing_comments_through_tools() {
|
||||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "polygon", "points": [[1, 1]] })),
|
||||
Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))
|
||||
));
|
||||
// Replace Text: a strikeout over the found text and a grouped caret.
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page" }));
|
||||
// Replace Text: a strikeout over the found text, grouped under a caret holding the
|
||||
// replacement (the group's primary, ISO 32000-2 §12.5.6.2).
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page", "color": "green" }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 }));
|
||||
let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone();
|
||||
assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout");
|
||||
let caret = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone();
|
||||
assert_eq!(caret["contents"], "second page", "the replacement is on the caret");
|
||||
assert_eq!(caret["replies"][0]["type"], "StrikeOut", "the strikeout is grouped under the caret");
|
||||
// Review on #5689 (CodeRabbit): the requested colour is the group's, so the primary has it.
|
||||
assert_eq!(caret["replies"][0]["color"], caret["color"], "one colour for the pair: {caret}");
|
||||
assert_eq!(caret["color"], json!("#2E9E5C"), "the requested green, not the caret's default blue: {caret}");
|
||||
// Restyling the caret restyles the proposal, its strikeout too, as one undo step.
|
||||
let pair = |a: &mut Automation| {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 2 }));
|
||||
let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone();
|
||||
(c["color"].clone(), c["replies"][0]["color"].clone())
|
||||
};
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 2, "index": caret["index"], "color": "purple" }));
|
||||
assert_eq!(pair(&mut a), (json!("#8A3FD1"), json!("#8A3FD1")), "the strikeout follows its caret");
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(pair(&mut a), (json!("#2E9E5C"), json!("#2E9E5C")), "one undo puts both back");
|
||||
assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_))));
|
||||
// A file attached as a comment.
|
||||
std::fs::write(dir.join("notes.txt"), b"remember").unwrap();
|
||||
@@ -8227,6 +8368,22 @@ fn action_run_keeps_outputs_with_duplicate_input_basenames() {
|
||||
assert_eq!(std::fs::read(first_path).unwrap(), first_bytes, "an earlier output must not be replaced");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn action_run_never_replaces_an_entry_that_exists_check_misses() {
|
||||
let dir = workdir("action-noclobber");
|
||||
std::fs::create_dir_all(dir.join("results")).unwrap();
|
||||
std::fs::write(dir.join("report.pdf"), fixture(1)).unwrap();
|
||||
// A dangling link is invisible to `exists()` but is still an occupied name.
|
||||
std::os::unix::fs::symlink(dir.join("nowhere.pdf"), dir.join("results/report.pdf")).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let args = json!({ "steps": [{ "step": "set_title", "arg": "Processed" }], "paths": ["report.pdf"], "folder": "results" });
|
||||
let result = ok(&mut a, "action_run", args);
|
||||
let output = result["files"][0]["output"].as_str().unwrap();
|
||||
assert!(output.ends_with("report (2).pdf"), "{output}");
|
||||
assert!(dir.join("results/report.pdf").symlink_metadata().unwrap().file_type().is_symlink(), "the existing entry must stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfa_through_tools() {
|
||||
let dir = workdir("pdfa");
|
||||
@@ -8521,6 +8678,119 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET";
|
||||
assert_eq!(paragraphs.len(), 2, "{xml}");
|
||||
}
|
||||
|
||||
/// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2
|
||||
/// and so on, each in its built-in encoding. A font given `bfchar` pairs ("<B7> <F0B7>") reads
|
||||
/// its codes through that ToUnicode map, as a symbol font Word embeds does.
|
||||
fn standard_fonts_pdf(content: &str, fonts: &[(&str, Option<&str>)]) -> Vec<u8> {
|
||||
let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect();
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut maps = Vec::new();
|
||||
for (font, map) in fonts {
|
||||
let to_unicode = map.map_or(String::new(), |map| {
|
||||
let cmap = format!(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange \
|
||||
1 beginbfchar {map} endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"
|
||||
);
|
||||
maps.push(format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()));
|
||||
format!(" /ToUnicode {} 0 R", fonts.len() + 4 + maps.len())
|
||||
});
|
||||
objs.push(format!("<< /Type /Font /Subtype /Type1 /BaseFont /{font}{to_unicode} >>"));
|
||||
}
|
||||
objs.extend(maps);
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// The text of each paragraph that holds any in `word/document.xml` of the .docx at `path`.
|
||||
fn word_paragraph_texts(path: &Path) -> Vec<String> {
|
||||
let zip = std::fs::read(path).unwrap();
|
||||
let mut at = 0;
|
||||
let mut xml = String::new();
|
||||
while zip[at..].starts_with(b"PK\x03\x04") {
|
||||
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
|
||||
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
|
||||
let data = at + 30 + len;
|
||||
if &zip[at + 30..data] == b"word/document.xml" {
|
||||
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
|
||||
break;
|
||||
}
|
||||
at = data + size;
|
||||
}
|
||||
xml.split("<w:p>")
|
||||
.skip(1)
|
||||
.map(|p| {
|
||||
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
|
||||
p.split("<w:t xml:space=\"preserve\">").skip(1).filter_map(|t| t.split_once("</w:t>").map(|(t, _)| t)).collect::<String>()
|
||||
})
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #4158: a sentence that wraps onto the next lines after a bold name and a slanted one reaches
|
||||
/// Word as one paragraph, though reading splits each of its lines where the style changes.
|
||||
#[test]
|
||||
fn exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph() {
|
||||
let dir = workdir("wrapped-sentence-word");
|
||||
let content = "BT /F1 10 Tf 72 700 Td (The parties to this lease are the owner, ) Tj /F2 10 Tf (Harbour Lane Properties) Tj \
|
||||
/F1 10 Tf (, of 4 Quay Street, and the) Tj ET \
|
||||
BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch Studio) Tj \
|
||||
/F1 10 Tf (, who agree as follows to rent the upper) Tj ET \
|
||||
BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \
|
||||
BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET";
|
||||
let fonts = [("Helvetica", None), ("Helvetica-Bold", None), ("Helvetica-Oblique", None)];
|
||||
std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &fonts)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" }));
|
||||
let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \
|
||||
schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term.";
|
||||
assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]);
|
||||
}
|
||||
|
||||
/// A bulleted list drawn as Word draws one, each bullet in the Symbol font (read as U+F0B7, as
|
||||
/// Word maps it) a tab stop before its item's text, reaches Word as one paragraph per item and
|
||||
/// not as a two-column table.
|
||||
#[test]
|
||||
fn exporting_to_word_keeps_a_bulleted_list_as_paragraphs() {
|
||||
let dir = workdir("bulleted-list-word");
|
||||
let mut content = String::from("BT /F1 10 Tf 72 700 Td (The tenant looks after:) Tj ET\n");
|
||||
for (n, item) in ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].iter().enumerate() {
|
||||
let y = 686 - 14 * n;
|
||||
content.push_str(&format!("BT /F2 10 Tf 90 {y} Td (\\267) Tj ET BT /F1 10 Tf 108 {y} Td ({item}) Tj ET\n"));
|
||||
}
|
||||
content.push_str("BT /F1 10 Tf 72 630 Td (Rent is paid monthly in advance.) Tj ET");
|
||||
std::fs::write(dir.join("list.pdf"), standard_fonts_pdf(&content, &[("Helvetica", None), ("Symbol", Some("<B7> <F0B7>"))])).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "list.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "list.docx" }));
|
||||
assert_eq!(
|
||||
word_paragraph_texts(&dir.join("list.docx")),
|
||||
[
|
||||
"The tenant looks after:",
|
||||
"\u{F0B7} the stairs and the landing;",
|
||||
"\u{F0B7} the shared yard and its gate;",
|
||||
"\u{F0B7} the bins, every week.",
|
||||
"Rent is paid monthly in advance."
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Hundreds of calculations run on opening and again after a change through the tools. The
|
||||
/// time limits are raised so that a busy machine does not stop them (the engine's tests check
|
||||
/// that the limits stop them).
|
||||
@@ -8769,6 +9039,49 @@ fn cut_stack_printing_through_tools() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_pages_per_sheet_prints_the_grid_as_given() {
|
||||
// Two narrow pages side by side on portrait A4: 2 columns by 1 row (#6551).
|
||||
let dir = workdir("custom-grid");
|
||||
std::fs::write(dir.join("narrow.pdf"), fixture(4)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "narrow.pdf"}))["doc"].as_u64().unwrap();
|
||||
for orientation in ["auto", "portrait"] {
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"doc_print",
|
||||
json!({"doc": doc, "layout": "multiple", "cols": 2, "rows": 1, "paper": "A4", "orientation": orientation, "path": "grid.pdf"}),
|
||||
);
|
||||
assert_eq!(r["sheets"], 2, "{orientation}");
|
||||
let printed = ok(&mut a, "doc_open", json!({"path": "grid.pdf"}))["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut a, "doc_info", json!({"doc": printed}));
|
||||
let sheet = &info["pages"][0];
|
||||
assert!(sheet["width"].as_f64().unwrap() < sheet["height"].as_f64().unwrap(), "portrait sheet with {orientation}: {sheet}");
|
||||
// Side by side: the second page level with the first, to its right.
|
||||
let rows = ok(&mut a, "text_extract", json!({"doc": printed, "rect": [0, 0, 600, 850]}));
|
||||
let rows: Vec<&str> = rows["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim()).collect();
|
||||
assert_eq!(rows, ["Page 1 Page 2", "Page 3 Page 4"], "{orientation}");
|
||||
ok(&mut a, "doc_close", json!({"doc": printed}));
|
||||
}
|
||||
for bad in [
|
||||
json!({"cols": 2}),
|
||||
json!({"rows": 1}),
|
||||
json!({"cols": 0, "rows": 1}),
|
||||
json!({"cols": 2, "rows": 100}),
|
||||
json!({"cols": -1, "rows": 2}),
|
||||
json!({"cols": 2, "rows": 1, "per_sheet": 2}),
|
||||
json!({"cols": 2, "rows": 1, "layout": "fit"}),
|
||||
] {
|
||||
let mut args = json!({"doc": doc, "layout": "multiple", "path": "refused.pdf"});
|
||||
for (k, v) in bad.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
assert!(matches!(a.call("doc_print", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
assert!(!dir.join("refused.pdf").exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurements_calibrate_draw_save_reopen_and_export() {
|
||||
let dir = workdir("measurements");
|
||||
@@ -12007,6 +12320,49 @@ fn comments_copy_and_paste_between_documents_through_tools() {
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
|
||||
}
|
||||
|
||||
/// A bitmap Type 3 font drawn with a flipped font matrix under a flipped text matrix (as TeX's
|
||||
/// bitmap fonts are): `text_lines` boxes a line by its glyphs' `d1` boxes (ISO 32000-2 §9.6.4),
|
||||
/// as high as the ink, not a 0.09-point sliver.
|
||||
#[test]
|
||||
fn text_lines_box_a_flipped_type3_line_by_its_glyphs() {
|
||||
let content = "BT /F1 1 Tf 0.15 0 0 -0.15 10 20 Tm (ab) Tj ET";
|
||||
let glyph_a = "46 0 3 -1 42 57 d1 3 -1 39 58 re f";
|
||||
let glyph_b = "42 0 2 -18 40 38 d1 2 -18 38 56 re f";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 50] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type3 /FontMatrix [1 0 0 -1 0 0] /FontBBox [-4 -18 51 57] /FirstChar 97 /LastChar 98 \
|
||||
/Widths [46 42] /Encoding << /Differences [97 /a /b] >> /CharProcs << /a 6 0 R /b 7 0 R >> >>"
|
||||
.into(),
|
||||
format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()),
|
||||
format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let dir = workdir("type3-text-lines");
|
||||
std::fs::write(dir.join("type3.pdf"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "type3.pdf" }))["doc"].as_u64().unwrap();
|
||||
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
|
||||
// Glyph space y -18..57, turned over twice: 57 × 0.15 above the baseline at y 20 (8.55) and
|
||||
// 18 × 0.15 below it (2.7). Top-left origin on a 50-point page: 21.45 down to 32.7.
|
||||
assert_eq!(lines["count"], 1, "{lines}");
|
||||
assert_eq!(lines["lines"][0]["text"], "ab");
|
||||
assert_eq!(lines["lines"][0]["rect"], json!([10.0, 21.45, 23.2, 32.7]), "{lines}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -110,3 +110,171 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() {
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orbit_frames_the_model_from_a_named_view_and_its_moves() {
|
||||
let (_dir, mut a) = setup("orbit");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
let draw = |a: &mut Automation, orbit: Value| orbit_picture(a, doc, false, &orbit);
|
||||
// The triangle lies in z = 0: the Top view looks down on its face, the Front view along its edge.
|
||||
let top = draw(&mut a, json!({ "view": "top" }));
|
||||
assert_eq!(rgb(&top, 32, 24), [190, 192, 200], "the top view sees the face head on");
|
||||
let front = draw(&mut a, json!({ "view": "front" }));
|
||||
assert!((0..48).all(|y| rgb(&front, 32, y) == [255, 255, 255]), "the front view sees only its edge");
|
||||
let tipped = draw(&mut a, json!({ "view": "front", "turn": [0, 30] }));
|
||||
assert_ne!(rgb(&tipped, 32, 24), [255, 255, 255], "tipped down from the front, the face is in the middle");
|
||||
// With y up the Front view looks along -z, onto the face.
|
||||
let y_up = draw(&mut a, json!({ "view": "front", "up": "+y" }));
|
||||
assert_eq!(rgb(&y_up, 32, 24), [190, 192, 200]);
|
||||
// Turning the front view down by 90° stops just short of the top view and sees the face.
|
||||
let turned = draw(&mut a, json!({ "view": "front", "turn": [0, 90] }));
|
||||
assert_ne!(rgb(&turned, 32, 24), [255, 255, 255]);
|
||||
// A pan moves the target right, so the model moves left; three radii take it out of the picture.
|
||||
let (left, right) = extent(&top, 24);
|
||||
let (moved_left, moved_right) = extent(&draw(&mut a, json!({ "view": "top", "pan": [0.5, 0] })), 24);
|
||||
assert!(moved_left < left && moved_right < right, "{left}..{right} moved to {moved_left}..{moved_right}");
|
||||
let panned = draw(&mut a, json!({ "view": "top", "pan": [3, 0] }));
|
||||
assert!((0..64).all(|x| rgb(&panned, x, 24) == [255, 255, 255]), "the model has left the picture");
|
||||
// Zooming in about the centre keeps the face under it and leaves no background at the corner.
|
||||
let zoomed = draw(&mut a, json!({ "view": "top", "zoom": 8 }));
|
||||
assert_eq!(rgb(&zoomed, 32, 24), [190, 192, 200]);
|
||||
assert_ne!(rgb(&zoomed, 0, 0), [255, 255, 255], "zoomed in, the face fills the corner too");
|
||||
}
|
||||
|
||||
/// The first and last columns on row `y` that are not background.
|
||||
fn extent(data: &[u8], y: usize) -> (usize, usize) {
|
||||
let drawn: Vec<usize> = (0..64).filter(|&x| rgb(data, x, y) != [255, 255, 255]).collect();
|
||||
(*drawn.first().expect("the model is on the row"), *drawn.last().expect("the model is on the row"))
|
||||
}
|
||||
|
||||
/// The first and last rows on column `x` that are not background.
|
||||
fn rows(data: &[u8], x: usize) -> (usize, usize) {
|
||||
let drawn: Vec<usize> = (0..48).filter(|&y| rgb(data, x, y) != [255, 255, 255]).collect();
|
||||
(*drawn.first().expect("the model is on the column"), *drawn.last().expect("the model is on the column"))
|
||||
}
|
||||
|
||||
/// A 64 by 48 picture of the model through `orbit`.
|
||||
fn orbit_picture(a: &mut Automation, doc: u64, perspective: bool, orbit: &Value) -> Vec<u8> {
|
||||
let out =
|
||||
a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "perspective": perspective, "orbit": orbit })).unwrap();
|
||||
let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") };
|
||||
data.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_orbit_frames_the_isometric_view_in_perspective() {
|
||||
let (_dir, mut a) = setup("orbit-default");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
let default = orbit_picture(&mut a, doc, false, &json!({}));
|
||||
assert_ne!(rgb(&default, 32, 24), [255, 255, 255], "the model is centred");
|
||||
let (left, right) = extent(&default, 24);
|
||||
let (high, low) = rows(&default, 32);
|
||||
assert!(left > 0 && right < 63 && high > 0 && low < 47, "inside the picture: {left}..{right}, {high}..{low}");
|
||||
assert!(right - left > 16, "and fills it: {left}..{right}");
|
||||
assert_eq!(default, orbit_picture(&mut a, doc, false, &json!({ "view": "iso", "up": "+z", "front": "+y" })), "the defaults");
|
||||
let unset = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "orbit": {} })).unwrap();
|
||||
let Content::Png { data: unset, .. } = &unset[0] else { panic!("expected a PNG") };
|
||||
assert_eq!(*unset, orbit_picture(&mut a, doc, true, &json!({})), "perspective is the default");
|
||||
assert_ne!(*unset, default, "and shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_at_keeps_the_point_under_it_still() {
|
||||
let (_dir, mut a) = setup("orbit-zoom-at");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
let (left, right) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2 })), 24);
|
||||
// A point 0.2 picture heights off centre stays there, so what was at the centre moves
|
||||
// 0.2 x (2 - 1) x 48 = 9.6 pixels the other way.
|
||||
for (at, sign) in [(0.2, -1.0), (-0.2, 1.0)] {
|
||||
let (l, r) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2, "zoom_at": [at, 0] })), 24);
|
||||
for (was, now) in [(left, l), (right, r)] {
|
||||
let moved = now as f64 - was as f64;
|
||||
assert!((moved - sign * 9.6).abs() <= 1.5, "at {at}: {was} moved to {now}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_is_held_within_its_range() {
|
||||
let (_dir, mut a) = setup("orbit-zoom-range");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
let far = orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.05 }));
|
||||
assert_eq!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.001 })), "the far end");
|
||||
assert_ne!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.2 })));
|
||||
// Panned to just inside the near edge, so a nearer view would move the edge in the picture.
|
||||
let close = |z: f64| json!({ "view": "top", "zoom": z, "pan": [0, -0.7] });
|
||||
let near = orbit_picture(&mut a, doc, false, &close(50.0));
|
||||
assert_eq!(near, orbit_picture(&mut a, doc, false, &close(1000.0)), "the near end");
|
||||
assert_ne!(near, orbit_picture(&mut a, doc, false, &close(25.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orbit_draws_in_perspective_at_the_same_scale() {
|
||||
let (_dir, mut a) = setup("orbit-perspective");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
let tipped = json!({ "view": "front", "turn": [0, 30] });
|
||||
let deep = orbit_picture(&mut a, doc, true, &tipped);
|
||||
assert_ne!(rgb(&deep, 32, 24), [255, 255, 255]);
|
||||
assert_ne!(deep, orbit_picture(&mut a, doc, false, &tipped), "foreshortened");
|
||||
let top = json!({ "view": "top" });
|
||||
let (left, right) = extent(&orbit_picture(&mut a, doc, true, &top), 24);
|
||||
let (flat_left, flat_right) = extent(&orbit_picture(&mut a, doc, false, &top), 24);
|
||||
assert!(left.abs_diff(flat_left) <= 2 && right.abs_diff(flat_right) <= 2, "{left}..{right} against {flat_left}..{flat_right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orbit_front_yaw_and_upward_pan_each_move_the_picture() {
|
||||
let (_dir, mut a) = setup("orbit-moves");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
let mut draw = |orbit: Value| orbit_picture(&mut a, doc, false, &orbit);
|
||||
// Yawing the Front view by 90 degrees is the Right view, which looks along -x; +x is its opposite.
|
||||
let right_view = draw(json!({ "view": "right", "turn": [0, 30] }));
|
||||
assert_eq!(right_view, draw(json!({ "view": "front", "turn": [90, 30] })), "yaw");
|
||||
assert_eq!(right_view, draw(json!({ "view": "front", "front": "-x", "turn": [0, 30] })), "front -x");
|
||||
assert_ne!(right_view, draw(json!({ "view": "front", "turn": [0, 30] })));
|
||||
assert_eq!(draw(json!({ "view": "right", "turn": [180, 30] })), draw(json!({ "view": "front", "front": "+x", "turn": [0, 30] })), "front +x");
|
||||
// From above, the triangle's apex is up the picture; a quarter yaw lays it along the row,
|
||||
// twice as wide there.
|
||||
let top = draw(json!({ "view": "top" }));
|
||||
let (left, right) = extent(&top, 24);
|
||||
let (yaw_left, yaw_right) = extent(&draw(json!({ "view": "top", "turn": [90, 0] })), 24);
|
||||
assert!(yaw_right - yaw_left > (right - left) * 3 / 2, "{left}..{right} yawed to {yaw_left}..{yaw_right}");
|
||||
// A pan up moves the target up, so the model moves down.
|
||||
let (high, low) = rows(&top, 32);
|
||||
let (moved_high, moved_low) = rows(&draw(json!({ "view": "top", "pan": [0, 0.25] })), 32);
|
||||
assert!(moved_high > high && moved_low >= low, "{high}..{low} moved to {moved_high}..{moved_low}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_orbit_is_a_clear_error() {
|
||||
let (_dir, mut a) = setup("orbit-bad");
|
||||
let doc = open(&mut a, "prc.pdf");
|
||||
for (bad, reason) in [
|
||||
(json!({ "orbit": { "view": "side" } }), "view as one of"),
|
||||
(json!({ "orbit": { "view": 1 } }), "view as a string"),
|
||||
(json!({ "orbit": { "up": "z" } }), "up as one of"),
|
||||
(json!({ "orbit": { "up": 1 } }), "up as a string"),
|
||||
(json!({ "orbit": { "front": "y" } }), "front as one of"),
|
||||
(json!({ "orbit": { "up": "+z", "front": "-z" } }), "front perpendicular to up"),
|
||||
(json!({ "orbit": { "turn": [1] } }), "turn as [yaw, pitch]"),
|
||||
(json!({ "orbit": { "turn": [1, 2, 3] } }), "turn as [yaw, pitch]"),
|
||||
(json!({ "orbit": { "turn": 1 } }), "turn as [yaw, pitch]"),
|
||||
(json!({ "orbit": { "zoom": 0 } }), "zoom as a positive number"),
|
||||
(json!({ "orbit": { "zoom": -2 } }), "zoom as a positive number"),
|
||||
(json!({ "orbit": { "zoom": "a" } }), "zoom as a positive number"),
|
||||
(json!({ "orbit": { "zoom_at": [0.1, 0] } }), "with zoom when it has zoom_at"),
|
||||
(json!({ "orbit": { "zoom": 2, "zoom_at": [1] } }), "zoom_at as [right, up]"),
|
||||
(json!({ "orbit": { "zoom": 2, "zoom_at": [1, 2, 3] } }), "zoom_at as [right, up]"),
|
||||
(json!({ "orbit": { "pan": [1, "a"] } }), "pan as [right, up]"),
|
||||
(json!({ "orbit": { "pan": [1] } }), "pan as [right, up]"),
|
||||
(json!({ "orbit": { "pan": [1, 2, 3] } }), "pan as [right, up]"),
|
||||
(json!({ "orbit": { "spin": 1 } }), "without spin"),
|
||||
(json!({ "orbit": 1 }), "an object"),
|
||||
(json!({ "orbit": {}, "direction": [0, 0, -1] }), "left out when direction is given"),
|
||||
] {
|
||||
let mut args = json!({ "doc": doc, "page": 1, "width": 16, "height": 16 });
|
||||
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
let err = a.call("model_render", &args).unwrap_err().to_string();
|
||||
assert!(err.contains("orbit must be") && err.contains(reason), "{bad}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +411,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of pixel (`x`, `y`) in a `w`-wide RGBA buffer, in wide integers so an image past 4 GiB
|
||||
/// (its `y * w` or `* 4` would wrap a `u32`) still indexes the right pixel; past `usize` it is out of range.
|
||||
fn rgba_index(x: u32, y: u32, w: u32) -> usize {
|
||||
usize::try_from((u128::from(y) * u128::from(w) + u128::from(x)) * 4).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Visual compare: the regions where two renderings of a page differ, as pixel boxes
|
||||
/// `[x0, y0, x1, y1]` (y down) in `a`'s image. Both images are RGBA; `b` is sampled at the
|
||||
/// same relative position when sizes differ. Pixels count as different when a channel differs
|
||||
@@ -421,6 +427,11 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8)
|
||||
if wa == 0 || ha == 0 || wb == 0 || hb == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Declared sizes the buffers cannot hold (a damaged render) would overflow the u32 index maths.
|
||||
let holds = |px: &[u8], w: u32, h: u32| u128::from(w) * u128::from(h) * 4 <= px.len() as u128;
|
||||
if !holds(pa, wa, ha) || !holds(pb, wb, hb) {
|
||||
return Vec::new();
|
||||
}
|
||||
const CELL: u32 = 8;
|
||||
let (gw, gh) = (wa.div_ceil(CELL), ha.div_ceil(CELL));
|
||||
let mut grid = vec![false; (gw * gh) as usize];
|
||||
@@ -428,9 +439,8 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8)
|
||||
let yb = (y as u64 * hb as u64 / ha as u64) as u32;
|
||||
for x in 0..wa {
|
||||
let xb = (x as u64 * wb as u64 / wa as u64) as u32;
|
||||
let ia = ((y * wa + x) * 4) as usize;
|
||||
let ib = ((yb * wb + xb) * 4) as usize;
|
||||
let (Some(ca), Some(cb)) = (pa.get(ia..ia + 3), pb.get(ib..ib + 3)) else { continue };
|
||||
let (ia, ib) = (rgba_index(x, y, wa), rgba_index(xb, yb, wb));
|
||||
let (Some(ca), Some(cb)) = (pa.get(ia..ia.saturating_add(3)), pb.get(ib..ib.saturating_add(3))) else { continue };
|
||||
if ca.iter().zip(cb).any(|(p, q)| p.abs_diff(*q) > tolerance) {
|
||||
grid[((y / CELL) * gw + x / CELL) as usize] = true;
|
||||
}
|
||||
@@ -500,3 +510,28 @@ mod visual_tests {
|
||||
assert_eq!(visual_regions((&a, 200, 200), (&c, 200, 200), 16).len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod visual_hostile_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absurd_dimensions_with_short_buffers_return_quickly() {
|
||||
let px = [0u8; 16];
|
||||
let r = visual_regions((&px, u32::MAX, u32::MAX), (&px, 2, 2), 0);
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_offsets_past_four_gib_do_not_wrap() {
|
||||
// A 32768 x 32769 RGBA image is 4,295,098,368 bytes, so a buffer that large passes the size check;
|
||||
// its last pixel sits past u32::MAX and must not wrap back onto an early pixel.
|
||||
let (w, h) = (32768u32, 32769u32);
|
||||
let last = rgba_index(w - 1, h - 1, w);
|
||||
assert_eq!(last as u64, (u64::from(h - 1) * u64::from(w) + u64::from(w - 1)) * 4);
|
||||
assert!(last as u64 > u64::from(u32::MAX));
|
||||
assert_eq!(rgba_index(0, 0, w), 0);
|
||||
// Out of range for any buffer: saturates instead of overflowing.
|
||||
assert_eq!(rgba_index(u32::MAX, u32::MAX, u32::MAX), usize::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ let doc = Document::open(bytes)?; // Arc<Vec<u8>>
|
||||
let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files
|
||||
|
||||
let root = doc.root(); // Option<ObjRef> — the catalog
|
||||
let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc<Object>
|
||||
let real = doc.resolve(&obj); // follow one indirect reference
|
||||
let obj = doc.get(ObjRef::new(12, 0)); // load (and cache): Arc<Object>
|
||||
let real = doc.resolve(&obj); // follow a bounded reference chain
|
||||
let d = doc.dict(&obj); // Option<Dict>, resolving first
|
||||
let data = stream.decoded()?; // filters applied on demand
|
||||
let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
|
||||
|
||||
@@ -209,6 +209,11 @@ impl ImageBudget {
|
||||
self.refused
|
||||
}
|
||||
|
||||
/// Charges `work` steps paid for out of another allowance, by a thread working on its own.
|
||||
pub fn charge(&mut self, work: u64) {
|
||||
self.work = self.work.saturating_sub(work);
|
||||
}
|
||||
|
||||
/// The steps of decoding still allowed.
|
||||
pub fn work_left(&self) -> u64 {
|
||||
self.work
|
||||
@@ -748,6 +753,38 @@ mod image_tests {
|
||||
assert_eq!(budget.work_left(), u64::MAX);
|
||||
}
|
||||
|
||||
/// PDFBOX-3932: an image and soft mask declared `/Predictor 15` that their writer never
|
||||
/// applied, so their rows carry no PNG filter-type byte. Exported, they were "shorter than
|
||||
/// their size says"; they are read as the samples they are, and tagged rows as before.
|
||||
#[test]
|
||||
fn an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples() {
|
||||
let doc = Document::new_empty();
|
||||
let samples: Vec<u8> = (0..36u8).map(|i| 200 - i).collect();
|
||||
let image = |height: Option<i64>, raw: Vec<u8>| {
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(15));
|
||||
parms.set(b"Colors".to_vec(), Object::Int(3));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(3));
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(3));
|
||||
if let Some(h) = height {
|
||||
dict.set(b"Height".to_vec(), Object::Int(h));
|
||||
}
|
||||
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
Stream::from_raw(dict, raw)
|
||||
};
|
||||
let untagged = pdfcraft_filters::encode_flate(&samples);
|
||||
let mut budget = ImageBudget::with_work(u64::MAX);
|
||||
assert_eq!(budget.decode(&doc, &image(Some(4), untagged.clone()), 1 << 20).unwrap(), samples);
|
||||
// Without a height to tell, the predictor is undone, as before.
|
||||
assert_ne!(budget.decode(&doc, &image(None, untagged.clone()), 1 << 20).ok(), Some(samples.clone()));
|
||||
assert_ne!(budget.decode(&doc, &image(Some(5), untagged), 1 << 20).ok(), Some(samples.clone()));
|
||||
let p = pdfcraft_filters::Params { predictor: 15, colors: 3, columns: 3, ..pdfcraft_filters::Params::default() };
|
||||
let tagged = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &samples).unwrap();
|
||||
assert_eq!(budget.decode(&doc, &image(Some(4), tagged), 1 << 20).unwrap(), samples);
|
||||
}
|
||||
|
||||
/// The images of a file share its globals, so an image refused doesn't read them: this one's
|
||||
/// can't be read, which is how to tell.
|
||||
#[test]
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct Document {
|
||||
/// `fill_xref_gaps`.
|
||||
located: Arc<BTreeMap<u32, (u64, u16)>>,
|
||||
repair_log: Arc<Vec<String>>,
|
||||
/// See [`Self::rebuilt_unlinked_update`].
|
||||
unlinked_update: bool,
|
||||
/// What loading each file object gave, a failure as well: a damaged object is otherwise
|
||||
/// loaded again on every read, and a member of an object stream that can't be decoded
|
||||
/// decodes the stream again (`Document::load`).
|
||||
|
||||
@@ -90,6 +90,13 @@ impl Document {
|
||||
&self.repair_log
|
||||
}
|
||||
|
||||
/// Whether the file was rebuilt from its object headers because its last update has no `/Prev`
|
||||
/// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The
|
||||
/// renderer rebuilds such a file under the same conditions, so the two read the same objects.
|
||||
pub fn rebuilt_unlinked_update(&self) -> bool {
|
||||
self.unlinked_update
|
||||
}
|
||||
|
||||
/// Objects of the file that could not be read, lowest number first, as lines such as
|
||||
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
|
||||
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
|
||||
@@ -479,7 +486,14 @@ impl Document {
|
||||
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? };
|
||||
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
|
||||
}
|
||||
let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) };
|
||||
let Some(p) = self.fax_params(s) else {
|
||||
// The last filter gives the image's rows: rows without the tags of the PNG predictor
|
||||
// they declare are read as they are (`pdfcraft_filters::decode_image_tolerant_within`).
|
||||
return match height {
|
||||
Some(rows) => s.decoded_image_rows_spending(max, rows, work),
|
||||
None => s.decoded_spending(max, work),
|
||||
};
|
||||
};
|
||||
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
|
||||
// rows past /Height aren't drawn.
|
||||
let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
|
||||
|
||||
+101
-11
@@ -21,6 +21,7 @@ impl Document {
|
||||
xref_oddities: Arc::default(),
|
||||
located: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
unlinked_update: false,
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
unreadable: Arc::default(),
|
||||
@@ -109,6 +110,7 @@ impl Document {
|
||||
xref_oddities: Arc::default(),
|
||||
located: Arc::default(),
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
unlinked_update: false,
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
unreadable: Arc::default(),
|
||||
@@ -134,6 +136,8 @@ impl Document {
|
||||
}
|
||||
let parsed = doc.read_xref_chain(&mut log);
|
||||
let mut authenticated = false;
|
||||
// The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`).
|
||||
let mut cut_off = false;
|
||||
let ok = match parsed {
|
||||
Ok((entries, trailer, revisions, oddities)) => {
|
||||
doc.entries = Arc::new(entries);
|
||||
@@ -147,17 +151,22 @@ impl Document {
|
||||
doc.authenticate(&enc, &credentials)?;
|
||||
authenticated = true;
|
||||
}
|
||||
doc.root_is_catalog()
|
||||
let catalog = doc.root_is_catalog();
|
||||
cut_off = catalog && doc.update_cuts_off_the_pages();
|
||||
catalog && !cut_off
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
|
||||
false
|
||||
}
|
||||
};
|
||||
if cut_off {
|
||||
log.push(UNLINKED_UPDATE.into());
|
||||
doc.unlinked_update = true;
|
||||
} else if !ok && !doc.entries.is_empty() {
|
||||
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
|
||||
}
|
||||
if !ok {
|
||||
if !doc.entries.is_empty() {
|
||||
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
|
||||
}
|
||||
doc.reconstruct(&mut log)?;
|
||||
}
|
||||
// Header generations are plaintext, including in encrypted files. Inspect only the
|
||||
@@ -182,6 +191,24 @@ impl Document {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one
|
||||
/// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages`
|
||||
/// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require
|
||||
/// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file
|
||||
/// is rebuilt from its object headers as a damaged one is (the newest definition of each
|
||||
/// object wins), and the repair log says why. (A file whose `/Root` itself is missing is
|
||||
/// rebuilt anyway.)
|
||||
fn update_cuts_off_the_pages(&self) -> bool {
|
||||
if self.revisions.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
let Some(root) = self.root() else { return false };
|
||||
let Ok(catalog) = self.try_get_reference(root) else { return false };
|
||||
let Object::Dict(catalog) = &*catalog else { return false };
|
||||
// The page tree first: the scan for an earlier `startxref` reads the whole file.
|
||||
!catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data)
|
||||
}
|
||||
|
||||
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
|
||||
// Authentication can change how the same input is decoded.
|
||||
self.source_identity = Arc::new(());
|
||||
@@ -203,13 +230,24 @@ impl Document {
|
||||
// Read here, while nothing is decrypted yet.
|
||||
let members = self.encryption_members(enc);
|
||||
let handler = match credentials {
|
||||
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?),
|
||||
// The same encryption as the document whose key this is: that key opens it.
|
||||
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
|
||||
Credentials::Password(password) => match pdfcraft_crypt::SecurityHandler::open(params.clone(), &id0, *password) {
|
||||
Ok(handler) => Arc::new(handler),
|
||||
// No password, or the empty one: what only the embedded files need isn't asked.
|
||||
Err(pdfcraft_crypt::CryptError::WrongPassword) => match self.without_password(&dict, params) {
|
||||
Some(handler) if password.is_none_or(str::is_empty) => Arc::new(handler),
|
||||
_ if password.is_none() => return Err(CosError::NeedsPassword),
|
||||
_ => return Err(CosError::WrongPassword),
|
||||
},
|
||||
Err(other) => return Err(CosError::Security(other.to_string())),
|
||||
},
|
||||
// The same encryption as the document whose key this is: that key opens it. A handler
|
||||
// without the key opens only what it would open by itself.
|
||||
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => {
|
||||
if !handler.has_key() && !crate::security::efopen_only(self, &dict) {
|
||||
return Err(CosError::NeedsPassword);
|
||||
}
|
||||
Arc::clone(handler)
|
||||
}
|
||||
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
|
||||
};
|
||||
self.security = Some(handler);
|
||||
@@ -222,6 +260,46 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The handler for a document opened without a password that only its embedded files need
|
||||
/// (see [`crate::security::efopen_only`]). Not a repair: ISO 32000-2 §7.6.6 has such a
|
||||
/// document open so; [`Document::embedded_files_locked`] tells it.
|
||||
fn without_password(&self, dict: &Dict, params: pdfcraft_crypt::EncryptDict) -> Option<pdfcraft_crypt::SecurityHandler> {
|
||||
crate::security::efopen_only(self, dict).then(|| pdfcraft_crypt::SecurityHandler::without_key(params)).flatten()
|
||||
}
|
||||
|
||||
/// A document opened without the password its embedded files need takes either password
|
||||
/// later: with the file key its embedded files decrypt, so what was read as stored is read
|
||||
/// again. Refused while an edited stream holds data read as stored, which the key would
|
||||
/// encrypt a second time.
|
||||
fn unlock_embedded_files(&mut self, password: &str) -> Result<(), CosError> {
|
||||
let Some(current) = self.security.as_deref() else { return Ok(()) };
|
||||
if self.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if self.reads_as_stored(s))) {
|
||||
return Err(CosError::Security("an embedded file was edited before the password was given: save or undo that edit first".into()));
|
||||
}
|
||||
let handler = pdfcraft_crypt::SecurityHandler::open(current.dict().clone(), &self.first_id(), Some(password)).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?;
|
||||
self.security = Some(Arc::new(handler));
|
||||
self.source_identity = Arc::new(());
|
||||
self.cache = Arc::default();
|
||||
self.objstms = Arc::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the document opened without the password its embedded files need (`/AuthEvent
|
||||
/// /EFOpen`): their data is held, and saved, as stored, and isn't to be read as the files.
|
||||
pub fn embedded_files_locked(&self) -> bool {
|
||||
self.security.as_deref().is_some_and(|h| !h.has_key())
|
||||
}
|
||||
|
||||
/// Whether `s`, a stream of this document, holds encrypted data that reads as stored because the
|
||||
/// document opened without the password its embedded files need (see
|
||||
/// [`Document::embedded_files_locked`]). Such data isn't the file it encrypts.
|
||||
pub fn reads_as_stored(&self, s: &crate::Stream) -> bool {
|
||||
self.security.as_deref().is_some_and(|h| crate::security::stream_reads_as_stored(self, h, s))
|
||||
}
|
||||
|
||||
/// The first file identifier, part of the encryption key (empty when there is none).
|
||||
fn first_id(&self) -> Vec<u8> {
|
||||
let id = self.trailer.get(b"ID").map(|id| self.resolve(id));
|
||||
@@ -268,6 +346,9 @@ impl Document {
|
||||
/// Both passwords unlock the same file key, so nothing read so far is decoded again.
|
||||
pub fn unlock(&mut self, password: &str) -> Result<(), CosError> {
|
||||
let Some(current) = self.security.as_deref() else { return Ok(()) };
|
||||
if !current.has_key() {
|
||||
return self.unlock_embedded_files(password);
|
||||
}
|
||||
if current.auth() == pdfcraft_crypt::Auth::Owner {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -411,3 +492,12 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The repair-log line for a file rebuilt because its last update has no `/Prev`.
|
||||
const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers";
|
||||
|
||||
/// Whether `data` has more than one `startxref`: an update was appended to it, or it is
|
||||
/// linearized (whose first-page section links to the main one).
|
||||
fn earlier_sections(data: &[u8]) -> bool {
|
||||
find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some()
|
||||
}
|
||||
|
||||
@@ -276,6 +276,10 @@ fn fax_images_decode_with_their_parameters() {
|
||||
let mut understated = parms.clone();
|
||||
understated.set(b"Rows".to_vec(), Object::Int(1));
|
||||
assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
|
||||
// More /Rows than /Height (PDFBOX-771: 1,084,975,104), as the renderer reads it too.
|
||||
let mut overstated = parms.clone();
|
||||
overstated.set(b"Rows".to_vec(), Object::Int(1_000_000_000));
|
||||
assert_eq!(image(&doc, Some(Object::Dict(overstated)), 10, 3).unwrap(), bits, "/Height rows, not /Rows past it");
|
||||
// The parameters as the file gives them, /Rows included, and Table 11's defaults.
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
|
||||
@@ -2190,6 +2194,69 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
|
||||
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
|
||||
}
|
||||
|
||||
/// The width (`/MediaBox` x1) of each of `pages`, in order.
|
||||
fn page_widths(pages: &[Dict]) -> Vec<Option<i64>> {
|
||||
pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect()
|
||||
}
|
||||
|
||||
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
|
||||
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
|
||||
let catalog = doc.get(doc.root().expect("root"));
|
||||
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
|
||||
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
|
||||
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
|
||||
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
|
||||
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
|
||||
/// the repair log says why; a save rewrites it in full, with every page.
|
||||
#[test]
|
||||
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
|
||||
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
|
||||
let pages = page_tree_pages(&doc);
|
||||
assert_eq!(pages.len(), 3);
|
||||
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
|
||||
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
|
||||
let catalog = doc.get(doc.root().expect("root"));
|
||||
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
|
||||
|
||||
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
|
||||
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
|
||||
let pages = page_tree_pages(&reopened);
|
||||
assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order");
|
||||
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}");
|
||||
let catalog = reopened.get(reopened.root().expect("root"));
|
||||
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved");
|
||||
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
|
||||
|
||||
// The same file with `/Prev` reads through its chain, as before.
|
||||
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
|
||||
assert_eq!(page_tree_pages(&linked).len(), 3);
|
||||
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
|
||||
assert_eq!(linked.revisions().len(), 2);
|
||||
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
|
||||
}
|
||||
|
||||
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
|
||||
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
|
||||
#[test]
|
||||
fn a_hybrid_update_without_prev_is_rebuilt_too() {
|
||||
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
|
||||
let pages = page_tree_pages(&doc);
|
||||
assert_eq!(pages.len(), 3);
|
||||
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
|
||||
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
|
||||
let catalog = doc.get(doc.root().expect("root"));
|
||||
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
|
||||
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
|
||||
assert_eq!(page_tree_pages(&linked).len(), 3);
|
||||
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
|
||||
assert_eq!(linked.revisions().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_agrees_with_a_plain_window_search() {
|
||||
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
|
||||
|
||||
@@ -25,12 +25,12 @@ mod writer;
|
||||
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
|
||||
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
|
||||
pub use parser::{Lexer, is_whitespace, parse_indirect};
|
||||
pub use pdfcraft_crypt::{
|
||||
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
|
||||
};
|
||||
pub use writer::{SaveOptions, pdf_date, reachable_objects, serialize, write_full, write_incremental};
|
||||
pub use writer::{SaveOptions, civil_from_days, pdf_date, reachable_objects, serialize, write_full, write_incremental};
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
||||
pub enum CosError {
|
||||
|
||||
+113
-7
@@ -57,19 +57,90 @@ impl PdfString {
|
||||
/// non-ASCII bytes (written by some producers) is read as UTF-8 rather than as
|
||||
/// PDFDocEncoding mojibake, and stray leading U+FEFF (doubled BOMs) are removed.
|
||||
pub fn to_text(&self) -> String {
|
||||
let s = match self.bytes.as_slice() {
|
||||
self.text_chars().collect()
|
||||
}
|
||||
|
||||
/// The characters [`PdfString::to_text`] decodes, one at a time, without building a string.
|
||||
pub fn text_chars(&self) -> TextChars<'_> {
|
||||
let decode = match self.bytes.as_slice() {
|
||||
[0xFE, 0xFF, rest @ ..] => {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
let units: fn(&[u8; 2]) -> u16 = |c| u16::from_be_bytes(*c);
|
||||
Decode::Utf16(char::decode_utf16(rest.as_chunks::<2>().0.iter().map(units)))
|
||||
}
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => Decode::Utf8Lossy { chunks: rest.utf8_chunks(), valid: "".chars(), replace: false },
|
||||
b => match std::str::from_utf8(b) {
|
||||
// Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch.
|
||||
Ok(utf8) if !b.is_ascii() => utf8.to_owned(),
|
||||
_ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(),
|
||||
Ok(utf8) if !b.is_ascii() => Decode::Utf8(utf8.chars()),
|
||||
_ => Decode::PdfDoc(b.iter()),
|
||||
},
|
||||
};
|
||||
if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s }
|
||||
TextChars { decode, leading: true }
|
||||
}
|
||||
|
||||
/// Whether the string decodes to `s` ([`PdfString::to_text`]), compared without allocating.
|
||||
pub fn text_eq(&self, s: &str) -> bool {
|
||||
self.text_chars().eq(s.chars())
|
||||
}
|
||||
}
|
||||
|
||||
/// The characters of a text string: [`PdfString::text_chars`].
|
||||
pub struct TextChars<'a> {
|
||||
decode: Decode<'a>,
|
||||
/// Still at the start, where stray U+FEFF (doubled BOMs) are dropped.
|
||||
leading: bool,
|
||||
}
|
||||
|
||||
/// The UTF-16BE code units after a BOM (an odd last byte is left out).
|
||||
type Utf16Units<'a> = std::iter::Map<std::slice::Iter<'a, [u8; 2]>, fn(&[u8; 2]) -> u16>;
|
||||
|
||||
enum Decode<'a> {
|
||||
Utf16(std::char::DecodeUtf16<Utf16Units<'a>>),
|
||||
/// After a UTF-8 BOM: each invalid sequence is one U+FFFD, as `String::from_utf8_lossy` has it.
|
||||
Utf8Lossy {
|
||||
chunks: std::str::Utf8Chunks<'a>,
|
||||
valid: std::str::Chars<'a>,
|
||||
replace: bool,
|
||||
},
|
||||
Utf8(std::str::Chars<'a>),
|
||||
PdfDoc(std::slice::Iter<'a, u8>),
|
||||
}
|
||||
|
||||
impl Iterator for Decode<'_> {
|
||||
type Item = char;
|
||||
|
||||
fn next(&mut self) -> Option<char> {
|
||||
match self {
|
||||
Decode::Utf16(units) => units.next().map(|u| u.unwrap_or(char::REPLACEMENT_CHARACTER)),
|
||||
Decode::Utf8Lossy { chunks, valid, replace } => loop {
|
||||
if let Some(c) = valid.next() {
|
||||
return Some(c);
|
||||
}
|
||||
if *replace {
|
||||
*replace = false;
|
||||
return Some(char::REPLACEMENT_CHARACTER);
|
||||
}
|
||||
let chunk = chunks.next()?;
|
||||
*valid = chunk.valid().chars();
|
||||
*replace = !chunk.invalid().is_empty();
|
||||
},
|
||||
Decode::Utf8(chars) => chars.next(),
|
||||
Decode::PdfDoc(bytes) => bytes.next().map(|&c| pdfcraft_crypt::pdfdoc_char(c)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for TextChars<'_> {
|
||||
type Item = char;
|
||||
|
||||
fn next(&mut self) -> Option<char> {
|
||||
loop {
|
||||
let c = self.decode.next()?;
|
||||
if self.leading && c == '\u{FEFF}' {
|
||||
continue;
|
||||
}
|
||||
self.leading = false;
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +390,14 @@ impl Stream {
|
||||
pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter)
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_spending`] for the samples of an image `rows` high (its `/Height`):
|
||||
/// [`pdfcraft_filters::decode_image_tolerant_within`].
|
||||
pub(crate) fn decoded_image_rows_spending(&self, max: usize, rows: u32, work: &mut u64) -> Result<Vec<u8>, CosError> {
|
||||
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
|
||||
let chain = self.readable_filters().map_err(filter)?;
|
||||
pdfcraft_filters::decode_image_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), rows, work).map_err(filter)
|
||||
}
|
||||
|
||||
/// The stream decoded through its filters by `decode`, or copied when it has none, within
|
||||
/// `max` and [`MAX_DECODED`].
|
||||
fn decode_chain(
|
||||
@@ -594,6 +673,33 @@ mod tests {
|
||||
assert_eq!(PdfString::literal(Vec::new()).to_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_eq_compares_what_to_text_decodes() {
|
||||
let utf16 = |s: &str| [0xFE, 0xFF].into_iter().chain(s.encode_utf16().flat_map(u16::to_be_bytes)).collect::<Vec<u8>>();
|
||||
// Each invalid UTF-8 sequence is one U+FFFD, as String::from_utf8_lossy has it.
|
||||
let lossy = [0xEF, 0xBB, 0xBF, 0xC3, 0x28, b'x', 0xF0, 0x9F, 0x92, 0xFF];
|
||||
assert_eq!(PdfString::literal(lossy.to_vec()).to_text(), String::from_utf8_lossy(&lossy[3..]));
|
||||
let cases: Vec<Vec<u8>> = vec![
|
||||
Vec::new(),
|
||||
b"Plain".to_vec(),
|
||||
vec![b'C', b'a', b'f', 0xE9, 0x18],
|
||||
"目录".as_bytes().to_vec(),
|
||||
utf16("第一章"),
|
||||
vec![0xFE, 0xFF, 0xD8, 0x00, 0x00, b'A'],
|
||||
vec![0xFE, 0xFF, 0xFE, 0xFF, 0x00, b'A', 0xFE, 0xFF],
|
||||
lossy.to_vec(),
|
||||
];
|
||||
for bytes in cases {
|
||||
let s = PdfString::literal(bytes);
|
||||
let text = s.to_text();
|
||||
assert!(s.text_eq(&text), "{text:?}");
|
||||
assert!(!s.text_eq(&format!("{text}x")), "{text:?}");
|
||||
assert!(text.is_empty() || !s.text_eq(&text[..text.len() - text.chars().last().map_or(0, char::len_utf8)]), "{text:?}");
|
||||
}
|
||||
// A U+FEFF after the first character is text and stays.
|
||||
assert!(PdfString::literal(utf16("A\u{FEFF}")).text_eq("A\u{FEFF}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfdoc_encoding_high_range() {
|
||||
assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A");
|
||||
|
||||
@@ -502,14 +502,19 @@ fn parse_indirect_in(
|
||||
let mut after = Lexer::new(data, end);
|
||||
after.eat_keyword(b"endstream")
|
||||
};
|
||||
// A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt.
|
||||
let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len()));
|
||||
let len = match declared {
|
||||
Some(l) if valid(l) => l as usize,
|
||||
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
|
||||
_ => {
|
||||
find_endstream(data, start, endstreams)
|
||||
.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?
|
||||
- start
|
||||
}
|
||||
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none
|
||||
// left in the file, a /Length that fits is the stream's extent, "how many bytes of the
|
||||
// PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers
|
||||
// take it: a producer that wrote `endstrea` after every stream lost every font and
|
||||
// every page's content.
|
||||
_ => match find_endstream(data, start, endstreams) {
|
||||
Some(end) => end - start,
|
||||
None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?,
|
||||
},
|
||||
};
|
||||
let range = start..start + len;
|
||||
let raw = match shared {
|
||||
@@ -660,6 +665,24 @@ mod tests {
|
||||
assert_eq!(s.dict.int(b"Length"), Some(5));
|
||||
}
|
||||
|
||||
/// With no `endstream` left in the file, as a producer that writes `endstrea` leaves every
|
||||
/// stream, a stream is the bytes its /Length gives, "how many bytes of the PDF file are used
|
||||
/// for the stream's data" (ISO 32000-2 §7.3.8.2): it was an error, and the object null, so
|
||||
/// such a file lost every font and page content. A /Length past the file, or none, is still
|
||||
/// no stream.
|
||||
#[test]
|
||||
fn indirect_stream_without_endstream_is_its_length() {
|
||||
for end in [&b"\r\nendstrea\rendobj\r\n"[..], b"\nendobj\n", b""] {
|
||||
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO".to_vec();
|
||||
data.extend_from_slice(end);
|
||||
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
|
||||
let Object::Stream(s) = o else { panic!("stream expected") };
|
||||
assert_eq!(&s.raw[..], b"HELLO", "{end:?}");
|
||||
}
|
||||
assert!(parse_indirect(b"7 0 obj\n<< /Length 6 >>\nstream\nHELLO", 0, &|_| None).is_err(), "past the file");
|
||||
assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_stream_with_a_space_before_the_line_break() {
|
||||
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
|
||||
|
||||
@@ -128,6 +128,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `d`, an `/Encrypt` dictionary, has crypt filters that encrypt (`/CFM` `V2`, `AESV2` or
|
||||
/// `AESV3`), each authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO
|
||||
/// 32000-2 §7.6.6, Table 25): what a document asks for its embedded files alone, which it opens
|
||||
/// without (§7.6.6: "EFOpen Authorization shall be required when accessing embedded files"). The
|
||||
/// files use them through `/EFF` or through a `/Crypt` filter of their own. Its strings and streams
|
||||
/// have to be in the clear too, which [`SecurityHandler::without_key`] checks.
|
||||
pub(crate) fn efopen_only(doc: &Document, d: &Dict) -> bool {
|
||||
let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec));
|
||||
let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) else { return false };
|
||||
let mut encrypting = Vec::new();
|
||||
for (k, v) in cf.iter() {
|
||||
let Some(f) = doc.resolve(v).as_dict().cloned() else { continue };
|
||||
if !matches!(name(&f, b"CFM").as_deref(), Some(b"V2" | b"AESV2" | b"AESV3")) {
|
||||
continue;
|
||||
}
|
||||
if name(&f, b"AuthEvent").as_deref() != Some(b"EFOpen") {
|
||||
return false;
|
||||
}
|
||||
encrypting.push(k.clone());
|
||||
}
|
||||
!encrypting.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `s`, read from a document whose handler has no file key, holds data that handler would
|
||||
/// decrypt, which therefore reads as stored: still encrypted.
|
||||
pub(crate) fn stream_reads_as_stored(doc: &Document, h: &SecurityHandler, s: &Stream) -> bool {
|
||||
if h.has_key() {
|
||||
return false;
|
||||
}
|
||||
let _scope = FollowScope::enter();
|
||||
let named = named_crypt_filter(doc, &s.dict);
|
||||
let ty = s.dict.get(b"Type").map(|t| followed(doc, t));
|
||||
let kind = match (&named, ty.as_deref().and_then(Object::as_name)) {
|
||||
(Some(n), _) => StreamKind::Named(n),
|
||||
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
|
||||
_ => StreamKind::Normal,
|
||||
};
|
||||
h.decrypts(kind)
|
||||
}
|
||||
|
||||
/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`.
|
||||
fn is_crypt(doc: &Document, f: &Object) -> bool {
|
||||
followed(doc, f).as_name() == Some(b"Crypt")
|
||||
|
||||
@@ -166,6 +166,24 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
}
|
||||
|
||||
/// A document opened without the password its embedded files need holds their data as stored and
|
||||
/// can't encrypt: a save keeps its `/Encrypt` as it is, and writes no edited or added stream that
|
||||
/// would have to be encrypted (an attached file put in, say), which would be written in the clear.
|
||||
fn keyless_check(doc: &Document) -> Result<(), CosError> {
|
||||
if !doc.embedded_files_locked() {
|
||||
return Ok(());
|
||||
}
|
||||
let refuse =
|
||||
|what: &str| Err(CosError::Security(format!("{what} without the password of the document's embedded files: open it with the password")));
|
||||
if doc.encryption_changed() || doc.trailer().get(b"Encrypt").is_none() {
|
||||
return refuse("the document's security can't change");
|
||||
}
|
||||
if doc.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if doc.reads_as_stored(s))) {
|
||||
return refuse("an edited or added embedded file can't be encrypted");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The object as written: encrypted with its (output) number when the document is encrypted,
|
||||
/// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document).
|
||||
/// `o` has the document's numbers in its references ([`crate::security::transform`]).
|
||||
@@ -242,6 +260,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
if doc.stream_limited() {
|
||||
return Err(CosError::ReadOnlyLimit);
|
||||
}
|
||||
keyless_check(doc)?;
|
||||
// Reconstructed files have no chain to append to; added or removed encryption must
|
||||
// rewrite every object; redaction must not leave the old revision behind. All need a full save.
|
||||
if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() {
|
||||
@@ -384,6 +403,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
if doc.stream_limited() {
|
||||
return Err(CosError::ReadOnlyLimit);
|
||||
}
|
||||
keyless_check(doc)?;
|
||||
// Renumbering breaks a key that mixes in the object number.
|
||||
if doc.security().is_some_and(|h| !h.has_key() && !h.survives_renumbering()) {
|
||||
return Err(CosError::Security(
|
||||
"the embedded files are encrypted and the document was opened without their password: open it with the password to save it in full"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// A full traversal must not fill the editor's shared caches (which undo snapshots also
|
||||
// own). Parsed objects and decoded object streams are temporary working data here.
|
||||
let mut reader = doc.object_reader();
|
||||
@@ -919,14 +946,14 @@ pub fn pdf_date(unix_secs: i64) -> String {
|
||||
format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). Total for any `i64` count:
|
||||
/// dates past `NaiveDate`'s ±262143-year range keep their proleptic Gregorian year instead of
|
||||
/// falling back to the epoch.
|
||||
///
|
||||
/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range,
|
||||
/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every
|
||||
/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back
|
||||
/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar
|
||||
/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
/// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day
|
||||
/// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't
|
||||
/// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
@@ -1110,6 +1137,12 @@ mod tests {
|
||||
// its sign, exactly as before).
|
||||
assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z");
|
||||
assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z");
|
||||
assert_eq!(crate::civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(crate::civil_from_days(-1), (1969, 12, 31));
|
||||
assert_eq!(crate::civil_from_days(11_016), (2000, 2, 29));
|
||||
assert_eq!(crate::civil_from_days(19_782), (2024, 2, 29));
|
||||
assert_eq!(crate::civil_from_days(i64::MAX), (25_252_734_927_768_524, 7, 27));
|
||||
assert_eq!(crate::civil_from_days(i64::MIN), (-25_252_734_927_764_585, 6, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent
|
||||
//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored,
|
||||
//! and its embedded files keep their encrypted bytes, flagged as such, through every save.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental};
|
||||
use pdfcraft_crypt::StreamKind;
|
||||
|
||||
const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
|
||||
const ATTACHED: &[u8] = b"the attached file's own words";
|
||||
const PASSWORD: &str = "attach pw";
|
||||
const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 };
|
||||
const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 };
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02X}")).collect()
|
||||
}
|
||||
|
||||
/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is
|
||||
/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`.
|
||||
fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec<u8> {
|
||||
fixture_with(algorithm, auth_event, false)
|
||||
}
|
||||
|
||||
/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with
|
||||
/// no `/EFF` (ISO 32000-2 §7.4.10).
|
||||
fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec<u8> {
|
||||
let id0 = [7u8; 16];
|
||||
let params =
|
||||
NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] };
|
||||
let handler = pdfcraft_crypt::create(¶ms, &id0).unwrap();
|
||||
let d = handler.dict();
|
||||
let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile };
|
||||
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind);
|
||||
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
|
||||
let (version, cfm, r6) = match algorithm {
|
||||
Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))),
|
||||
_ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()),
|
||||
};
|
||||
let encrypt = format!(
|
||||
"<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>",
|
||||
hex(&d.o),
|
||||
hex(&d.u),
|
||||
d.p
|
||||
);
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(),
|
||||
b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(),
|
||||
];
|
||||
let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes();
|
||||
stream.extend_from_slice(&cipher);
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
objs.push(stream);
|
||||
objs.push(encrypt.into_bytes());
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
let trailer = format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n",
|
||||
objs.len() + 1,
|
||||
hex(&id0),
|
||||
hex(&id0)
|
||||
);
|
||||
out.extend_from_slice(trailer.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(doc: &Document, r: ObjRef) -> pdfcraft_cos::Stream {
|
||||
match &*doc.get(r) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
other => panic!("object {} isn't a stream: {other:?}", r.num),
|
||||
}
|
||||
}
|
||||
|
||||
/// The attachment as the password reads it from `bytes`.
|
||||
fn attachment_with_password(bytes: Vec<u8>) -> Vec<u8> {
|
||||
let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap();
|
||||
assert!(!doc.embedded_files_locked());
|
||||
let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
|
||||
assert!(embedded.get(b"Names").is_some());
|
||||
stream(&doc, EMBEDDED).decoded().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
|
||||
let bytes = fixture(Algorithm::Aes256, "EFOpen");
|
||||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||||
assert!(doc.embedded_files_locked());
|
||||
// Not a repair: the specification has it open so.
|
||||
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
|
||||
assert!(doc.trailer().get(b"Encrypt").is_some());
|
||||
// The page reads as stored; the attachment keeps its encrypted bytes and says so.
|
||||
let content = stream(&doc, CONTENT);
|
||||
assert_eq!(content.decoded().unwrap(), PAGE.as_bytes());
|
||||
assert!(!doc.reads_as_stored(&content));
|
||||
let embedded = stream(&doc, EMBEDDED);
|
||||
assert!(doc.reads_as_stored(&embedded));
|
||||
assert_ne!(embedded.decoded().unwrap(), ATTACHED);
|
||||
// The password still opens it, and reads the attachment.
|
||||
assert_eq!(attachment_with_password(bytes.clone()), ATTACHED);
|
||||
// A wrong password is still refused.
|
||||
assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() {
|
||||
let bytes = fixture(Algorithm::Aes256, "DocOpen");
|
||||
assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword)));
|
||||
assert_eq!(attachment_with_password(bytes), ATTACHED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_keep_the_encrypted_embedded_files_as_stored() {
|
||||
for algorithm in [Algorithm::Aes256, Algorithm::Aes128] {
|
||||
let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap();
|
||||
assert!(doc.embedded_files_locked(), "{algorithm:?}");
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap();
|
||||
// An incremental save keeps /Encrypt, and the attachment decrypts with the password.
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}");
|
||||
// A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do.
|
||||
match algorithm {
|
||||
Algorithm::Aes256 => {
|
||||
let full = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(full.clone())).unwrap();
|
||||
assert!(reopened.embedded_files_locked());
|
||||
let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap();
|
||||
let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
|
||||
let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap();
|
||||
let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
|
||||
let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap();
|
||||
let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap();
|
||||
let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap();
|
||||
let file = match &*doc.resolve(ef.get(b"F").unwrap()) {
|
||||
Object::Stream(s) => s.decoded().unwrap(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(file, ATTACHED);
|
||||
}
|
||||
_ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_cannot_change_without_the_embedded_files_password() {
|
||||
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
|
||||
doc.remove_encryption();
|
||||
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
|
||||
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
|
||||
}
|
||||
|
||||
/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a
|
||||
/// document opened without the key open no other.
|
||||
#[test]
|
||||
fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() {
|
||||
let bytes = fixture(Algorithm::Aes256, "EFOpen");
|
||||
let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap();
|
||||
assert!(doc.embedded_files_locked());
|
||||
assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked());
|
||||
// The same /Encrypt values and /ID, authorised when the document opens.
|
||||
let doc_open = fixture(Algorithm::Aes256, "DocOpen");
|
||||
assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword)));
|
||||
assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword)));
|
||||
}
|
||||
|
||||
/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way.
|
||||
#[test]
|
||||
fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() {
|
||||
let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true);
|
||||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||||
assert!(doc.embedded_files_locked());
|
||||
assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED)));
|
||||
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
|
||||
assert_eq!(attachment_with_password(bytes), ATTACHED);
|
||||
}
|
||||
|
||||
/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an
|
||||
/// embedded file, is refused rather than written in the clear.
|
||||
#[test]
|
||||
fn saves_refuse_what_would_need_the_key() {
|
||||
let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
|
||||
let mut doc = open();
|
||||
doc.trailer_mut().remove(b"Encrypt");
|
||||
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
|
||||
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
|
||||
let mut doc = open();
|
||||
let mut replaced = stream(&doc, EMBEDDED);
|
||||
replaced.raw = b"new words".to_vec().into();
|
||||
doc.set(EMBEDDED, Object::Stream(replaced));
|
||||
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
|
||||
}
|
||||
|
||||
/// The password given after opening unlocks the embedded files; a wrong one doesn't.
|
||||
#[test]
|
||||
fn the_password_given_later_unlocks_the_embedded_files() {
|
||||
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
|
||||
assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword)));
|
||||
assert!(doc.embedded_files_locked());
|
||||
doc.unlock(PASSWORD).unwrap();
|
||||
assert!(!doc.embedded_files_locked());
|
||||
assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED);
|
||||
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
|
||||
// Not while an embedded file read as stored has been edited.
|
||||
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
|
||||
let edited = stream(&doc, EMBEDDED);
|
||||
doc.set(EMBEDDED, Object::Stream(edited));
|
||||
assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_))));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod bounded_writer;
|
||||
mod corpus;
|
||||
mod efopen;
|
||||
mod encryption;
|
||||
mod generations;
|
||||
mod proptests;
|
||||
|
||||
@@ -302,9 +302,14 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary
|
||||
/// says: an image of one pixel can hold a header of 500 megapixels.
|
||||
/// says: an image of one pixel can hold a header of 500 megapixels. `None`, as for a JPEG that
|
||||
/// can't be read, when its metadata would take the decoder quadratic time
|
||||
/// ([`decodable_metadata`](crate::upright_image::decodable_metadata)).
|
||||
fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> {
|
||||
use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions};
|
||||
if !crate::upright_image::decodable_metadata(data) {
|
||||
return None;
|
||||
}
|
||||
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
|
||||
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
|
||||
dec.decode_headers().ok()?;
|
||||
@@ -329,6 +334,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
|
||||
Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),
|
||||
_ => {}
|
||||
}
|
||||
// Past the decoder's metadata bound, drop the Extended XMP chunks rather than the mask:
|
||||
// decoded as it stands, an over-chunked JPEG is written as is, without its /SMask.
|
||||
let stripped;
|
||||
let data = if crate::upright_image::decodable_metadata(data) {
|
||||
data
|
||||
} else {
|
||||
stripped = crate::upright_image::without_extended_xmp(data);
|
||||
&stripped[..]
|
||||
};
|
||||
let img = jpeg_of_size(data, w, h)?;
|
||||
Some(match space {
|
||||
Space::Gray => (img.into_luma8().into_raw(), Space::Gray),
|
||||
@@ -340,6 +354,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
|
||||
/// a decoder converting to RGB inverts them, with YCCK converted to CMYK.
|
||||
fn cmyk_jpeg_samples(data: &[u8], w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
|
||||
// Past the decoder's metadata bound, decode the image without its Extended XMP chunks
|
||||
// rather than not at all: written out as stored, a CMYK JPEG shows inverted colours.
|
||||
let stripped;
|
||||
let data = if crate::upright_image::decodable_metadata(data) {
|
||||
data
|
||||
} else {
|
||||
stripped = crate::upright_image::without_extended_xmp(data);
|
||||
&stripped[..]
|
||||
};
|
||||
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
|
||||
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
|
||||
dec.decode_headers().ok()?;
|
||||
|
||||
@@ -941,6 +941,43 @@ fn damaged_masks_and_sizes_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_with_thousands_of_extended_xmp_chunks_keeps_its_mask() {
|
||||
// Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha. A JPEG
|
||||
// whose Extended XMP chunks would take the decoder quadratic time is decoded without them,
|
||||
// which is linear in what is left, so the mask survives; a JPEG mask past the same bound is
|
||||
// still left out, without reaching the decoder.
|
||||
let flood = crate::upright_image::tests::with_extended_xmp_chunks(&real_jpeg(), 5_000);
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0; 8])));
|
||||
let mut d = image_dict(4, 2, "DeviceGray", 8);
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
d.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
Stream::from_raw(d, flood.clone())
|
||||
});
|
||||
crate::extract::JPEGS_DECODED.with(|n| n.set(0));
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert_eq!(out.images[0].extension, "png");
|
||||
assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 1);
|
||||
// The chunks themselves are gone, so the decoder never saw them.
|
||||
assert!(!out.images[0].data.windows(5).any(|w| w == b"http:"));
|
||||
|
||||
crate::extract::JPEGS_DECODED.with(|n| n.set(0));
|
||||
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut md = image_dict(4, 2, "DeviceGray", 8);
|
||||
md.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
let mask = doc.add(Object::Stream(Stream::from_raw(md, flood.clone())));
|
||||
let mut d = image_dict(4, 2, "DeviceRGB", 8);
|
||||
d.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
Stream::from_raw(d, vec![10; 24])
|
||||
});
|
||||
assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]);
|
||||
assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_image_export_clamps_samples_to_hival() {
|
||||
for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] {
|
||||
@@ -2788,3 +2825,30 @@ fn proportions_up_to_the_limit_scale_as_they_did() {
|
||||
let mask = noise(20, 10);
|
||||
assert_eq!(crate::extract::scaled(&mask, 20, 10), mask);
|
||||
}
|
||||
|
||||
/// A CMYK JPEG past the decoder's Extended XMP bound is still decoded (its chunks left out) and
|
||||
/// exported with the colours the page shows, not written as stored, which inverts them.
|
||||
#[test]
|
||||
fn a_cmyk_jpeg_flooded_with_extended_xmp_is_exported_in_its_colours() {
|
||||
let jpeg = crate::upright_image::tests::with_extended_xmp_chunks(&flat_four_component_jpeg([255, 0, 0, 0], 0), 5_000);
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut d = image_dict(8, 8, "DeviceCMYK", 8);
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64])));
|
||||
d.set(b"SMask".to_vec(), Object::Ref(m));
|
||||
Stream::from_raw(d, jpeg.clone())
|
||||
});
|
||||
let (_, rgba) = exported_rgba(&doc);
|
||||
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{:?}", &rgba[..4]);
|
||||
}
|
||||
|
||||
/// Stripping leaves everything but the Extended XMP chunks, byte for byte.
|
||||
#[test]
|
||||
fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() {
|
||||
let plain = flat_four_component_jpeg([255, 0, 0, 0], 0);
|
||||
let flooded = crate::upright_image::tests::with_extended_xmp_chunks(&plain, 3);
|
||||
assert_ne!(flooded, plain);
|
||||
// The helper puts a stray byte before the chunks, which the decoder skips: it stays.
|
||||
assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat());
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
|
||||
}
|
||||
|
||||
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) || !decodable_metadata(bytes) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -65,10 +65,41 @@ fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
/// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number.
|
||||
const MAX_EXTENDED_XMP_CHUNKS: usize = 1024;
|
||||
|
||||
/// Whether JPEG `bytes` may be handed to a decoder (`image`'s or `zune-jpeg`'s, which `image` uses),
|
||||
/// headers included: not when it carries more than [`MAX_EXTENDED_XMP_CHUNKS`]. Every path that
|
||||
/// decodes a JPEG from a file checks this first: image import here, and image export.
|
||||
pub(crate) fn decodable_metadata(bytes: &[u8]) -> bool {
|
||||
extended_xmp_chunks(bytes) <= MAX_EXTENDED_XMP_CHUNKS
|
||||
}
|
||||
|
||||
/// How many Extended XMP chunks (APP1 segments) come before the image data.
|
||||
fn extended_xmp_chunks(bytes: &[u8]) -> usize {
|
||||
let mut chunks = 0usize;
|
||||
each_extended_xmp(bytes, |_| chunks += 1);
|
||||
chunks
|
||||
}
|
||||
|
||||
/// JPEG `bytes` without their Extended XMP chunks, which nothing here reads: what is left decodes
|
||||
/// in time linear in its size however many chunks the file carried.
|
||||
pub(crate) fn without_extended_xmp(bytes: &[u8]) -> Vec<u8> {
|
||||
// Only the bytes that are kept: a CMYK JPEG can be mostly Extended XMP, and reserving the
|
||||
// whole file for the little that remains aborts the export under a tight address-space limit.
|
||||
let mut removed = 0usize;
|
||||
each_extended_xmp(bytes, |r| removed = removed.saturating_add(r.len()));
|
||||
let mut out = Vec::with_capacity(bytes.len().saturating_sub(removed));
|
||||
let mut at = 0;
|
||||
each_extended_xmp(bytes, |r| {
|
||||
out.extend_from_slice(bytes.get(at..r.start).unwrap_or_default());
|
||||
at = r.end;
|
||||
});
|
||||
out.extend_from_slice(bytes.get(at..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Calls `f` with the byte range of each Extended XMP chunk (APP1 segment) before the image data.
|
||||
fn each_extended_xmp(bytes: &[u8], mut f: impl FnMut(std::ops::Range<usize>)) {
|
||||
const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
|
||||
let (mut i, mut chunks) = (2usize, 0usize);
|
||||
let mut i = 2usize;
|
||||
while let Some(&byte) = bytes.get(i) {
|
||||
// Stray bytes between segments, which the decoder skips too.
|
||||
if byte != 0xFF {
|
||||
@@ -86,14 +117,16 @@ fn extended_xmp_chunks(bytes: &[u8]) -> usize {
|
||||
_ => {
|
||||
let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break };
|
||||
let len = usize::from(u16::from_be_bytes([hi, lo]));
|
||||
if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) {
|
||||
chunks += 1;
|
||||
let end = i.saturating_add(2).saturating_add(len.max(2));
|
||||
// The prefix must start the APP1 segment's own payload: a zero-length header
|
||||
// followed by prefix bytes is not a chunk.
|
||||
if marker == 0xE1 && bytes.get(i.saturating_add(4)..end.min(bytes.len())).is_some_and(|seg| seg.starts_with(PREFIX)) {
|
||||
f(i..end.min(bytes.len()));
|
||||
}
|
||||
i = i.saturating_add(2).saturating_add(len.max(2));
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
|
||||
@@ -120,7 +153,7 @@ fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::tests::phone_jpeg;
|
||||
|
||||
@@ -138,22 +171,24 @@ mod tests {
|
||||
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
/// `jpeg` with `n` empty chunks of one never-complete Extended XMP packet after its SOI.
|
||||
pub(crate) fn with_extended_xmp_chunks(jpeg: &[u8], n: usize) -> Vec<u8> {
|
||||
let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
|
||||
seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID
|
||||
seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length
|
||||
seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset
|
||||
let mut chunk = vec![0xFF, 0xE1];
|
||||
chunk.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes());
|
||||
chunk.extend_from_slice(&seg);
|
||||
[&jpeg[..2], b"\0".as_slice(), &chunk.repeat(n), &jpeg[2..]].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() {
|
||||
// Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every
|
||||
// chunk so far after each marker, quadratic in their number.
|
||||
let chunk = |n: usize| {
|
||||
let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
|
||||
seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID
|
||||
seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length
|
||||
seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset
|
||||
let mut out = vec![0xFF, 0xE1];
|
||||
out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.repeat(n)
|
||||
};
|
||||
let photo = phone_jpeg(6, 144);
|
||||
let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat();
|
||||
let with = |n: usize| with_extended_xmp_chunks(&photo, n);
|
||||
DECODES.with(|c| c.set(0));
|
||||
assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo");
|
||||
assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_)));
|
||||
|
||||
+57
-4
@@ -234,6 +234,42 @@ impl SecurityHandler {
|
||||
Ok(Self::new(dict, key, auth, recovered_user))
|
||||
}
|
||||
|
||||
/// A handler with no file key, for a document opened without a password whose strings and
|
||||
/// streams aren't encrypted and whose encrypted crypt filters are all ones the caller found
|
||||
/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6,
|
||||
/// Table 25). Data such a filter encrypts is left as stored, read and written alike, so it
|
||||
/// keeps its encrypted bytes; [`SecurityHandler::decrypts`] tells which. `None` when strings or
|
||||
/// streams are encrypted, which needs the key, or the handler is older than crypt filters (V4).
|
||||
pub fn without_key(dict: EncryptDict) -> Option<Self> {
|
||||
if dict.v < 4 {
|
||||
return None;
|
||||
}
|
||||
let methods = Methods::new(&dict);
|
||||
if methods.string != Method::Identity || methods.stream != Method::Identity {
|
||||
return None;
|
||||
}
|
||||
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
|
||||
}
|
||||
|
||||
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
|
||||
pub fn has_key(&self) -> bool {
|
||||
!self.key.is_empty()
|
||||
}
|
||||
|
||||
/// Whether data of `kind` is encrypted, so that reading it needs the file key.
|
||||
pub fn decrypts(&self, kind: StreamKind) -> bool {
|
||||
self.method(Some(kind)) != Method::Identity
|
||||
}
|
||||
|
||||
/// Whether data left as stored by a handler without the key still decrypts after its object
|
||||
/// is renumbered: only AES-256 doesn't mix the object number into the key (Algorithm 1.A).
|
||||
pub fn survives_renumbering(&self) -> bool {
|
||||
[self.methods.string, self.methods.stream, self.methods.embedded_file]
|
||||
.into_iter()
|
||||
.chain(self.methods.named.values().copied())
|
||||
.all(|m| matches!(m, Method::Identity | Method::Aes256))
|
||||
}
|
||||
|
||||
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
|
||||
/// document is unlocked after opening. The user password, or any other, is `WrongPassword`.
|
||||
/// Both passwords unlock the same file key, so what was decrypted so far stays valid; a
|
||||
@@ -267,7 +303,7 @@ impl SecurityHandler {
|
||||
|
||||
/// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
|
||||
fn perms_p(&self) -> Option<i32> {
|
||||
if !matches!(self.dict.r, 5 | 6) {
|
||||
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
|
||||
return None;
|
||||
}
|
||||
let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
|
||||
@@ -302,23 +338,40 @@ impl SecurityHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Algorithm 1 (§7.6.3.3): the key for one object. AES-256 uses the file key directly.
|
||||
/// Algorithm 1 (§7.6.3.2): the key for one object. AES-256 uses the file key directly
|
||||
/// (Algorithm 1.A, §7.6.3.3).
|
||||
///
|
||||
/// For an RC4 crypt filter under `/V 4`, a file key shorter than 16 bytes is first extended
|
||||
/// to 16 bytes with zeros. The standard doesn't say so (Algorithm 1 takes the n-byte key as
|
||||
/// it is), but that is how Acrobat writes and reads such files: a `/V 4` RC4 crypt filter
|
||||
/// with a 40- or 48-bit key (pdf.js issue 19484). The renderer's `hayro-syntax` reads them
|
||||
/// the same way, so a page it shows is one this crate can read and save. AES-128 keys are
|
||||
/// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only
|
||||
/// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already.
|
||||
fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec<u8> {
|
||||
if method == Method::Aes256 {
|
||||
return self.key.clone();
|
||||
}
|
||||
let mut key = self.key.clone();
|
||||
if method == Method::Rc4 && self.dict.v == 4 && key.len() < 16 {
|
||||
key.resize(16, 0);
|
||||
}
|
||||
let mut h = Md5::new();
|
||||
h.update(&self.key);
|
||||
h.update(&key);
|
||||
h.update(&num.to_le_bytes()[..3]);
|
||||
h.update(generation.to_le_bytes());
|
||||
if method == Method::Aes128 {
|
||||
h.update(b"sAlT");
|
||||
}
|
||||
let digest = h.finalize();
|
||||
digest[..(self.key.len() + 5).min(16)].to_vec()
|
||||
digest[..(key.len() + 5).min(16)].to_vec()
|
||||
}
|
||||
|
||||
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
|
||||
// Without the key, encrypted data stays as stored (see `without_key`).
|
||||
if !self.has_key() {
|
||||
return data.to_vec();
|
||||
}
|
||||
match method {
|
||||
Method::Identity => data.to_vec(),
|
||||
Method::Rc4 => rc4(&self.object_key(method, num, generation), data),
|
||||
|
||||
@@ -280,6 +280,69 @@ proptest::proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/V 4` RC4 crypt filter with a 40- or 48-bit key, as Acrobat writes it (pdf.js issue 19484):
|
||||
/// the file key is extended to 16 bytes with zeros before Algorithm 1, so each object's key is
|
||||
/// all 16 bytes of MD5(key, zeros, object, generation). Everything else keeps Algorithm 1 as the
|
||||
/// standard has it, n + 5 bytes (at most 16) of MD5(key, object, generation[, "sAlT"]): a `/V 2`
|
||||
/// file with the same short key, a full 128-bit `/V 4` RC4 key, and AESV2, short (88 bits) or
|
||||
/// full. Each object key is computed here by hand; data written with it must decrypt.
|
||||
#[test]
|
||||
fn a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros() {
|
||||
let text = b"Page tree in an object stream".to_vec();
|
||||
let cases =
|
||||
[(4, 40, Method::Rc4), (4, 48, Method::Rc4), (2, 40, Method::Rc4), (4, 128, Method::Rc4), (4, 88, Method::Aes128), (4, 128, Method::Aes128)];
|
||||
for (v, bits, method) in cases {
|
||||
let mut d = EncryptDict {
|
||||
filter: b"Standard".to_vec(),
|
||||
v,
|
||||
r: if v == 4 { 4 } else { 3 },
|
||||
length_bits: bits,
|
||||
o: vec![7; 32],
|
||||
p: -3392,
|
||||
encrypt_metadata: v != 4,
|
||||
..Default::default()
|
||||
};
|
||||
if v == 4 {
|
||||
d.crypt_filters = vec![(b"StdCF".to_vec(), method)];
|
||||
d.stm_f = b"StdCF".to_vec();
|
||||
d.str_f = b"StdCF".to_vec();
|
||||
}
|
||||
let key = file_key_r4(&d, ID, b"");
|
||||
assert_eq!(key.len(), (bits / 8) as usize);
|
||||
d.u = u_value_r4(&d, ID, &key);
|
||||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||||
let (num, generation) = (13u32, 0u16);
|
||||
let extended = v == 4 && method == Method::Rc4 && key.len() < 16;
|
||||
let mut md5 = Md5::new();
|
||||
md5.update(&key);
|
||||
if extended {
|
||||
md5.update(vec![0u8; 16 - key.len()]);
|
||||
}
|
||||
md5.update(&num.to_le_bytes()[..3]);
|
||||
md5.update(generation.to_le_bytes());
|
||||
if method == Method::Aes128 {
|
||||
md5.update(b"sAlT");
|
||||
}
|
||||
let digest = md5.finalize();
|
||||
let n = if extended { 16 } else { key.len() };
|
||||
let object_key = digest[..(n + 5).min(16)].to_vec();
|
||||
let case = format!("V{v}, {bits}-bit key, {method:?}");
|
||||
let written = if method == Method::Rc4 {
|
||||
rc4(&object_key, &text)
|
||||
} else {
|
||||
let iv = [3u8; 16];
|
||||
[iv.to_vec(), aes_cbc_encrypt(&object_key, &iv, &text, true)].concat()
|
||||
};
|
||||
assert_eq!(h.decrypt_string(num, generation, &written), text, "{case}");
|
||||
assert_eq!(h.decrypt_stream(num, generation, &written, StreamKind::Normal), text, "{case}");
|
||||
let saved = h.encrypt_stream(num, generation, &text, StreamKind::Normal);
|
||||
if method == Method::Rc4 {
|
||||
assert_eq!(saved, written, "saved as it was read: {case}");
|
||||
}
|
||||
assert_eq!(h.decrypt_stream(num, generation, &saved, StreamKind::Normal), text, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_authentication_recovers_the_user_password() {
|
||||
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||||
|
||||
@@ -54,16 +54,21 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
|
||||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) };
|
||||
let space = measure(" ");
|
||||
let (mut line, mut line_w) = (String::new(), 0.0);
|
||||
// Widths add up (the standard-14 widths have no kerning), so each word is measured once
|
||||
// and appended in place: a line that never fills (negative character spacing) stays
|
||||
// linear instead of being copied and measured again for every word.
|
||||
for word in para.split(' ') {
|
||||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
let candidate_w =
|
||||
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
let word_w = measure(word);
|
||||
let candidate_w = if line.is_empty() { word_w } else { line_w + space + word_w };
|
||||
if !line.is_empty() && candidate_w > width {
|
||||
out.push((std::mem::take(&mut line), rtl, index));
|
||||
line = word.to_string();
|
||||
line_w = measure(word);
|
||||
line.push_str(word);
|
||||
line_w = word_w;
|
||||
} else {
|
||||
line = candidate;
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
line_w = candidate_w;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,8 +328,15 @@ pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<
|
||||
/// names them so), so they go when the item is replaced or deleted.
|
||||
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
|
||||
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };
|
||||
let Ok(data) = s.decoded() else { return Vec::new() };
|
||||
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect()
|
||||
// The stream is one this writer put there, so its decode is bounded by the stream itself: the
|
||||
// page limit alone would refuse an item larger than a page's content, and its fonts would stay
|
||||
// on the page's /Font, which `write` and `delete_content` are supposed to take them off.
|
||||
let limit = pdfcraft_cos::MAX_PAGE_DECODED.max(s.raw.len().saturating_mul(16));
|
||||
let Ok(data) = s.decoded_within(limit) else { return Vec::new() };
|
||||
// Each name once: a stream that repeats a name is not copied out once per use.
|
||||
let names: std::collections::BTreeSet<&[u8]> =
|
||||
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).collect();
|
||||
names.into_iter().map(<[u8]>::to_vec).collect()
|
||||
}
|
||||
|
||||
fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
|
||||
|
||||
+225
-21
@@ -136,6 +136,160 @@ fn xobject_name(existing: &Dict, pending: &Dict, n: &mut u32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy usable property resources, including inherited resources supplied by the page model.
|
||||
/// Invalid data must stay intact rather than being replaced while transferring membership.
|
||||
fn properties(doc: &Document, page: &Dict) -> Option<Dict> {
|
||||
let Some(resources) = page.get(b"Resources") else { return Some(Dict::new()) };
|
||||
let resources = doc.resolve(resources);
|
||||
let Object::Dict(resources) = &*resources else { return None };
|
||||
let Some(properties) = resources.get(b"Properties") else { return Some(Dict::new()) };
|
||||
match &*doc.resolve(properties) {
|
||||
Object::Dict(properties) => Some(properties.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an ISO 32000-1 §8.11.2.2 `/VE` visibility expression within recursion depth 16
|
||||
/// and a shared node-visit `budget`.
|
||||
fn valid_ve(doc: &Document, expr: &Object, depth: usize, budget: &mut usize) -> bool {
|
||||
if depth > 16 || *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let resolved = doc.resolve(expr);
|
||||
if let Some(arr) = resolved.as_array() {
|
||||
let [op, rest @ ..] = arr.as_slice() else { return false };
|
||||
let op_obj = doc.resolve(op);
|
||||
let Some(name) = op_obj.as_name() else { return false };
|
||||
match (name, rest) {
|
||||
(b"Not", [operand]) => valid_ve(doc, operand, depth + 1, budget),
|
||||
(b"And" | b"Or", [_, ..]) => rest.iter().all(|o| valid_ve(doc, o, depth + 1, budget)),
|
||||
_ => false,
|
||||
}
|
||||
} else if depth > 0 {
|
||||
expr.as_ref().is_some_and(|r| matches!(&*doc.get(r), Object::Dict(d) if d.name(b"Type") == Some(b"OCG")))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an `/OCMD` dictionary's `/P` policy and `/VE` visibility expression, if present.
|
||||
fn valid_ocmd(doc: &Document, d: &Dict) -> bool {
|
||||
if let Some(p) = d.get(b"P")
|
||||
&& !matches!(doc.resolve(p).as_name(), Some(b"AllOn" | b"AnyOn" | b"AnyOff" | b"AllOff"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(ve) = d.get(b"VE")
|
||||
&& !valid_ve(doc, ve, 0, &mut 4096)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Preserve the stored value, not a reconstructed membership dictionary. A direct inline
|
||||
/// dictionary lacks an indirect reference (ObjRef) to identify it in page resources, and an
|
||||
/// OCMD with a malformed or overly deep `/VE` expression stays on the annotation.
|
||||
fn transferable(doc: &Document, annotation: &Dict, usable_properties: bool) -> bool {
|
||||
let Some(membership) = annotation.get(b"OC") else { return true };
|
||||
if !usable_properties {
|
||||
return false;
|
||||
}
|
||||
match &*doc.resolve(membership) {
|
||||
Object::Dict(d) => match d.name(b"Type") {
|
||||
Some(b"OCG") => membership.as_ref().is_some(),
|
||||
Some(b"OCMD") => valid_ocmd(doc, d),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Retained {
|
||||
refs: HashSet<ObjRef>,
|
||||
inline: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl Retained {
|
||||
fn contains(&self, index: usize, entry: &Object) -> bool {
|
||||
self.inline.contains(&index) || entry.as_ref().is_some_and(|r| self.refs.contains(&r))
|
||||
}
|
||||
|
||||
fn insert(&mut self, index: usize, entry: &Object) {
|
||||
if let Some(r) = entry.as_ref() {
|
||||
self.refs.insert(r);
|
||||
} else {
|
||||
self.inline.insert(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retained annotations keep their replies and pop-ups, including transitive reply chains.
|
||||
///
|
||||
/// Retention is strictly downward (parent -> child/reply/popup). A non-transferable child does not
|
||||
/// keep its transferable parent, matching the engine invariant for undrawn children (#1794).
|
||||
fn retained_annotations(doc: &Document, list: &[Object], usable_properties: bool) -> Retained {
|
||||
let mut children: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
|
||||
let mut inline_children: HashMap<ObjRef, Vec<usize>> = HashMap::new();
|
||||
let mut pending = Vec::new();
|
||||
let mut retained = Retained::default();
|
||||
for (k, entry) in list.iter().enumerate() {
|
||||
let object = doc.resolve(entry);
|
||||
let Some(annotation) = object.as_dict() else { continue };
|
||||
let not_transferable = !transferable(doc, annotation, usable_properties);
|
||||
if let Some(reference) = entry.as_ref() {
|
||||
if not_transferable {
|
||||
pending.push(reference);
|
||||
}
|
||||
if let Some(parent) = annotation.reference(b"IRT") {
|
||||
children.entry(parent).or_default().push(reference);
|
||||
}
|
||||
if annotation.name(b"Subtype") == Some(b"Popup")
|
||||
&& let Some(parent) = annotation.reference(b"Parent")
|
||||
{
|
||||
children.entry(parent).or_default().push(reference);
|
||||
}
|
||||
} else {
|
||||
if not_transferable {
|
||||
retained.inline.insert(k);
|
||||
}
|
||||
if let Some(parent) = annotation.reference(b"IRT") {
|
||||
inline_children.entry(parent).or_default().push(k);
|
||||
}
|
||||
if annotation.name(b"Subtype") == Some(b"Popup")
|
||||
&& let Some(parent) = annotation.reference(b"Parent")
|
||||
{
|
||||
inline_children.entry(parent).or_default().push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some(current) = pending.pop() {
|
||||
if !retained.refs.insert(current) {
|
||||
continue;
|
||||
}
|
||||
if let Some(kids) = children.get(¤t) {
|
||||
pending.extend(kids.iter().copied());
|
||||
}
|
||||
if let Some(inline_kids) = inline_children.get(¤t) {
|
||||
retained.inline.extend(inline_kids.iter().copied());
|
||||
}
|
||||
}
|
||||
retained
|
||||
}
|
||||
|
||||
/// A Properties resource name that is not already on the page or in `pending`.
|
||||
fn property_name(existing: &Dict, pending: &Dict, n: &mut u64) -> Option<String> {
|
||||
loop {
|
||||
let name = format!("PCFlOC{n}");
|
||||
*n = n.checked_add(1)?;
|
||||
if !existing.contains(name.as_bytes()) && !pending.contains(name.as_bytes()) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged
|
||||
/// into page content.
|
||||
pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result<usize, EditError> {
|
||||
@@ -157,10 +311,15 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result<usize, E
|
||||
fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], which: Which) -> Option<(usize, bool, char)> {
|
||||
for &i in pages {
|
||||
let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
for entry in &list {
|
||||
let usable_properties = properties(doc, &all[i].dict).is_some();
|
||||
let retained = retained_annotations(doc, &list, usable_properties);
|
||||
for (k, entry) in list.iter().enumerate() {
|
||||
if retained.contains(k, entry) {
|
||||
continue;
|
||||
}
|
||||
let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue };
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 {
|
||||
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) {
|
||||
continue;
|
||||
}
|
||||
let widget = d.name(b"Subtype") == Some(b"Widget");
|
||||
@@ -174,6 +333,26 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi
|
||||
None
|
||||
}
|
||||
|
||||
/// Ensure comments without stored appearances have appearances generated before flattening.
|
||||
fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retained: &HashSet<ObjRef>, usable_properties: bool) {
|
||||
for entry in list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
if retained.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default();
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
if subtype == b"Widget" || !selected(doc, &d, which) || !transferable(doc, &d, usable_properties) {
|
||||
continue;
|
||||
}
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
|
||||
continue;
|
||||
}
|
||||
let _ = pdfcraft_annot::set_appearance(doc, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on
|
||||
/// success, so a page refused part way leaves the pages before it as they were.
|
||||
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
|
||||
@@ -183,6 +362,19 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
Ok(drawn)
|
||||
}
|
||||
|
||||
/// `page` with what its own dictionary holds now, after an edit: re-reading this one page,
|
||||
/// where walking the whole page tree again for every flattened page would be quadratic.
|
||||
/// Inherited attributes stay as they were collected, under the page's own entries.
|
||||
fn refreshed(doc: &Document, page: &pdfcraft_model::Page) -> pdfcraft_model::Page {
|
||||
let mut dict = page.dict.clone();
|
||||
if let Some(own) = doc.get(page.obj).as_dict() {
|
||||
for (k, v) in own.iter() {
|
||||
dict.set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
pdfcraft_model::Page { obj: page.obj, dict }
|
||||
}
|
||||
|
||||
fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
@@ -205,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let Some(list) = annots_obj.as_ref().map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue };
|
||||
let mut content = String::new();
|
||||
let mut xobjects = Dict::new();
|
||||
let existing_properties = properties(doc, &page.dict);
|
||||
let mut added_properties = Dict::new();
|
||||
let mut retained = retained_annotations(doc, &list, existing_properties.is_some());
|
||||
let mut property_n = 0u64;
|
||||
let mut name_n = 0u32;
|
||||
let existing_xo = page
|
||||
.dict
|
||||
@@ -221,27 +417,16 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
// A viewer draws many comments from their dictionary even when the file stores no
|
||||
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
|
||||
// flattening keeps a comment that still has ink on screen instead of deleting it.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default();
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
if subtype == b"Widget" || !selected(doc, &d, which) {
|
||||
continue;
|
||||
}
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
|
||||
continue;
|
||||
}
|
||||
// A subtype this build cannot draw fails here and is kept below, so the comment is
|
||||
// not deleted with nothing drawn in its place. (Text the standard fonts can't draw
|
||||
// was refused before anything changed: `undrawn_on`.)
|
||||
let _ = pdfcraft_annot::set_appearance(doc, r);
|
||||
}
|
||||
prepare_appearances(doc, &list, which, &retained.refs, existing_properties.is_some());
|
||||
for (k, entry) in list.iter().enumerate() {
|
||||
let obj = doc.resolve(entry);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
let widget = subtype == b"Widget";
|
||||
if retained.contains(k, entry) || !transferable(doc, d, existing_properties.is_some()) {
|
||||
retained.insert(k, entry);
|
||||
continue;
|
||||
}
|
||||
if !selected(doc, d, which) {
|
||||
continue;
|
||||
}
|
||||
@@ -255,8 +440,20 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
|
||||
if let Some(m) = placement(doc, &form, rect) {
|
||||
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
|
||||
if let Some(membership) = d.get(b"OC") {
|
||||
let Some(existing) = existing_properties.as_ref() else { continue };
|
||||
let Some(name) = property_name(existing, &added_properties, &mut property_n) else {
|
||||
retained.insert(k, entry);
|
||||
continue;
|
||||
};
|
||||
content.push_str(&format!("/OC /{name} BDC\n"));
|
||||
added_properties.set(name.into_bytes(), membership.clone());
|
||||
}
|
||||
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
|
||||
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
|
||||
if d.contains(b"OC") {
|
||||
content.push_str("EMC\n");
|
||||
}
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
drawn += 1;
|
||||
drew = true;
|
||||
@@ -297,7 +494,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
// it could not be drawn: its content would go with nothing in its place.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
if undrawn.contains(&r) {
|
||||
if undrawn.contains(&r) || retained.refs.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
@@ -321,11 +518,18 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
xo.set(k.clone(), v.clone());
|
||||
}
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
if !added_properties.is_empty() {
|
||||
let mut properties = existing_properties.unwrap_or_default();
|
||||
for (key, value) in added_properties.iter() {
|
||||
properties.set(key.clone(), value.clone());
|
||||
}
|
||||
res.set(b"Properties".to_vec(), Object::Dict(properties));
|
||||
}
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
let page = page_list(doc)[i].clone();
|
||||
let page = refreshed(doc, &page);
|
||||
place_tagged(doc, &page, "Flattened", content.into_bytes(), false)?;
|
||||
}
|
||||
let page_now = page_list(doc)[i].clone();
|
||||
let page_now = refreshed(doc, &page);
|
||||
match page_now.dict.get(b"Annots").and_then(|a| a.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) {
|
||||
Some(r) => doc.set(r, Object::Array(kept)),
|
||||
None => doc.update_dict(page.obj, |d| {
|
||||
|
||||
@@ -577,6 +577,83 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
|
||||
case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false);
|
||||
}
|
||||
|
||||
/// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and
|
||||
/// valid OCMDs, including ISO 32000-1 §8.11.2.2 `/VE` expressions within depth 16 and the
|
||||
/// 4,096-node visit budget) in `/OC /PCFlOC0 BDC … EMC` and registers them in
|
||||
/// `/Resources /Properties`, while retaining annotations with untransferable, malformed,
|
||||
/// overly deep, or over-budget `/OC` definitions in `/Annots`.
|
||||
#[test]
|
||||
fn flattening_transfers_annotation_optional_content_to_marked_content() {
|
||||
let page = stream("", " ");
|
||||
let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream";
|
||||
let case = |extra: &str, drawn: usize, left: bool, bdc: bool| {
|
||||
let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /AP << /N 6 0 R >> {extra} >>");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R 8 0 R 13 0 R] /D << /OFF [7 0 R] /ON [8 0 R 13 0 R] >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>",
|
||||
&annot,
|
||||
&page,
|
||||
form,
|
||||
"<< /Type /OCG /Name (Hidden layer) >>",
|
||||
"<< /Type /OCG /Name (Shown layer) >>",
|
||||
"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>",
|
||||
"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOn >>",
|
||||
"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOff >>",
|
||||
"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOff >>",
|
||||
"<< /Type /OCG /Name (Second shown layer) >>",
|
||||
"[ /Or 15 0 R 15 0 R 15 0 R 15 0 R ]",
|
||||
"[ /Or 16 0 R 16 0 R 16 0 R 16 0 R ]",
|
||||
"[ /Or 17 0 R 17 0 R 17 0 R 17 0 R ]",
|
||||
"[ /Or 18 0 R 18 0 R 18 0 R 18 0 R ]",
|
||||
"[ /Or 8 0 R 8 0 R 8 0 R 8 0 R ]",
|
||||
]);
|
||||
let before = streams(&doc, 0);
|
||||
let expected_oc = doc.get(pdfcraft_cos::ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"OC").cloned());
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn, "{extra}");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert_eq!(p.dict.contains(b"Annots"), left, "{extra}");
|
||||
if drawn == 0 {
|
||||
assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the retained comment: {extra}");
|
||||
} else {
|
||||
let flat = streams(&doc, 0).pop().unwrap();
|
||||
assert_eq!(flat.contains("/OC /PCFlOC0 BDC") && flat.contains("EMC"), bdc, "{extra}: {flat}");
|
||||
if bdc {
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let props = doc.resolve(res.as_dict().unwrap().get(b"Properties").unwrap());
|
||||
assert_eq!(props.as_dict().unwrap().get(b"PCFlOC0").cloned(), expected_oc, "{extra}");
|
||||
}
|
||||
}
|
||||
};
|
||||
case("/OC 7 0 R", 1, false, true);
|
||||
case("/OC 8 0 R", 1, false, true);
|
||||
case("/OC 9 0 R", 1, false, true);
|
||||
case("/OC 10 0 R", 1, false, true);
|
||||
case("/OC 11 0 R", 1, false, true);
|
||||
case("/OC 12 0 R", 1, false, true);
|
||||
case("", 1, false, false);
|
||||
// Valid ISO 32000-1 §8.11.2.2 /VE visibility expressions are transferred with their OCMD.
|
||||
case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /And 8 0 R 7 0 R ] >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /Or 8 0 R 7 0 R ] >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /Or 7 0 R [ /And 8 0 R 13 0 R ] ] >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /And 8 0 R [ /Not 13 0 R ] ] >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] /OCGs [8 0 R] /P /AllOn >>", 1, false, true);
|
||||
case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] /OCGs [7 0 R] /P /AllOn >>", 1, false, true);
|
||||
// Malformed /VE, invalid /P, inline /OCG, and /VE exceeding depth 16 or the node budget fail closed and stay in /Annots.
|
||||
case("/OC << /Type /OCG /Name (Inline layer) >>", 0, true, false);
|
||||
case("/OC << /Type /OCMD /OCGs [8 0 R] /P /InvalidPolicy >>", 0, true, false);
|
||||
case("/OC << /Type /OCMD /VE 8 0 R /OCGs [8 0 R] /P /AllOn >>", 0, true, false);
|
||||
case("/OC << /Type /OCMD /VE [] /OCGs [7 0 R] /P /AllOn >>", 0, true, false);
|
||||
case("/OC << /Type /OCMD /VE [ /Not 7 0 R 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false);
|
||||
case("/OC << /Type /OCMD /VE [ /Xor 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false);
|
||||
let deep_ve = format!("{}8 0 R{}", "[ /Not ".repeat(17), " ]".repeat(17));
|
||||
case(&format!("/OC << /Type /OCMD /VE {deep_ve} /OCGs [8 0 R] /P /AllOn >>"), 0, true, false);
|
||||
case("/OC << /Type /OCMD /VE [ /Or 14 0 R 14 0 R 14 0 R 14 0 R ] /OCGs [8 0 R] /P /AllOn >>", 0, true, false);
|
||||
}
|
||||
|
||||
/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must
|
||||
/// generate that appearance and bake it into the page, not delete a comment that shows an icon.
|
||||
#[test]
|
||||
@@ -1521,6 +1598,45 @@ fn text_page(content: &str) -> Document {
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §9.6.4: an Edit-text line set in a Type 3 bitmap font is as high as the boxes its
|
||||
/// glyphs declare with `d1`, whatever its `/FontBBox` says and whichever way its font matrix
|
||||
/// turns glyph space, so two lines 8 points apart are two lines with 7-point boxes, not slivers.
|
||||
#[test]
|
||||
fn edit_lines_in_a_type3_font_are_as_high_as_their_glyphs() {
|
||||
// Glyph `a` is drawn 60 units wide, from 20 below the baseline to 50 above it, at 0.1 Tf:
|
||||
// 7 points high, 2 below the baseline.
|
||||
for (matrix, flip) in [("1 0 0 1 0 0", 1), ("1 0 0 -1 0 0", -1)] {
|
||||
for bbox in ["[0 0 0 0]", "[0 0 1 1]"] {
|
||||
let mut doc = text_page(&format!("BT /T3 0.1 Tf 1 0 0 {flip} 72 700 Tm (aa) Tj 1 0 0 {flip} 72 692 Tm (aa) Tj ET"));
|
||||
let glyph = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"60 0 0 -20 60 50 d1 0 -20 60 70 re f".to_vec())));
|
||||
let font = doc.add(
|
||||
pdfcraft_cos::parse_indirect(
|
||||
format!(
|
||||
"1 0 obj << /Type /Font /Subtype /Type3 /FontMatrix [{matrix}] /FontBBox {bbox} /FirstChar 97 /LastChar 97 /Widths [60] /Encoding << /Differences [97 /a] >> /CharProcs << /a {} 0 R >> >> endobj",
|
||||
glyph.num
|
||||
)
|
||||
.as_bytes(),
|
||||
0,
|
||||
&|_| None,
|
||||
)
|
||||
.unwrap()
|
||||
.1,
|
||||
);
|
||||
let p = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(p, |d| {
|
||||
d.set(
|
||||
b"Resources".to_vec(),
|
||||
Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(font))])))])),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let rects: Vec<_> = lines.iter().map(|l| (l.text.as_str(), l.rect.map(|v| (v * 100.0).round() / 100.0))).collect();
|
||||
assert_eq!(rects, [("aa", [72.0, 698.0, 84.0, 705.0]), ("aa", [72.0, 690.0, 84.0, 697.0])], "{matrix} {bbox}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_text_keeps_words_in_paragraphs_and_objects() {
|
||||
let expected = "TRACKED SAMPLE TEXT";
|
||||
@@ -9748,6 +9864,20 @@ fn added_text_is_wrapped_once_per_write() {
|
||||
assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines");
|
||||
}
|
||||
|
||||
/// Negative character spacing keeps a line from ever filling its box. Wrapping then holds every
|
||||
/// word on one line, appended and measured once each rather than copied and measured again for
|
||||
/// each word (which took minutes for this text); the widths still decide the breaks.
|
||||
#[test]
|
||||
fn added_text_that_never_fills_its_line_wraps_in_linear_time() {
|
||||
let words = vec!["word"; 200_000].join(" ");
|
||||
let packed = AddedText { rect: [0.0, 0.0, 100.0, 100.0], text: words.clone(), char_spacing: -10.0, ..AddedText::default() };
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(added::lines(&packed), [words]);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
let plain = AddedText { rect: [0.0, 0.0, 60.0, 100.0], text: "one two three four".into(), size: 10.0, ..AddedText::default() };
|
||||
assert_eq!(added::lines(&plain), ["one two", "three four"]);
|
||||
}
|
||||
|
||||
/// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item
|
||||
/// reopens with it; an item written without it reads 0.
|
||||
#[test]
|
||||
@@ -10854,6 +10984,116 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
}
|
||||
|
||||
/// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to
|
||||
/// the line takes a positioning gap for its spaces, not code 32 at width 0, which would run the words together.
|
||||
#[test]
|
||||
fn a_space_in_a_subset_that_gives_it_no_width_is_a_gap_not_code_32() {
|
||||
for subtype in ["TrueType", "Type1"] {
|
||||
// Codes 32..=98: the space and everything else 0 wide, but a and b (97, 98).
|
||||
let widths: Vec<String> = (32..=98).map(|c| if c == 97 || c == 98 { "500" } else { "0" }.to_string()).collect();
|
||||
let font = format!(
|
||||
"<< /Type /Font /Subtype /{subtype} /BaseFont /ABCDEF+Sample /FirstChar 32 /LastChar 98 /Widths [{}] /Encoding /WinAnsiEncoding >>",
|
||||
widths.join(" ")
|
||||
);
|
||||
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
|
||||
format!(
|
||||
"<< /Length {} >>
|
||||
stream
|
||||
{content}
|
||||
endstream",
|
||||
content.len()
|
||||
),
|
||||
font,
|
||||
];
|
||||
let mut out = b"%PDF-1.7
|
||||
"
|
||||
.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
|
||||
{o}
|
||||
endobj
|
||||
",
|
||||
i + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"xref
|
||||
0 {}
|
||||
0000000000 65535 f
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for o in offs {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{o:010} 00000 n
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer
|
||||
<< /Size {} /Root 1 0 R >>
|
||||
startxref
|
||||
{x}
|
||||
%%EOF
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let mut doc = Document::open(Arc::new(out)).unwrap();
|
||||
assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba").unwrap().substituted, None, "{subtype}");
|
||||
// Every byte the text-showing operators draw (strings inside `TJ` arrays too) and every
|
||||
// `TJ` adjustment, read from the saved content rather than matched as text.
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&reopen(&doc), 0)).ops;
|
||||
let mut shown: Vec<u8> = Vec::new();
|
||||
let mut adjustments: Vec<f64> = Vec::new();
|
||||
// How many bytes had been drawn when each adjustment came: the gap must sit between the words.
|
||||
let mut positions: Vec<usize> = Vec::new();
|
||||
for op in ops.iter().filter(|o| matches!(o.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")) {
|
||||
for operand in &op.operands {
|
||||
match operand {
|
||||
Object::String(s) => shown.extend_from_slice(&s.bytes),
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
match item {
|
||||
Object::String(s) => shown.extend_from_slice(&s.bytes),
|
||||
number => {
|
||||
if let Some(adjustment) = number.as_f64() {
|
||||
adjustments.push(adjustment);
|
||||
positions.push(shown.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(shown, b"abba", "{subtype}: the words are drawn and no code 32 at width 0 between them");
|
||||
assert!(matches!(adjustments.as_slice(), [gap] if *gap < 0.0), "{subtype}: one adjustment moves the second word right: {adjustments:?}");
|
||||
assert_eq!(positions, [2], "{subtype}: the gap is between the words, after \"ab\"");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_exclusive_scope_resets_path_on_scope_exit() {
|
||||
// An earlier scope has a path operation closed by Q (with 'h' closepath).
|
||||
@@ -11116,6 +11356,38 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() {
|
||||
let replacement = "1\u{FE0F}";
|
||||
let mut results = Vec::new();
|
||||
for kind in ["line", "block"] {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
if kind == "line" {
|
||||
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
} else {
|
||||
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
|
||||
}
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
results.push((kind, lines[0].text.clone()));
|
||||
}
|
||||
assert_eq!(results, vec![("line", replacement.to_owned()), ("block", replacement.to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() {
|
||||
// қ requires the same bundled fallback for both drafts, without optional font inputs.
|
||||
for spacing in [0.0, 2.0] {
|
||||
let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let mut selected = plain.clone();
|
||||
let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() };
|
||||
text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap();
|
||||
text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap();
|
||||
let a = text::text_lines(&reopen(&plain), 0).unwrap();
|
||||
let b = text::text_lines(&reopen(&selected), 0).unwrap();
|
||||
assert_eq!(a[0].base_font, b[0].base_font);
|
||||
assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect);
|
||||
}
|
||||
}
|
||||
/// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen.
|
||||
/// Selected-row alignment for existing PDF content is withheld until it can persist safely.
|
||||
#[test]
|
||||
@@ -13757,3 +14029,84 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft
|
||||
assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Unicode row breaks are invisible moves, not glyphs. A draft carrying them (text pasted
|
||||
/// from a browser or Word) breaks the row on both rewrite paths, rather than being refused
|
||||
/// because no fallback face has an outline for U+2028 (#6272). The paragraph is one wide row, so
|
||||
/// a break that was dropped would leave the words on one row instead of two. A one-line edit has
|
||||
/// no next row, so there each break is written as a space.
|
||||
#[test]
|
||||
fn unicode_row_breaks_break_the_row_instead_of_needing_a_glyph() {
|
||||
let red = text::RangeStyle {
|
||||
range: 0..3,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: None,
|
||||
color: Some([1.0, 0.0, 0.0]),
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
for separator in ['\u{2028}', '\u{2029}', '\u{0085}', '\u{000B}', '\u{000C}', '\u{001C}', '\u{001D}', '\u{001E}'] {
|
||||
let draft = format!("one{separator}two");
|
||||
for style in [
|
||||
text::BlockStyle::default(),
|
||||
text::BlockStyle { ranges: vec![red.clone()], ..Default::default() },
|
||||
text::BlockStyle { ranges: vec![text::RangeStyle { range: 0..draft.len(), ..red.clone() }], ..Default::default() },
|
||||
text::BlockStyle { bold: Some(true), ..Default::default() },
|
||||
] {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator)));
|
||||
let doc = reopen(&doc);
|
||||
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, ["one", "two"], "U+{:04X}", u32::from(separator));
|
||||
}
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, &draft).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator)));
|
||||
let doc = reopen(&doc);
|
||||
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, ["one two"], "a one-line edit keeps U+{:04X} on its line", u32::from(separator));
|
||||
}
|
||||
}
|
||||
|
||||
/// Thai text on the styled path has its own coverage checks and row splits. A Unicode row break
|
||||
/// in a Thai draft breaks the row there too, whether the paragraph keeps its legacy subset, takes
|
||||
/// the bundled Thai face by choice or is completed by the matching one, instead of being refused
|
||||
/// for a glyph no face has (#6272).
|
||||
#[test]
|
||||
fn thai_paragraphs_break_the_row_on_unicode_row_breaks() {
|
||||
let rows = |doc: &Document| -> Vec<String> { text::text_lines(&reopen(doc), 0).unwrap().into_iter().map(|l| l.text).collect() };
|
||||
let thai_face = pdfcraft_fonts::document_thai_font().is_some();
|
||||
if !thai_face {
|
||||
eprintln!("thai_paragraphs_break_the_row_on_unicode_row_breaks: built without craft-fonts, so only the legacy subset is checked");
|
||||
}
|
||||
for separator in ['\u{2028}', '\u{001C}'] {
|
||||
let draft = format!("กข{separator}ค");
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
|
||||
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE);
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
|
||||
let style = BlockStyle { width: Some(400.0), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("legacy U+{:04X}: {e}", u32::from(separator)));
|
||||
assert_eq!(rows(&doc), ["กข", "ค"], "legacy U+{:04X}", u32::from(separator));
|
||||
if !thai_face {
|
||||
continue;
|
||||
}
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
|
||||
let chosen = BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &chosen).unwrap_or_else(|e| panic!("chosen U+{:04X}: {e}", u32::from(separator)));
|
||||
assert_eq!(rows(&doc), ["กข", "ค"], "chosen U+{:04X}", u32::from(separator));
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET");
|
||||
let cmap = doc.add(Object::Stream(Stream::flate(
|
||||
Dict::new(),
|
||||
b"1 begincodespacerange <00> <FF> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar",
|
||||
)));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| {
|
||||
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"));
|
||||
f.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
|
||||
})
|
||||
.unwrap();
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &BlockStyle::default())
|
||||
.unwrap_or_else(|e| panic!("matching U+{:04X}: {e}", u32::from(separator)));
|
||||
assert_eq!(rows(&doc), ["กข", "ค"], "matching U+{:04X}", u32::from(separator));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ mod interp;
|
||||
mod lines;
|
||||
mod rewrite;
|
||||
|
||||
use blocks::paragraph_separator;
|
||||
#[cfg(test)]
|
||||
use blocks::wrap;
|
||||
pub use blocks::{
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
replace_block, text_blocks,
|
||||
};
|
||||
use blocks::{paragraph_separator, row_break};
|
||||
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
|
||||
use editor::{builtin_thai_choice, layout_draft};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -344,6 +344,15 @@ pub(crate) fn paragraph_separator(c: char) -> bool {
|
||||
c.is_whitespace() && c != '\u{3000}'
|
||||
}
|
||||
|
||||
/// Characters that end a row rather than draw a glyph: the Unicode mandatory breaks (UAX #14
|
||||
/// class BK: vertical tab, form feed, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR), the
|
||||
/// next line (U+0085), the information separators (bidi class B) and CR/LF. Text pasted from a
|
||||
/// browser or Word carries these, so a rewrite moves to the next row instead of asking a font
|
||||
/// for an outline no face has (#6272).
|
||||
pub(crate) fn row_break(c: char) -> bool {
|
||||
matches!(c, '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{001C}'..='\u{001E}' | '\u{2028}' | '\u{2029}')
|
||||
}
|
||||
|
||||
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
|
||||
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
|
||||
/// those pieces join without a space. Every white-space character is written as one space, so
|
||||
|
||||
@@ -5,7 +5,27 @@ use super::{
|
||||
};
|
||||
|
||||
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
|
||||
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
|
||||
pub(super) type Encoder = Box<dyn Fn(&str) -> Option<Vec<Type3Run>>>;
|
||||
|
||||
/// One run a rewrite shows: bytes drawn in one generated font. `spacing` set (a variation
|
||||
/// selector's run) shows at that character spacing: its empty glyph advances nothing, and the
|
||||
/// spacing in force must not add to it either.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Type3Run {
|
||||
pub(super) name: String,
|
||||
pub(super) bytes: Vec<u8>,
|
||||
pub(super) spacing: Option<f64>,
|
||||
}
|
||||
|
||||
/// A Unicode variation selector (U+FE00..U+FE0F): it picks a variant of the character before it
|
||||
/// and has no glyph of its own. The Type 3 fallback gives it an empty zero-width glyph instead of
|
||||
/// failing the edit on it.
|
||||
pub(super) fn is_variation_selector(ch: char) -> bool {
|
||||
matches!(u32::from(ch), 0xFE00..=0xFE0F)
|
||||
}
|
||||
|
||||
/// A variation selector's glyph procedure: nothing painted, zero advance.
|
||||
const SELECTOR_GLYPH_PROC: &[u8] = b"0 0 0 0 0 0 d1\n";
|
||||
|
||||
fn is_win_ansi_char(c: char) -> bool {
|
||||
c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some()
|
||||
@@ -377,7 +397,13 @@ pub(super) fn type3_font(
|
||||
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
|
||||
// character. When none has them all, the best face reports the character it lacks. Hebrew
|
||||
// text keeps the Hebrew face and takes each character it lacks from the next face that has it.
|
||||
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
|
||||
// A variation selector has no outline anywhere: it takes a glyph slot of its own below, so the
|
||||
// face is chosen by the characters that have outlines.
|
||||
let named: Vec<char> = chars.iter().copied().filter(|ch| !is_variation_selector(*ch)).collect();
|
||||
if named.is_empty() {
|
||||
return Err(EditError::Invalid("replacement text is empty".into()));
|
||||
}
|
||||
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &named, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
|
||||
.ok_or_else(|| no_fallback_font(text))?;
|
||||
let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] };
|
||||
// Every outline is read before the document is touched, so a character that fails leaves it
|
||||
@@ -385,15 +411,21 @@ pub(super) fn type3_font(
|
||||
let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let mut glyphs = Vec::with_capacity(chars.len());
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
// The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew
|
||||
// face lacks from the next face that has it); a character no face draws is reported once.
|
||||
let mut drawn = type3_glyph(face, ch)?;
|
||||
for f in others.iter().copied() {
|
||||
if drawn.is_some() {
|
||||
break;
|
||||
let drawn = if is_variation_selector(ch) {
|
||||
Some((SELECTOR_GLYPH_PROC.to_vec(), 0.0))
|
||||
} else {
|
||||
// The chosen face first, then the faces after it (Hebrew text takes a character the
|
||||
// Hebrew face lacks from the next face that has it); a character no face draws is
|
||||
// reported once.
|
||||
let mut drawn = type3_glyph(face, ch)?;
|
||||
for f in others.iter().copied() {
|
||||
if drawn.is_some() {
|
||||
break;
|
||||
}
|
||||
drawn = type3_glyph(f, ch)?;
|
||||
}
|
||||
drawn = type3_glyph(f, ch)?;
|
||||
}
|
||||
drawn
|
||||
};
|
||||
let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) };
|
||||
let code = u8::try_from((i % per_font).saturating_add(1))
|
||||
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
|
||||
@@ -580,17 +612,22 @@ pub(crate) fn type3_font_dict(doc: &mut Document, glyphs: &[(char, Vec<u8>, f64)
|
||||
}
|
||||
|
||||
/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so
|
||||
/// a font is switched only where the text moves to another font. `None` if a character has no code.
|
||||
pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec<u8>)>> {
|
||||
let mut runs: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
/// a font is switched only where the text moves to another font. A variation selector forms its
|
||||
/// own run, drawn at character spacing zero (see [`Type3Run`]). `None` if a character has no code.
|
||||
pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<Type3Run>> {
|
||||
let mut runs: Vec<(usize, Vec<u8>, Option<f64>)> = Vec::new();
|
||||
for ch in text.chars() {
|
||||
let (font, code) = *fallback.codes.get(&ch)?;
|
||||
if is_variation_selector(ch) {
|
||||
runs.push((font, vec![code], Some(0.0)));
|
||||
continue;
|
||||
}
|
||||
match runs.last_mut() {
|
||||
Some((last, bytes)) if *last == font => bytes.push(code),
|
||||
_ => runs.push((font, vec![code])),
|
||||
Some((last, bytes, None)) if *last == font => bytes.push(code),
|
||||
_ => runs.push((font, vec![code], None)),
|
||||
}
|
||||
}
|
||||
runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect()
|
||||
runs.into_iter().map(|(font, bytes, spacing)| Some(Type3Run { name: fallback.names.get(font)?.clone(), bytes, spacing })).collect()
|
||||
}
|
||||
|
||||
/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose
|
||||
@@ -685,3 +722,26 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn variation_selectors_show_an_empty_glyph_at_zero_spacing() {
|
||||
assert!(is_variation_selector('\u{FE0F}'));
|
||||
assert!(!is_variation_selector('\u{27A1}'));
|
||||
let fallback = Type3Fallback {
|
||||
family: "Inter",
|
||||
names: vec!["PCLat".into()],
|
||||
widths: vec![('1', 0.556), ('\u{FE0F}', 0.0)],
|
||||
codes: [('1', (0, 1)), ('\u{FE0F}', (0, 2))].into_iter().collect(),
|
||||
};
|
||||
let runs = type3_encode(&fallback, "1\u{FE0F}").expect("the selector after a drawn base is kept, not an error");
|
||||
assert_eq!(runs.len(), 2, "the selector forms its own run");
|
||||
assert_eq!(runs[0], Type3Run { name: "PCLat".into(), bytes: vec![1], spacing: None });
|
||||
assert_eq!(runs[1], Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }, "the selector draws at spacing zero");
|
||||
let runs = type3_encode(&fallback, "\u{FE0F}").expect("a lone selector keeps its empty glyph rather than failing");
|
||||
assert_eq!(runs, vec![Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap};
|
||||
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
|
||||
use super::fallback::{
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family,
|
||||
source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font,
|
||||
source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
|
||||
};
|
||||
use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
@@ -42,7 +42,7 @@ fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet<
|
||||
|
||||
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
|
||||
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
|
||||
let text = text.replace(['\n', '\r'], " ");
|
||||
let text = text.replace(row_break, " ");
|
||||
let lines = text_lines(doc, page)?;
|
||||
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
|
||||
target.ensure_editable()?;
|
||||
@@ -94,9 +94,18 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
let runs = type3_encode(&fallback, &fallback_shown)
|
||||
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?;
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
for (name, bytes) in runs {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
let spacing = target.origin.state.char_spacing;
|
||||
for run in runs {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&run.name), pdfcraft_content::num(size)]));
|
||||
if let Some(tc) = run.spacing {
|
||||
// A variation selector's empty glyph: shown at spacing zero, then the
|
||||
// line's own spacing back, so it adds no advance.
|
||||
replacement.push(Op::new("Tc", vec![pdfcraft_content::num(tc)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
replacement.push(Op::new("Tc", vec![pdfcraft_content::num(spacing)]));
|
||||
} else {
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
}
|
||||
}
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
@@ -240,7 +249,7 @@ fn choose_rewrite_path(
|
||||
}
|
||||
let visual_indic = members.iter().any(|line| line.visual_order);
|
||||
let has_special_text = text.is_some_and(|draft| {
|
||||
draft.contains(['\n', '\r'])
|
||||
draft.contains(row_break)
|
||||
|| pdfcraft_fonts::source_shaping::required(draft)
|
||||
|| !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty()
|
||||
});
|
||||
@@ -259,14 +268,14 @@ fn choose_rewrite_path(
|
||||
}
|
||||
|
||||
/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed
|
||||
/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are
|
||||
/// breaks too; other white space is written as one space.
|
||||
/// U+0020 space: a run of them is not folded into one (#4199, #3797). Every [`row_break`] is a break
|
||||
/// too; other white space is written as one space.
|
||||
fn paragraph_text(text: &str) -> String {
|
||||
text.replace("\r\n", "\n")
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'\r' => '\n',
|
||||
c if c != '\n' && paragraph_separator(c) => ' ',
|
||||
c if row_break(c) => '\n',
|
||||
c if paragraph_separator(c) => ' ',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
@@ -351,7 +360,8 @@ struct Advance<'a> {
|
||||
|
||||
impl Advance<'_> {
|
||||
fn of(&self, s: &str) -> f64 {
|
||||
let extra = s.chars().count() as f64 * self.char_spacing;
|
||||
// A variation selector draws nothing and adds no spacing: it takes no room.
|
||||
let extra = s.chars().filter(|ch| !is_variation_selector(*ch)).count() as f64 * self.char_spacing;
|
||||
let t = match (self.source, self.type3) {
|
||||
(Some(m), _) => {
|
||||
let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() };
|
||||
@@ -399,7 +409,7 @@ fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result<FontChoi
|
||||
let name = inputs.font_name.to_owned();
|
||||
return Ok(FontChoice {
|
||||
show_font: name.clone(),
|
||||
encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])),
|
||||
encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![Type3Run { name: name.clone(), bytes, spacing: None }])),
|
||||
substituted: None,
|
||||
});
|
||||
}
|
||||
@@ -426,7 +436,11 @@ fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result<FontChoi
|
||||
fonts_res.set(name.clone().into_bytes(), Object::Dict(f));
|
||||
let substituted = (!inputs.family_requested).then(|| base.to_string());
|
||||
let shown = name.clone();
|
||||
Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted })
|
||||
Ok(FontChoice {
|
||||
show_font: name,
|
||||
encode: Box::new(move |s: &str| Some(vec![Type3Run { name: shown.clone(), bytes: pdfcraft_fonts::win_ansi(s), spacing: None }])),
|
||||
substituted,
|
||||
})
|
||||
}
|
||||
|
||||
/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken
|
||||
@@ -656,12 +670,20 @@ impl LineWriter<'_> {
|
||||
ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?);
|
||||
return Ok(());
|
||||
}
|
||||
for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if font != cursor.shown_font {
|
||||
ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)]));
|
||||
cursor.shown_font = font;
|
||||
for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if run.name != cursor.shown_font {
|
||||
ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)]));
|
||||
cursor.shown_font = run.name;
|
||||
}
|
||||
if let Some(tc) = run.spacing {
|
||||
// A variation selector's empty glyph: shown at spacing zero, then the paragraph's
|
||||
// own spacing back, so it adds no advance.
|
||||
ops.push(Op::new("Tc", vec![n(tc)]));
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
ops.push(Op::new("Tc", vec![n(self.char_spacing)]));
|
||||
} else {
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))]));
|
||||
}
|
||||
ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font,
|
||||
source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph,
|
||||
union, write_type3_glyphs,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font,
|
||||
same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces,
|
||||
type3_glyph, union, write_type3_glyphs,
|
||||
};
|
||||
|
||||
#[path = "text_regions/grouping.rs"]
|
||||
|
||||
@@ -366,10 +366,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
if !block.rows.is_empty() {
|
||||
block.editor_text.push('\n');
|
||||
}
|
||||
// The run placed before this one in the row, tracked rather than searched for: rows
|
||||
// don't share runs, so it is the last one this loop pushed.
|
||||
let mut prev_in_row: Option<&TextLine> = None;
|
||||
for &index in row {
|
||||
let Some(line) = lines.get(index) else { continue };
|
||||
// Some producers position separate words without encoding a space.
|
||||
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
|
||||
if let Some(prev) = prev_in_row
|
||||
&& !row_text.ends_with(char::is_whitespace)
|
||||
&& !line.text.starts_with(char::is_whitespace)
|
||||
&& line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12
|
||||
@@ -383,6 +386,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
row_text.push_str(&line.text);
|
||||
block.invisible &= line.origin.state.render_mode == 3;
|
||||
block.lines.push(index);
|
||||
prev_in_row = Some(line);
|
||||
}
|
||||
if !block.text.is_empty() {
|
||||
if block.text.ends_with('-') {
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::grouping::{across, is_centred, row_rect};
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, row_break, source_encodes, source_family, source_program,
|
||||
source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
};
|
||||
|
||||
@@ -181,8 +181,8 @@ impl CharacterPreparation<'_> {
|
||||
"the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists"
|
||||
))
|
||||
})?;
|
||||
if !pdfcraft_fonts::embedding::covers(face.bytes, span) {
|
||||
let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
|
||||
if !covers_rows(face.bytes, span) {
|
||||
let missing = span.chars().find(|&c| !row_break(c) && !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
|
||||
return Err(EditError::Invalid(match missing {
|
||||
Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)),
|
||||
None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family),
|
||||
@@ -251,12 +251,13 @@ impl CharacterPreparation<'_> {
|
||||
};
|
||||
let start = range.start;
|
||||
for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() {
|
||||
if ch == '\n' {
|
||||
chars.push((ch, index, source.font.clone()));
|
||||
continue;
|
||||
}
|
||||
if ch == '\r' {
|
||||
let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
|
||||
// A row break moves to the next row instead of asking a font for a glyph. The CR
|
||||
// of a CRLF stays a `\r` the rows skip, so the pair is one break; every other break
|
||||
// is written as `\n`. Either way it keeps its place among the draft's characters,
|
||||
// which rows are counted in.
|
||||
if row_break(ch) {
|
||||
let crlf = ch == '\r'
|
||||
&& start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
|
||||
chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone()));
|
||||
continue;
|
||||
}
|
||||
@@ -923,7 +924,7 @@ fn legacy_thai_font(
|
||||
if !has_thai_source_face(cache, &family) {
|
||||
return Ok(None);
|
||||
}
|
||||
let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty());
|
||||
let rows = || span.split(row_break).filter(|row| !row.is_empty());
|
||||
for row in rows() {
|
||||
if let Err(error) = shape_existing_thai(cache, &source.font, row) {
|
||||
// Not in the face's existing codes. A complete program of the face that shapes every
|
||||
@@ -1097,7 +1098,7 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span:
|
||||
}
|
||||
if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
|| !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face))
|
||||
|| !pdfcraft_fonts::embedding::covers(face.bytes, span)
|
||||
|| !covers_rows(face.bytes, span)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1112,16 +1113,22 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span:
|
||||
&& pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| {
|
||||
pdfcraft_fonts::embedding::validate(&bytes).is_ok()
|
||||
&& pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)
|
||||
&& pdfcraft_fonts::embedding::covers(&bytes, span)
|
||||
&& span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
|
||||
&& covers_rows(&bytes, span)
|
||||
&& span.split(row_break).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a complete program draws every row of `span`. [`row_break`] characters move to the next
|
||||
/// row and are never drawn, so they need no glyph (#6272); `embedding::covers` exempts only CR and LF.
|
||||
fn covers_rows(bytes: &[u8], span: &str) -> bool {
|
||||
span.split(row_break).all(|row| pdfcraft_fonts::embedding::covers(bytes, row))
|
||||
}
|
||||
|
||||
/// Use the same Thai script selection and mark/cluster positioning as Add Text, including
|
||||
/// rows beginning with Latin or digits (guessing from their first character would select Latin).
|
||||
fn shape_thai_row(text: &str) -> Result<pdfcraft_fonts::source_shaping::ShapedRun, EditError> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
if text.len() > 262_144 || text.contains(row_break) {
|
||||
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
|
||||
}
|
||||
let clusters =
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Optional-content regression fixtures for issue #3213.
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental};
|
||||
|
||||
/// Build a single-page PDF fixture with a hidden OCG (`4 0 R`), a shown OCG (`5 0 R`),
|
||||
/// a form appearance (`7 0 R`), an annotation (`8 0 R`), and an OCMD (`9 0 R`).
|
||||
fn fixture(membership: &str, flags: i64, appearance_membership: &str) -> Document {
|
||||
let ink = "1 0 0 rg 0 0 100 50 re f";
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [4 0 R] /ON [5 0 R] /Order [4 0 R 5 0 R] >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Properties << /Existing 5 0 R /PCFlOC0 5 0 R /Private (keep) >> >> /Contents 6 0 R /Annots [8 0 R] >>".into(),
|
||||
"<< /Type /OCG /Name (Hidden layer) /Private (keep layer) >>".into(),
|
||||
"<< /Type /OCG /Name (Shown layer) >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] {appearance_membership} /Length {} >>\nstream\n{ink}\nendstream", ink.len()),
|
||||
format!("<< /Type /Annot /Subtype /Square /Rect [50 50 150 100] /C [1 0 0] /AP << /N 7 0 R >> /F {flags} {membership} /Private (keep annotation) >>"),
|
||||
"<< /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep membership) >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// Serialize `doc` using an incremental save.
|
||||
fn saved(doc: &Document) -> Vec<u8> {
|
||||
write_incremental(doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Optionally export test PDF bytes when `PDFCRAFT_3213_ARTIFACTS` is set.
|
||||
fn export(name: &str, stage: &str, bytes: &[u8]) {
|
||||
if let Some(directory) = std::env::var_os("PDFCRAFT_3213_ARTIFACTS") {
|
||||
std::fs::write(std::path::Path::new(&directory).join(format!("{name}-{stage}.pdf")), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that transferable optional-content memberships are bound to flattened appearances
|
||||
/// after saving and reopening, while hidden-flag annotations remain untouched.
|
||||
#[test]
|
||||
fn flatten_keeps_annotation_optional_content_after_save_and_reopen() {
|
||||
let mut lost = Vec::new();
|
||||
for (name, membership, flags, ap_oc) in [
|
||||
("off", "/OC 4 0 R", 4, ""),
|
||||
("on", "/OC 5 0 R", 4, ""),
|
||||
("ocmd", "/OC 9 0 R", 4, ""),
|
||||
("independent_ap", "/OC 4 0 R", 4, "/OC 5 0 R"),
|
||||
("direct_ocmd", "/OC << /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep inline) >>", 4, ""),
|
||||
("ocmd_ve", "/OC << /Type /OCMD /VE [/And 4 0 R [/Not 5 0 R]] /Private (keep expression) >>", 4, ""),
|
||||
("visible", "", 4, ""),
|
||||
("hidden_flag", "", 2, ""),
|
||||
] {
|
||||
let mut doc = fixture(membership, flags, ap_oc);
|
||||
let original_appearance = (*doc.get(ObjRef::new(7, 0))).clone();
|
||||
let expected_membership = doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().unwrap().get(b"OC").cloned();
|
||||
export(name, "before", &saved(&doc));
|
||||
let count = pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap();
|
||||
let bytes = saved(&doc);
|
||||
export(name, "flat", &bytes);
|
||||
let reopened = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(count, usize::from(flags != 2), "{name}");
|
||||
let paints = paint_memberships(&reopened);
|
||||
if flags != 2 && paints != vec![(ObjRef::new(7, 0), expected_membership)] {
|
||||
lost.push(name);
|
||||
}
|
||||
assert_eq!(*reopened.get(ObjRef::new(7, 0)), original_appearance, "{name}: preserve appearance");
|
||||
}
|
||||
assert!(lost.is_empty(), "flattened paint loses its original membership: {lost:?}");
|
||||
}
|
||||
|
||||
/// Verify that multiple annotations sharing one appearance stream keep separate memberships
|
||||
/// without mutating the shared appearance stream itself.
|
||||
#[test]
|
||||
fn flatten_shared_appearance_keeps_separate_memberships_and_original_stream() {
|
||||
let mut doc = fixture("/OC 4 0 R", 4, "");
|
||||
let first = ObjRef::new(8, 0);
|
||||
let ap = ObjRef::new(7, 0);
|
||||
let original_ap = (*doc.get(ap)).clone();
|
||||
let mut second = doc.get(first).as_dict().unwrap().clone();
|
||||
second.set(b"OC".to_vec(), Object::Ref(ObjRef::new(5, 0)));
|
||||
second.set(b"Rect".to_vec(), Object::Array([50, 120, 150, 170].into_iter().map(Object::Int).collect()));
|
||||
let second = doc.add(Object::Dict(second));
|
||||
let mut plain = doc.get(first).as_dict().unwrap().clone();
|
||||
plain.remove(b"OC");
|
||||
plain.set(b"Rect".to_vec(), Object::Array([0, 0, 20, 10].into_iter().map(Object::Int).collect()));
|
||||
let plain = doc.add(Object::Dict(plain));
|
||||
doc.update_dict(ObjRef::new(3, 0), |page| {
|
||||
page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second), Object::Ref(plain)]))
|
||||
})
|
||||
.unwrap();
|
||||
export("shared", "before", &saved(&doc));
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 3);
|
||||
let bytes = saved(&doc);
|
||||
export("shared", "flat", &bytes);
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(*doc.get(ap), original_ap, "a shared appearance must never acquire either annotation's /OC");
|
||||
assert_eq!(paint_memberships(&doc), vec![(ap, Some(Object::Ref(ObjRef::new(4, 0)))), (ap, Some(Object::Ref(ObjRef::new(5, 0)))), (ap, None)]);
|
||||
}
|
||||
|
||||
/// Associate each `Do` invocation with its active `/OC` marked-content block.
|
||||
fn paint_memberships(doc: &Document) -> Vec<(ObjRef, Option<Object>)> {
|
||||
let page = pdfcraft_model::pages(doc).remove(0);
|
||||
let resources = doc.resolve(page.dict.get(b"Resources").unwrap());
|
||||
let resources = resources.as_dict().unwrap();
|
||||
let properties = resources.get(b"Properties").map(|p| doc.resolve(p));
|
||||
let xobjects = resources.get(b"XObject").map(|p| doc.resolve(p));
|
||||
let content = doc.resolve(page.dict.get(b"Contents").unwrap());
|
||||
let streams = match &*content {
|
||||
Object::Array(list) => list.clone(),
|
||||
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
|
||||
};
|
||||
let mut contexts = Vec::new();
|
||||
let mut paints = Vec::new();
|
||||
for stream in streams {
|
||||
let resolved = doc.resolve(&stream);
|
||||
let Object::Stream(stream) = &*resolved else { panic!("content stream") };
|
||||
let parsed = pdfcraft_content::parse(&stream.decoded().unwrap());
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
for op in parsed.ops {
|
||||
if op.is("BDC") {
|
||||
let membership = if op.name(0) == Some(b"OC") {
|
||||
Some(properties.as_ref().unwrap().as_dict().unwrap().get(op.name(1).unwrap()).unwrap().clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
contexts.push(membership);
|
||||
} else if op.is("BMC") {
|
||||
contexts.push(None);
|
||||
} else if op.is("EMC") {
|
||||
assert!(contexts.pop().is_some(), "balanced marked content");
|
||||
} else if op.is("Do") {
|
||||
let reference = xobjects.as_ref().unwrap().as_dict().unwrap().reference(op.name(0).unwrap()).unwrap();
|
||||
paints.push((reference, contexts.iter().rev().find_map(Clone::clone)));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(contexts.is_empty(), "every optional-content block must close");
|
||||
paints
|
||||
}
|
||||
|
||||
/// Verify that unsupported optional-content memberships retain the annotation, its transitive
|
||||
/// replies and pop-ups, and its AcroForm field links.
|
||||
#[test]
|
||||
fn unsupported_membership_keeps_annotations_replies_and_field_links() {
|
||||
use pdfcraft_cos::Stream;
|
||||
let direct_ocg = Object::Dict({
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec()));
|
||||
d
|
||||
});
|
||||
let wrong_type = Object::Dict({
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::Name(b"Other".to_vec()));
|
||||
d
|
||||
});
|
||||
for membership in [Object::Null, Object::Int(7), Object::Ref(ObjRef::new(999, 0)), Object::Ref(ObjRef::new(7, 0)), direct_ocg, wrong_type] {
|
||||
for widget in [false, true] {
|
||||
let mut doc = fixture("", 4, "");
|
||||
let retained = ObjRef::new(8, 0);
|
||||
let mut successful = doc.get(retained).as_dict().unwrap().clone();
|
||||
successful.remove(b"OC");
|
||||
let successful = doc.add(Object::Dict(successful));
|
||||
let empty_appearance = if widget {
|
||||
let mut empty = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().clone();
|
||||
empty.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4]));
|
||||
let reference = doc.add(Object::Stream(Stream::from_raw(empty, Vec::new())));
|
||||
let mut ap = pdfcraft_cos::Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Ref(reference));
|
||||
Some(Object::Dict(ap))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
doc.update_dict(retained, |d| {
|
||||
d.set(b"OC".to_vec(), membership.clone());
|
||||
d.set(b"IRT".to_vec(), Object::Ref(successful));
|
||||
if widget {
|
||||
d.set(b"Subtype".to_vec(), Object::Name(b"Widget".to_vec()));
|
||||
d.set(b"AP".to_vec(), empty_appearance.clone().unwrap());
|
||||
} else {
|
||||
d.remove(b"AP");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let mut reply = doc.get(retained).as_dict().unwrap().clone();
|
||||
reply.remove(b"OC");
|
||||
reply.set(b"Subtype".to_vec(), Object::Name(b"Square".to_vec()));
|
||||
reply.set(b"IRT".to_vec(), Object::Ref(retained));
|
||||
reply.set(b"AP".to_vec(), doc.get(successful).as_dict().unwrap().get(b"AP").unwrap().clone());
|
||||
let reply = doc.add(Object::Dict(reply));
|
||||
let mut grandchild = doc.get(reply).as_dict().unwrap().clone();
|
||||
grandchild.set(b"IRT".to_vec(), Object::Ref(reply));
|
||||
let grandchild = doc.add(Object::Dict(grandchild));
|
||||
let popup = doc.add(Object::Dict({
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec()));
|
||||
d.set(b"Parent".to_vec(), Object::Ref(retained));
|
||||
d
|
||||
}));
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.set(
|
||||
b"Annots".to_vec(),
|
||||
Object::Array(vec![
|
||||
Object::Ref(successful),
|
||||
Object::Ref(retained),
|
||||
Object::Ref(reply),
|
||||
Object::Ref(grandchild),
|
||||
Object::Ref(popup),
|
||||
]),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
if widget {
|
||||
let fields = doc.add(Object::Dict({
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(retained)]));
|
||||
d
|
||||
}));
|
||||
doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"AcroForm".to_vec(), Object::Ref(fields))).unwrap();
|
||||
}
|
||||
let originals = [retained, reply, grandchild, popup].map(|r| (*doc.get(r)).clone());
|
||||
let original_fields = doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone());
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, true).unwrap(), 1, "{membership:?}");
|
||||
let doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
for (reference, original) in [retained, reply, grandchild, popup].into_iter().zip(originals) {
|
||||
assert_eq!(*doc.get(reference), original);
|
||||
}
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
assert_eq!(
|
||||
doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(),
|
||||
&vec![Object::Ref(retained), Object::Ref(reply), Object::Ref(grandchild), Object::Ref(popup)]
|
||||
);
|
||||
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()), original_fields);
|
||||
}
|
||||
}
|
||||
// A stream that claims Type OCG must not qualify through Object::as_dict().
|
||||
let mut doc = fixture("/OC 4 0 R", 4, "");
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec()));
|
||||
doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(d, Vec::new())));
|
||||
let before = saved(&doc);
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0);
|
||||
assert_eq!(saved(&doc), before);
|
||||
|
||||
// Shared-subexpression /VE fan-out (10^16 paths within depth 16) stops at the node budget
|
||||
// and retains the annotation unchanged instead of hanging.
|
||||
let mut doc = fixture("/OC 9 0 R", 4, "");
|
||||
let mut target = ObjRef::new(4, 0);
|
||||
for _ in 0..16 {
|
||||
let mut expr = vec![Object::Name(b"Or".to_vec())];
|
||||
expr.extend(std::iter::repeat_n(Object::Ref(target), 10));
|
||||
target = doc.add(Object::Array(expr));
|
||||
}
|
||||
let ve = (*doc.get(target)).clone();
|
||||
doc.update_dict(ObjRef::new(9, 0), |d| {
|
||||
d.set(b"VE".to_vec(), ve.clone());
|
||||
})
|
||||
.unwrap();
|
||||
let before = saved(&doc);
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0);
|
||||
assert_eq!(saved(&doc), before);
|
||||
}
|
||||
|
||||
/// Preserve a malformed reply that targets a retained popup instead of a markup annotation.
|
||||
#[test]
|
||||
fn unsupported_membership_keeps_popup_replies_after_save_and_reopen() {
|
||||
let mut doc = fixture("/OC null", 4, "");
|
||||
let root = ObjRef::new(8, 0);
|
||||
let popup = doc.add(Object::Dict({
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec()));
|
||||
d.set(b"Parent".to_vec(), Object::Ref(root));
|
||||
d.set(b"Private".to_vec(), Object::String(pdfcraft_cos::PdfString::text("keep popup")));
|
||||
d
|
||||
}));
|
||||
let mut reply = doc.get(root).as_dict().unwrap().clone();
|
||||
reply.remove(b"OC");
|
||||
reply.set(b"IRT".to_vec(), Object::Ref(popup));
|
||||
let reply = doc.add(Object::Dict(reply));
|
||||
let mut drawable = doc.get(root).as_dict().unwrap().clone();
|
||||
drawable.remove(b"OC");
|
||||
let drawable = doc.add(Object::Dict(drawable));
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.set(b"Annots".to_vec(), Object::Array([root, popup, reply, drawable].map(Object::Ref).to_vec()));
|
||||
})
|
||||
.unwrap();
|
||||
let originals = [root, popup, reply].map(|r| (*doc.get(r)).clone());
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the unrelated annotation can flatten");
|
||||
let doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
for (reference, original) in [root, popup, reply].into_iter().zip(originals) {
|
||||
assert_eq!(*doc.get(reference), original, "the retained popup graph stays unchanged");
|
||||
}
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, popup, reply].map(Object::Ref));
|
||||
assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]);
|
||||
}
|
||||
|
||||
/// Undrawable text in a retained reply must not block an unrelated drawable annotation.
|
||||
#[test]
|
||||
fn unsupported_membership_keeps_undrawable_reply_without_refusing_flatten() {
|
||||
for marked_appearance in [false, true] {
|
||||
let mut doc = fixture("/OC null", 4, "");
|
||||
let root = ObjRef::new(8, 0);
|
||||
let mut reply = doc.get(root).as_dict().unwrap().clone();
|
||||
reply.remove(b"OC");
|
||||
reply.set(b"Subtype".to_vec(), Object::Name(b"FreeText".to_vec()));
|
||||
reply.set(b"IRT".to_vec(), Object::Ref(root));
|
||||
reply.set(b"Contents".to_vec(), Object::String(pdfcraft_cos::PdfString::text("漢")));
|
||||
if marked_appearance {
|
||||
pdfcraft_fonts::set_undrawn_mark(&mut reply, Some('漢'));
|
||||
} else {
|
||||
reply.remove(b"AP");
|
||||
}
|
||||
assert_eq!(pdfcraft_annot::appearance::undrawable_text(&reply).map(|(_, c)| c), Some('漢'));
|
||||
let reply = doc.add(Object::Dict(reply));
|
||||
let mut drawable = doc.get(root).as_dict().unwrap().clone();
|
||||
drawable.remove(b"OC");
|
||||
let drawable = doc.add(Object::Dict(drawable));
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.set(b"Annots".to_vec(), Object::Array([root, reply, drawable].map(Object::Ref).to_vec()));
|
||||
})
|
||||
.unwrap();
|
||||
// Compare serialized dictionaries before and after flatten, including PDF string syntax.
|
||||
let mut doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
let originals = [root, reply].map(|r| (*doc.get(r)).clone());
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "retained text is not baked into page content");
|
||||
let doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
for (reference, original) in [root, reply].into_iter().zip(originals) {
|
||||
assert_eq!(*doc.get(reference), original);
|
||||
}
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, reply].map(Object::Ref));
|
||||
assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that shared/inherited `/Resources` and `/Properties` dictionaries are never mutated in
|
||||
/// place and that consecutive flatten runs allocate distinct `/PCFlOC{n}` property names.
|
||||
#[test]
|
||||
fn flatten_preserves_shared_resources_and_uses_new_names_on_each_call() {
|
||||
for inherited in [false, true] {
|
||||
for indirect in [false, true] {
|
||||
let mut doc = fixture("/OC 4 0 R", 4, "");
|
||||
let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone();
|
||||
let original_properties = resources.get(b"Properties").unwrap().clone();
|
||||
if indirect {
|
||||
let r = doc.add(original_properties.clone());
|
||||
resources.set(b"Properties".to_vec(), Object::Ref(r));
|
||||
}
|
||||
let resources = if indirect { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) };
|
||||
let owner = ObjRef::new(if inherited { 2 } else { 3 }, 0);
|
||||
if inherited {
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.remove(b"Resources");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap();
|
||||
let original_resource_object = (*doc.resolve(&resources)).clone();
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1);
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap();
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1);
|
||||
let doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0)))); 2]);
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let effective = doc.resolve(page.dict.get(b"Resources").unwrap());
|
||||
let properties = doc.resolve(effective.as_dict().unwrap().get(b"Properties").unwrap());
|
||||
let properties = properties.as_dict().unwrap();
|
||||
for (key, value) in original_properties.as_dict().unwrap().iter() {
|
||||
assert_eq!(properties.get(key), Some(value));
|
||||
}
|
||||
assert_eq!(properties.len(), original_properties.as_dict().unwrap().len() + 2);
|
||||
if inherited || indirect {
|
||||
assert_eq!(*doc.resolve(&resources), original_resource_object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that malformed `/Resources` or `/Properties` entries retain `/OC` annotations without
|
||||
/// generating appearances, while inline annotations with valid `/OC` still flatten cleanly.
|
||||
#[test]
|
||||
fn malformed_resources_and_direct_ocg_stay_unchanged_before_appearance_generation() {
|
||||
for malformed_resources in [false, true] {
|
||||
let mut doc = fixture("/OC 4 0 R", 4, "");
|
||||
if malformed_resources {
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Null)).unwrap();
|
||||
} else {
|
||||
let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone();
|
||||
resources.set(b"Properties".to_vec(), Object::Int(1));
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
}
|
||||
doc.update_dict(ObjRef::new(8, 0), |d| {
|
||||
d.remove(b"AP");
|
||||
})
|
||||
.unwrap();
|
||||
let before = saved(&doc);
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0);
|
||||
assert_eq!(saved(&doc), before);
|
||||
}
|
||||
let mut doc = fixture("/OC 4 0 R", 4, "");
|
||||
let annotation = doc.get(ObjRef::new(8, 0)).as_dict().unwrap().clone();
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annotation)]))).unwrap();
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1);
|
||||
let doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0))))]);
|
||||
}
|
||||
|
||||
/// Inline replies and inline popups pointing to a retained annotation are kept in /Annots.
|
||||
#[test]
|
||||
fn unsupported_membership_keeps_inline_replies_and_popups_after_save_and_reopen() {
|
||||
let mut doc = fixture("/OC null", 4, "");
|
||||
let root = ObjRef::new(8, 0);
|
||||
|
||||
let mut inline_reply = doc.get(root).as_dict().unwrap().clone();
|
||||
inline_reply.remove(b"OC");
|
||||
inline_reply.set(b"IRT".to_vec(), Object::Ref(root));
|
||||
|
||||
let mut indirect_reply = doc.get(root).as_dict().unwrap().clone();
|
||||
indirect_reply.remove(b"OC");
|
||||
indirect_reply.set(b"IRT".to_vec(), Object::Ref(root));
|
||||
let indirect_reply = doc.add(Object::Dict(indirect_reply));
|
||||
|
||||
let mut inline_grandchild = doc.get(root).as_dict().unwrap().clone();
|
||||
inline_grandchild.remove(b"OC");
|
||||
inline_grandchild.set(b"IRT".to_vec(), Object::Ref(indirect_reply));
|
||||
|
||||
let mut inline_popup = pdfcraft_cos::Dict::new();
|
||||
inline_popup.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec()));
|
||||
inline_popup.set(b"Parent".to_vec(), Object::Ref(root));
|
||||
|
||||
let mut drawable = doc.get(root).as_dict().unwrap().clone();
|
||||
drawable.remove(b"OC");
|
||||
let drawable = doc.add(Object::Dict(drawable));
|
||||
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.set(
|
||||
b"Annots".to_vec(),
|
||||
Object::Array(vec![
|
||||
Object::Dict(inline_reply.clone()),
|
||||
Object::Ref(root),
|
||||
Object::Ref(indirect_reply),
|
||||
Object::Dict(inline_grandchild.clone()),
|
||||
Object::Dict(inline_popup.clone()),
|
||||
Object::Ref(drawable),
|
||||
]),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1);
|
||||
|
||||
let doc = Document::open(Arc::new(saved(&doc))).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc).remove(0);
|
||||
let annots = doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap().clone();
|
||||
|
||||
assert_eq!(annots.len(), 5);
|
||||
assert_eq!(annots[0], Object::Dict(inline_reply));
|
||||
assert_eq!(annots[1], Object::Ref(root));
|
||||
assert_eq!(annots[2], Object::Ref(indirect_reply));
|
||||
assert_eq!(annots[3], Object::Dict(inline_grandchild));
|
||||
assert_eq!(annots[4], Object::Dict(inline_popup));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! file in its original format. A picture drawn through grouped
|
||||
//! Form artwork (as Word, InDesign and many scanners write them) is found inside the group.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object};
|
||||
|
||||
@@ -13,6 +13,31 @@ const MAX_COPY_PIXELS: u64 = 64_000_000;
|
||||
/// How deep Form artwork is searched for its picture.
|
||||
const MAX_FORM_DEPTH: usize = 12;
|
||||
|
||||
/// A picture on a page, with its index in [`Document::page_images`].
|
||||
pub type PagePicture = (usize, pdfcraft_edit::PageImage);
|
||||
|
||||
/// The topmost of `pictures` under user-space point `at` (`[x, y]`). `pictures` is bottom first,
|
||||
/// as [`Document::page_pictures_job`] lists them, so the last one that holds `at` wins.
|
||||
pub fn picture_at(pictures: &[PagePicture], at: [f64; 2]) -> Option<&PagePicture> {
|
||||
pictures.iter().rev().find(|(_, image)| image.contains(at))
|
||||
}
|
||||
|
||||
/// The pictures on `page`, bottom first: raster images, and Form artwork that holds one.
|
||||
fn pictures(cos: &Cos, page: usize) -> Vec<PagePicture> {
|
||||
let images = pdfcraft_edit::page_images(cos, page).unwrap_or_default();
|
||||
// Artwork drawn many times is searched once.
|
||||
let mut holds_one: HashMap<ObjRef, bool> = HashMap::new();
|
||||
images
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, i)| match (i.is_form, i.object) {
|
||||
(false, Some(_)) => true,
|
||||
(true, Some(form)) => *holds_one.entry(form).or_insert_with(|| picture_in_form(cos, form).is_some()),
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The topmost picture on `page` under user-space point `at`, as its index in
|
||||
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
|
||||
@@ -28,6 +53,18 @@ impl Document {
|
||||
})
|
||||
}
|
||||
|
||||
/// Work that lists the pictures on `page`, bottom first, for another thread: it reads every
|
||||
/// drawing operator on the page, seconds on a dense one, so it must not run on the window
|
||||
/// thread. Look a point up with [`picture_at`].
|
||||
///
|
||||
/// `None` when the document can't be read. The work reads the document as it is now: later
|
||||
/// edits aren't seen (key its answer by [`Document::edit_generation`]). It returns no pictures
|
||||
/// for a page that doesn't exist or can't be read.
|
||||
pub fn page_pictures_job(&self, page: usize) -> Option<impl FnOnce() -> Vec<PagePicture> + Send + 'static> {
|
||||
let cos = self.editor.as_ref()?.cos.clone();
|
||||
Some(move || crate::guard(|| pictures(&cos, page)).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes):
|
||||
/// a JPEG as stored in the PDF, otherwise a lossless PNG.
|
||||
pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
|
||||
|
||||
@@ -32,7 +32,8 @@ pub struct Document {
|
||||
pub dirty: bool,
|
||||
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
|
||||
pub(crate) generation: u64,
|
||||
/// The generation last handed out by `autosave_snapshots`.
|
||||
/// The generation last handed out by `autosave_snapshots`, or another one when storing that
|
||||
/// snapshot failed (`autosave_failed`), so it is due again.
|
||||
pub(crate) snapshot_generation: u64,
|
||||
/// Why the document cannot be edited (e.g. encryption), if so.
|
||||
pub read_only_reason: Option<String>,
|
||||
@@ -727,6 +728,14 @@ pub(crate) fn write_display_copy(
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` as the reader shows it: comments without an appearance stream drawn
|
||||
/// (`pdfcraft_annot::with_missing_appearances`) and form fields drawn from their values where the
|
||||
/// file leaves that to the viewer. `None` when `doc` needs neither. The vector print draws from
|
||||
/// this copy too, so what prints is what the page shows (#371, #3475 draw them for display only).
|
||||
pub(crate) fn as_displayed(doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
|
||||
with_field_appearances(pdfcraft_annot::with_missing_appearances(doc), doc)
|
||||
}
|
||||
|
||||
/// Carry the marks across a revalidation that leaves the display copy alone, when it may: only
|
||||
/// when every verdict is the one the copy was drawn with. Returns whether it did; otherwise the
|
||||
/// copy has to be drawn again.
|
||||
@@ -754,8 +763,9 @@ pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::
|
||||
}
|
||||
|
||||
/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read
|
||||
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none.
|
||||
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 6] {
|
||||
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none, dates shown as
|
||||
/// [`pdfcraft_render::pretty_date`] shows them.
|
||||
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 8] {
|
||||
let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
|
||||
[
|
||||
(&mut info.title, read("Title")),
|
||||
@@ -764,6 +774,8 @@ pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Documen
|
||||
(&mut info.keywords, read("Keywords")),
|
||||
(&mut info.creator, read("Creator")),
|
||||
(&mut info.producer, read("Producer")),
|
||||
(&mut info.created, read("CreationDate").map(|d| pdfcraft_render::pretty_date(&d))),
|
||||
(&mut info.modified, read("ModDate").map(|d| pdfcraft_render::pretty_date(&d))),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -778,7 +790,7 @@ pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec
|
||||
let cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).ok()?;
|
||||
let mut signatures = signatures_of(&cos, &bytes, &TrustStore::default(), &pdfcraft_sign::DigestCache::default());
|
||||
let layered = pdfcraft_sign::has_status_layers(&cos, &signatures);
|
||||
let (mut display, added) = match with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos) {
|
||||
let (mut display, added) = match as_displayed(&cos) {
|
||||
Some(display) => (display, true),
|
||||
None if layered => (cos, false),
|
||||
None => return None,
|
||||
|
||||
@@ -561,8 +561,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?,
|
||||
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
|
||||
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
|
||||
// the strikeout's `/NM` (#2666).
|
||||
// One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by
|
||||
// the caret's `/NM` (#2666).
|
||||
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
|
||||
pdfcraft_annot::add_text_replacement(
|
||||
doc,
|
||||
|
||||
@@ -95,7 +95,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::AddHeaderFooter { .. }
|
||||
| Edit::AddWatermark { .. }
|
||||
| Edit::AddBackground { .. }
|
||||
| Edit::RemoveMarks { .. } => Scope::Content,
|
||||
| Edit::RemoveMarks { .. }
|
||||
// Recognized text is an invisible text layer stamped on its page, with its font in the
|
||||
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
|
||||
// a large file.
|
||||
| Edit::AddOcrText { .. } => Scope::Content,
|
||||
Edit::Batch { edits, .. } => {
|
||||
let mut scopes = edits.iter().map(scope_of);
|
||||
let first = scopes.next().unwrap_or(Scope::Full);
|
||||
|
||||
+113
-14
@@ -26,29 +26,70 @@ pub struct JsOutput {
|
||||
pub doc_errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Console lines one output keeps, from every script an edit runs together (a form's
|
||||
/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise
|
||||
/// be multiplied by the number of fields a document declares.
|
||||
pub const MAX_CONSOLE_LINES: usize = 10_000;
|
||||
/// Bytes of console text one output keeps, for the same reason.
|
||||
pub const MAX_CONSOLE_BYTES: usize = 1 << 20;
|
||||
/// Alerts one output keeps.
|
||||
pub const MAX_ALERTS: usize = 1_000;
|
||||
|
||||
impl JsOutput {
|
||||
/// Whether scripts produced nothing: no alerts, console lines, requests or errors.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
|
||||
}
|
||||
|
||||
/// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what
|
||||
/// is past them is left out, which one closing line says.
|
||||
fn say(&mut self, lines: impl IntoIterator<Item = String>) {
|
||||
let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes");
|
||||
// Once the notice is written the console is closed: nothing may follow it, and a later
|
||||
// rejection must not add a second copy.
|
||||
if self.console.last() == Some(¬e) {
|
||||
return;
|
||||
}
|
||||
let mut bytes: usize = self.console.iter().map(String::len).sum();
|
||||
let mut dropped = false;
|
||||
for l in lines {
|
||||
if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES {
|
||||
dropped = true;
|
||||
break;
|
||||
}
|
||||
bytes += l.len();
|
||||
self.console.push(l);
|
||||
}
|
||||
if dropped && !self.console.iter().any(|l| l == ¬e) {
|
||||
self.console.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `alerts` within [`MAX_ALERTS`].
|
||||
fn alert(&mut self, alerts: impl IntoIterator<Item = Message>) {
|
||||
let room = MAX_ALERTS.saturating_sub(self.alerts.len());
|
||||
self.alerts.extend(alerts.into_iter().take(room));
|
||||
}
|
||||
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to show a side panel or open Save As, which is noted on the console
|
||||
/// and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
self.alerts.extend(o.alerts.iter().cloned());
|
||||
self.console.extend(o.console.iter().cloned());
|
||||
self.alert(o.alerts.iter().cloned());
|
||||
self.say(o.console.iter().cloned());
|
||||
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
||||
// button's may show or hide a side panel, or open Save As.
|
||||
// The warnings go through `say` like any other console line, so they count against the
|
||||
// line and byte caps instead of growing the console past them.
|
||||
let mut notes = Vec::new();
|
||||
for r in &o.requests {
|
||||
match r {
|
||||
Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
|
||||
Request::SaveAs => {
|
||||
self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into())
|
||||
}
|
||||
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
|
||||
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
self.say(notes);
|
||||
self.errors.extend(o.error.iter().cloned());
|
||||
// The document scripts run before every field script, so a failing one would repeat
|
||||
// once per calculation; it is reported once.
|
||||
@@ -61,8 +102,8 @@ impl JsOutput {
|
||||
}
|
||||
|
||||
pub(crate) fn append(&mut self, other: JsOutput) {
|
||||
self.alerts.extend(other.alerts);
|
||||
self.console.extend(other.console);
|
||||
self.alert(other.alerts);
|
||||
self.say(other.console);
|
||||
self.requests.extend(other.requests);
|
||||
// As in `absorb`: a document script's error, already reported, is not repeated; a field
|
||||
// script's errors are each their own, even one worded like a document script's.
|
||||
@@ -118,6 +159,7 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState
|
||||
s.readonly = f.read_only();
|
||||
s.required = f.has(pdfcraft_forms::flags::REQUIRED);
|
||||
s.display = display;
|
||||
s.rect = f.widgets.first().map(|w| w.rect);
|
||||
s.char_limit = f.max_len;
|
||||
s.options = match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
|
||||
@@ -147,8 +189,9 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]
|
||||
read_only: (a.readonly != b.readonly).then_some(a.readonly),
|
||||
required: (a.required != b.required).then_some(a.required),
|
||||
display: (a.display != b.display).then_some(a.display),
|
||||
rect: (a.rect != b.rect).then_some(a.rect).flatten(),
|
||||
};
|
||||
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c)
|
||||
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -203,6 +246,7 @@ impl Scripts for JsRunner {
|
||||
self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult {
|
||||
rc: true,
|
||||
value: value.to_string(),
|
||||
value_set: false,
|
||||
changes: Vec::new(),
|
||||
message: None,
|
||||
})
|
||||
@@ -239,6 +283,7 @@ impl JsRunner {
|
||||
Some(ScriptResult {
|
||||
rc: o.rc,
|
||||
value: o.value.clone(),
|
||||
value_set: o.value_set,
|
||||
changes: changes(&states, &o.changed),
|
||||
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
||||
})
|
||||
@@ -330,11 +375,9 @@ impl Session {
|
||||
if !changes.is_empty() {
|
||||
edits.push(Edit::ApplyScriptChanges { changes });
|
||||
}
|
||||
for names in resets {
|
||||
let all: Vec<String> = fields.iter().map(|f| f.name.clone()).collect();
|
||||
let names = (!names.is_empty())
|
||||
.then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect());
|
||||
edits.push(Edit::ResetForm { names });
|
||||
if !resets.is_empty() {
|
||||
let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) });
|
||||
}
|
||||
if !edits.is_empty() {
|
||||
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
|
||||
@@ -343,6 +386,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the
|
||||
/// defaults, so resetting the union once does what each call in turn would, and recalculates
|
||||
/// once. `None` is the whole form (a call with no names). A field is named by a selector that
|
||||
/// is its name or the name of a field above it, looked up in a set of the selectors, so the
|
||||
/// work is the fields times their depth, not the fields times the selectors, which a script
|
||||
/// controls.
|
||||
fn reset_targets(fields: &[&str], requests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
if requests.iter().any(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect();
|
||||
let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p));
|
||||
Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect())
|
||||
}
|
||||
|
||||
/// A page's words for form-field detection, in user space (underscore runs split from the
|
||||
/// text around them).
|
||||
fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<pdfcraft_forms::detect::Word> {
|
||||
@@ -492,6 +550,47 @@ impl Session {
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
|
||||
/// Many `resetForm` calls with many selectors over a big form: one reset of the union, found
|
||||
/// by looking each field's name and parents up, not by comparing every field with every
|
||||
/// selector.
|
||||
#[test]
|
||||
fn reset_requests_are_coalesced_and_matched_through_an_index() {
|
||||
let fields: Vec<String> = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect();
|
||||
let names: Vec<&str> = fields.iter().map(String::as_str).collect();
|
||||
let requests: Vec<Vec<String>> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field");
|
||||
let mut requests = requests;
|
||||
requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]);
|
||||
let hit = reset_targets(&names, &requests).unwrap();
|
||||
assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name");
|
||||
assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
|
||||
requests.push(Vec::new());
|
||||
assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form");
|
||||
}
|
||||
|
||||
/// A form of many calculated fields, each printing its own cap of console lines, keeps a
|
||||
/// bounded console: past the budget, lines are left out and one closing line says so.
|
||||
#[test]
|
||||
fn console_output_of_one_edit_is_bounded() {
|
||||
let mut out = JsOutput::default();
|
||||
let line = "x".repeat(4_096);
|
||||
let run = Outcome { console: vec![line; 1_000], alerts: vec![Message::plain("hi"); 100], ..Default::default() };
|
||||
for _ in 0..1_000 {
|
||||
out.absorb(&run);
|
||||
}
|
||||
let bytes: usize = out.console.iter().map(String::len).sum();
|
||||
assert!(bytes <= MAX_CONSOLE_BYTES + 256, "{bytes} bytes kept");
|
||||
assert!(out.console.len() <= MAX_CONSOLE_LINES + 1, "{} lines kept", out.console.len());
|
||||
assert!(out.console.last().unwrap().starts_with("more console lines were left out"));
|
||||
assert_eq!(out.console.iter().filter(|l| l.starts_with("more console lines")).count(), 1, "said once");
|
||||
assert_eq!(out.alerts.len(), MAX_ALERTS);
|
||||
let mut merged = JsOutput::default();
|
||||
merged.append(out);
|
||||
assert!(merged.console.len() <= MAX_CONSOLE_LINES + 1);
|
||||
}
|
||||
|
||||
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
|
||||
/// the word so far without reading the word again.
|
||||
#[test]
|
||||
|
||||
+112
-3
@@ -6,10 +6,102 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool};
|
||||
|
||||
use crate::{Document, display_config, render_threads};
|
||||
|
||||
/// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and
|
||||
/// most category names and groups it reads in all of them together (entries can share one
|
||||
/// array, so the count is over every entry, not each).
|
||||
const MAX_AUTO_STATES: usize = 1024;
|
||||
const MAX_AUTO_ITEMS: usize = 65_536;
|
||||
|
||||
/// The usage dictionary entry and its state key that a `/Category` name of a usage application
|
||||
/// dictionary reads when printing (ISO 32000-2 Table 101): Print, View and Export each name a
|
||||
/// state of the group's own. Zoom, User and Language depend on the magnification, the user and
|
||||
/// the system language, which printing doesn't fix, so they give no recommendation here.
|
||||
fn print_category(name: &[u8]) -> Option<(&'static [u8], &'static [u8])> {
|
||||
match name {
|
||||
b"Print" => Some((b"Print", b"PrintState")),
|
||||
b"View" => Some((b"View", b"ViewState")),
|
||||
b"Export" => Some((b"Export", b"ExportState")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers as they print: `layers` as shown, then the default configuration's auto state for
|
||||
/// the Print event (ISO 32000-2 §8.11.4.4, Table 101). Each usage application dictionary
|
||||
/// (`/AS` entry) whose `/Event` is `/Print` recommends, for each group in its `/OCGs` and each of
|
||||
/// its categories ([`print_category`]), the state the group's usage dictionary gives (for the
|
||||
/// Print category, `/Usage /Print /PrintState`). A group with recommendations is on only if all
|
||||
/// of them, in all the entries naming it, are ON; other groups keep their state. `/Category` is
|
||||
/// an array; a single name is read as one. `None` when no `/AS` entry is for printing.
|
||||
pub(crate) fn print_layer_states(cos: &pdfcraft_cos::Document, layers: &[Layer]) -> Option<Vec<Layer>> {
|
||||
use pdfcraft_cos::Object;
|
||||
let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r))?;
|
||||
let properties = root.as_dict()?.get(b"OCProperties").map(|p| cos.resolve(p))?;
|
||||
let config = properties.as_dict()?.get(b"D").map(|d| cos.resolve(d))?;
|
||||
let auto = config.as_dict()?.get(b"AS").map(|a| cos.resolve(a))?;
|
||||
let mut printing = false;
|
||||
let mut recommended: std::collections::HashMap<(u32, u16), bool> = std::collections::HashMap::new();
|
||||
let mut items_left = MAX_AUTO_ITEMS;
|
||||
for entry in auto.as_array()?.iter().take(MAX_AUTO_STATES) {
|
||||
let entry = cos.resolve(entry);
|
||||
let Some(entry) = entry.as_dict() else { continue };
|
||||
if entry.get(b"Event").map(|e| cos.resolve(e)).as_deref().and_then(Object::as_name) != Some(b"Print") {
|
||||
continue;
|
||||
}
|
||||
printing = true;
|
||||
let categories = entry.get(b"Category").map(|c| cos.resolve(c));
|
||||
let mut read: Vec<(&[u8], &[u8])> = Vec::new();
|
||||
let mut consider = |name: Option<&[u8]>| {
|
||||
if let Some(category) = name.and_then(print_category).filter(|c| !read.contains(c)) {
|
||||
read.push(category);
|
||||
}
|
||||
};
|
||||
match categories.as_deref() {
|
||||
Some(Object::Array(names)) => {
|
||||
let count = names.len().min(items_left);
|
||||
items_left -= count;
|
||||
names.iter().take(count).for_each(|n| consider(cos.resolve(n).as_name()));
|
||||
}
|
||||
Some(Object::Name(n)) => consider(Some(n.as_slice())),
|
||||
_ => {}
|
||||
}
|
||||
if read.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(groups) = entry.get(b"OCGs").map(|g| cos.resolve(g)) else { continue };
|
||||
for group in groups.as_array().into_iter().flatten() {
|
||||
let Some(left) = items_left.checked_sub(1) else { break };
|
||||
items_left = left;
|
||||
let Some(id) = group.as_ref() else { continue };
|
||||
let ocg = cos.resolve(group);
|
||||
let usage = ocg.as_dict().and_then(|g| g.get(b"Usage").map(|u| cos.resolve(u)));
|
||||
for (entry_key, state_key) in &read {
|
||||
// No state given (a Print category without `/PrintState`): no recommendation, so
|
||||
// the group is left as it is.
|
||||
let state = usage
|
||||
.as_deref()
|
||||
.and_then(|u| u.as_dict().and_then(|u| u.get(entry_key).map(|e| cos.resolve(e))))
|
||||
.and_then(|e| e.as_dict().and_then(|e| e.get(state_key).map(|s| cos.resolve(s))))
|
||||
.and_then(|s| match s.as_name() {
|
||||
Some(b"ON") => Some(true),
|
||||
Some(b"OFF") => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(on) = state {
|
||||
let all_on = recommended.entry((id.num, id.generation)).or_insert(true);
|
||||
*all_on = *all_on && on;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
printing.then(|| layers.iter().map(|l| Layer { visible: recommended.get(&l.id).copied().unwrap_or(l.visible), ..l.clone() }).collect())
|
||||
}
|
||||
|
||||
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
|
||||
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
|
||||
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
|
||||
/// is left alone.
|
||||
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. The
|
||||
/// states written are final, so what would set them again goes: the auto state (`/AS`), and the
|
||||
/// layers' own `/Usage`, which some readers apply when they draw (a `/ViewState /ON` turns a layer
|
||||
/// that prints off back on). The configuration's other entries (`/Order`, `/RBGroups`…) are kept.
|
||||
/// A document without `/OCProperties` is left alone.
|
||||
pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object};
|
||||
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
|
||||
@@ -22,18 +114,35 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers:
|
||||
config.set(b"BaseState".to_vec(), Object::name("ON"));
|
||||
config.set(b"ON".to_vec(), refs(true));
|
||||
config.set(b"OFF".to_vec(), refs(false));
|
||||
config.remove(b"AS");
|
||||
properties.set(b"D".to_vec(), Object::Dict(config));
|
||||
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
|
||||
cos.set(root_ref, Object::Dict(root));
|
||||
for layer in layers {
|
||||
let id = ObjRef::new(layer.id.0, layer.id.1);
|
||||
let Some(mut group) = cos.get(id).as_dict().cloned() else { continue };
|
||||
if group.remove(b"Usage").is_some() {
|
||||
cos.set(id, Object::Dict(group));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `doc` with the layer visibility in its `info.layers`.
|
||||
pub(crate) fn use_layer_choices(doc: &mut Document) {
|
||||
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
|
||||
doc.config.layers = Arc::new(overrides);
|
||||
doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
|
||||
}
|
||||
|
||||
/// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as
|
||||
/// they print ([`print_layer_states`]), so the preview shows the page as it prints. `None` when
|
||||
/// the document has no auto state for printing (or no structure to read it from).
|
||||
pub(crate) fn print_preview_layers(cos: Option<&pdfcraft_cos::Document>, layers: &[Layer]) -> Option<Arc<Vec<(i32, i32, bool)>>> {
|
||||
let printed = print_layer_states(cos?, layers)?;
|
||||
Some(Arc::new(printed.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect()))
|
||||
}
|
||||
|
||||
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
|
||||
/// state the changes before it left. Returns whether any layer ended up changed.
|
||||
pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod commands;
|
||||
mod comments;
|
||||
pub mod compare;
|
||||
mod copy_image;
|
||||
pub use copy_image::{PagePicture, picture_at};
|
||||
pub mod dates;
|
||||
mod document;
|
||||
mod edit;
|
||||
@@ -86,7 +87,7 @@ pub use pdfcraft_organize::Standard;
|
||||
pub use pdfcraft_organize::check_custom_key;
|
||||
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
|
||||
|
||||
pub use pdfcraft_cos::Algorithm;
|
||||
pub use pdfcraft_cos::{Algorithm, civil_from_days};
|
||||
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
|
||||
pub use pdfcraft_edit::added::{
|
||||
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
|
||||
@@ -116,7 +117,7 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
|
||||
|
||||
/// 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};
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom};
|
||||
pub use pdfcraft_annot::{
|
||||
Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont,
|
||||
OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
@@ -149,7 +150,7 @@ pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit};
|
||||
pub use error::{EditError, SourceProblem};
|
||||
pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape};
|
||||
pub use history::Label;
|
||||
use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
|
||||
use layers::{apply_layer_state, print_layer_states, print_preview_layers, set_default_layer_state, use_layer_choices};
|
||||
pub use open::{OpenContext, PreparedDocument};
|
||||
pub use page_filter::{PageOrientation, PageParity, filter_pages};
|
||||
pub use pictures::{MarkFile, image_dimensions};
|
||||
@@ -160,7 +161,10 @@ pub use session::Session;
|
||||
pub use sources::{CombineSource, Combined, combine_source_check, source_page_count};
|
||||
use sources::{open_source, open_source_with};
|
||||
#[cfg(test)]
|
||||
use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts};
|
||||
use test_hooks::{
|
||||
check_refresh_panic, panic_on_refresh, record_full_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts,
|
||||
secondary_scan_counts, take_full_refreshes,
|
||||
};
|
||||
use xfa_form::{
|
||||
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state,
|
||||
undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets,
|
||||
|
||||
@@ -971,10 +971,23 @@ impl Session {
|
||||
pub fn ocr_job(&self, id: DocId, pages: &[usize], settings: OcrSettings) -> Option<OcrJob> {
|
||||
let doc = self.get(id)?;
|
||||
let all = doc.info.pages.len();
|
||||
let pages: Vec<usize> = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() };
|
||||
let mut seen = vec![false; all];
|
||||
let pages: Vec<usize> = if pages.is_empty() {
|
||||
seen.fill(true);
|
||||
(0..all).collect()
|
||||
} else {
|
||||
let mut deduped = Vec::new();
|
||||
for &p in pages {
|
||||
if seen.get(p) == Some(&false) {
|
||||
seen[p] = true;
|
||||
deduped.push(p);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
};
|
||||
let native = (0..all)
|
||||
.map(|p| {
|
||||
if !pages.contains(&p) {
|
||||
if seen.get(p) != Some(&true) {
|
||||
return None;
|
||||
}
|
||||
doc.page_images(p)
|
||||
@@ -2234,6 +2247,59 @@ mod tests {
|
||||
assert_eq!(page, 0);
|
||||
}
|
||||
|
||||
/// A scan selected explicitly is rendered at its native scale, like the same page in an
|
||||
/// all-pages job: selecting pages must not lose the native-resolution lookup and render
|
||||
/// the scan above its own resolution at a higher chosen dpi.
|
||||
#[test]
|
||||
fn selected_scan_page_uses_native_scale_like_all_pages() {
|
||||
let mut s = Session::new();
|
||||
let page = export::encode_png(2480, 3508, &vec![255; (2480 * 3508 * 4) as usize]).unwrap();
|
||||
let bytes = s.create_from_images(&[("a4.png".into(), page)]).unwrap();
|
||||
let id = s.open("scan.pdf", None, bytes, None).unwrap();
|
||||
let settings = OcrSettings { dpi: 300.0, ..Default::default() };
|
||||
let all = s.ocr_job(id, &[], settings.clone()).unwrap();
|
||||
let selected = s.ocr_job(id, &[0], settings).unwrap();
|
||||
assert_eq!(selected.pages, vec![0]);
|
||||
let wanted = 300.0 / 72.0;
|
||||
assert!(all.render_scale(0) < wanted, "native resolution limits the all-pages job: {}", all.render_scale(0));
|
||||
assert!(
|
||||
(selected.render_scale(0) - all.render_scale(0)).abs() < 1e-4,
|
||||
"selected page {} vs all-pages {}",
|
||||
selected.render_scale(0),
|
||||
all.render_scale(0)
|
||||
);
|
||||
}
|
||||
|
||||
/// A job requested with the same page twice carries each page once: a duplicate would
|
||||
/// otherwise be recognized twice.
|
||||
#[test]
|
||||
fn ocr_job_deduplicates_requested_pages() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap();
|
||||
let job = s.ocr_job(id, &[2, 0, 2, 1, 0, 2], OcrSettings::default()).unwrap();
|
||||
assert_eq!(job.pages, vec![2, 0, 1], "each requested page appears once, in request order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocr_job_bounds_page_vector_reservation_by_page_count() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap();
|
||||
let requested = vec![999usize; 1024];
|
||||
let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap();
|
||||
assert!(job.pages.is_empty());
|
||||
assert!(job.pages.capacity() <= 3, "reservation {} exceeds the 3 pages that can survive deduplication", job.pages.capacity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocr_job_does_not_reserve_capacity_for_invalid_pages() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("t.pdf", None, Arc::new(fixture(1024)), None).unwrap();
|
||||
let requested = vec![4096usize; 1024];
|
||||
let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap();
|
||||
assert!(job.pages.is_empty());
|
||||
assert_eq!(job.pages.capacity(), 0, "no valid pages were found, so no capacity should be reserved");
|
||||
}
|
||||
|
||||
/// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel
|
||||
/// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution
|
||||
/// letter page is still never shrunk below one pixel per point.
|
||||
|
||||
@@ -8,7 +8,8 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
|
||||
use crate::history::{Editor, Keys, TextBlocksCache};
|
||||
use crate::{
|
||||
AppearanceCache, DocId, Document, Session, StatusCaptions, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard,
|
||||
info_fields, js, note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
|
||||
info_fields, js, note_warnings, print_preview_layers, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout,
|
||||
xfa_values_from_datasets,
|
||||
};
|
||||
|
||||
/// Immutable session state needed while a document is prepared away from its owning thread.
|
||||
@@ -125,11 +126,17 @@ impl Session {
|
||||
// pages and fields so the rest of the engine works on it. The original bytes stay: the
|
||||
// laid-out form is one appended revision.
|
||||
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// Write `work` as one more revision and reopen it: what the document then is.
|
||||
// Write `work` as one more revision and reopen it: what the document then is. The notes
|
||||
// about damage repaired while reading the file stay with it (Document Properties), once.
|
||||
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
|
||||
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
|
||||
let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
let mut new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
for line in work.repair_log() {
|
||||
if !new_cos.repair_log().contains(line) {
|
||||
new_cos.note_repair(line.clone());
|
||||
}
|
||||
}
|
||||
Ok((new_bytes, new_info, new_cos))
|
||||
};
|
||||
let (bytes, info, cos, xfa) = match (info.xfa, cos) {
|
||||
@@ -254,9 +261,6 @@ impl Session {
|
||||
};
|
||||
match rebuilt {
|
||||
Some((b, i, mut c)) => {
|
||||
for line in cos.repair_log() {
|
||||
c.note_repair(line.clone());
|
||||
}
|
||||
c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len()));
|
||||
(b, i, Ok(Ok(c)))
|
||||
}
|
||||
@@ -283,7 +287,7 @@ impl Session {
|
||||
/// The last step of opening: build the document record and register it.
|
||||
fn build_document(context: &OpenContext, data: DocumentOpenData<'_>) -> Document {
|
||||
let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data;
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let mut config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let (mut editor, read_only_reason) = match cos {
|
||||
Ok(Ok(cos)) => {
|
||||
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
|
||||
@@ -318,6 +322,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The print preview draws the layers as they print.
|
||||
config.print_layers = print_preview_layers(editor.as_ref().map(|e| &e.cos), &info.layers);
|
||||
let display = display_bytes(editor.as_mut(), &bytes, &mut signatures, &context.captions);
|
||||
let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
|
||||
let (form, adopted) = editor
|
||||
|
||||
@@ -12,11 +12,12 @@ use pdfcraft_cos::{SaveOptions, write_full};
|
||||
use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize};
|
||||
|
||||
/// Where an optimization is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OptimizeStage {
|
||||
/// Removing the user data chosen under Discard User Data.
|
||||
#[default]
|
||||
/// Removing the user data chosen under Discard User Data, if any was.
|
||||
Discarding,
|
||||
/// Reading what the pages draw: `done` of `total` read.
|
||||
Reading { done: usize, total: usize },
|
||||
/// Merging the fragments of a page picture into one image.
|
||||
Fragments,
|
||||
/// About to process image `done` (0-based) of `total`.
|
||||
@@ -29,14 +30,21 @@ pub enum OptimizeStage {
|
||||
Writing,
|
||||
}
|
||||
|
||||
impl Default for OptimizeStage {
|
||||
fn default() -> Self {
|
||||
OptimizeStage::Reading { done: 0, total: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl OptimizeStage {
|
||||
/// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are
|
||||
/// small fixed steps.
|
||||
/// How far along the whole run is, from 0 to 1, weighed by where the time goes: reading pages
|
||||
/// and pictures. The rest are small fixed steps.
|
||||
pub fn fraction(self) -> f32 {
|
||||
let part = |done: usize, total: usize| if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 };
|
||||
match self {
|
||||
OptimizeStage::Discarding => 0.0,
|
||||
OptimizeStage::Fragments => 0.03,
|
||||
OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 },
|
||||
OptimizeStage::Discarding | OptimizeStage::Fragments => 0.0,
|
||||
OptimizeStage::Reading { done, total } => 0.25 * if total == 0 { 0.0 } else { part(done, total) },
|
||||
OptimizeStage::Images { done, total } => 0.25 + 0.55 * part(done, total),
|
||||
OptimizeStage::CleaningUp => 0.8,
|
||||
OptimizeStage::Merging => 0.85,
|
||||
OptimizeStage::Writing => 0.9,
|
||||
@@ -91,10 +99,10 @@ impl OptimizeJob {
|
||||
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
|
||||
step(OptimizeStage::Discarding)?;
|
||||
let discarded = if discard.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
step(OptimizeStage::Discarding)?;
|
||||
// Every ticked category, as `remove_hidden` decides between them: with form fields
|
||||
// ticked, discarding hidden text also takes the hidden text the flattened appearances
|
||||
// bring onto the page, which no scan before flattening counts. It refuses when its scan
|
||||
@@ -112,6 +120,7 @@ impl OptimizeJob {
|
||||
};
|
||||
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
|
||||
step(match s {
|
||||
optimize::Stage::Reading { done, total } => OptimizeStage::Reading { done, total },
|
||||
optimize::Stage::Fragments => OptimizeStage::Fragments,
|
||||
optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total },
|
||||
optimize::Stage::CleanUp => OptimizeStage::CleaningUp,
|
||||
|
||||
@@ -11,7 +11,7 @@ use pdfcraft_print::{Content, Raster};
|
||||
use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest};
|
||||
|
||||
use crate::export::encode_png;
|
||||
use crate::{Document, guard};
|
||||
use crate::{Document, guard, print_layer_states};
|
||||
|
||||
/// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one
|
||||
/// page per entry, the size of the source page as displayed, drawing what `content` prints.
|
||||
@@ -22,11 +22,16 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont
|
||||
let mut config = src.config.clone();
|
||||
// The copy below holds only what prints, so the viewer's own comment hiding plays no part.
|
||||
config.hide_comments = false;
|
||||
let printed = guard(|| -> Result<Vec<u8>, String> {
|
||||
let (printed, layers) = guard(|| -> Result<(Vec<u8>, Option<Vec<pdfcraft_render::Layer>>), String> {
|
||||
let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?;
|
||||
pdfcraft_print::keep_printed(&mut cos, content);
|
||||
write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())
|
||||
// The layers as the vector print sets them: as shown, then the Print event's auto state.
|
||||
let layers = print_layer_states(&cos, &doc.info.layers);
|
||||
Ok((write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())?, layers))
|
||||
})??;
|
||||
if let Some(layers) = layers {
|
||||
config.layers = std::sync::Arc::new(layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect());
|
||||
}
|
||||
let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config);
|
||||
let mut images: Vec<(String, Vec<u8>)> = Vec::with_capacity(pages.len());
|
||||
let mut used: Option<f64> = None;
|
||||
|
||||
@@ -14,8 +14,8 @@ use crate::{
|
||||
LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort,
|
||||
TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text,
|
||||
display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows,
|
||||
parse_ymd, print, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind,
|
||||
sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets,
|
||||
parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of,
|
||||
source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets,
|
||||
};
|
||||
|
||||
mod editing;
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::{
|
||||
inspect, js, keys_after, note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state,
|
||||
undrawn_notices, uses_scripts, write_incremental, xfa, xfa_sync_datasets,
|
||||
};
|
||||
use crate::{EditNotice, MarkKind};
|
||||
use crate::{EditNotice, MarkKind, print_preview_layers};
|
||||
|
||||
/// Whether the edit, or any edit inside a batch, deletes pages.
|
||||
fn deletes_pages(edit: &Edit) -> bool {
|
||||
@@ -515,6 +515,8 @@ impl Session {
|
||||
}
|
||||
|
||||
fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> {
|
||||
#[cfg(test)]
|
||||
crate::record_full_refresh();
|
||||
// A full refresh may follow structural or annotation changes: cached appearances
|
||||
// may be stale, so they are rebuilt below.
|
||||
if let Some(editor) = doc.editor.as_mut() {
|
||||
@@ -545,6 +547,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
doc.info = info;
|
||||
doc.config.print_layers = print_preview_layers(Some(&editor.cos), &doc.info.layers);
|
||||
doc.fonts_stale = false;
|
||||
let mut form = pdfcraft_forms::fields(&editor.cos);
|
||||
xfa::mark_script_buttons(&editor.cos, &mut form);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
|
||||
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state,
|
||||
source_kind, writable, write_full,
|
||||
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster,
|
||||
set_default_layer_state, source_kind, writable, write_full,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
@@ -164,10 +164,21 @@ impl Session {
|
||||
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
||||
}
|
||||
};
|
||||
// Comments and fields without an appearance print as the page shows them (and as Print
|
||||
// as Image already printed them): the vector print drew only appearance streams, so a
|
||||
// text box without one was on screen but not on paper. ISO 32000-2 §12.5.3: an
|
||||
// annotation with the Print flag is printed.
|
||||
if let Some(displayed) = guard(|| crate::document::as_displayed(&cos)).ok().flatten() {
|
||||
cos = displayed;
|
||||
}
|
||||
// #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
|
||||
// action) has changed one, the print copy's default configuration says which are on, so a
|
||||
// hidden layer stays off on paper too; untouched, the document's own configuration prints.
|
||||
if !doc.config.layers.is_empty() {
|
||||
// On top of either, the configuration's auto state for the Print event sets the layers it
|
||||
// names (`print_layer_states`), as printing does in a viewer (ISO 32000-2 §8.11.4.4).
|
||||
if let Some(printed) = print_layer_states(&cos, &doc.info.layers) {
|
||||
set_default_layer_state(&mut cos, &printed);
|
||||
} else if !doc.config.layers.is_empty() {
|
||||
set_default_layer_state(&mut cos, &doc.info.layers);
|
||||
}
|
||||
// Signature states as the screen shows them, so not when its display copy could not be
|
||||
|
||||
@@ -217,6 +217,15 @@ impl Session {
|
||||
out
|
||||
}
|
||||
|
||||
/// The snapshot [`Self::autosave_snapshots`] handed out for `id` could not be stored: the
|
||||
/// document is due again, so the next autosave tries it again.
|
||||
pub fn autosave_failed(&mut self, id: DocId) {
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
// Any generation but the current one leaves it due.
|
||||
d.snapshot_generation = d.generation.wrapping_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a document opened from a recovery file: it has unsaved changes and belongs at
|
||||
/// `path` (where Save writes), as when the session ended.
|
||||
pub fn mark_recovered(&mut self, id: DocId, path: Option<String>) {
|
||||
|
||||
@@ -120,17 +120,7 @@ impl Session {
|
||||
let rects: Vec<[f64; 4]> = text
|
||||
.line_rects(glyphs)
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
let q = doc.info.pages[page].view_rect_to_quad(v);
|
||||
let xs = [q[0], q[2], q[4], q[6]];
|
||||
let ys = [q[1], q[3], q[5], q[7]];
|
||||
[
|
||||
xs.iter().copied().fold(f64::MAX, f64::min),
|
||||
ys.iter().copied().fold(f64::MAX, f64::min),
|
||||
xs.iter().copied().fold(f64::MIN, f64::max),
|
||||
ys.iter().copied().fold(f64::MIN, f64::max),
|
||||
]
|
||||
})
|
||||
.filter_map(|v| line_user_rect(&doc.info.pages[page], v))
|
||||
.filter(|r| {
|
||||
!doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3])
|
||||
})
|
||||
@@ -313,3 +303,112 @@ impl Session {
|
||||
&self.docs
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a view-space text line rectangle into a normalized, finite PDF user-space rectangle.
|
||||
fn line_user_rect(page: &pdfcraft_render::PageInfo, v: [f32; 4]) -> Option<[f64; 4]> {
|
||||
if !v.iter().all(|x| x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let r = page.view_rect_to_user(v).map(f64::from);
|
||||
r.iter().all(|x| x.is_finite()).then_some(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::line_user_rect;
|
||||
use crate::{Edit, Session};
|
||||
use pdfcraft_render::PageInfo;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Build a sample `PageInfo` with a non-origin crop box `[10.0, 20.0, 210.0, 320.0]` (`200 × 300`).
|
||||
fn sample_page(rotation: u16) -> PageInfo {
|
||||
let (width, height) = if rotation % 180 == 90 { (300.0, 200.0) } else { (200.0, 300.0) };
|
||||
PageInfo { width, height, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_user_rect_rejects_non_finite_coordinates() {
|
||||
let page = sample_page(0);
|
||||
assert_eq!(line_user_rect(&page, [f32::NAN; 4]), None);
|
||||
assert_eq!(line_user_rect(&page, [f32::NAN, 10.0, 50.0, 20.0]), None);
|
||||
assert_eq!(line_user_rect(&page, [10.0, 20.0, f32::INFINITY, 30.0]), None);
|
||||
assert_eq!(line_user_rect(&page, [10.0, f32::NEG_INFINITY, 50.0, 30.0]), None);
|
||||
|
||||
for bad_crop_box in [[f32::NAN, 0.0, 200.0, 300.0], [0.0, f32::INFINITY, 200.0, 300.0], [0.0, 0.0, f32::NEG_INFINITY, 300.0]] {
|
||||
let mut bad_crop = sample_page(0);
|
||||
bad_crop.crop = bad_crop_box;
|
||||
assert_eq!(line_user_rect(&bad_crop, [10.0, 20.0, 50.0, 40.0]), None);
|
||||
}
|
||||
|
||||
// Finite inputs whose crop span overflows f32 during view-to-user mapping must also be rejected.
|
||||
let overflow_page = PageInfo { width: 1.0, height: 1.0, label: String::new(), crop: [-f32::MAX, 0.0, f32::MAX, 300.0], rotation: 0 };
|
||||
assert_eq!(line_user_rect(&overflow_page, [1.0, 10.0, 2.0, 20.0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_user_rect_matches_view_rect_to_user_across_rotations() {
|
||||
let v = [12.0, 24.0, 96.0, 48.0];
|
||||
let cases = [
|
||||
(0, [22.0, 272.0, 106.0, 296.0]),
|
||||
(90, [34.0, 32.0, 58.0, 116.0]),
|
||||
(180, [114.0, 44.0, 198.0, 68.0]),
|
||||
(270, [162.0, 224.0, 186.0, 308.0]),
|
||||
];
|
||||
for (rotation, expected) in cases {
|
||||
let page = sample_page(rotation);
|
||||
let got = line_user_rect(&page, v).expect("finite view rect maps to user space");
|
||||
for (g, e) in got.into_iter().zip(expected) {
|
||||
assert!((g - e).abs() < 1e-4, "rotation {rotation}: got {got:?}, expected {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_urls_returns_finite_user_rects_on_rotated_page() {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
|
||||
];
|
||||
let body = "BT /F1 12 Tf 20 150 Td (Visit https://example.com today) Tj ET";
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
|
||||
let mut session = Session::new();
|
||||
let id = session.open("urls.pdf", None, Arc::new(pdf), None).expect("opens");
|
||||
let expected_rect = [46.004, 147.6, 153.38, 159.6];
|
||||
let base_urls = session.find_urls(id);
|
||||
assert_eq!(base_urls.len(), 1);
|
||||
let base_rect = base_urls[0].1[0];
|
||||
for (got, exp) in base_rect.into_iter().zip(expected_rect) {
|
||||
assert!((got - exp).abs() < 1e-2, "0 deg: got {base_rect:?}, expected {expected_rect:?}");
|
||||
}
|
||||
|
||||
for step in [90, 180, 270] {
|
||||
session.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotates");
|
||||
let urls = session.find_urls(id);
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert_eq!(urls[0].0, 0);
|
||||
assert_eq!(urls[0].2, "https://example.com");
|
||||
assert_eq!(urls[0].1.len(), 1);
|
||||
let r = urls[0].1[0];
|
||||
for (got, exp) in r.into_iter().zip(expected_rect) {
|
||||
assert!((got - exp).abs() < 1e-2, "rotation {step} deg: got {r:?}, expected {expected_rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +40,17 @@ pub(crate) fn record_link_scan() {
|
||||
counts.set((markers, links + 1));
|
||||
});
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FULL_REFRESHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A whole-document refresh (re-inspection) ran on this thread.
|
||||
pub(crate) fn record_full_refresh() {
|
||||
FULL_REFRESHES.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
|
||||
/// How many whole-document refreshes ran on this thread since the last call, and starts over.
|
||||
pub(crate) fn take_full_refreshes() -> usize {
|
||||
FULL_REFRESHES.with(|n| n.replace(0))
|
||||
}
|
||||
|
||||
+584
-3
@@ -1779,6 +1779,45 @@ fn low_resolution_printing_prints_only_the_chosen_content() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment and a field the file leaves without an appearance print as the page shows them:
|
||||
/// a text box (`/FreeText` with only `/Contents` and `/DA`) and a check box set on with
|
||||
/// `/NeedAppearances`. The vector print drew only appearance streams, so both were on screen and
|
||||
/// in Print as Image but not on paper (pdf.js `bug1865341`, `freetext_print_no_appearance`).
|
||||
/// ISO 32000-2 §12.5.3: an annotation with the Print flag is printed.
|
||||
#[test]
|
||||
fn comments_and_fields_without_an_appearance_print_as_shown() {
|
||||
let content = "";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [5 0 R 6 0 R] >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Annot /Subtype /FreeText /F 4 /P 3 0 R /Rect [10 120 190 190] /DA (/Helv 40 Tf 0 g) /Contents (WWWW) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (Box) /V /Yes /AS /Yes /F 4 /P 3 0 R /Rect [10 10 110 110] /MK << /CA (8) >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len(),
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("bare.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap();
|
||||
let settings =
|
||||
print::Settings { pages: vec![0], paper: (200.0, 200.0), layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() };
|
||||
let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap());
|
||||
assert_eq!((w, h), (200, 200));
|
||||
// Dark pixels in the text box (top half) and in the check box (bottom half).
|
||||
let dark = |y0: usize, y1: usize| {
|
||||
(y0..y1)
|
||||
.flat_map(|y| (0..w as usize).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| rgba.get((y * w as usize + x) * 4).is_some_and(|&r| r < 128))
|
||||
.count()
|
||||
};
|
||||
assert!(dark(10, 80) > 50, "the text box prints");
|
||||
assert!(dark(90, 190) > 50, "the check box prints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_permissions_password_unlocks_an_open_document() {
|
||||
// Only a permissions password (#995, #1077): opens without asking. Changes are allowed,
|
||||
@@ -3120,6 +3159,66 @@ fn setting_the_calculation_order_recalculates_and_undoes() {
|
||||
assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_))));
|
||||
}
|
||||
|
||||
/// A Calculate script that sets its own field through the field object, not `event.value`,
|
||||
/// gives the field that value, text included; when it sets `event.value` as well, that wins.
|
||||
#[test]
|
||||
fn a_calculate_script_setting_its_own_field_object_keeps_the_value() {
|
||||
let (mut s, id) = session_with(1);
|
||||
for (i, name) in ["origin", "copy", "both"].into_iter().enumerate() {
|
||||
let y = 250.0 - 40.0 * i as f64;
|
||||
let kind = NewField::Text { multiline: false };
|
||||
s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap();
|
||||
}
|
||||
let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) };
|
||||
s.apply(id, script("copy", "var o = this.getField(\"origin\");\nthis.getField(\"copy\").value = o.value;")).unwrap();
|
||||
s.apply(id, script("both", "this.getField(\"both\").value = \"field\";\nevent.value = this.getField(\"origin\").value + \"!\";")).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("abc".into()) }).unwrap();
|
||||
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned());
|
||||
assert_eq!(value(&s, "copy").as_deref(), Some("abc"));
|
||||
assert_eq!(value(&s, "both").as_deref(), Some("abc!"), "event.value decides when the script sets both");
|
||||
s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("12".into()) }).unwrap();
|
||||
assert_eq!(value(&s, "copy").as_deref(), Some("12"));
|
||||
}
|
||||
|
||||
/// A Calculate script that assigns `event.value` the value its field already holds keeps it, even
|
||||
/// after writing another value through its field object; and a combo box a script moves to
|
||||
/// another option selects that option in `/I` too.
|
||||
#[test]
|
||||
fn a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection() {
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(
|
||||
id,
|
||||
Edit::AddField { page: 0, rect: [20.0, 250.0, 180.0, 272.0], kind: NewField::Text { multiline: false }, name: Some("origin".into()) },
|
||||
)
|
||||
.unwrap();
|
||||
s.apply(id, Edit::AddField { page: 0, rect: [20.0, 210.0, 180.0, 232.0], kind: NewField::Text { multiline: false }, name: Some("kept".into()) })
|
||||
.unwrap();
|
||||
let kind = NewField::Combo { options: vec!["Small".into(), "Large".into()], editable: false };
|
||||
s.apply(id, Edit::AddField { page: 0, rect: [20.0, 170.0, 180.0, 192.0], kind, name: Some("size".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "size".into(), value: FieldValue::Choice(vec!["Small".into()]) }).unwrap();
|
||||
let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) };
|
||||
s.apply(
|
||||
id,
|
||||
script(
|
||||
"kept",
|
||||
"var v = event.value;
|
||||
this.getField(\"kept\").value = \"field\";
|
||||
event.value = v;",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
s.apply(id, script("size", "event.value = this.getField(\"origin\").value > 5 ? \"Large\" : \"Small\";")).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("9".into()) }).unwrap();
|
||||
assert!(s.take_js_output(id).errors.is_empty(), "both scripts ran to the end");
|
||||
let doc = s.get(id).unwrap();
|
||||
let value = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned());
|
||||
assert_eq!(value("kept").as_deref(), None, "event.value, set to the field's own value, decides");
|
||||
assert_eq!(value("size").as_deref(), Some("Large"));
|
||||
let size = doc.form.iter().find(|f| f.name == "size").map(|f| f.obj).unwrap();
|
||||
let selected = doc.editor.as_ref().unwrap().cos.get(size).as_dict().and_then(|d| d.get(b"I")).cloned();
|
||||
assert_eq!(selected, Some(pdfcraft_cos::Object::Array(vec![pdfcraft_cos::Object::Int(1)])));
|
||||
}
|
||||
|
||||
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
|
||||
/// the page's /Resources holds.
|
||||
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
|
||||
@@ -3441,6 +3540,185 @@ fn printing_keeps_hidden_layers_hidden() {
|
||||
assert!(red > 100, "shown again, Layer A prints again: {red} red");
|
||||
}
|
||||
|
||||
/// A PDF of `objs`, numbered from 1, the first being the catalog.
|
||||
fn pdf_of_objects(objs: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §8.11.4.4: the default configuration's auto state for the Print event sets the
|
||||
/// layers it names when the document prints. Red is on screen but `/PrintState /OFF`, green is off
|
||||
/// on screen but `/PrintState /ON`: on paper, vector or as an image, green prints and red doesn't.
|
||||
/// Two blue squares are on screen and `/PrintState /OFF` too, but no Print recommendation reaches
|
||||
/// them (one is named only in the View entry, the other in a Print entry without the Print
|
||||
/// category), so they print as shown.
|
||||
#[test]
|
||||
fn printing_applies_the_print_auto_state() {
|
||||
let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC \
|
||||
/OC /C BDC 0 0 1 rg 210 10 40 40 re f EMC /OC /D BDC 0 0 1 rg 210 55 40 40 re f EMC";
|
||||
let screen_only = b"/Usage << /View << /ViewState /ON >> /Print << /PrintState /OFF >> >> >>";
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R 8 0 R] /D << /OFF [6 0 R] \
|
||||
/AS [<< /Event /Print /OCGs [5 0 R 6 0 R] /Category [/Print] >> << /Event /View /OCGs [5 0 R 6 0 R 7 0 R] /Category [/View] >> \
|
||||
<< /Event /Print /OCGs [8 0 R] /Category [/Language] >>] >> >> >>"
|
||||
.to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R \
|
||||
/Resources << /Properties << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >> >>"
|
||||
.to_vec(),
|
||||
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
|
||||
[b"<< /Type /OCG /Name (View only) ".as_slice(), screen_only].concat(),
|
||||
b"<< /Type /OCG /Name (Print only) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>".to_vec(),
|
||||
[b"<< /Type /OCG /Name (View entry) ".as_slice(), screen_only].concat(),
|
||||
[b"<< /Type /OCG /Name (Other category) ".as_slice(), screen_only].concat(),
|
||||
];
|
||||
let pdf = pdf_of_objects(&objs);
|
||||
|
||||
let colours = |page: &pdfcraft_render::RenderedPage| {
|
||||
let px = page.rgba.as_chunks::<4>().0;
|
||||
let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
|
||||
let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
|
||||
let blue = px.iter().filter(|p| p[2] > 200 && p[0] < 60 && p[1] < 60).count();
|
||||
(red, green, blue)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("auto-state.pdf", None, Arc::new(pdf), None).unwrap();
|
||||
let (red, green, shown_blue) = colours(&render_page(&s, id));
|
||||
assert!(red > 100 && green == 0, "on screen, red shows and green doesn't: {red} red, {green} green");
|
||||
// The print dialog's preview shows the page as it prints, the view as it shows; so after a
|
||||
// Layers panel change too.
|
||||
let preview = |s: &Session, tag: u64| {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), display_config(&doc.config));
|
||||
colours(&renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag, ..Default::default() }))
|
||||
};
|
||||
let view_entry = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "View entry").unwrap();
|
||||
let mut printed_blue = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (red, green, blue) = preview(&s, pdfcraft_render::PRINT_TAG);
|
||||
assert!(red == 0 && green > 100, "the preview shows the print: {red} red, {green} green");
|
||||
printed_blue.push(blue);
|
||||
let (red, green, _) = preview(&s, 0);
|
||||
assert!(red > 100 && green == 0, "the view is unchanged: {red} red, {green} green");
|
||||
// Hidden after the first round, shown again after the second.
|
||||
assert!(s.set_layer_visible(id, view_entry, printed_blue.len() == 2));
|
||||
}
|
||||
// Hidden in the panel, View entry (no Print recommendation) leaves the preview too.
|
||||
assert!(printed_blue[1] * 4 < printed_blue[0] * 3, "the preview follows the panel: {printed_blue:?} blue");
|
||||
for raster in [None, Some(print::Raster { dpi: 72.0, grayscale: false })] {
|
||||
let bytes = Arc::new(s.print_pdf(id, &print::Settings { pages: vec![0], raster, ..Default::default() }).unwrap());
|
||||
let mut out = Session::new();
|
||||
let printed = out.open("print.pdf", None, bytes.clone(), None).unwrap();
|
||||
let (red, green, blue) = colours(&render_page(&out, printed));
|
||||
assert_eq!(red, 0, "the view-only layer doesn't print (as image: {})", raster.is_some());
|
||||
assert!(green > 100, "the print-only layer prints (as image: {}): {green} green", raster.is_some());
|
||||
// Both blue squares print: the one shown on screen alone is half of them.
|
||||
assert!(
|
||||
blue * 4 > shown_blue * 3,
|
||||
"the layers no Print recommendation reaches print as shown (as image: {}): {blue} of {shown_blue} blue",
|
||||
raster.is_some()
|
||||
);
|
||||
// The vector copy's states are final: nothing a reader applies when drawing is left to
|
||||
// turn them back (the auto state, or a layer's `/Usage /View /ViewState`).
|
||||
let layers = &out.get(printed).unwrap().info.layers;
|
||||
if raster.is_none() {
|
||||
let cos = pdfcraft_cos::Document::open(bytes).unwrap();
|
||||
assert_eq!(layers.len(), 4);
|
||||
for layer in layers {
|
||||
let group = cos.get(pdfcraft_cos::ObjRef::new(layer.id.0, layer.id.1));
|
||||
assert!(group.as_dict().is_some_and(|g| !g.contains(b"Usage")), "{} keeps its /Usage", layer.name);
|
||||
}
|
||||
let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r)).unwrap();
|
||||
let properties = root.as_dict().and_then(|r| r.get(b"OCProperties")).map(|p| cos.resolve(p)).unwrap();
|
||||
let config = properties.as_dict().and_then(|p| p.get(b"D")).map(|d| cos.resolve(d)).unwrap();
|
||||
assert!(config.as_dict().is_some_and(|d| !d.contains(b"AS")), "the print copy keeps /AS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The print auto state of `layers` (object numbers from 5) in a document whose default
|
||||
/// configuration has `auto` as its `/AS` and `groups` as the layers' dictionaries.
|
||||
fn print_states_of(auto: &str, groups: &[&str]) -> Vec<bool> {
|
||||
let ocgs: String = (0..groups.len()).map(|i| format!("{} 0 R ", i + 5)).collect();
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [{ocgs}] /D << /AS {auto} >> >> >>").into_bytes(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_vec(),
|
||||
b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
];
|
||||
objs.extend(groups.iter().map(|g| g.as_bytes().to_vec()));
|
||||
let cos = pdfcraft_cos::Document::open(Arc::new(pdf_of_objects(&objs))).unwrap();
|
||||
let layers: Vec<Layer> = (0..groups.len()).map(|i| Layer { id: (i as u32 + 5, 0), name: format!("{i}"), visible: true, initial: true }).collect();
|
||||
crate::print_layer_states(&cos, &layers).expect("there are Print entries").iter().map(|l| l.visible).collect()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §8.11.4.4: a group prints only if every category of every Print entry naming it
|
||||
/// recommends ON. A Print category (`/PrintState /ON`) and a View category of another Print entry
|
||||
/// (`/ViewState /OFF`) disagree, so the group stays off; with both ON it prints. A `/Category`
|
||||
/// written as a single name (Table 101 asks for an array) is read as one.
|
||||
#[test]
|
||||
fn a_layer_prints_only_if_every_recommendation_is_on() {
|
||||
let auto = "[<< /Event /Print /OCGs [5 0 R 6 0 R] /Category [/Print] >> << /Event /Print /OCGs [5 0 R 6 0 R] /Category [/View] >> \
|
||||
<< /Event /Print /OCGs [7 0 R] /Category /Print >>]";
|
||||
let states = print_states_of(
|
||||
auto,
|
||||
&[
|
||||
"<< /Type /OCG /Name (Disagree) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>",
|
||||
"<< /Type /OCG /Name (Agree) /Usage << /View << /ViewState /ON >> /Print << /PrintState /ON >> >> >>",
|
||||
"<< /Type /OCG /Name (Single name) /Usage << /Print << /PrintState /OFF >> >> >>",
|
||||
],
|
||||
);
|
||||
assert_eq!(states, [false, true, false]);
|
||||
}
|
||||
|
||||
/// At most 1,024 `/AS` entries are read: an entry past them that would turn the layer off is not
|
||||
/// reached, while the same entry within them is.
|
||||
#[test]
|
||||
fn the_print_auto_state_reads_a_bounded_number_of_entries() {
|
||||
let layer = "<< /Type /OCG /Name (Layer) /Usage << /Print << /PrintState /OFF >> >> >>";
|
||||
let other = "<< /Type /OCG /Name (Other) >>";
|
||||
let auto = |before: usize| {
|
||||
let filler = "<< /Event /Print /OCGs [6 0 R] /Category [/Print] >> ".repeat(before);
|
||||
format!("[{filler} << /Event /Print /OCGs [5 0 R] /Category [/Print] >>]")
|
||||
};
|
||||
assert_eq!(print_states_of(&auto(1023), &[layer, other]), [false, true], "the 1,024th entry is read");
|
||||
assert_eq!(print_states_of(&auto(1024), &[layer, other]), [true, true], "the 1,025th entry is not");
|
||||
}
|
||||
|
||||
/// Reading the print auto state is bounded over all `/AS` entries together: 1,024 Print entries
|
||||
/// sharing one `/Category` array of 70,000 names, `/Print` last, read 65,536 names in all, find no
|
||||
/// Print category and recommend nothing, instead of reading the array 1,024 times.
|
||||
#[test]
|
||||
fn the_print_auto_state_reads_a_bounded_number_of_categories() {
|
||||
let names = format!("[{} /Print]", "/View ".repeat(70_000));
|
||||
let entries = "<< /Event /Print /OCGs [5 0 R] /Category 6 0 R >> ".repeat(1024);
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /AS [{entries}] >> >> >>").into_bytes(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_vec(),
|
||||
b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
b"<< /Type /OCG /Name (Layer) /Usage << /Print << /PrintState /OFF >> >> >>".to_vec(),
|
||||
names.into_bytes(),
|
||||
];
|
||||
let cos = pdfcraft_cos::Document::open(Arc::new(pdf_of_objects(&objs))).unwrap();
|
||||
let layer = Layer { id: (5, 0), name: "Layer".into(), visible: true, initial: true };
|
||||
let printed = crate::print_layer_states(&cos, std::slice::from_ref(&layer)).expect("there are Print entries");
|
||||
assert!(printed.iter().all(|l| l.visible), "past the bound nothing is read, so the layer prints as shown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_lays_out_sheets_that_render() {
|
||||
let (s, id) = session_with(5);
|
||||
@@ -3904,7 +4182,9 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
assert_eq!(stages.first(), Some(&OptimizeStage::Discarding));
|
||||
assert_eq!(stages.first(), Some(&OptimizeStage::Reading { done: 0, total: 2 }), "it says what it does first");
|
||||
assert!(!stages.contains(&OptimizeStage::Discarding), "nothing was chosen to discard: {stages:?}");
|
||||
assert!(stages.iter().any(|st| matches!(st, OptimizeStage::Images { .. })), "{stages:?}");
|
||||
assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]);
|
||||
let fractions: Vec<f32> = stages.iter().map(|st| st.fraction()).collect();
|
||||
assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}");
|
||||
@@ -3915,6 +4195,20 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
|
||||
"no images: no division by zero"
|
||||
);
|
||||
|
||||
// What the bar shows before anything is reported says nothing of user data either.
|
||||
assert_eq!(OptimizeStage::default(), OptimizeStage::Reading { done: 0, total: 0 });
|
||||
assert_eq!(OptimizeStage::default().fraction(), 0.0);
|
||||
assert!(OptimizeStage::Reading { done: 2, total: 4 }.fraction() > 0.0, "reading pages moves the bar");
|
||||
|
||||
// Discarding says so when something is. This document has no metadata, which only stops it after
|
||||
// the stage is reported.
|
||||
let mut with_discard = Vec::new();
|
||||
let _ = s.optimize_job(id, &settings, &[crate::Hidden::Metadata]).unwrap().run(|st| {
|
||||
with_discard.push(st);
|
||||
true
|
||||
});
|
||||
assert_eq!(with_discard.first(), Some(&OptimizeStage::Discarding));
|
||||
|
||||
// Cancelled at the merge: nothing is written, the open document is untouched.
|
||||
let before = s.get(id).unwrap().bytes.clone();
|
||||
let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging);
|
||||
@@ -4593,6 +4887,25 @@ fn split_by_size_and_bookmarks_and_page_filters() {
|
||||
assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]);
|
||||
}
|
||||
|
||||
/// A zoomed link follows its target page, not its number: deleting a page before the target keeps
|
||||
/// the link on that page at its zoom. Deleting the target page drops the link's destination, as
|
||||
/// for any link to a deleted page, and undo brings it back with its zoom.
|
||||
#[test]
|
||||
fn a_zoomed_link_follows_its_page_and_loses_it_when_the_page_is_deleted() {
|
||||
let (mut s, id) = session_with(3);
|
||||
let zoom = LinkZoom::Percent(200.0);
|
||||
let rect = [10.0, 10.0, 60.0, 30.0];
|
||||
s.apply(id, Edit::AddLink { page: 0, rect, action: LinkAction::Page { page: 2, zoom }, style: LinkStyle::default() }).unwrap();
|
||||
let action = |s: &Session| s.get(id).unwrap().links()[0].action.clone();
|
||||
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
|
||||
assert_eq!(action(&s), LinkAction::Page { page: 1, zoom }, "the link follows its page");
|
||||
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
|
||||
// The destination is removed, not left pointing at a page that is gone.
|
||||
assert_eq!(action(&s), LinkAction::Other("no action".into()), "no destination left");
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(action(&s), LinkAction::Page { page: 1, zoom }, "undo restores the destination and its zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_from_urls_and_link_edits() {
|
||||
// One page whose text contains a web address.
|
||||
@@ -4634,8 +4947,12 @@ fn links_from_urls_and_link_edits() {
|
||||
assert_eq!(s.get(id).unwrap().links().len(), 1);
|
||||
// Link Properties and delete.
|
||||
let l = s.get(id).unwrap().links()[0].clone();
|
||||
s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page(0));
|
||||
s.apply(
|
||||
id,
|
||||
Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }), style: None },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page { page: 0, zoom: LinkZoom::FitPage });
|
||||
s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap();
|
||||
assert!(s.get(id).unwrap().links().is_empty());
|
||||
assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove");
|
||||
@@ -5016,6 +5333,7 @@ fn sign_prepared_with(
|
||||
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true }));
|
||||
v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
value(&mut v);
|
||||
let certifies = v.get(b"Reference").is_some();
|
||||
let sig = doc.add(Object::Dict(v));
|
||||
// The field by name, never by number: a full save (encryption, repairs) is
|
||||
// free to renumber objects, and a hardcoded reference would stamp the value
|
||||
@@ -5025,6 +5343,12 @@ fn sign_prepared_with(
|
||||
let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields");
|
||||
let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field");
|
||||
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
|
||||
if certifies {
|
||||
// A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2).
|
||||
let mut perms = Dict::new();
|
||||
perms.set(b"DocMDP".to_vec(), Object::Ref(sig));
|
||||
doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap();
|
||||
}
|
||||
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() };
|
||||
let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap();
|
||||
let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]);
|
||||
@@ -6080,6 +6404,20 @@ fn form_javascript_validates_calculates_and_formats() {
|
||||
assert!(s.run_javascript(id, "1", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_field_rect_moves_first_widget_and_undoes() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("rect.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
let initial = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect;
|
||||
s.run_javascript(id, "getField('price').rect = [30, 40, 130, 60];", Some("go")).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]);
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, initial);
|
||||
s.redo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]);
|
||||
}
|
||||
|
||||
/// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before
|
||||
/// each of their scripts, and alert when either is missing.
|
||||
#[test]
|
||||
@@ -8857,6 +9195,46 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
|
||||
assert_eq!(Session::content_refresh_lists(None), (true, true), "undo/redo re-read both");
|
||||
}
|
||||
|
||||
/// Recognized text is an invisible text layer on its page, so applying it refreshes like any
|
||||
/// other content edit, not by re-inspecting the whole document (#6310): on a 567-page file with
|
||||
/// 60,102 font resources one word took 2.4 s, the full refresh nearly all of it. The page tree,
|
||||
/// comments, fields, links and marks stay as they were; the words are on their page, the font
|
||||
/// list picks up the OCR font on `refresh_fonts`, undo and redo take the words out and back
|
||||
/// without a full refresh either, and a save keeps them.
|
||||
#[test]
|
||||
fn ocr_words_refresh_only_the_page_content_they_add() {
|
||||
use crate::ocr::{OcrPage, PlacedWord};
|
||||
let (mut s, id) = session_with(3);
|
||||
let words_on = |s: &Session, page: usize| s.get(id).unwrap().text_lines(page).iter().map(|l| l.text.trim().to_string()).collect::<Vec<_>>();
|
||||
let before = s.get(id).unwrap();
|
||||
let view = format!("{:?}", (&before.info.pages, &before.info.annotations, &before.info.fields, &before.links, before.marks()));
|
||||
let others = (words_on(&s, 0), words_on(&s, 2));
|
||||
assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")));
|
||||
take_full_refreshes();
|
||||
let word =
|
||||
PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0], confidence: None, source: "ocrs".into() };
|
||||
assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() }]).unwrap(), 1);
|
||||
assert_eq!(take_full_refreshes(), 0, "applying OCR words doesn't re-inspect the document");
|
||||
let after = s.get(id).unwrap();
|
||||
assert_eq!(format!("{:?}", (&after.info.pages, &after.info.annotations, &after.info.fields, &after.links, after.marks())), view);
|
||||
assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "{:?}", words_on(&s, 1));
|
||||
assert_eq!((words_on(&s, 0), words_on(&s, 2)), others, "the other pages are untouched");
|
||||
s.refresh_fonts(id);
|
||||
let d = s.get(id).unwrap();
|
||||
let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap();
|
||||
assert_eq!(format!("{:?}", d.info.fonts), format!("{:?}", full.fonts), "the font list is what a full inspection reads");
|
||||
assert_eq!(d.info.file_size, full.file_size);
|
||||
s.undo(id).unwrap();
|
||||
assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")), "undo takes the words out");
|
||||
s.redo(id).unwrap();
|
||||
assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "redo puts them back");
|
||||
assert_eq!(take_full_refreshes(), 0, "undo and redo refresh the content alone too");
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
let rid = reopened.open("saved.pdf", None, saved, None).unwrap();
|
||||
assert!(reopened.get(rid).unwrap().text_lines(1).iter().any(|l| l.text.trim() == "HELLO"), "the save keeps the words");
|
||||
}
|
||||
|
||||
/// A content edit can add a font (Add text in Times); the narrow refresh leaves the font list
|
||||
/// as it was, and `refresh_fonts` reads it again before Properties ▸ Fonts or `doc_info` show it.
|
||||
#[test]
|
||||
@@ -10147,6 +10525,159 @@ fn a_date_its_validate_script_refuses_is_not_tried_again() {
|
||||
assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name"));
|
||||
}
|
||||
|
||||
/// Floating fields (XFA 3.3 p. 1222): text that embeds a hidden field shows the value its
|
||||
/// initialize script gives it, the value a button's script gives it later and the value a
|
||||
/// calculation gives one, as it does the sheet number and count; the fields sit in an unnamed
|
||||
/// subform, as forms place them (#6160).
|
||||
#[test]
|
||||
fn text_shows_the_values_scripts_give_its_floating_fields() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform w="7in" h="9in">
|
||||
<field name="Viewer" id="floatViewer" presence="hidden" access="readOnly" w="1in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Opened";</script></event><bind match="none"/></field>
|
||||
<field name="Sheet" id="floatSheet" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.sheet(this)+1</script></event><bind match="none"/></field>
|
||||
<field name="Sheets" id="floatSheets" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.sheetCount()</script></event><bind match="none"/></field>
|
||||
<field name="Shout" id="floatShout" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui>
|
||||
<calculate><script contentType="application/x-javascript">Viewer.rawValue + "!"</script></calculate><bind match="none"/></field>
|
||||
<field name="set" y="1in" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">Viewer.rawValue = "Clicked";</script></event></field>
|
||||
<draw name="line" y="2in" w="6in" h="1in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Viewer: <span xfa:embedType="uri" xfa:embedMode="raw" xfa:embed="#floatViewer"/><br/>Sheet <span xfa:embed="#floatSheet"/> of <span xfa:embed="#floatSheets"/><br/>Calculated: <span xfa:embed="#floatShout"/></p></body></exData></value></draw>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("float.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
let text = page_texts(&s, id).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert!(text.contains("Viewer: Opened"), "{text}");
|
||||
assert!(text.contains("Sheet 1 of 1"), "{text}");
|
||||
assert!(text.contains("Calculated: Opened!"), "{text}");
|
||||
let o = s.run_javascript(id, "", Some("set")).expect("clicks");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
let text = page_texts(&s, id).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert!(text.contains("Viewer: Clicked"), "{text}");
|
||||
assert!(text.contains("Calculated: Clicked!"), "{text}");
|
||||
}
|
||||
|
||||
/// Text that embeds a field follows its data whoever writes it (XFA 3.3 pp. 221–222): a
|
||||
/// script writing another field bound to the same data, a script writing a page area's field,
|
||||
/// a typed value in a plain one-line field, a pick in a drop-down list, and a reset (of a
|
||||
/// drop-down list, and of a page area's field that has no widget).
|
||||
#[test]
|
||||
fn floating_fields_follow_their_data_whoever_writes_it() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="1in" w="7.5in" h="9in"/><medium short="8.5in" long="11in"/>
|
||||
<field name="Stamp" id="floatStamp" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<draw x="0.5in" y="0.2in" w="5in" h="0.4in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Stamp: <span xfa:embed="#floatStamp"/></p></body></exData></value></draw>
|
||||
</pageArea></pageSet>
|
||||
<subform w="7in" h="8in">
|
||||
<field name="Note" id="floatNote" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<area name="box" y="0.5in"><field name="Note" w="2in" h="0.3in"><ui><textEdit/></ui></field></area>
|
||||
<field name="Edit" id="floatEdit" y="1in" w="2in" h="0.3in"><ui><textEdit/></ui><value><text>start</text></value></field>
|
||||
<field name="Pick" id="floatPick" y="4.5in" w="2in" h="0.3in"><ui><choiceList/></ui><items><text>A</text><text>B</text></items><value><text>A</text></value></field>
|
||||
<field name="pickB" y="5in" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">Pick.rawValue = "B";</script></event></field>
|
||||
<field name="clearPick" y="5.5in" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">xfa.host.resetData("Pick");</script></event></field>
|
||||
<field name="share" y="1.5in" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">box.Note.rawValue = "Shared";</script></event></field>
|
||||
<field name="stamp" y="2in" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">front.Stamp.rawValue = "Paid";</script></event></field>
|
||||
<field name="clear" y="2.5in" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">xfa.host.resetData();</script></event></field>
|
||||
<draw y="3in" w="6in" h="1in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Note: <span xfa:embed="#floatNote"/><br/>Edit: <span xfa:embed="#floatEdit"/><br/>Pick: <span xfa:embed="#floatPick"/></p></body></exData></value></draw>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("follow.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
let text = |s: &Session| {
|
||||
page_texts(s, id).join(
|
||||
"
|
||||
",
|
||||
)
|
||||
};
|
||||
let click = |s: &mut Session, button: &str| {
|
||||
let o = s.run_javascript(id, "", Some(button)).expect("clicks");
|
||||
assert_eq!(o.error, None, "{button}: {o:?}");
|
||||
};
|
||||
assert!(text(&s).contains("Edit: start"), "{}", text(&s));
|
||||
click(&mut s, "share");
|
||||
assert!(text(&s).contains("Note: Shared"), "the field bound to the same data: {}", text(&s));
|
||||
click(&mut s, "stamp");
|
||||
assert!(text(&s).contains("Stamp: Paid"), "a page area's field: {}", text(&s));
|
||||
let edit = s.get(id).unwrap().form.iter().find(|f| f.name.ends_with("Edit")).map(|f| f.name.clone()).expect("the Edit field");
|
||||
s.apply(id, Edit::SetFieldValue { name: edit, value: FieldValue::Text("typed".into()) }).unwrap();
|
||||
assert!(text(&s).contains("Edit: typed"), "a typed value in a one-line field: {}", text(&s));
|
||||
click(&mut s, "pickB");
|
||||
assert!(text(&s).contains("Pick: B"), "{}", text(&s));
|
||||
click(&mut s, "clearPick");
|
||||
assert!(text(&s).contains("Pick: A"), "a reset drop-down list: {}", text(&s));
|
||||
let pick = s.get(id).unwrap().form.iter().find(|f| f.name.ends_with("Pick")).map(|f| f.name.clone()).expect("the Pick field");
|
||||
s.apply(id, Edit::SetFieldValue { name: pick, value: FieldValue::Choice(vec!["B".into()]) }).unwrap();
|
||||
assert!(text(&s).contains("Pick: B"), "a picked value: {}", text(&s));
|
||||
click(&mut s, "pickB");
|
||||
click(&mut s, "clear");
|
||||
let after = text(&s);
|
||||
assert!(after.contains("Edit: start") && !after.contains("Shared"), "reset: {after}");
|
||||
assert!(after.contains("Pick: A"), "a reset drop-down list: {after}");
|
||||
assert!(after.contains("Stamp:") && !after.contains("Paid"), "a reset page-area field with no widget: {after}");
|
||||
}
|
||||
|
||||
/// A reset gives a hidden floating field its default in every row of a repeating subform, not
|
||||
/// only the first, and each row's text shows it.
|
||||
#[test]
|
||||
fn a_reset_clears_a_floating_field_in_every_row() {
|
||||
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="row" layout="tb"><occur min="1" max="-1"/>
|
||||
<field name="amount" id="floatAmount" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui><value><text>none</text></value></field>
|
||||
<draw w="5in" h="0.4in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Amount: <span xfa:embed="#floatAmount"/></p></body></exData></value></draw>
|
||||
</subform>
|
||||
<field name="clear" w="2in" h="0.3in"><ui><button/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">xfa.host.resetData();</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><row><amount>10</amount></row><row><amount>20</amount></row></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
let text = page_texts(&s, id).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert!(text.contains("Amount: 10") && text.contains("Amount: 20"), "{text}");
|
||||
let o = s.run_javascript(id, "", Some("clear")).expect("clicks");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
let text = page_texts(&s, id).join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert_eq!(text.matches("Amount: none").count(), 2, "both rows reset: {text}");
|
||||
}
|
||||
|
||||
/// An XFA button's click script may show the attachments panel; a text field's exit script
|
||||
/// asking the same is noted on the console and ignored.
|
||||
#[test]
|
||||
@@ -11338,3 +11869,53 @@ fn imported_bookmarks_undo_in_one_step() {
|
||||
assert!(err.to_string().contains("no bookmark"), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §7.6.6, Table 25: a document whose only encryption is for its embedded files,
|
||||
/// authorised when one is opened (`/AuthEvent /EFOpen`), opens without a password, lists its
|
||||
/// attachment, and saves with the attachment still encrypted.
|
||||
#[test]
|
||||
fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password() {
|
||||
// The password values of an AES-256 protection, as a save writes them.
|
||||
let protected = protected("pw", "owner", -1);
|
||||
let source = pdfcraft_cos::Document::open_with_password(protected, Some("pw")).unwrap();
|
||||
let encrypt = source.resolve(source.trailer().get(b"Encrypt").unwrap());
|
||||
let values: String = ["O", "U", "OE", "UE", "Perms"]
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let value = source.resolve(encrypt.as_dict().unwrap().get(k.as_bytes()).unwrap());
|
||||
format!(" /{k} <{}>", value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect::<String>())
|
||||
})
|
||||
.collect();
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>".to_string(),
|
||||
"<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(),
|
||||
"<< /Type /EmbeddedFile /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream".to_string(),
|
||||
format!(
|
||||
"<< /Filter /Standard /V 5 /R 6{values} /P -4 /CF << /StdCF << /AuthEvent /EFOpen /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity /EFF /StdCF >>"
|
||||
),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes(),
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("efopen.pdf", None, Arc::new(pdf), None).expect("opens without a password");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
assert_eq!(doc.info.attachments.len(), 1);
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
|
||||
assert!(reopened.embedded_files_locked(), "the attachment stays encrypted");
|
||||
}
|
||||
|
||||
+194
-10
@@ -275,6 +275,9 @@ struct Runner<'a, 'b> {
|
||||
/// rebuilt when the structure changed).
|
||||
session: Option<XfaSession>,
|
||||
changes: Changes,
|
||||
/// A field embedded in rich text (a floating field) took a new value since the form was
|
||||
/// last laid out: the text that shows it is laid out again when the event ends.
|
||||
embedded_changed: bool,
|
||||
}
|
||||
|
||||
impl<'a, 'b> Runner<'a, 'b> {
|
||||
@@ -304,6 +307,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
cached: None,
|
||||
session: None,
|
||||
changes: Changes::default(),
|
||||
embedded_changed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +426,9 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn set_value(&mut self, som: &str, value: &str) -> bool {
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, som);
|
||||
// A floating field shows the value of its data node in the text that embeds it (XFA 3.3
|
||||
// pp. 221–222): a page area's, or one another field shares, as well.
|
||||
let embedded = pdfcraft_xfa::path_is_embedded(self.tpl, &path);
|
||||
let names = self.widgets_at(&path);
|
||||
// The data text: the widget at `som` says how its kind writes the value; without one,
|
||||
// the first widget bound to the node, else the value itself.
|
||||
@@ -471,6 +478,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
if changed {
|
||||
self.changes.values += 1;
|
||||
self.embedded_changed |= embedded;
|
||||
}
|
||||
changed
|
||||
}
|
||||
@@ -521,6 +529,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
self.relayouts += 1;
|
||||
self.flush()?;
|
||||
self.embedded_changed = false;
|
||||
let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?;
|
||||
self.console(report.warnings);
|
||||
crate::xfa_redraw(self.doc)?;
|
||||
@@ -541,13 +550,14 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
} else {
|
||||
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
|
||||
};
|
||||
if listed.as_ref().is_some_and(Vec::is_empty) {
|
||||
return Ok(());
|
||||
// Names no widget answers to may still name a floating field (below).
|
||||
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
|
||||
if widgets {
|
||||
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
|
||||
self.fields = None;
|
||||
let infos = self.fields().clone();
|
||||
for (som, name) in &by_som {
|
||||
for (som, name) in by_som.iter().filter(|_| widgets) {
|
||||
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
@@ -558,10 +568,28 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, som);
|
||||
let text = data_text(info);
|
||||
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
|
||||
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
self.changes.values += 1;
|
||||
}
|
||||
}
|
||||
// A floating field without a widget (a hidden one, as forms place them) goes back to
|
||||
// its default too, and the text that embeds it shows that.
|
||||
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
|
||||
if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) {
|
||||
continue;
|
||||
}
|
||||
// Every row that holds it, not only the first.
|
||||
let names: Vec<String> = pdfcraft_xfa::data_path(self.tpl, &som).into_iter().map(|(name, _)| name).collect();
|
||||
let paths = self.data.as_ref().map(|d| d.paths_named(&names, pdfcraft_xfa::MAX_FORM_NODES)).unwrap_or_default();
|
||||
for path in paths {
|
||||
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != default {
|
||||
self.embedded_changed = true;
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: default.clone() });
|
||||
self.changes.values += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.invalidate();
|
||||
Ok(())
|
||||
}
|
||||
@@ -719,7 +747,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn finish(mut self) -> Result<Changes, String> {
|
||||
#[cfg(test)]
|
||||
self.check_live();
|
||||
if self.relayouts_skipped {
|
||||
if self.relayouts_skipped || self.embedded_changed {
|
||||
self.relayouts = 0;
|
||||
self.relayouts_skipped = false;
|
||||
self.relayout()?;
|
||||
@@ -779,7 +807,9 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must
|
||||
/// Reflow a laid-out XFA form when an edited multiline field can grow, or an edited text field,
|
||||
/// choice list or check box is embedded in rich text (a floating field, whose value that text
|
||||
/// shows). The datasets must
|
||||
/// already hold the final values, including calculation results. Fixed-height fields keep
|
||||
/// their existing geometry; forms without scripts parse their template only when needed.
|
||||
/// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot.
|
||||
@@ -794,12 +824,14 @@ pub(crate) fn relayout_changed_text(
|
||||
let current = &*doc;
|
||||
let mut changed = before
|
||||
.iter()
|
||||
.filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE))
|
||||
.filter(|f| {
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
matches!(f.kind, K::Text | K::Combo | K::List | K::CheckBox)
|
||||
})
|
||||
.filter_map(|f| {
|
||||
let object = current.get(f.obj);
|
||||
let dict = object.as_dict()?;
|
||||
let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
|
||||
if f.value.first().map_or("", String::as_str) == value {
|
||||
if value_unchanged(current, f, dict.get(b"V")) {
|
||||
return None;
|
||||
}
|
||||
dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text()))
|
||||
@@ -812,7 +844,7 @@ pub(crate) fn relayout_changed_text(
|
||||
Some(tpl) => std::borrow::Cow::Borrowed(tpl),
|
||||
None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?),
|
||||
};
|
||||
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som));
|
||||
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som) || pdfcraft_xfa::field_is_embedded(&template, &som));
|
||||
drop(changed);
|
||||
if !reflow {
|
||||
return Ok(None);
|
||||
@@ -822,6 +854,60 @@ pub(crate) fn relayout_changed_text(
|
||||
Ok(Some(report.warnings))
|
||||
}
|
||||
|
||||
/// Whether field `f` still holds the value it had (`f.value`), its `/V` being `v`: a list box's
|
||||
/// picks are an array, a check box's state a name (Off for none, as the field's value has
|
||||
/// none), and of a text field only the first value counts. Compared in place, so an edit costs
|
||||
/// no copy of every field's value.
|
||||
fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: Option<&pdfcraft_cos::Object>) -> bool {
|
||||
use pdfcraft_cos::Object as O;
|
||||
let text = f.kind == pdfcraft_forms::FieldKind::Text;
|
||||
let first = f.value.first().map_or("", String::as_str);
|
||||
// The value is the one string `is` accepts.
|
||||
let one = |is: &dyn Fn(&str) -> bool| if text { is(first) } else { matches!(f.value.as_slice(), [old] if is(old)) };
|
||||
let none = if text { first.is_empty() } else { f.value.is_empty() };
|
||||
let held;
|
||||
let v = match v {
|
||||
Some(r @ O::Ref(_)) => {
|
||||
held = doc.resolve(r);
|
||||
Some(&*held)
|
||||
}
|
||||
v => v,
|
||||
};
|
||||
match v {
|
||||
Some(O::Array(items)) => {
|
||||
let mut old = f.value.iter();
|
||||
for item in items {
|
||||
let held_item;
|
||||
let item = match item {
|
||||
O::Ref(_) => {
|
||||
held_item = doc.resolve(item);
|
||||
&*held_item
|
||||
}
|
||||
i => i,
|
||||
};
|
||||
let Some(s) = item.as_string() else { continue };
|
||||
if text {
|
||||
return s.text_eq(first);
|
||||
}
|
||||
if !old.next().is_some_and(|o| s.text_eq(o)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if text { none } else { old.next().is_none() }
|
||||
}
|
||||
Some(O::Name(n)) if n.as_slice() == b"Off" => none,
|
||||
Some(O::Name(n)) => {
|
||||
let n = String::from_utf8_lossy(n);
|
||||
one(&|old| old == n)
|
||||
}
|
||||
Some(v) => match v.as_string() {
|
||||
Some(s) => one(&|old| s.text_eq(old)),
|
||||
None => none,
|
||||
},
|
||||
None => none,
|
||||
}
|
||||
}
|
||||
|
||||
/// On open: initialize scripts, then calculations. Returns what they changed.
|
||||
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
|
||||
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
|
||||
@@ -954,4 +1040,102 @@ mod tests {
|
||||
Some((2, "form[0].table[0]".into(), "row".into(), 2))
|
||||
);
|
||||
}
|
||||
|
||||
fn field(obj: pdfcraft_cos::ObjRef, kind: pdfcraft_forms::FieldKind, value: &[&str]) -> pdfcraft_forms::Field {
|
||||
pdfcraft_forms::Field {
|
||||
name: "f".into(),
|
||||
obj,
|
||||
kind,
|
||||
value: value.iter().map(|v| (*v).to_owned()).collect(),
|
||||
default: Vec::new(),
|
||||
flags: 0,
|
||||
max_len: None,
|
||||
options: Vec::new(),
|
||||
da: String::new(),
|
||||
quadding: 0,
|
||||
tooltip: None,
|
||||
widgets: Vec::new(),
|
||||
actions: pdfcraft_forms::af::Actions::default(),
|
||||
button: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field dictionary whose `/V` is `v`, with a SOM path.
|
||||
fn field_dict(doc: &mut pdfcraft_cos::Document, v: Option<pdfcraft_cos::Object>) -> pdfcraft_cos::ObjRef {
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
if let Some(v) = v {
|
||||
d.set(b"V".to_vec(), v);
|
||||
}
|
||||
d.set(pdfcraft_xfa::SOM_KEY.to_vec(), pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("form[0].f[0]")));
|
||||
doc.add(d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_values_are_found_in_place() {
|
||||
use pdfcraft_cos::{Object as O, PdfString};
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
let s = |t: &str| O::String(PdfString::text(t));
|
||||
let mut doc = pdfcraft_cos::Document::new_empty();
|
||||
let same = |doc: &mut pdfcraft_cos::Document, kind, old: &[&str], v: Option<O>| {
|
||||
let r = field_dict(doc, v);
|
||||
let dict = doc.get(r);
|
||||
value_unchanged(doc, &field(r, kind, old), dict.as_dict().and_then(|d| d.get(b"V")))
|
||||
};
|
||||
// Text: the first value only, an absent or non-text value being empty.
|
||||
assert!(same(&mut doc, K::Text, &["Café"], Some(s("Café"))));
|
||||
assert!(!same(&mut doc, K::Text, &["Café"], Some(s("Cafe"))));
|
||||
assert!(same(&mut doc, K::Text, &[], None));
|
||||
assert!(same(&mut doc, K::Text, &[], Some(s(""))));
|
||||
assert!(!same(&mut doc, K::Text, &["a"], None));
|
||||
assert!(same(&mut doc, K::Text, &["a", "b"], Some(O::Array(vec![s("a"), s("c")]))));
|
||||
// A value given by reference is followed.
|
||||
let inner = doc.add(s("日本"));
|
||||
assert!(same(&mut doc, K::Text, &["日本"], Some(O::Ref(inner))));
|
||||
assert!(!same(&mut doc, K::Combo, &["日"], Some(O::Ref(inner))));
|
||||
// Combo box: one value.
|
||||
assert!(same(&mut doc, K::Combo, &["Two"], Some(s("Two"))));
|
||||
assert!(!same(&mut doc, K::Combo, &["Two"], Some(s("Three"))));
|
||||
assert!(!same(&mut doc, K::Combo, &[], Some(s("Two"))));
|
||||
assert!(!same(&mut doc, K::Combo, &["Two"], None));
|
||||
// List box: every pick, in order, by count; non-strings in the array are skipped.
|
||||
let picks = |v: &[&str]| O::Array(v.iter().map(|t| s(t)).collect());
|
||||
assert!(same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "b"]))));
|
||||
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["b", "a"]))));
|
||||
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a"]))));
|
||||
assert!(!same(&mut doc, K::List, &["a"], Some(picks(&["a", "b"]))));
|
||||
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "c"]))));
|
||||
assert!(same(&mut doc, K::List, &[], Some(picks(&[]))));
|
||||
let item = doc.add(s("b"));
|
||||
assert!(same(&mut doc, K::List, &["a", "b"], Some(O::Array(vec![s("a"), O::Int(3), O::Ref(item)]))));
|
||||
// Check box: a state name, Off for none.
|
||||
assert!(same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Yes"))));
|
||||
assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Off"))));
|
||||
assert!(same(&mut doc, K::CheckBox, &[], Some(O::name("Off"))));
|
||||
assert!(!same(&mut doc, K::CheckBox, &[], Some(O::name("Yes"))));
|
||||
assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("On"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_values_return_before_the_template_is_read() {
|
||||
use pdfcraft_cos::{Object as O, PdfString};
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
// Laid out, but with no XFA packets: reading the template fails.
|
||||
let mut doc = pdfcraft_cos::Document::new_empty();
|
||||
let mut acro = pdfcraft_cos::Dict::new();
|
||||
acro.set(pdfcraft_xfa::LAYOUT_KEY.to_vec(), pdfcraft_cos::Dict::new());
|
||||
let acro = doc.add(acro);
|
||||
let root = doc.root().unwrap_or_else(|| panic!("no catalog"));
|
||||
let mut catalog = doc.get(root).as_dict().cloned().unwrap_or_default();
|
||||
catalog.set(b"AcroForm".to_vec(), O::Ref(acro));
|
||||
doc.set(root, catalog);
|
||||
assert!(pdfcraft_xfa::existing_layout(&doc).is_some());
|
||||
let text = field_dict(&mut doc, Some(O::String(PdfString::text("same"))));
|
||||
let list = field_dict(&mut doc, Some(O::Array(vec![O::String(PdfString::text("a")), O::String(PdfString::text("b"))])));
|
||||
let check = field_dict(&mut doc, Some(O::name("Yes")));
|
||||
let before = [field(text, K::Text, &["same"]), field(list, K::List, &["a", "b"]), field(check, K::CheckBox, &["Yes"])];
|
||||
assert_eq!(relayout_changed_text(&mut doc, None, &before), Ok(None));
|
||||
// A changed pick reaches the template, which this document doesn't have.
|
||||
let before = [field(text, K::Text, &["same"]), field(list, K::List, &["b", "a"]), field(check, K::CheckBox, &["Yes"])];
|
||||
assert!(relayout_changed_text(&mut doc, None, &before).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,20 +60,23 @@ pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close:
|
||||
pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String {
|
||||
let mut merged: Vec<(String, bool, bool)> = Vec::new();
|
||||
for &(text, bold, italic) in runs {
|
||||
let text = md_text(text);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match merged.last_mut() {
|
||||
Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text),
|
||||
_ => merged.push((text.to_string(), bold, italic)),
|
||||
Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(&text),
|
||||
_ => merged.push((text, bold, italic)),
|
||||
}
|
||||
}
|
||||
let texts: Vec<String> = merged.iter().map(|(text, ..)| md_text(text)).collect();
|
||||
let mut out = String::new();
|
||||
for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() {
|
||||
for (k, (text, bold, italic)) in merged.iter().enumerate() {
|
||||
let core = text.trim();
|
||||
let open = marks(core.chars().next(), out.chars().last());
|
||||
// Markers of a styled neighbour right next to these would join them into one run.
|
||||
let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) {
|
||||
(Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false,
|
||||
(_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())),
|
||||
let close = match merged.get(k.saturating_add(1)) {
|
||||
Some((next, b, i)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false,
|
||||
next => marks(core.chars().last(), next.and_then(|(n, ..)| n.chars().next())),
|
||||
};
|
||||
out.push_str(&emphasis(text, *bold, *italic, open, close));
|
||||
}
|
||||
@@ -149,3 +152,27 @@ pub fn markdown(pages: &[Page]) -> String {
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::paragraph;
|
||||
|
||||
#[test]
|
||||
fn empty_or_control_run_does_not_split_styled_runs() {
|
||||
assert_eq!(paragraph(&[("hello", true, false), ("", false, false), ("world", true, false)]), "**helloworld**");
|
||||
assert_eq!(paragraph(&[("hello", true, false), ("\u{1}", false, false), ("world", true, false)]), "**helloworld**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_styled_run_does_not_affect_neighbor_emphasis() {
|
||||
assert_eq!(paragraph(&[("x", true, false), ("", false, true)]), "**x**");
|
||||
assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("", true, true), ("bar", false, false)]), "foo<strong>.</strong>bar");
|
||||
// Empty/control runs between differently styled runs still look ahead to the next non-empty run.
|
||||
assert_eq!(paragraph(&[("bold", true, false), ("", false, false), ("italic", false, true)]), "<strong>bold</strong>*italic*");
|
||||
assert_eq!(paragraph(&[("bold", true, false), ("\u{1}", false, false), (" italic", false, true)]), "**bold** *italic*");
|
||||
// Leading empty/control runs do not affect the opening delimiter.
|
||||
assert_eq!(paragraph(&[("", false, true), ("\u{1}", true, false), ("lead", true, false)]), "**lead**");
|
||||
// All-empty/control paragraphs produce empty output.
|
||||
assert_eq!(paragraph(&[("", true, false), ("\u{1}", false, true), ("\u{7f}", false, false)]), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Text that runs on from one line to the next.
|
||||
//!
|
||||
//! The reading blocks keep style boundaries (#4158), so a sentence with a bold name or an italic
|
||||
//! word in it reaches the exporter as several blocks, and `layout::paragraphs` joins only the ones
|
||||
//! that run on along one line. A sentence that wraps onto the next line after
|
||||
//! such a run came out as one paragraph per line. [`join_wrapped`] joins a paragraph whose last
|
||||
//! line reaches the right edge of the page's text with the one that starts just below it at the
|
||||
//! same left edge, in the same size: the text wrapped there. Headings, list items, a line that
|
||||
//! ends a sentence and a line with paragraph space after it stay apart.
|
||||
//!
|
||||
//! A list item's bullet is often a block of its own: drawn in a symbol font, a tab stop before
|
||||
//! the item's text. Table detection read such a list as a two-column table, bullets in one
|
||||
//! column and the items in the other. [`bullet_items`] pairs each bullet with its text, so the
|
||||
//! text runs on from its bullet and the bullets stay out of table detection.
|
||||
|
||||
use super::layout::{Block, Run};
|
||||
|
||||
/// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets
|
||||
/// Word writes into its PDFs (U+F0B7, U+F0A7).
|
||||
const BULLETS: [char; 11] = ['•', '◦', '▪', '‣', '⁃', '●', '○', '■', '□', '\u{F0B7}', '\u{F0A7}'];
|
||||
|
||||
/// Dashes and a middle dot: list markers at the start of a line, punctuation inside one.
|
||||
const DASHES: [char; 4] = ['-', '\u{2013}', '\u{2014}', '·'];
|
||||
|
||||
/// A run taller than this many times its font size is more than one line: a block of several
|
||||
/// rows ends where its widest row does, not where its last one does.
|
||||
const ONE_LINE: f64 = 1.6;
|
||||
|
||||
/// How far (as a multiple of the font size) the space between two lines may be from none, and
|
||||
/// they still follow each other within a paragraph: more is paragraph spacing.
|
||||
const LINE_GAP: f64 = 0.5;
|
||||
|
||||
/// How close to the right edge of the page's text a full line ends: within this many times the
|
||||
/// font size, or this fraction of the text's width, whichever is more (a ragged line ends short
|
||||
/// of the edge by up to a long word).
|
||||
const FULL_LINE: (f64, f64) = (3.0, 0.12);
|
||||
|
||||
/// How far (points) the first line of a paragraph may start from the left edge of the line above
|
||||
/// it and still be at the same left edge.
|
||||
const SAME_LEFT: f64 = 2.0;
|
||||
|
||||
/// How far after its bullet (as a multiple of the bullet's size) a list item's text may start: a
|
||||
/// tab stop away.
|
||||
const BULLET_GAP: f64 = 3.0;
|
||||
|
||||
/// How many blocks [`bullet_items`] may look at on one page, so that a page of many bullets stays
|
||||
/// quick; past it, the bullets left are not taken for bullets.
|
||||
const BULLET_BUDGET: usize = 1_000_000;
|
||||
|
||||
/// Takes a step from `budget`: whether there was one left.
|
||||
fn step(budget: &mut usize) -> bool {
|
||||
budget.checked_sub(1).map(|left| *budget = left).is_some()
|
||||
}
|
||||
|
||||
/// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half
|
||||
/// the shorter one, as `layout::continued_from` has it.
|
||||
fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1])
|
||||
}
|
||||
|
||||
/// Whether a block is a bullet drawn on its own: one bullet, dash or middle dot, no wider than one
|
||||
/// and a half times its size.
|
||||
fn is_bullet(b: &Block) -> bool {
|
||||
let mut chars = b.text.trim().chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else { return false };
|
||||
(BULLETS.contains(&c) || DASHES.contains(&c)) && b.rect[2] - b.rect[0] <= 1.5 * b.size
|
||||
}
|
||||
|
||||
/// Whether no other block on `blocks[i]`'s line starts left of it. Each block looked at takes a
|
||||
/// step from `budget`; when the budget runs out the answer is no.
|
||||
fn opens_line(i: usize, blocks: &[Block], budget: &mut usize) -> bool {
|
||||
let Some(a) = blocks.get(i) else { return false };
|
||||
for (j, b) in blocks.iter().enumerate() {
|
||||
if !step(budget) {
|
||||
return false;
|
||||
}
|
||||
if j != i && b.rect[0] < a.rect[0] && on_one_line(a.rect, b.rect) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The list items of a page whose bullet is a block of its own, as (bullet, text): the text is the
|
||||
/// block that starts nearest after the bullet on its line, within [`BULLET_GAP`] times its size.
|
||||
/// A dash or a middle dot is a bullet only when it opens its line; inside a line it is
|
||||
/// punctuation, or a table cell's "none". The items' texts of a list all start at one tab stop,
|
||||
/// so the search looks through every block that starts there, within [`BULLET_BUDGET`].
|
||||
pub(crate) fn bullet_items(blocks: &[Block]) -> Vec<(usize, usize)> {
|
||||
let mut by_start: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[0], i)).collect();
|
||||
by_start.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut budget = BULLET_BUDGET;
|
||||
let mut out = Vec::new();
|
||||
for (ai, a) in blocks.iter().enumerate().filter(|(_, b)| is_bullet(b)) {
|
||||
// The text may start a point inside the bullet's box, as a run that continues another can.
|
||||
let k = by_start.partition_point(|(x, _)| *x < a.rect[2] - 1.0);
|
||||
let text = by_start
|
||||
.get(k..)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take_while(|(x, _)| *x <= a.rect[2] + BULLET_GAP * a.size)
|
||||
.take_while(|_| step(&mut budget))
|
||||
.filter(|(_, bi)| *bi != ai)
|
||||
.filter_map(|(_, bi)| blocks.get(*bi).map(|b| (*bi, b)))
|
||||
.find(|(_, b)| on_one_line(a.rect, b.rect) && (a.rotation - b.rotation).abs() <= 0.1 && !is_bullet(b))
|
||||
.map(|(bi, _)| bi);
|
||||
let Some(text) = text else { continue };
|
||||
let dash = a.text.trim().chars().next().is_some_and(|c| DASHES.contains(&c));
|
||||
if !dash || opens_line(ai, blocks, &mut budget) {
|
||||
out.push((ai, text));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed
|
||||
/// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg",
|
||||
/// "i.e." and "2026. Year" are text.
|
||||
pub(crate) fn list_item(text: &str) -> bool {
|
||||
let t = text.trim_start();
|
||||
let Some(first) = t.chars().next() else { return false };
|
||||
if BULLETS.contains(&first) || DASHES.contains(&first) || first == '*' {
|
||||
return true;
|
||||
}
|
||||
let digits = t.chars().take_while(char::is_ascii_digit).count();
|
||||
// ASCII digits are one byte each and `first` is one whole character, so both cuts fall on
|
||||
// character boundaries.
|
||||
let (rest, marks): (&str, &[char]) = if (1..=3).contains(&digits) {
|
||||
(t.get(digits..).unwrap_or_default(), &['.', ')'])
|
||||
} else if digits == 0 && first.is_alphabetic() {
|
||||
(t.get(first.len_utf8()..).unwrap_or_default(), &[')'])
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let mut after = rest.chars();
|
||||
after.next().is_some_and(|c| marks.contains(&c)) && after.next().is_none_or(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// Whether a line's text ends a sentence (closing quotes and brackets aside). Text set without
|
||||
/// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left
|
||||
/// apart from the next rather than joined to it wrongly.
|
||||
fn ends_sentence(text: &str) -> bool {
|
||||
text.trim_end()
|
||||
.trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']', '」', '』'])
|
||||
.ends_with(['.', '!', '?', ':', ';', '…', '。', '!', '?'])
|
||||
}
|
||||
|
||||
/// Whether `c` is of the writing that puts no space between words: Chinese and Japanese
|
||||
/// characters, their punctuation and fullwidth forms. A line that wraps between two of them
|
||||
/// joins without a word space.
|
||||
fn unspaced(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{3000}'..='\u{30FF}' // CJK punctuation, Hiragana, Katakana
|
||||
| '\u{31F0}'..='\u{31FF}' // Katakana extension
|
||||
| '\u{3400}'..='\u{4DBF}' // CJK extension A
|
||||
| '\u{4E00}'..='\u{9FFF}' // CJK unified ideographs
|
||||
| '\u{F900}'..='\u{FAFF}' // CJK compatibility ideographs
|
||||
| '\u{FF01}'..='\u{FF9F}' // fullwidth forms, halfwidth Katakana
|
||||
| '\u{20000}'..='\u{3FFFF}' // CJK extension B onwards
|
||||
)
|
||||
}
|
||||
|
||||
/// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto
|
||||
/// the next line joined to it. `level` is a paragraph's heading level (0 for body text).
|
||||
pub(crate) fn join_wrapped<'a>(mut paras: Vec<Vec<Run<'a>>>, level: impl Fn(&[Run<'a>]) -> u8) -> Vec<Vec<Run<'a>>> {
|
||||
// The left and right edges of the page's text: where a full line starts and ends.
|
||||
let (left, right) = paras
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|r| r.block.rect)
|
||||
.filter(|r| r[0].is_finite() && r[2].is_finite())
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x[0]), r.max(x[2])));
|
||||
if right <= left {
|
||||
return paras;
|
||||
}
|
||||
// Top to bottom, then left to right, by each paragraph's first run.
|
||||
paras.sort_by(|a, b| {
|
||||
let top_left = |p: &[Run]| p.first().map_or([0.0; 2], |r| [r.block.rect[3], r.block.rect[0]]);
|
||||
let (a, b) = (top_left(a), top_left(b));
|
||||
b[0].total_cmp(&a[0]).then(a[1].total_cmp(&b[1]))
|
||||
});
|
||||
// Each paragraph with its heading level. A joined paragraph is body text: only body text joins.
|
||||
let mut out: Vec<(Vec<Run<'a>>, u8)> = Vec::with_capacity(paras.len());
|
||||
for para in paras {
|
||||
let lvl = level(¶);
|
||||
match out.last_mut() {
|
||||
Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => {
|
||||
// The line break was a word space, unless the line ends in a hyphen; the hyphen
|
||||
// stays, as it may belong to the word ("well-known"). Chinese and Japanese text
|
||||
// wraps between characters, with no space.
|
||||
let unspaced_break = |end: &str| {
|
||||
end.chars().next_back().is_some_and(unspaced) && para.first().and_then(|r| r.text.chars().next()).is_some_and(unspaced)
|
||||
};
|
||||
if let Some(end) = prev.last_mut()
|
||||
&& !end.text.ends_with(char::is_whitespace)
|
||||
&& !end.text.ends_with('-')
|
||||
&& !unspaced_break(&end.text)
|
||||
{
|
||||
end.text.push(' ');
|
||||
}
|
||||
prev.extend(para);
|
||||
}
|
||||
_ => out.push((para, lvl)),
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(para, _)| para).collect()
|
||||
}
|
||||
|
||||
/// Whether the body text paragraph `prev` ends in a full line whose text runs on in `next`, the
|
||||
/// body text paragraph just below it. `[left, right]` are the edges of the page's text.
|
||||
fn wraps_into(prev: &[Run], next: &[Run], [left, right]: [f64; 2]) -> bool {
|
||||
let (Some(last), Some(first)) = (prev.last(), next.first()) else { return false };
|
||||
let (a, b) = (last.block, first.block);
|
||||
let size = a.size;
|
||||
let alike = size.is_finite() && size > 0.0 && (a.size - b.size).abs() <= 0.5 && a.rotation.abs() <= 0.1 && b.rotation.abs() <= 0.1;
|
||||
if !alike || a.rect[3] - a.rect[1] > ONE_LINE * size || list_item(&first.text) || ends_sentence(&last.text) {
|
||||
return false;
|
||||
}
|
||||
// The last line: the runs at the end of `prev` that share the last run's line.
|
||||
let (line_left, line_right) = prev
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|r| on_one_line(r.block.rect, a.rect))
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x.block.rect[0]), r.max(x.block.rect[2])));
|
||||
let full = line_right >= right - (FULL_LINE.0 * size).max(FULL_LINE.1 * (right - left));
|
||||
let same_left = (b.rect[0] - line_left).abs() <= SAME_LEFT;
|
||||
// Just below: no paragraph space between the two lines.
|
||||
let gap = a.rect[1] - b.rect[3];
|
||||
full && same_left && (-LINE_GAP * size..=LINE_GAP * size).contains(&gap)
|
||||
}
|
||||
+43
-10
@@ -145,19 +145,31 @@ const INLINE_GAP: f64 = 0.5;
|
||||
/// page laid out so that many end at the same place stays quick.
|
||||
const CONTINUATION_PROBE: usize = 64;
|
||||
|
||||
/// How far (as a fraction of the font size) the block before may end past where a block starts
|
||||
/// and still be the one it continues: a slanted italic's box leans into the next word by about a
|
||||
/// quarter of its size.
|
||||
const OVERHANG: f64 = 0.3;
|
||||
|
||||
/// [`OVERHANG`] in points for a block of `size`: at least a point (rounding), at most six.
|
||||
fn overhang(size: f64) -> f64 {
|
||||
if size.is_finite() { (OVERHANG * size).clamp(1.0, 6.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Per block, whether it continues another on the same line: it starts where that one ends (up to
|
||||
/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running
|
||||
/// on (a link inside a sentence, a superscript), not a cell of its own.
|
||||
/// [`INLINE_GAP`] later, or up to [`overhang`] earlier, as a slanted italic leans into the word
|
||||
/// after it), so it is the same line of text running on (a link inside a sentence, a
|
||||
/// superscript), not a cell of its own.
|
||||
fn continuations(blocks: &[Block]) -> Vec<bool> {
|
||||
continued_from(blocks).into_iter().map(|from| from.is_some()).collect()
|
||||
}
|
||||
|
||||
/// Per block, the block it continues on the same line (see [`continuations`]): of those it could
|
||||
/// continue, the one that ends nearest to where it starts.
|
||||
/// continue, the one that ends nearest to where it starts. A list item's text continues its
|
||||
/// bullet, a tab stop before it (`flow::bullet_items`).
|
||||
fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
|
||||
let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect();
|
||||
by_end.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
blocks
|
||||
let mut from: Vec<Option<usize>> = blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(bi, b)| {
|
||||
@@ -166,14 +178,20 @@ fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
|
||||
.get(k..)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take_while(|(end, _)| *end <= b.rect[0] + 1.0)
|
||||
.take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size))
|
||||
.take(CONTINUATION_PROBE)
|
||||
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a)))
|
||||
.filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
|
||||
.min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs()))
|
||||
.map(|(ai, _)| ai)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
for (bullet, text) in super::flow::bullet_items(blocks) {
|
||||
if let Some(slot @ None) = from.get_mut(text) {
|
||||
*slot = Some(bullet);
|
||||
}
|
||||
}
|
||||
from
|
||||
}
|
||||
|
||||
/// A gap between two runs wider than this (as a fraction of the font size) is a word space the
|
||||
@@ -241,6 +259,12 @@ pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec<Vec<Run<
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`paragraphs`] as the flowing writers lay them out, top to bottom: a paragraph that wraps onto
|
||||
/// the next line is joined to it (`flow::join_wrapped`). `body` is the body text size.
|
||||
pub(crate) fn flowing_paragraphs<'a>(blocks: &'a [Block], skip: &[bool], body: f64) -> Vec<Vec<Run<'a>>> {
|
||||
super::flow::join_wrapped(paragraphs(blocks, skip), |runs| para_level(runs, body))
|
||||
}
|
||||
|
||||
/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold
|
||||
/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run
|
||||
/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker.
|
||||
@@ -355,8 +379,16 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64
|
||||
pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<bool>) {
|
||||
let mut budget = RULE_BUDGET;
|
||||
let mut consumed = vec![false; blocks.len()];
|
||||
// A list's bullets are not cells: beside their items' text they read as a two-column table.
|
||||
let mut bullet = vec![false; blocks.len()];
|
||||
for (i, _) in super::flow::bullet_items(blocks) {
|
||||
if let Some(b) = bullet.get_mut(i) {
|
||||
*b = true;
|
||||
}
|
||||
}
|
||||
let cell_like = |i: usize, b: &Block| is_cell_like(b) && !bullet.get(i).copied().unwrap_or(false);
|
||||
// Candidate columns: cluster the left edges of cell-like blocks.
|
||||
let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect();
|
||||
let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, b)| cell_like(*i, b)).map(|(i, b)| (b.rect[0], i)).collect();
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut cols: Vec<(f64, Vec<usize>)> = Vec::new(); // (left edge, member block indices)
|
||||
for (x, i) in xs {
|
||||
@@ -392,7 +424,7 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<b
|
||||
order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0])));
|
||||
for &bi in &order {
|
||||
let b = &blocks[bi];
|
||||
if !is_cell_like(b) {
|
||||
if !cell_like(bi, b) {
|
||||
continue;
|
||||
}
|
||||
let Some(ci) = column_at(b.rect[0]) else { continue };
|
||||
@@ -558,8 +590,9 @@ pub(crate) fn items(pages: &[Page]) -> Vec<Item<'_>> {
|
||||
}
|
||||
let (tables, consumed) = tables(&p.blocks, &p.rules);
|
||||
// Blocks and images by their top edge, top to bottom.
|
||||
// A paragraph sits where its first run does: its other runs follow along the same line.
|
||||
let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed)
|
||||
// A paragraph sits where its first run does: its other runs follow along its line and the
|
||||
// lines it wraps onto.
|
||||
let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body)
|
||||
.into_iter()
|
||||
.filter_map(|runs| {
|
||||
let first = runs.first()?.block;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
|
||||
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
|
||||
//! document: headings are the paragraphs set larger than the body text, and a block that
|
||||
//! continues another on its line (an italic word, a link) is a run of that block's paragraph.
|
||||
//! Word keeps pages with
|
||||
//! document: headings are the paragraphs set larger than the body text; a block that
|
||||
//! continues another on its line (an italic word, a link) is a run of that block's paragraph, and
|
||||
//! a paragraph that wraps onto the next line runs on there. Word keeps pages with
|
||||
//! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable
|
||||
//! shapes to avoid reflowing overlays; other pages flow with images inline.
|
||||
|
||||
@@ -21,6 +21,7 @@ mod as_html;
|
||||
mod as_markdown;
|
||||
mod as_rtf;
|
||||
mod clip;
|
||||
mod flow;
|
||||
mod layout;
|
||||
|
||||
pub(crate) use as_docx::hex;
|
||||
@@ -36,10 +37,12 @@ pub use as_markdown::markdown;
|
||||
use as_markdown::{emphasis, paragraph};
|
||||
pub use as_rtf::rtf;
|
||||
pub use clip::{ClipBuilder, ClipSpan, ClipStyle, ClipText, MAX_CLIP_HTML_BYTES, MAX_CLIP_SPANS, MAX_CLIP_TEXT_BYTES, clip_html, font_family};
|
||||
#[cfg(test)]
|
||||
use flow::list_item;
|
||||
pub use layout::{Block, Cell, Image, Page, Table};
|
||||
#[cfg(test)]
|
||||
use layout::{LOOKUP_STEPS, MAX_COLS, body_size};
|
||||
pub(crate) use layout::{Run, paragraphs, tables};
|
||||
pub(crate) use layout::{Run, flowing_paragraphs, tables};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -125,6 +125,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
|
||||
let mut global_media: Vec<(String, &Image)> = Vec::new();
|
||||
let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels)
|
||||
let body = crate::layout::body_size(pages);
|
||||
|
||||
for p in pages {
|
||||
let (tables, consumed) = crate::tables(&p.blocks, &p.rules);
|
||||
@@ -139,7 +140,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new();
|
||||
// The title is a shape of its own, so it joins no paragraph.
|
||||
let skip: Vec<bool> = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect();
|
||||
for runs in crate::paragraphs(&p.blocks, &skip) {
|
||||
for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs)));
|
||||
}
|
||||
|
||||
@@ -983,3 +983,283 @@ fn a_ruled_table_gets_outer_borders_only_where_the_page_draws_them() {
|
||||
ruled_top.push([0.0, 0.0, 320.0, 0.0]);
|
||||
assert_eq!(tables(&blocks, &ruled_top).0[0].ruled, Some([true, false, false, false]), "only the top line near the table frames it");
|
||||
}
|
||||
|
||||
/// A line of 10 pt text from `x0` to `x1` whose box starts at `y0`: 12 pt tall, so lines 14 pt
|
||||
/// apart leave 2 pt between their boxes.
|
||||
fn line(t: &str, x0: f64, x1: f64, y0: f64) -> Block {
|
||||
Block { size: 10.0, ..block(t, x0, y0, x1, y0 + 12.0) }
|
||||
}
|
||||
|
||||
/// The text of each flowing paragraph Word gets for a page of `blocks`.
|
||||
fn word_texts(blocks: Vec<Block>) -> Vec<String> {
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
|
||||
let xml = part(&docx(&[p], "Lines"), "word/document.xml");
|
||||
word_paragraphs(&xml).into_iter().map(|runs| runs.into_iter().map(|(t, _)| t).collect()).collect()
|
||||
}
|
||||
|
||||
/// A sentence over three lines, as the reading blocks give it: split at each style change, a
|
||||
/// bold name in the first line and a slanted name in the second whose box leans 2.5 pt into the
|
||||
/// comma after it (#4158). The next paragraph follows after paragraph space.
|
||||
fn wrapped_sentence() -> Vec<Block> {
|
||||
vec![
|
||||
line("The parties to this lease are the owner,", 72.0, 262.0, 700.0),
|
||||
Block { bold: true, ..line("Harbour Lane Properties", 265.0, 400.0, 700.0) },
|
||||
line(", of 4 Quay Street, and the", 400.5, 540.0, 700.0),
|
||||
line("tenant named in the schedule,", 72.0, 215.0, 686.0),
|
||||
Block { italic: true, ..line("Blue Finch Studio", 218.0, 312.0, 686.0) },
|
||||
line(", who agree as follows to rent the upper", 309.5, 538.0, 686.0),
|
||||
line("rooms for the whole of the term.", 72.0, 230.0, 672.0),
|
||||
line("Rent is paid monthly in advance.", 72.0, 230.0, 640.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// #4158: a sentence that wraps onto the next line after a change of style is one paragraph in
|
||||
/// every format, its lines joined by a word space.
|
||||
#[test]
|
||||
fn a_sentence_that_wraps_after_a_style_change_is_one_paragraph() {
|
||||
let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \
|
||||
schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term.";
|
||||
assert_eq!(word_texts(wrapped_sentence()), [sentence, "Rent is paid monthly in advance."]);
|
||||
let p = Page { width: 612.0, height: 792.0, blocks: wrapped_sentence(), ..Page::default() };
|
||||
let h = html(std::slice::from_ref(&p), "Lease");
|
||||
assert_eq!(h.matches("<p>").count(), 2, "{h}");
|
||||
assert!(
|
||||
h.contains(
|
||||
"<p>The parties to this lease are the owner, <strong>Harbour Lane Properties</strong>, of 4 Quay Street, and the tenant \
|
||||
named in the schedule, <em>Blue Finch Studio</em>, who agree as follows to rent the upper rooms for the whole of the term.</p>"
|
||||
),
|
||||
"{h}"
|
||||
);
|
||||
let r = rtf(std::slice::from_ref(&p));
|
||||
assert_eq!(r.matches("{\\pard").count(), 2, "{r}");
|
||||
let md = markdown(std::slice::from_ref(&p));
|
||||
assert_eq!(md.trim().split("\n\n").count(), 2, "{md}");
|
||||
assert!(md.contains("of 4 Quay Street, and the tenant named in the schedule, "), "{md}");
|
||||
// On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph.
|
||||
let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml");
|
||||
let slide: Vec<String> =
|
||||
s.split("<a:p>").skip(1).map(|p| p.split("<a:t>").skip(1).filter_map(|t| t.split_once("</a:t>").map(|(t, _)| t)).collect()).collect();
|
||||
assert!(slide.iter().any(|p| p == sentence), "{s}");
|
||||
}
|
||||
|
||||
/// A slanted word's box leans into the word after it by about a quarter of its size, so that
|
||||
/// word still runs on from it; text that starts further back is drawn over the text before it,
|
||||
/// not after it.
|
||||
#[test]
|
||||
fn a_slanted_word_that_leans_into_the_next_stays_in_its_line() {
|
||||
let leaning = vec![
|
||||
line("named in the schedule,", 72.0, 180.0, 700.0),
|
||||
Block { italic: true, ..line("Blue Finch Studio", 183.0, 276.0, 700.0) },
|
||||
line(", who agree", 273.5, 330.0, 700.0),
|
||||
];
|
||||
assert_eq!(word_texts(leaning), ["named in the schedule, Blue Finch Studio, who agree"]);
|
||||
let over = vec![line("A label", 72.0, 120.0, 700.0), line("over another", 115.0, 180.0, 700.0)];
|
||||
assert_eq!(word_texts(over), ["A label", "over another"]);
|
||||
}
|
||||
|
||||
/// A line runs on into the next only when it reaches the right edge of the text and the next
|
||||
/// starts just below at the same left edge, in the same size. A line that ends a sentence or ends
|
||||
/// short, and a next line that is a heading, a list item, indented or after paragraph space, keep
|
||||
/// their paragraph break. Each case is a page of its own.
|
||||
#[test]
|
||||
fn a_paragraph_breaks_where_its_line_does_not_run_on() {
|
||||
let full = "The tenant keeps the rooms, the stairs and the shared yard clean and in good repair";
|
||||
let next = "throughout the term of the lease";
|
||||
let first = |t: &str| line(t, 72.0, 540.0, 700.0);
|
||||
let second = |t: &str| line(t, 72.0, 240.0, 686.0);
|
||||
assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]);
|
||||
assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1);
|
||||
// Chinese and Japanese wrap between characters: their lines join without a space. Korean
|
||||
// spaces its words, so its lines join with one.
|
||||
let zh = ("本合同自双方签字之日起生效,有效期", "为一年,期满前一个月双方协商续签事宜");
|
||||
assert_eq!(word_texts(vec![first(zh.0), second(zh.1)]), [format!("{}{}", zh.0, zh.1)]);
|
||||
assert_eq!(
|
||||
word_texts(vec![first("この契約は双方が署名した日から"), second("一年間有効とする")]),
|
||||
["この契約は双方が署名した日から一年間有効とする"]
|
||||
);
|
||||
assert_eq!(word_texts(vec![first("임차인은 방과 계단을 깨끗하게"), second("유지합니다")]), ["임차인은 방과 계단을 깨끗하게 유지합니다"]);
|
||||
let heading = |b: Block| Block { bold: true, ..b };
|
||||
for (why, blocks) in [
|
||||
("a sentence ends", vec![first(&format!("{full}.")), second(next)]),
|
||||
("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]),
|
||||
("a Chinese sentence ends", vec![first("本合同自双方签字之日起生效。"), second(zh.1)]),
|
||||
("a Japanese sentence ends in quotes", vec![first("「この契約は一年間有効ですか?」"), second("双方が署名した日から")]),
|
||||
("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]),
|
||||
("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]),
|
||||
("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]),
|
||||
("a larger size", vec![first(full), Block { size: 12.0, ..second(next) }]),
|
||||
("a numbered item", vec![first(full), second("2. The tenant pays the rent")]),
|
||||
("a lettered item", vec![first(full), second("а) поддръжка на сървърите")]),
|
||||
("a bulleted item", vec![first(full), second("• the stairs")]),
|
||||
("a heading below", vec![first(full), heading(second("Rent and deposit"))]),
|
||||
("a heading above", vec![heading(first("Obligations of the tenant")), second(next)]),
|
||||
] {
|
||||
assert_eq!(word_texts(blocks).len(), 2, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two columns of text, the right one set half a line lower: a full line in one column never runs
|
||||
/// on into the line of the other just below it, though the right column's lines reach the page's
|
||||
/// right edge and the left column's start at its left.
|
||||
#[test]
|
||||
fn a_line_never_runs_on_into_the_other_column() {
|
||||
let left = ["The tenant keeps the rooms and the", "stairs clean and in good repair for", "the whole of the term of the lease"];
|
||||
let right = ["The landlord keeps the roof and the", "outer walls sound and dry for the", "whole of the term of the lease too"];
|
||||
let mut blocks = Vec::new();
|
||||
for (n, (l, r)) in left.iter().zip(right).enumerate() {
|
||||
let y = 700.0 - 14.0 * n as f64;
|
||||
blocks.push(line(l, 72.0, 290.0, y));
|
||||
blocks.push(line(r, 322.0, 540.0, y - 7.0));
|
||||
}
|
||||
let paragraphs = word_texts(blocks);
|
||||
for line in left.iter().chain(&right) {
|
||||
assert!(paragraphs.iter().any(|p| p.contains(line)), "{line}: {paragraphs:?}");
|
||||
}
|
||||
for p in ¶graphs {
|
||||
assert!(!(left.iter().any(|l| p.contains(l)) && right.iter().any(|r| p.contains(r))), "{p:?} mixes the columns");
|
||||
}
|
||||
}
|
||||
|
||||
/// A poem quoted between paragraphs keeps its lines: they stop well short of the right edge the
|
||||
/// prose around it reaches, so none of them is a full line that wraps.
|
||||
#[test]
|
||||
fn a_poem_keeps_its_lines() {
|
||||
let poem = [
|
||||
"The kettle sings beside the door",
|
||||
"the cat is curled upon the chair",
|
||||
"and rain runs down the window glass",
|
||||
"while evening settles on the stair",
|
||||
];
|
||||
let prose = "The tenant may read aloud in the shared garden on summer evenings, as in the verse below";
|
||||
let mut blocks = vec![line(prose, 72.0, 540.0, 720.0)];
|
||||
for (n, (t, end)) in poem.iter().zip([236.0, 230.0, 248.0, 232.0]).enumerate() {
|
||||
blocks.push(line(t, 90.0, end, 696.0 - 14.0 * n as f64));
|
||||
}
|
||||
let mut expected = vec![prose];
|
||||
expected.extend(poem);
|
||||
assert_eq!(word_texts(blocks), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_items_are_told_from_text() {
|
||||
for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] {
|
||||
assert!(list_item(item), "{item}");
|
||||
}
|
||||
for text in ["1.5 kg of", "2026. Year", "i.e. this", "т.е. така", "38 rooms", "Rent 12)", "1234) many", ""] {
|
||||
assert!(!list_item(text), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapped lines join on hostile boxes and sizes without a panic, and never lose a line's text.
|
||||
#[test]
|
||||
fn joining_lines_survives_hostile_boxes() {
|
||||
let mut blocks = Vec::new();
|
||||
for (i, v) in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e300].into_iter().enumerate() {
|
||||
let y = 700.0 - 14.0 * i as f64;
|
||||
blocks.push(Block { size: v, ..line(&format!("size {i}"), 72.0, 540.0, y) });
|
||||
blocks.push(Block { rect: [v, y, 540.0, y + 12.0], ..line(&format!("left {i}"), 72.0, 540.0, y) });
|
||||
blocks.push(Block { rect: [72.0, v, v, y + 12.0], ..line(&format!("box {i}"), 72.0, 540.0, y) });
|
||||
blocks.push(Block { rotation: v, ..line(&format!("turn {i}"), 72.0, 540.0, y) });
|
||||
}
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
|
||||
let outputs = [
|
||||
html(std::slice::from_ref(&p), "Hostile"),
|
||||
rtf(std::slice::from_ref(&p)),
|
||||
part(&pptx(std::slice::from_ref(&p), "Hostile"), "ppt/slides/slide1.xml"),
|
||||
word_texts(p.blocks.clone()).concat(),
|
||||
];
|
||||
for output in outputs {
|
||||
for b in &p.blocks {
|
||||
assert!(output.contains(&b.text), "{} was lost", b.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bulleted list as Word and LibreOffice draw one: each bullet a block of its own in a symbol
|
||||
/// font, a tab stop before its item's text, between a lead-in line and the next paragraph.
|
||||
fn bulleted_list(bullet: &str) -> Vec<Block> {
|
||||
let mut blocks = vec![line("The tenant looks after:", 72.0, 180.0, 700.0)];
|
||||
for (n, (text, end)) in
|
||||
[("the stairs and the landing;", 230.0), ("the shared yard and its gate;", 240.0), ("the bins, every week.", 200.0)].into_iter().enumerate()
|
||||
{
|
||||
let y = 686.0 - 14.0 * n as f64;
|
||||
blocks.push(line(bullet, 90.0, 94.6, y));
|
||||
blocks.push(line(text, 108.0, end, y));
|
||||
}
|
||||
blocks.push(line("Rent is paid monthly in advance.", 72.0, 230.0, 630.0));
|
||||
blocks
|
||||
}
|
||||
|
||||
/// A bulleted list whose bullets are blocks of their own is one paragraph per item, each item's
|
||||
/// text after its bullet, in every format: not a two-column table of bullets and text. The
|
||||
/// Symbol and Wingdings bullets Word writes count, and so does a dash that opens its line.
|
||||
#[test]
|
||||
fn a_bulleted_list_is_paragraphs_not_a_table() {
|
||||
for bullet in ["•", "\u{F0B7}", "\u{F0A7}", "\u{2013}"] {
|
||||
let p = Page { width: 612.0, height: 792.0, blocks: bulleted_list(bullet), ..Page::default() };
|
||||
let (found, _) = tables(&p.blocks, &[]);
|
||||
assert!(found.is_empty(), "{bullet}: {found:?}");
|
||||
let items = ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].map(|t| format!("{bullet} {t}"));
|
||||
let mut expected = vec!["The tenant looks after:".to_string()];
|
||||
expected.extend(items.iter().cloned());
|
||||
expected.push("Rent is paid monthly in advance.".into());
|
||||
assert_eq!(word_texts(p.blocks.clone()), expected, "{bullet}");
|
||||
let h = html(std::slice::from_ref(&p), "List");
|
||||
assert!(!h.contains("<table>") && items.iter().all(|i| h.contains(&format!("<p>{i}</p>"))), "{bullet}: {h}");
|
||||
assert!(!rtf(std::slice::from_ref(&p)).contains("\\trowd"), "{bullet}");
|
||||
let s = part(&pptx(std::slice::from_ref(&p), "List"), "ppt/slides/slide1.xml");
|
||||
assert!(!s.contains("<a:tbl>"), "{bullet}: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dash in a table row stands for "none": it is a cell like the others, not a bullet, so the
|
||||
/// table keeps all its rows.
|
||||
#[test]
|
||||
fn a_dash_in_a_table_row_stays_a_cell() {
|
||||
let mut blocks = Vec::new();
|
||||
for (n, (name, first, second)) in [("Apples", "\u{2013}", "5"), ("Pears", "3", "4"), ("Plums", "2", "6")].into_iter().enumerate() {
|
||||
let y = 700.0 - 14.0 * n as f64;
|
||||
blocks.push(line(name, 72.0, 110.0, y));
|
||||
blocks.push(line(first, 200.0, 205.0, y));
|
||||
blocks.push(line(second, 225.0, 230.0, y));
|
||||
}
|
||||
let (found, consumed) = tables(&blocks, &[]);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].rows.len(), 3, "{found:?}");
|
||||
assert!(consumed.iter().all(|used| *used), "{consumed:?}");
|
||||
}
|
||||
|
||||
/// A bullet is never a table cell, even when its items carry a second column such as an amount:
|
||||
/// every item keeps its bullet in front of its text.
|
||||
#[test]
|
||||
fn a_bullet_is_never_a_table_cell() {
|
||||
let mut blocks = Vec::new();
|
||||
for (n, (item, amount)) in [("apples", "5 kg"), ("pears", "4 kg"), ("plums", "2 kg")].into_iter().enumerate() {
|
||||
let y = 700.0 - 14.0 * n as f64;
|
||||
blocks.push(line("•", 90.0, 94.6, y));
|
||||
blocks.push(line(item, 108.0, 140.0, y));
|
||||
blocks.push(line(amount, 300.0, 325.0, y));
|
||||
}
|
||||
let (found, _) = tables(&blocks, &[]);
|
||||
assert!(found.iter().flat_map(|t| &t.rows).flatten().all(|cell| cell.text != "•"), "{found:?}");
|
||||
let paragraphs = word_texts(blocks);
|
||||
for item in ["• apples", "• pears", "• plums"] {
|
||||
assert!(paragraphs.iter().any(|p| p.starts_with(item)), "{item}: {paragraphs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every item of a long list keeps its bullet, though all the items' texts start at one tab stop.
|
||||
#[test]
|
||||
fn a_long_bulleted_list_keeps_every_bullet_with_its_item() {
|
||||
let mut blocks = Vec::new();
|
||||
for n in 0..150 {
|
||||
let y = 2200.0 - 14.0 * f64::from(n);
|
||||
blocks.push(line("\u{F0B7}", 90.0, 94.6, y));
|
||||
blocks.push(line(&format!("item {n};"), 108.0, 160.0, y));
|
||||
}
|
||||
let (found, _) = tables(&blocks, &[]);
|
||||
assert!(found.is_empty(), "{found:?}");
|
||||
let paragraphs = word_texts(blocks);
|
||||
assert_eq!(paragraphs.len(), 150, "{paragraphs:?}");
|
||||
assert!(paragraphs.iter().enumerate().all(|(n, p)| *p == format!("\u{F0B7} item {n};")), "{paragraphs:?}");
|
||||
}
|
||||
|
||||
@@ -110,6 +110,125 @@ mod tests {
|
||||
data
|
||||
}
|
||||
|
||||
/// The JBIG2 file header's ID string (ISO 14492 D.4.1).
|
||||
const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
/// `embedded`, segments as [`encode_jbig2_generic`] writes them (11-byte headers: no references,
|
||||
/// a one-byte page association), behind a file header with `flags` and the page count `pages`,
|
||||
/// in the organization `flags` declares (D.4.2, bit 0): 1 sequential, each segment's header
|
||||
/// before its data (D.1); 0 random access, every header first, the last an end of file segment
|
||||
/// numbered 2, which tells where they end, then every segment's data in the same order (D.2).
|
||||
fn with_file_header(embedded: &[u8], flags: u8, pages: Option<u32>) -> Vec<u8> {
|
||||
let mut data = FILE_HEADER_ID.to_vec();
|
||||
data.push(flags);
|
||||
data.extend(pages.map(u32::to_be_bytes).into_iter().flatten());
|
||||
if flags & 0x01 != 0 {
|
||||
data.extend_from_slice(embedded);
|
||||
return data;
|
||||
}
|
||||
let (mut headers, mut bodies, mut rest) = (Vec::new(), Vec::new(), embedded);
|
||||
while !rest.is_empty() {
|
||||
let (header, after) = rest.split_at(11);
|
||||
assert_eq!(header[5], 0, "no references");
|
||||
let (body, after) = after.split_at(u32::from_be_bytes(header[7..11].try_into().unwrap()) as usize);
|
||||
headers.extend_from_slice(header);
|
||||
bodies.extend_from_slice(body);
|
||||
rest = after;
|
||||
}
|
||||
headers.extend_from_slice(&[0, 0, 0, 2, 51, 0, 1, 0, 0, 0, 0]);
|
||||
data.extend(headers);
|
||||
data.extend(bodies);
|
||||
data
|
||||
}
|
||||
|
||||
/// A stream that starts with a JBIG2 file header (ISO 14492 D.4), which some producers write
|
||||
/// into `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded organization there,
|
||||
/// decodes past it as Acrobat and PDFBox draw it (hayro-jbig2 patch 17), in either organization
|
||||
/// the header declares, with the page count and without it. "Bit 0: File organisation type. If
|
||||
/// this bit is 0, the file uses the random-access organisation. If this bit is 1, the file uses
|
||||
/// the sequential organisation." (D.4.2) A header with its reserved flag bits set is an error,
|
||||
/// not a panic.
|
||||
#[test]
|
||||
fn a_stream_with_a_jbig2_file_header_decodes_past_it() {
|
||||
let (w, h) = (203u32, 41u32);
|
||||
let bits = noise(w, h);
|
||||
let max = bits.len();
|
||||
let embedded = encode_jbig2_generic(&bits, w, h).unwrap();
|
||||
let decode = |flags: u8, pages: Option<u32>| decode_jbig2(&with_file_header(&embedded, flags, pages), None, w, h, max);
|
||||
// Sequential organization (bit 0 is 1), one page.
|
||||
assert_eq!(decode(0x01, Some(1)).unwrap(), bits, "sequential, page count given");
|
||||
// Sequential, number of pages unknown (bit 1 is 1): no count follows (D.4.3).
|
||||
assert_eq!(decode(0x03, None).unwrap(), bits, "sequential, page count unknown");
|
||||
// Random-access organization (bit 0 is 0): the segment headers, then their data.
|
||||
assert_ne!(with_file_header(&embedded, 0x00, Some(1))[13..], embedded[..], "laid out otherwise");
|
||||
assert_eq!(decode(0x00, Some(1)).unwrap(), bits, "random access, page count given");
|
||||
assert_eq!(decode(0x02, None).unwrap(), bits, "random access, page count unknown");
|
||||
assert!(decode(0x11, Some(1)).is_err(), "reserved bits");
|
||||
}
|
||||
|
||||
/// A file header cut short, or the segments after it, is an error and not a panic: the ID string
|
||||
/// alone or in part, a header whose flags promise a page count with fewer than its four bytes,
|
||||
/// a header with no segments after it, and segments cut anywhere in either organization. No
|
||||
/// prefix of a whole stream decodes to its samples.
|
||||
#[test]
|
||||
fn a_truncated_jbig2_file_header_is_an_error() {
|
||||
let (w, h) = (24u32, 8u32);
|
||||
let bits = noise(w, h);
|
||||
let max = bits.len();
|
||||
let embedded = encode_jbig2_generic(&bits, w, h).unwrap();
|
||||
let decode = |data: &[u8]| decode_jbig2(data, None, w, h, max);
|
||||
assert!(decode(&FILE_HEADER_ID[..5]).is_err(), "part of the ID string");
|
||||
assert!(decode(&FILE_HEADER_ID).is_err(), "the ID string, no flags");
|
||||
for count in 0..4 {
|
||||
let mut data = FILE_HEADER_ID.to_vec();
|
||||
data.push(0x01);
|
||||
data.extend_from_slice(&1u32.to_be_bytes()[..count]);
|
||||
assert!(decode(&data).is_err(), "{count} bytes of the page count");
|
||||
}
|
||||
for (flags, pages) in [(0x01, Some(1)), (0x03, None), (0x00, Some(1)), (0x02, None)] {
|
||||
let whole = with_file_header(&embedded, flags, pages);
|
||||
let header = 9 + if pages.is_some() { 4 } else { 0 };
|
||||
assert!(decode(&whole[..header]).is_err(), "flags {flags:#04x}: the header alone");
|
||||
assert!(decode(&whole[..whole.len() - 1]).is_err(), "flags {flags:#04x}: the last byte cut");
|
||||
for end in 0..whole.len() {
|
||||
assert_ne!(decode(&whole[..end]).ok(), Some(bits.clone()), "flags {flags:#04x}: {end} bytes");
|
||||
}
|
||||
assert_eq!(decode(&whole).unwrap(), bits, "flags {flags:#04x}: whole");
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments after a file header count toward hayro-jbig2's cap on the segments of a stream
|
||||
/// together with its `/JBIG2Globals`, in either organization (patches 8 and 17): globals of
|
||||
/// extension segments, which decode to nothing, fill the cap to the image's segments, and one
|
||||
/// more is refused.
|
||||
#[test]
|
||||
fn segments_after_a_jbig2_file_header_count_with_the_globals() {
|
||||
/// hayro-jbig2's `MAX_SEGMENTS`, the most segments of a stream with its globals.
|
||||
const MAX_SEGMENTS: u32 = 1 << 20;
|
||||
let (w, h) = (24u32, 8u32);
|
||||
let bits = noise(w, h);
|
||||
let max = bits.len();
|
||||
let embedded = encode_jbig2_generic(&bits, w, h).unwrap();
|
||||
// Numbered past the image's segments (0 to 2), of no page, with no data.
|
||||
let globals = |count: u32| -> Vec<u8> {
|
||||
(16..16 + count)
|
||||
.flat_map(|number| {
|
||||
let mut segment = number.to_be_bytes().to_vec();
|
||||
segment.extend_from_slice(&[62, 0, 0, 0, 0, 0, 0]);
|
||||
segment
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The image's page information and region, and in random access its end of file segment.
|
||||
for (flags, organization, image) in [(0x01, "sequential", 2), (0x00, "random access", 3)] {
|
||||
let data = with_file_header(&embedded, flags, Some(1));
|
||||
let (at_cap, past_cap) = (globals(MAX_SEGMENTS - image), globals(MAX_SEGMENTS - image + 1));
|
||||
assert_eq!(decode_jbig2(&data, Some(&at_cap), w, h, max).unwrap(), bits, "{organization}: at the cap");
|
||||
let refused = decode_jbig2(&data, Some(&past_cap), w, h, max).unwrap_err();
|
||||
assert!(refused.to_string().contains("too many segments"), "{organization}: {refused}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic
|
||||
/// region of noise, and symbols for letters repeated across the page. With no rows asked for,
|
||||
/// nothing is decoded.
|
||||
|
||||
+148
-20
@@ -189,7 +189,7 @@ pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> {
|
||||
/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than
|
||||
/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer.
|
||||
pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<Vec<u8>, FilterError> {
|
||||
run(chain, data, max_output, None, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e)
|
||||
run(chain, data, max_output, None, true, false, None).map(|(v, _, _)| v).map_err(|(e, _)| e)
|
||||
}
|
||||
|
||||
/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages
|
||||
@@ -197,19 +197,19 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res
|
||||
/// bytes, on success and on failure. A stage's output counts even when a later stage or its
|
||||
/// predictor shrinks it.
|
||||
pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, usize), (FilterError, usize)> {
|
||||
run(chain, data, max_output, Some(max_output), true, false).map(|(v, _, spent)| (v, spent))
|
||||
run(chain, data, max_output, Some(max_output), true, false, None).map(|(v, _, spent)| (v, spent))
|
||||
}
|
||||
|
||||
/// Like [`decode`], but on corrupt data returns what was decoded before the error and
|
||||
/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
|
||||
/// chain still run on the partial data. Limit and unsupported errors are still errors.
|
||||
pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool), FilterError> {
|
||||
run(chain, data, max_output, None, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
|
||||
run(chain, data, max_output, None, false, false, None).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is.
|
||||
pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
|
||||
run(chain, data, max_output, Some(max_output), false, false)
|
||||
run(chain, data, max_output, Some(max_output), false, false, None)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
|
||||
@@ -222,23 +222,46 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp
|
||||
///
|
||||
/// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`.
|
||||
pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
|
||||
spending(chain, data, max_output, work, false)
|
||||
spending(chain, data, max_output, work, false, None)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant_within`] for the samples of an image `rows` high (its `/Height`), which the
|
||||
/// last stage gives. When that stage is Flate with a PNG predictor and gives exactly `rows` rows
|
||||
/// without the filter-type byte each predicted row starts with (ISO 32000-2 §7.4.4.4), its writer
|
||||
/// declared the predictor without applying it: the output is taken as the samples, as other
|
||||
/// readers take it, and reported as the damage it is, like any other corruption tolerated here.
|
||||
/// Any other output has its predictor undone as [`decode_tolerant_within`] does.
|
||||
pub fn decode_image_tolerant_within(
|
||||
chain: &[(Filter, Params)],
|
||||
data: &[u8],
|
||||
max_output: usize,
|
||||
rows: u32,
|
||||
work: &mut u64,
|
||||
) -> Result<Vec<u8>, FilterError> {
|
||||
spending(chain, data, max_output, work, false, usize::try_from(rows).ok())
|
||||
}
|
||||
|
||||
/// [`decode`], spending from `work` as [`decode_tolerant_within`] does: corrupt data is an error,
|
||||
/// having paid for what was decoded before it.
|
||||
pub fn decode_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
|
||||
spending(chain, data, max_output, work, true)
|
||||
spending(chain, data, max_output, work, true, None)
|
||||
}
|
||||
|
||||
fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64, strict: bool) -> Result<Vec<u8>, FilterError> {
|
||||
fn spending(
|
||||
chain: &[(Filter, Params)],
|
||||
data: &[u8],
|
||||
max_output: usize,
|
||||
work: &mut u64,
|
||||
strict: bool,
|
||||
rows: Option<usize>,
|
||||
) -> Result<Vec<u8>, FilterError> {
|
||||
decodable(chain)?;
|
||||
pay(work, data.len() as u64)?;
|
||||
if chain.is_empty() {
|
||||
return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) };
|
||||
}
|
||||
let left = usize::try_from(*work).unwrap_or(usize::MAX);
|
||||
match run(chain, data, max_output, Some(left), strict, true) {
|
||||
match run(chain, data, max_output, Some(left), strict, true, rows) {
|
||||
Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out),
|
||||
// The stage stopped at the lower of the two, which took as much to find out.
|
||||
Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => {
|
||||
@@ -284,10 +307,11 @@ fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> {
|
||||
}
|
||||
|
||||
/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and
|
||||
/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together.
|
||||
/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together. With
|
||||
/// `priced`, the count is steps: each Flate stage also pays [`FLATE_READ_WORK`] for each byte it
|
||||
/// reads, before it runs, so a stage `total` can't pay for doesn't run
|
||||
/// ([`FilterError::Spent`]).
|
||||
/// ([`FilterError::Spent`]). `rows` is the height of the image whose samples the last stage
|
||||
/// produces, if it does ([`decode_image_tolerant_within`]).
|
||||
fn run(
|
||||
chain: &[(Filter, Params)],
|
||||
data: &[u8],
|
||||
@@ -295,13 +319,14 @@ fn run(
|
||||
total: Option<usize>,
|
||||
strict: bool,
|
||||
priced: bool,
|
||||
rows: Option<usize>,
|
||||
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
|
||||
decodable(chain).map_err(|e| (e, 0))?;
|
||||
let mut cur: Cow<[u8]> = Cow::Borrowed(data);
|
||||
let mut partial = false;
|
||||
let mut spent = 0usize;
|
||||
let price = usize::try_from(FLATE_READ_WORK).unwrap_or(usize::MAX);
|
||||
for (filter, params) in chain {
|
||||
for (i, (filter, params)) in chain.iter().enumerate() {
|
||||
let read = if priced && matches!(filter, Filter::Flate) { price.saturating_mul(cur.len()) } else { 0 };
|
||||
let room = total.map_or(usize::MAX, |total| total.saturating_sub(spent));
|
||||
if read > room {
|
||||
@@ -313,8 +338,9 @@ fn run(
|
||||
let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent)));
|
||||
// A second reading of the same bytes (Flate's raw retry) is paid for as the first was, and
|
||||
// makes no more than is left once it has been.
|
||||
let rows = rows.filter(|_| i + 1 == chain.len());
|
||||
let again = room.checked_sub(read.saturating_mul(2)).map(|left| max.min(left));
|
||||
let (step, work, again) = decode_one(filter, params, &cur, limit, again);
|
||||
let (step, work, again) = decode_one(filter, params, &cur, limit, again, rows);
|
||||
if again {
|
||||
spent = spent.saturating_add(read);
|
||||
}
|
||||
@@ -333,16 +359,18 @@ fn run(
|
||||
}
|
||||
|
||||
/// One stage's result, the bytes its decoder produced (before any predictor), and whether it
|
||||
/// read its input twice, which it does only within `again` ([`flate::decode`]).
|
||||
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option<usize>) -> (Step, usize, bool) {
|
||||
/// read its input twice, which it does only within `again` ([`flate::decode`]); `rows` as for
|
||||
/// [`run`].
|
||||
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option<usize>, rows: Option<usize>) -> (Step, usize, bool) {
|
||||
let once = |(step, n): (Step, usize)| (step, n, false);
|
||||
match filter {
|
||||
Filter::Flate => {
|
||||
let (step, twice) = flate::decode(data, max, again);
|
||||
let (step, n) = with_predictor(step, params, "FlateDecode");
|
||||
let (step, n) = with_predictor(step, params, "FlateDecode", rows);
|
||||
(step, n, twice)
|
||||
}
|
||||
Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode")),
|
||||
// LZW rows are predicted whatever their length, as the renderer reads them.
|
||||
Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode", None)),
|
||||
Filter::AsciiHex => once(counted(asciihex::decode(data, max))),
|
||||
Filter::Ascii85 => once(counted(ascii85::decode(data, max))),
|
||||
Filter::RunLength => once(counted(runlength::decode(data, max))),
|
||||
@@ -361,13 +389,14 @@ fn counted(step: Step) -> (Step, usize) {
|
||||
|
||||
/// Applies the predictor to a decoder's result, including to partial output on failure. The
|
||||
/// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder
|
||||
/// produced it.
|
||||
fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) {
|
||||
/// produced it. `rows` (an image's height, for its last stage) applies only to data that decoded
|
||||
/// whole: data cut short by damage has its predictor undone as it always had.
|
||||
fn with_predictor(step: Step, params: &Params, name: &'static str, rows: Option<usize>) -> (Step, usize) {
|
||||
let (step, n) = counted(step);
|
||||
let step = match step {
|
||||
Ok(v) => predictor::decode(params, v, name),
|
||||
Ok(v) => predictor::decode(params, v, name, rows),
|
||||
Err(Failure { error, partial, discarded }) => {
|
||||
let partial = match predictor::decode(params, partial, name) {
|
||||
let partial = match predictor::decode(params, partial, name, None) {
|
||||
Ok(v) => v,
|
||||
Err(f) => f.partial,
|
||||
};
|
||||
@@ -791,4 +820,103 @@ mod tests {
|
||||
assert!(refused(&decode_tolerant_within(&[], b"data", 4, &mut work).unwrap_err()));
|
||||
assert_eq!(work, 0);
|
||||
}
|
||||
|
||||
/// Four rows of three RGB pixels, every sample above 4 (none reads as a PNG filter type).
|
||||
fn rgb_rows() -> (Params, Vec<u8>) {
|
||||
let p = Params { predictor: 15, colors: 3, columns: 3, ..Params::default() };
|
||||
(p, (0..36u8).map(|i| 200 - i).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples_and_reported() {
|
||||
let (p, samples) = rgb_rows();
|
||||
let chain = [(Filter::Flate, p.clone())];
|
||||
let data = encode_flate(&samples);
|
||||
let mut work = u64::MAX;
|
||||
assert_eq!(decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut work).unwrap(), samples);
|
||||
// The repair is reported as the damage it is: tolerant decoding says so, and strict refuses.
|
||||
let (out, partial, _) = run(&chain, &data, 1 << 20, None, false, false, Some(4)).map_err(|(e, _)| e).unwrap();
|
||||
assert_eq!((out, partial), (samples.clone(), true));
|
||||
let strict = run(&chain, &data, 1 << 20, None, true, false, Some(4)).map(|_| ()).map_err(|(e, _)| e.to_string());
|
||||
assert!(strict.as_ref().is_err_and(|e| e.contains("carry no filter-type bytes")), "{strict:?}");
|
||||
// The same data that isn't known to be an image's rows still goes through the predictor.
|
||||
let mut work = u64::MAX;
|
||||
assert_ne!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), samples);
|
||||
// LZW is read as before, as the renderer reads it.
|
||||
let lzw = [(Filter::Lzw, p)];
|
||||
let data = encode(&Filter::Lzw, &Params::default(), &samples).unwrap();
|
||||
let mut plain = u64::MAX;
|
||||
assert_eq!(
|
||||
decode_image_tolerant_within(&lzw, &data, 1 << 20, 4, &mut work).ok(),
|
||||
decode_tolerant_within(&lzw, &data, 1 << 20, &mut plain).ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_rows_carry_png_tags_has_its_predictor_undone() {
|
||||
let (p, samples) = rgb_rows();
|
||||
let chain = [(Filter::Flate, p.clone())];
|
||||
let data = encode(&Filter::Flate, &p, &samples).unwrap();
|
||||
let (mut a, mut b) = (u64::MAX, u64::MAX);
|
||||
assert_eq!(decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut a).unwrap(), samples);
|
||||
assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut b).unwrap(), samples);
|
||||
assert_eq!(a, b, "the same work");
|
||||
let (_, partial, _) = run(&chain, &data, 1 << 20, None, false, false, Some(4)).map_err(|(e, _)| e).unwrap();
|
||||
assert!(!partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_data_of_neither_length_decodes_as_it_did() {
|
||||
let (p, _) = rgb_rows();
|
||||
let chain = [(Filter::Flate, p.clone())];
|
||||
for len in [0, 1, 9, 27, 35, 37, 39, 40, 41, 72] {
|
||||
let data = encode_flate(&(0..len).map(|i| (i * 7 % 5) as u8).collect::<Vec<_>>());
|
||||
let (mut a, mut b) = (u64::MAX, u64::MAX);
|
||||
let image = decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut a).map_err(|e| e.to_string());
|
||||
let plain = decode_tolerant_within(&chain, &data, 1 << 20, &mut b).map_err(|e| e.to_string());
|
||||
assert_eq!(image, plain, "{len} bytes");
|
||||
}
|
||||
// Rows of the untagged length for another height, or no height.
|
||||
let samples = encode_flate(&[9; 36]);
|
||||
for rows in [0, 3, 5, u32::MAX] {
|
||||
let (mut a, mut b) = (u64::MAX, u64::MAX);
|
||||
assert_eq!(
|
||||
decode_image_tolerant_within(&chain, &samples, 1 << 20, rows, &mut a).ok(),
|
||||
decode_tolerant_within(&chain, &samples, 1 << 20, &mut b).ok(),
|
||||
"{rows} rows"
|
||||
);
|
||||
}
|
||||
// Only the last stage produces the rows: an earlier one is predicted as before.
|
||||
let hex: Vec<u8> = [9u8; 36].iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect();
|
||||
let earlier = [(Filter::Flate, p.clone()), (Filter::AsciiHex, Params::default())];
|
||||
let data = encode_flate(&hex[..36]);
|
||||
let (mut a, mut b) = (u64::MAX, u64::MAX);
|
||||
assert_eq!(
|
||||
decode_image_tolerant_within(&earlier, &data, 1 << 20, 4, &mut a).ok(),
|
||||
decode_tolerant_within(&earlier, &data, 1 << 20, &mut b).ok()
|
||||
);
|
||||
// TIFF predictor 2 and no predictor are left alone.
|
||||
for predictor in [1, 2] {
|
||||
let chain = [(Filter::Flate, Params { predictor, ..p.clone() })];
|
||||
let (mut a, mut b) = (u64::MAX, u64::MAX);
|
||||
assert_eq!(
|
||||
decode_image_tolerant_within(&chain, &samples, 1 << 20, 4, &mut a).ok(),
|
||||
decode_tolerant_within(&chain, &samples, 1 << 20, &mut b).ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_rows_of_no_bytes_or_past_memory_do_not_panic() {
|
||||
for (colors, bits_per_component, columns) in [(0, 8, 3), (3, 8, 0), (3, 8, -1), (255, 16, i64::MAX), (1, 1, i64::MAX), (3, 3, 3)] {
|
||||
let chain = [(Filter::Flate, Params { predictor: 15, colors, bits_per_component, columns, early_change: 1 })];
|
||||
for len in [0, 1, 36] {
|
||||
let data = encode_flate(&vec![7; len]);
|
||||
for rows in [0, 1, 4, u32::MAX] {
|
||||
let mut work = u64::MAX;
|
||||
let _ = decode_image_tolerant_within(&chain, &data, 1 << 20, rows, &mut work);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! byte (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth), whatever the predictor value is.
|
||||
//!
|
||||
//! A final partial row is decoded as far as its data goes.
|
||||
//!
|
||||
//! The samples of an image whose rows carry no filter-type byte, although a PNG predictor is
|
||||
//! declared, are taken as they are and reported as damage ([`decode`]).
|
||||
|
||||
use crate::{Failure, FilterError, Params, Step};
|
||||
|
||||
@@ -37,18 +40,39 @@ fn layout(p: &Params) -> Result<Layout, String> {
|
||||
Ok(Layout { bpc, colors, bpp: (colors * bpc).div_ceil(8), row: bits.div_ceil(8), samples })
|
||||
}
|
||||
|
||||
/// Undoes the predictor named by `p.predictor` (1 = identity).
|
||||
pub(crate) fn decode(p: &Params, data: Vec<u8>, name: &'static str) -> Step {
|
||||
/// Undoes the predictor named by `p.predictor` (1 = identity). `rows` is the height of the image
|
||||
/// `data` is the samples of, if it is: with a PNG predictor, data of exactly `rows` rows without
|
||||
/// their filter-type bytes ([`untagged`]) is given back as it is, as a [`Failure`] whose partial
|
||||
/// output is all of it, so that tolerant decoding takes the samples and reports the repair, and
|
||||
/// strict decoding refuses them.
|
||||
pub(crate) fn decode(p: &Params, data: Vec<u8>, name: &'static str, rows: Option<usize>) -> Step {
|
||||
match p.predictor {
|
||||
1 => Ok(data),
|
||||
2 | 10..=15 => {
|
||||
let l = layout(p).map_err(|e| Failure::corrupt(name, e, Vec::new()))?;
|
||||
if p.predictor == 2 { Ok(tiff_decode(&l, data)) } else { png_decode(&l, &data, name) }
|
||||
if p.predictor == 2 {
|
||||
return Ok(tiff_decode(&l, data));
|
||||
}
|
||||
if let Some(rows) = rows.filter(|&rows| untagged(&l, data.len(), rows)) {
|
||||
let detail =
|
||||
format!("PNG predictor {} declared, but its {rows} rows carry no filter-type bytes: read as unpredicted samples", p.predictor);
|
||||
return Err(Failure::corrupt(name, detail, data));
|
||||
}
|
||||
png_decode(&l, &data, name)
|
||||
}
|
||||
other => Err(Failure::corrupt(name, format!("invalid Predictor {other}"), Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `len` bytes are exactly `rows` rows of `l` without the filter-type byte that starts each
|
||||
/// PNG-predicted row (ISO 32000-2 §7.4.4.4), where predicted data would be `rows` × (row + 1): a
|
||||
/// writer that declared the predictor and didn't apply it. Read with the predictor, every row's
|
||||
/// first sample was taken for a filter type and the image came out a row short and shifted.
|
||||
/// PDFium, MuPDF and Poppler read such data as the samples it is.
|
||||
fn untagged(l: &Layout, len: usize, rows: usize) -> bool {
|
||||
rows > 0 && l.row > 0 && rows.checked_mul(l.row) == Some(len)
|
||||
}
|
||||
|
||||
/// Applies the predictor named by `p.predictor` (1 = identity; 15 = per-row optimum).
|
||||
pub(crate) fn encode(p: &Params, data: &[u8]) -> Result<Vec<u8>, FilterError> {
|
||||
match p.predictor {
|
||||
@@ -213,7 +237,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn dec(params: &Params, data: &[u8]) -> Result<Vec<u8>, FilterError> {
|
||||
decode(params, data.to_vec(), "FlateDecode").map_err(|f| f.error)
|
||||
decode(params, data.to_vec(), "FlateDecode", None).map_err(|f| f.error)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -262,7 +286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn png_invalid_tag() {
|
||||
let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode");
|
||||
let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode", None);
|
||||
let f = r.err().unwrap();
|
||||
assert!(matches!(f.error, FilterError::Corrupt { .. }));
|
||||
assert_eq!(f.partial, [1, 2]);
|
||||
|
||||
+123
-10
@@ -356,6 +356,94 @@ const MAX_CMAP_RANGES: usize = 1 << 16;
|
||||
/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
|
||||
/// one is not decoded at all.
|
||||
const MAX_CMAP_BYTES: usize = 16 << 20;
|
||||
/// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes.
|
||||
const MAX_TYPE3_GLYPHS: usize = 256;
|
||||
/// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped.
|
||||
const MAX_TYPE3_GLYPH_BYTES: usize = 64 << 10;
|
||||
/// Decoded bytes all of one font's glyph descriptions may take together.
|
||||
const MAX_TYPE3_FONT_BYTES: usize = 4 << 20;
|
||||
/// Farthest a Type 3 glyph box reaches from the baseline, in text space per unit of font size. A
|
||||
/// bitmap font drawn in page units at `0.01 Tf` is a few thousand; past this a box is garbage, and
|
||||
/// clamping it keeps the run boxes built from it finite.
|
||||
const MAX_TYPE3_EXTENT: f64 = 1e6;
|
||||
|
||||
/// The union of the boxes a Type 3 font's glyph descriptions declare with `d1` (ISO 32000-2
|
||||
/// §9.6.4, Table 111), in glyph space, and whether every description chosen was read; `None` when
|
||||
/// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences`
|
||||
/// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a
|
||||
/// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`]
|
||||
/// descriptions within [`MAX_TYPE3_FONT_BYTES`].
|
||||
fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
|
||||
let procs = dict(doc, font.get(b"CharProcs"))?;
|
||||
let mut names: Vec<&[u8]> = Vec::new();
|
||||
// Whether every glyph that can be drawn is read; if not, the union may be short of them.
|
||||
let mut complete = true;
|
||||
let encoding = dict(doc, font.get(b"Encoding"));
|
||||
// A base encoding reaches glyphs `/Differences` doesn't name, which Type 3 doesn't provide for (its
|
||||
// `/Differences` "shall specify the complete character encoding", Table 110): those boxes go unread.
|
||||
if encoding.as_ref().is_some_and(|e| e.contains(b"BaseEncoding")) {
|
||||
complete = false;
|
||||
}
|
||||
let diffs = encoding.as_ref().and_then(|e| e.get(b"Differences")).map(|d| doc.resolve(d));
|
||||
for name in diffs.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_name) {
|
||||
if procs.get(name).is_none() || names.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
if names.len() >= MAX_TYPE3_GLYPHS {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
let chosen: Vec<&Object> = if names.is_empty() {
|
||||
complete = procs.len() <= MAX_TYPE3_GLYPHS;
|
||||
procs.iter().take(MAX_TYPE3_GLYPHS).map(|(_, proc)| proc).collect()
|
||||
} else {
|
||||
names.iter().filter_map(|n| procs.get(n)).collect()
|
||||
};
|
||||
let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0);
|
||||
let mut union: Option<[f64; 4]> = None;
|
||||
for proc in chosen {
|
||||
let Object::Stream(s) = &*doc.resolve(proc) else { continue };
|
||||
let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else {
|
||||
if budget.remaining() == 0 {
|
||||
// The rest go unread too.
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if let Some([llx, lly, urx, ury]) = d1_box(&data) {
|
||||
let r = [llx.min(urx), lly.min(ury), llx.max(urx), lly.max(ury)];
|
||||
union = Some(union.map_or(r, |u| [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])]));
|
||||
}
|
||||
}
|
||||
union.map(|u| (u, complete))
|
||||
}
|
||||
|
||||
/// The glyph box `llx lly urx ury` of a glyph description that begins with `wx wy llx lly urx ury
|
||||
/// d1`; `None` for `d0` or anything else first.
|
||||
fn d1_box(data: &[u8]) -> Option<[f64; 4]> {
|
||||
let mut lexer = pdfcraft_cos::Lexer::new(data, 0);
|
||||
let mut operands = [0.0; 6];
|
||||
for slot in &mut operands {
|
||||
lexer.skip_ws();
|
||||
*slot = std::str::from_utf8(lexer.token()).ok()?.parse::<f64>().ok().filter(|v| v.is_finite())?;
|
||||
}
|
||||
lexer.skip_ws();
|
||||
(lexer.token() == b"d1").then(|| [operands[2], operands[3], operands[4], operands[5]])
|
||||
}
|
||||
|
||||
/// The lowest and highest text-space y of the glyph-space box `r` under a font matrix whose
|
||||
/// y column is `b`, `d`, `f` (§9.2.4): a flipped matrix (`d < 0`) turns the box over. `None`
|
||||
/// when the box has no height in text space or the numbers overflow; clamped to
|
||||
/// [`MAX_TYPE3_EXTENT`].
|
||||
fn text_y_extent(r: [f64; 4], b: f64, d: f64, f: f64) -> Option<(f64, f64)> {
|
||||
let ys = [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].map(|(x, y)| b * x + d * y + f);
|
||||
let lo = ys.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
(lo.is_finite() && hi.is_finite() && hi > lo).then_some((lo.max(-MAX_TYPE3_EXTENT), hi.min(MAX_TYPE3_EXTENT)))
|
||||
}
|
||||
|
||||
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
|
||||
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
|
||||
@@ -754,6 +842,8 @@ impl Metrics {
|
||||
m.std14 = None;
|
||||
let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec();
|
||||
let descriptor;
|
||||
// A Type 3 font measured from its glyph descriptions' `d1` boxes or its `/FontBBox` (see below).
|
||||
let mut type3_measured = false;
|
||||
if subtype == b"Type0" {
|
||||
m.composite = true;
|
||||
m.default = 1000.0;
|
||||
@@ -814,13 +904,28 @@ impl Metrics {
|
||||
descriptor = dict(doc, font.get(b"FontDescriptor"));
|
||||
m.default = descriptor.as_ref().and_then(|d| d.get(b"MissingWidth")).and_then(|w| doc.resolve(w).as_f64()).unwrap_or(0.0);
|
||||
if subtype == b"Type3" {
|
||||
let fm = nums(doc, font.get(b"FontMatrix"));
|
||||
if fm.len() == 6 {
|
||||
m.scale = fm[0];
|
||||
let bbox = nums(doc, font.get(b"FontBBox"));
|
||||
if bbox.len() == 4 && fm[3] != 0.0 {
|
||||
m.ascent = (bbox[3] * fm[3]).max(0.5);
|
||||
m.descent = (bbox[1] * fm[3]).min(-0.1);
|
||||
if let [a, b, _, d, _, f] = nums(doc, font.get(b"FontMatrix"))[..] {
|
||||
m.scale = a;
|
||||
// The glyphs' own boxes first: a bitmap font's `/FontBBox` is often all zero
|
||||
// or one unit high, while each `d1` declares the box its glyph fills.
|
||||
// Each `d1` box "shall be correct" (Table 111), so their union is used as
|
||||
// declared, only widened to reach the baseline: a font of small glyphs gets a
|
||||
// small box. A `/FontBBox` is often one unit high, so it keeps the 0.5 / 0.1 em
|
||||
// minimum.
|
||||
// A union cut short by a limit may miss glyphs that are drawn, so a nonzero
|
||||
// `/FontBBox` comes first then; all zero, the partial union is still better
|
||||
// than no box. A box with no height in text space gives way to the next.
|
||||
let glyphs = type3_glyph_box(doc, font);
|
||||
// All zero: no assumption about glyph sizes (Table 110).
|
||||
let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0));
|
||||
let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0));
|
||||
let candidates = [union(true), bbox.map(|r| (r, 0.5, -0.1)), union(false)];
|
||||
if let Some(((lo, hi), min_ascent, max_descent)) =
|
||||
candidates.into_iter().flatten().find_map(|(r, up, down)| text_y_extent(r, b, d, f).map(|e| (e, up, down)))
|
||||
{
|
||||
m.ascent = hi.max(min_ascent);
|
||||
m.descent = lo.min(max_descent);
|
||||
type3_measured = true;
|
||||
}
|
||||
}
|
||||
} else if m.widths.is_empty() {
|
||||
@@ -840,11 +945,15 @@ impl Metrics {
|
||||
let ascent = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64());
|
||||
let descent = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64());
|
||||
// Fonts often claim 0; never shrink the glyph box below a sensible minimum.
|
||||
if ascent.is_some() || subtype != b"Type3" {
|
||||
// A Type 3 font measured from its glyphs' `d1` boxes or its `/FontBBox` keeps those:
|
||||
// they are in glyph space and go through its font matrix, while a descriptor's
|
||||
// /Ascent and /Descent are optional for Type 3 (Table 120) and read here as 1000-unit
|
||||
// values, whatever the matrix.
|
||||
if (ascent.is_some() && !type3_measured) || subtype != b"Type3" {
|
||||
let a = ascent.unwrap_or(0.0) / 1000.0;
|
||||
m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 };
|
||||
}
|
||||
if descent.is_some() || subtype != b"Type3" {
|
||||
if (descent.is_some() && !type3_measured) || subtype != b"Type3" {
|
||||
let de = descent.unwrap_or(0.0) / 1000.0;
|
||||
m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 };
|
||||
}
|
||||
@@ -951,7 +1060,11 @@ impl Metrics {
|
||||
|| (self.outlined_cids.is_empty() && self.explicit_default);
|
||||
}
|
||||
match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
|
||||
Some(w) => *w > 0.0 || code == 32,
|
||||
// A full font keeps its space whatever `/Widths` says; a subset that gives its space no width has no
|
||||
// space glyph (pdfTeX writes 0 for the codes it never uses), and a typed space drawn as code 32 would
|
||||
// glue the words together (#6131): the edit sets it as a positioning gap instead. Only when code 32
|
||||
// is a space: another glyph a subset puts there keeps its code.
|
||||
Some(w) => *w > 0.0 || (code == 32 && !(self.subset && self.text_of(code).is_some_and(|t| t.chars().all(char::is_whitespace)))),
|
||||
None => self.widths.is_empty() && !self.subset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,228 @@ fn type3_descriptor_names_the_font_without_changing_its_metrics() {
|
||||
assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence");
|
||||
}
|
||||
|
||||
/// A Type 3 font with `matrix`, `bbox` and one glyph description per entry of `procs`.
|
||||
fn type3(doc: &mut Document, matrix: [f64; 6], bbox: [i64; 4], procs: &[&str]) -> Dict {
|
||||
let mut charprocs = Dict::new();
|
||||
for (i, proc) in procs.iter().enumerate() {
|
||||
let glyph = doc.add(Object::Stream(Stream::flate(Dict::new(), proc.as_bytes())));
|
||||
charprocs.set(format!("g{i}").into_bytes(), Object::Ref(glyph));
|
||||
}
|
||||
font(
|
||||
doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type3")),
|
||||
("FontMatrix", Object::Array(matrix.iter().map(|&v| Object::Real(v)).collect())),
|
||||
("FontBBox", Object::Array(bbox.iter().map(|&v| Object::Int(v)).collect())),
|
||||
("CharProcs", Object::Dict(charprocs)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §9.6.4: a bitmap font whose `/FontBBox` is all zero (no assumption about glyph
|
||||
/// sizes) is as high as the boxes its glyphs declare with `d1`, not a fixed 0.6 of the size.
|
||||
#[test]
|
||||
fn a_type3_font_is_as_high_as_its_d1_glyph_boxes() {
|
||||
let mut doc = Document::new_empty();
|
||||
let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let f = type3(&mut doc, unit, [0, 0, 0, 0], &["100 0 0 0 100 100 d1\nq 100 0 0 100 0 0 cm BI /W 1 /H 1 /IM true ID \x00 EI Q"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (100.0, 0.0));
|
||||
// Shown at `0.12 Tf`, the glyph is 12 points high.
|
||||
assert!((m.ascent * 0.12 - 12.0).abs() < 1e-9);
|
||||
|
||||
// The union of every glyph's box, leading comments and white space allowed; `d0` glyphs
|
||||
// declare none, and a non-numeric operand isn't a box.
|
||||
let f = type3(&mut doc, unit, [0, 0, 0, 0], &["% c\n 50 0 0 -20 40 60 d1", "60 0 d0", "60 0 0 -30 40 x d1", "60 0 0 0 40 80 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (80.0, -20.0));
|
||||
|
||||
// A wrong but non-zero `/FontBBox` (one unit high) gives way to the glyphs.
|
||||
let f = type3(&mut doc, unit, [0, 0, 1, 1], &["100 0 0 -10 100 90 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (90.0, -10.0));
|
||||
|
||||
// No `d1` anywhere: the `/FontBBox` still decides, scaled by the matrix.
|
||||
let f = type3(&mut doc, [0.001, 0.0, 0.0, 0.001, 0.0, 0.0], [0, -300, 1000, 900], &["600 0 d0"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert!((m.ascent - 0.9).abs() < 1e-12 && (m.descent + 0.3).abs() < 1e-12, "{} {}", m.ascent, m.descent);
|
||||
}
|
||||
|
||||
/// Small glyphs keep their small box: there is no 0.5 em / 0.1 em minimum for `d1` boxes, which
|
||||
/// made a 58-point line of 0.15 em glyphs 35 points high. The shape of a scanned-text bitmap
|
||||
/// font: matrix 0.0042, `/FontBBox` all zero, `d1` boxes from -8 to 33 units.
|
||||
#[test]
|
||||
fn small_type3_glyphs_keep_their_d1_height() {
|
||||
let mut doc = Document::new_empty();
|
||||
let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0];
|
||||
let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1", "26 0 2 -8 24 28 d1", "13 0 0 0 1 1 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent);
|
||||
// Glyphs that all sit above the baseline still reach down to it.
|
||||
let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && m.descent == 0.0, "{} {}", m.ascent, m.descent);
|
||||
}
|
||||
|
||||
/// A font matrix that turns glyph space over (`[1 0 0 -1 0 0]`, drawn under a flipped text
|
||||
/// matrix) puts the top of the glyph box at text-space y -top: the box is as high as before,
|
||||
/// upside down, not clamped to a sliver.
|
||||
#[test]
|
||||
fn a_flipped_type3_font_matrix_keeps_the_glyph_height() {
|
||||
let mut doc = Document::new_empty();
|
||||
let flipped = [1.0, 0.0, 0.0, -1.0, 0.0, 0.0];
|
||||
let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &["37 0 2 -1 34 38 d1", "42 0 2 -18 40 38 d1", "46 0 3 -1 42 57 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (18.0, -57.0));
|
||||
let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &[]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (18.0, -57.0), "the /FontBBox, turned over the same way");
|
||||
}
|
||||
|
||||
/// Glyph descriptions are untrusted: unreadable ones, a box of infinities, a degenerate matrix
|
||||
/// and a font with more glyphs than are read all leave finite metrics.
|
||||
#[test]
|
||||
fn type3_glyph_boxes_are_read_within_bounds() {
|
||||
let mut doc = Document::new_empty();
|
||||
let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
for procs in [&["inf 0 0 -inf 1 inf d1"][..], &["d1"], &["1 2 3 d1"], &[""], &["1 0 0 0 1 0 d1"], &["1e308 0 -1e308 -1e308 1e308 1e308 d1"]] {
|
||||
let f = type3(&mut doc, unit, [0, 0, 0, 0], procs);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert!(m.ascent > 0.0 && m.descent < 0.0, "{procs:?}");
|
||||
assert!(m.ascent <= MAX_TYPE3_EXTENT && m.descent >= -MAX_TYPE3_EXTENT, "{procs:?}");
|
||||
}
|
||||
// A matrix with no y scale gives the box no height: the fallback metrics stand.
|
||||
let f = type3(&mut doc, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0, 0, 0, 0], &["1 0 0 0 1 100 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (Metrics::fallback().ascent, Metrics::fallback().descent));
|
||||
// Glyphs past the first MAX_TYPE3_GLYPHS are not read.
|
||||
let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS];
|
||||
procs.push("1 0 0 0 1 1000 d1");
|
||||
let f = type3(&mut doc, unit, [0, 0, 0, 0], &procs);
|
||||
assert_eq!(Metrics::from_dict(&doc, &f).ascent, 10.0);
|
||||
}
|
||||
|
||||
/// `/Differences` with the glyph names `names` from code 0, set on the Type 3 font `f`.
|
||||
fn with_differences(f: &mut Dict, names: &[String]) {
|
||||
let mut items = vec![Object::Int(0)];
|
||||
items.extend(names.iter().map(|n| Object::name(n)));
|
||||
let mut enc = Dict::new();
|
||||
enc.set(b"Differences".to_vec(), Object::Array(items));
|
||||
f.set(b"Encoding".to_vec(), Object::Dict(enc));
|
||||
}
|
||||
|
||||
/// The glyph descriptions the encoding reaches are the ones read, since its `/Differences`
|
||||
/// "shall specify the complete character encoding" (ISO 32000-2 Table 110): a drawn glyph past
|
||||
/// the first [`MAX_TYPE3_GLYPHS`] descriptions still counts.
|
||||
#[test]
|
||||
fn type3_glyph_boxes_are_read_for_the_glyphs_the_encoding_reaches() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS + 44];
|
||||
procs.push("1 0 0 0 1 1000 d1");
|
||||
let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs);
|
||||
with_differences(&mut f, &[format!("g{}", MAX_TYPE3_GLYPHS + 44), "g0".into()]);
|
||||
assert_eq!(Metrics::from_dict(&doc, &f).ascent, 1000.0);
|
||||
}
|
||||
|
||||
/// Byte limits on glyph descriptions: one larger than [`MAX_TYPE3_GLYPH_BYTES`] is skipped, its
|
||||
/// `d1` box unread, and the rest are read; once the font's [`MAX_TYPE3_FONT_BYTES`] run out,
|
||||
/// the boxes read so far may miss glyphs that are drawn, so a nonzero `/FontBBox` stands.
|
||||
#[test]
|
||||
fn type3_glyph_byte_limits_leave_honest_metrics() {
|
||||
let mut doc = Document::new_empty();
|
||||
let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let pad = |head: &str, len: usize| format!("{head}{}", " ".repeat(len.saturating_sub(head.len())));
|
||||
let oversized = pad("1 0 0 0 1 1000 d1", MAX_TYPE3_GLYPH_BYTES + 100);
|
||||
let procs = [oversized.as_str(), "1 0 0 0 1 3 d1"];
|
||||
let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs);
|
||||
with_differences(&mut f, &["g0".into(), "g1".into()]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (3.0, 0.0), "the oversized glyph is skipped, the other read");
|
||||
|
||||
let filler = pad("0 0 m", MAX_TYPE3_GLYPH_BYTES - 1000);
|
||||
let fillers = MAX_TYPE3_FONT_BYTES / (MAX_TYPE3_GLYPH_BYTES - 1000) + 1;
|
||||
let mut procs = vec!["1 0 0 0 1 1 d1"];
|
||||
procs.extend(std::iter::repeat_n(filler.as_str(), fillers));
|
||||
procs.push("1 0 0 0 1 1000 d1");
|
||||
let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs);
|
||||
let names: Vec<String> = (0..procs.len()).map(|i| format!("g{i}")).collect();
|
||||
with_differences(&mut f, &names);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone");
|
||||
}
|
||||
|
||||
/// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent`
|
||||
/// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120).
|
||||
#[test]
|
||||
fn type3_d1_glyph_boxes_win_over_descriptor_metrics() {
|
||||
let mut doc = Document::new_empty();
|
||||
let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0];
|
||||
let mut f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 -8 9 33 d1"]);
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"Ascent".to_vec(), Object::Int(891));
|
||||
descriptor.set(b"Descent".to_vec(), Object::Int(-216));
|
||||
let reference = doc.add(Object::Dict(descriptor));
|
||||
f.set(b"FontDescriptor".to_vec(), Object::Ref(reference));
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent);
|
||||
}
|
||||
|
||||
/// A `/Differences` naming more distinct glyphs than [`MAX_TYPE3_GLYPHS`] leaves some unread, so
|
||||
/// a nonzero `/FontBBox` stands over the boxes of those read.
|
||||
#[test]
|
||||
fn type3_glyphs_past_the_limit_leave_the_font_bbox_standing() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS];
|
||||
procs.push("1 0 0 0 1 1000 d1");
|
||||
let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &procs);
|
||||
let names: Vec<String> = (0..procs.len()).map(|i| format!("g{i}")).collect();
|
||||
with_differences(&mut f, &names);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (50.0, -2.0));
|
||||
}
|
||||
|
||||
/// `d1` boxes with no height in text space (all flat) give way to a usable `/FontBBox`.
|
||||
#[test]
|
||||
fn flat_type3_glyph_boxes_give_way_to_the_font_bbox() {
|
||||
let mut doc = Document::new_empty();
|
||||
let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 5 1 5 d1"]);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (50.0, -2.0));
|
||||
}
|
||||
|
||||
/// A Type 3 font measured from its `/FontBBox` keeps that box through its font matrix over a
|
||||
/// descriptor's `/Ascent` and `/Descent`, which would be read as 1000-unit values.
|
||||
#[test]
|
||||
fn a_type3_font_bbox_wins_over_descriptor_metrics() {
|
||||
let mut doc = Document::new_empty();
|
||||
let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0];
|
||||
let mut f = type3(&mut doc, small, [0, -50, 100, 200], &[]);
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"Ascent".to_vec(), Object::Int(891));
|
||||
descriptor.set(b"Descent".to_vec(), Object::Int(-216));
|
||||
let reference = doc.add(Object::Dict(descriptor));
|
||||
f.set(b"FontDescriptor".to_vec(), Object::Ref(reference));
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
// 200 and -50 units at 0.0042, kept at the 0.5 / -0.1 em floor.
|
||||
assert!((m.ascent - 0.84).abs() < 1e-9 && (m.descent + 0.21).abs() < 1e-9, "{} {}", m.ascent, m.descent);
|
||||
}
|
||||
|
||||
/// An `/Encoding` with a `/BaseEncoding` reaches glyphs its `/Differences` doesn't name, whose
|
||||
/// boxes go unread, so a nonzero `/FontBBox` stands over the boxes of those named.
|
||||
#[test]
|
||||
fn a_type3_base_encoding_leaves_the_font_bbox_standing() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 0 1 10 d1", "1 0 0 0 1 1000 d1"]);
|
||||
let mut enc = Dict::new();
|
||||
enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0), Object::name("g0")]));
|
||||
f.set(b"Encoding".to_vec(), Object::Dict(enc));
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!((m.ascent, m.descent), (50.0, -2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_fonts_decode_and_encode_through_their_encoding() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -334,6 +556,76 @@ fn subset_fonts_only_encode_the_glyphs_they_have() {
|
||||
assert_eq!(m.encode("abc"), None);
|
||||
}
|
||||
|
||||
/// #6131: pdfTeX writes `/FirstChar` below 32 and `/Widths` of 0 for the codes it never uses, the space
|
||||
/// among them: the subset has no space glyph, and the words are apart by `TJ` gaps. Writing a typed space as
|
||||
/// code 32 would glue the words together at width 0, so the font can't encode one and the edit sets the
|
||||
/// space as a gap instead (as for a subset whose widths stop short of 32).
|
||||
#[test]
|
||||
fn a_subset_that_gives_the_space_no_width_cannot_encode_one() {
|
||||
// pdfTeX writes Type 1 fonts; the same rule holds for a TrueType subset.
|
||||
for subtype in ["TrueType", "Type1"] {
|
||||
a_zero_width_space_in_a_subset_of(subtype);
|
||||
}
|
||||
}
|
||||
|
||||
fn a_zero_width_space_in_a_subset_of(subtype: &str) {
|
||||
let metrics = |base: &str, space: i64| {
|
||||
let mut doc = Document::new_empty();
|
||||
// Codes 32..=98: the space, then a and b (97, 98) with a width, everything else 0.
|
||||
let widths: Vec<Object> = (32..=98)
|
||||
.map(|code| {
|
||||
Object::Int(match code {
|
||||
32 => space,
|
||||
97 | 98 => 500,
|
||||
_ => 0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let f = font(
|
||||
&mut doc,
|
||||
vec![
|
||||
("Subtype", Object::name(subtype)),
|
||||
("BaseFont", Object::name(base)),
|
||||
("FirstChar", Object::Int(32)),
|
||||
("Widths", Object::Array(widths)),
|
||||
("Encoding", Object::name("WinAnsiEncoding")),
|
||||
],
|
||||
);
|
||||
Metrics::from_dict(&doc, &f)
|
||||
};
|
||||
let subset = metrics("ABCDEF+Sample", 0);
|
||||
assert!(subset.subset);
|
||||
assert_eq!(subset.encode("ab").as_deref(), Some(&b"ab"[..]));
|
||||
assert_eq!(subset.encode("a b"), None, "no space glyph: a gap sets it");
|
||||
assert_eq!(subset.encode(" "), None);
|
||||
assert_eq!(metrics("ABCDEF+Sample", 250).encode("a b").as_deref(), Some(&b"a b"[..]), "a subset that does draw the space");
|
||||
assert_eq!(metrics("Sample", 0).encode("a b").as_deref(), Some(&b"a b"[..]), "a full font keeps its space whatever the width says");
|
||||
}
|
||||
|
||||
/// Only a *space* at code 32 is given up for a width of 0: a subset that puts another glyph there (a symbol
|
||||
/// font, say) keeps encoding it.
|
||||
#[test]
|
||||
fn a_subset_keeps_a_zero_width_glyph_that_is_not_a_space_at_code_32() {
|
||||
let mut doc = Document::new_empty();
|
||||
let widths: Vec<Object> = (32..=98).map(|code| Object::Int(if code == 97 { 500 } else { 0 })).collect();
|
||||
let mut enc = Dict::new();
|
||||
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(32), Object::name("Z")]));
|
||||
let f = font(
|
||||
&mut doc,
|
||||
vec![
|
||||
("Subtype", Object::name("TrueType")),
|
||||
("BaseFont", Object::name("ABCDEF+Sample")),
|
||||
("FirstChar", Object::Int(32)),
|
||||
("Widths", Object::Array(widths)),
|
||||
("Encoding", Object::Dict(enc)),
|
||||
],
|
||||
);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert!(m.subset);
|
||||
assert_eq!(m.text_of(32).as_deref(), Some("Z"));
|
||||
assert_eq!(m.encode("Z").as_deref(), Some(&[32][..]), "code 32 is a glyph here, not a space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmap_streams_decode_within_a_size_cap() {
|
||||
// A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole.
|
||||
|
||||
+99
-11
@@ -97,21 +97,13 @@ impl FieldAction {
|
||||
}
|
||||
}
|
||||
|
||||
fn texts(doc: &Document, o: &Object) -> Vec<String> {
|
||||
match &*doc.resolve(o) {
|
||||
Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(),
|
||||
Object::String(s) => vec![s.to_text()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option<FieldAction> {
|
||||
let a = doc.resolve(o);
|
||||
let d = a.as_dict()?;
|
||||
Some(match d.name(b"S")? {
|
||||
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()),
|
||||
b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()),
|
||||
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()),
|
||||
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| crate::read::action_fields(doc, f)).unwrap_or_default()),
|
||||
b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()),
|
||||
b"GoTo" => {
|
||||
let dest = d.get(b"D").map(|x| doc.resolve(x))?;
|
||||
@@ -119,8 +111,8 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu
|
||||
FieldAction::GoTo(pages.iter().position(|p| *p == target)?)
|
||||
}
|
||||
b"Hide" => FieldAction::ShowHide {
|
||||
fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(),
|
||||
hide: !matches!(d.get(b"H"), Some(Object::Bool(false))),
|
||||
fields: d.get(b"T").map(|t| crate::read::action_fields(doc, t)).unwrap_or_default(),
|
||||
hide: !d.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))),
|
||||
},
|
||||
b"SubmitForm" => FieldAction::Submit(
|
||||
d.get(b"F")
|
||||
@@ -344,3 +336,99 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reset_and_hide_actions_resolve_hierarchical_widget_and_indirect_field_targets() {
|
||||
let mut doc = Document::new_empty();
|
||||
let rect = || Object::Array(vec![0.into(), 0.into(), 50.into(), 20.into()]);
|
||||
|
||||
// 1. Hierarchical child field `address.city` (merged field + widget).
|
||||
let address = doc.add(Object::Null);
|
||||
let mut city = Dict::new();
|
||||
city.set(b"Parent".to_vec(), Object::Ref(address));
|
||||
city.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
city.set(b"T".to_vec(), PdfString::text("city"));
|
||||
city.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
city.set(b"Rect".to_vec(), rect());
|
||||
let city = doc.add(Object::Dict(city));
|
||||
let mut addr_dict = Dict::new();
|
||||
addr_dict.set(b"T".to_vec(), PdfString::text("address"));
|
||||
addr_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(city)]));
|
||||
doc.set(address, Object::Dict(addr_dict));
|
||||
|
||||
// 2. Separated field `phone` whose widget dictionary has `/Parent` instead of `/T`.
|
||||
let phone = doc.add(Object::Null);
|
||||
let mut phone_widget = Dict::new();
|
||||
phone_widget.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
phone_widget.set(b"Parent".to_vec(), Object::Ref(phone));
|
||||
phone_widget.set(b"Rect".to_vec(), rect());
|
||||
let phone_widget = doc.add(Object::Dict(phone_widget));
|
||||
let mut phone_dict = Dict::new();
|
||||
phone_dict.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
phone_dict.set(b"T".to_vec(), PdfString::text("phone"));
|
||||
phone_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(phone_widget)]));
|
||||
doc.set(phone, Object::Dict(phone_dict));
|
||||
|
||||
// 3. Indirect string target `"email"`.
|
||||
let email_str = doc.add(Object::String(PdfString::text("email")));
|
||||
let targets = Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(email_str)]);
|
||||
|
||||
let mut reset_action = Dict::new();
|
||||
reset_action.set(b"S".to_vec(), Object::name("ResetForm"));
|
||||
reset_action.set(b"Fields".to_vec(), targets.clone());
|
||||
let reset_action = doc.add(Object::Dict(reset_action));
|
||||
|
||||
let indirect_false = doc.add(Object::Bool(false));
|
||||
let mut hide_action = Dict::new();
|
||||
hide_action.set(b"S".to_vec(), Object::name("Hide"));
|
||||
hide_action.set(b"T".to_vec(), targets);
|
||||
hide_action.set(b"H".to_vec(), Object::Ref(indirect_false));
|
||||
let hide_action = doc.add(Object::Dict(hide_action));
|
||||
|
||||
let mut aa = Dict::new();
|
||||
aa.set(b"D".to_vec(), Object::Ref(hide_action));
|
||||
let mut btn = Dict::new();
|
||||
btn.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
btn.set(b"Ff".to_vec(), Object::Int(1 << 16));
|
||||
btn.set(b"T".to_vec(), PdfString::text("btn"));
|
||||
btn.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
btn.set(b"Rect".to_vec(), rect());
|
||||
btn.set(b"A".to_vec(), Object::Ref(reset_action));
|
||||
btn.set(b"AA".to_vec(), Object::Dict(aa));
|
||||
let btn = doc.add(Object::Dict(btn));
|
||||
|
||||
let root = doc.root().unwrap();
|
||||
let pages_ref = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).unwrap();
|
||||
let mut page_dict = Dict::new();
|
||||
page_dict.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page_dict.set(b"Parent".to_vec(), Object::Ref(pages_ref));
|
||||
page_dict.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
page_dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(btn)]));
|
||||
let page = doc.add(Object::Dict(page_dict));
|
||||
doc.update_dict(pages_ref, |p| {
|
||||
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
p.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(address), Object::Ref(phone), Object::Ref(btn)]));
|
||||
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
|
||||
|
||||
let expected = vec!["address.city".to_string(), "phone".to_string(), "email".to_string()];
|
||||
let btn_field = fields(&doc).into_iter().find(|f| f.name == "btn").unwrap();
|
||||
assert_eq!(btn_field.button, Some(crate::af::ButtonAction::Reset { fields: expected.clone(), exclude: false }));
|
||||
|
||||
let acts = field_actions(&doc, "btn").unwrap();
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
(Trigger::MouseUp, FieldAction::Reset(expected.clone())),
|
||||
(Trigger::MouseDown, FieldAction::ShowHide { fields: expected, hide: false }),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +531,10 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<
|
||||
let num = |s: &String| make_number(s).unwrap_or(0.0);
|
||||
match c {
|
||||
Calculate::None => None,
|
||||
// A product of no values is 0, as an empty sum, average, minimum or maximum is, not the
|
||||
// empty product 1: a blank quantity × price row reads 0, and a total over the rows
|
||||
// doesn't count it as 1. (The form's `values` leaves out a field that has no value.)
|
||||
Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()),
|
||||
Calculate::Simple { op, fields } => {
|
||||
let xs: Vec<f64> = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect();
|
||||
let v = match op {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user