mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 15:25:10 +00:00
Merge remote-tracking branch 'origin/main' into merge/redact-forms-6341
# Conflicts: # crates/edit/src/tests.rs
This commit is contained in:
Generated
+8
-2
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -4416,6 +4416,7 @@ dependencies = [
|
||||
"sha2 0.11.1",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4656,10 +4657,12 @@ name = "pdfcraft-print"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"log",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -4747,6 +4750,7 @@ dependencies = [
|
||||
"sha2 0.11.1",
|
||||
"sha3",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4808,6 +4812,7 @@ dependencies = [
|
||||
"web-time",
|
||||
"winsafe",
|
||||
"xattr",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8310,7 +8315,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "winprint"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fmt-derive",
|
||||
@@ -8325,6 +8330,7 @@ dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-implement",
|
||||
"windows-numerics",
|
||||
"xml",
|
||||
]
|
||||
|
||||
|
||||
+2
-1
@@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
|
||||
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
@@ -84,6 +84,7 @@ hayro-jbig2 = "0.3"
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
zeroize = "1"
|
||||
# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
|
||||
# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
|
||||
>
|
||||
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
|
||||
@@ -238,6 +240,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
|
||||
|
||||
- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `<appearance>` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
|
||||
@@ -330,6 +334,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint<n>`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged.
|
||||
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
@@ -527,6 +532,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
|
||||
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
|
||||
- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file.
|
||||
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
|
||||
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
|
||||
- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`.
|
||||
@@ -827,6 +833,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. |
|
||||
| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
|
||||
| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
|
||||
| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
|
||||
|
||||
@@ -434,7 +434,10 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
|
||||
for warning in &p.warnings {
|
||||
match warning {
|
||||
pdfcraft_render::RenderWarning::ContentTruncated => {
|
||||
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
|
||||
);
|
||||
}
|
||||
pdfcraft_render::RenderWarning::ColourFallback => {
|
||||
let _ =
|
||||
|
||||
@@ -475,6 +475,7 @@ fn app_creator<'a>(
|
||||
}
|
||||
app.set_startup_update_check(true);
|
||||
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
|
||||
app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided());
|
||||
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool {
|
||||
|
||||
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
|
||||
/// follows it, within a few steps (a cycle stops there).
|
||||
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
|
||||
for _ in 0..8 {
|
||||
match &*doc.get(r) {
|
||||
Object::Ref(next) => r = *next,
|
||||
@@ -340,6 +340,9 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let direct = crate::draw::with_direct_values(doc, raw);
|
||||
let d = direct.as_ref();
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
if !is_comment_subtype(&subtype) {
|
||||
return None;
|
||||
}
|
||||
// A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one).
|
||||
let style = (subtype == "FreeText").then(|| text_style::text_style(d, None));
|
||||
let color = match &style {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::info::text_value;
|
||||
use super::info::{is_comment_subtype, text_value};
|
||||
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
|
||||
|
||||
@@ -106,16 +106,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
|
||||
/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]).
|
||||
pub(crate) fn is_comment_dict(d: &Dict) -> bool {
|
||||
d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s)))
|
||||
}
|
||||
|
||||
/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
|
||||
pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let mut list = annots(doc, p);
|
||||
let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let r = match entry {
|
||||
Object::Ref(r) if doc.get(r).as_dict().is_some() => r,
|
||||
Object::Dict(d) => {
|
||||
Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r,
|
||||
Object::Dict(d) if is_comment_dict(&d) => {
|
||||
let r = doc.add(Object::Dict(d));
|
||||
list[index] = Object::Ref(r);
|
||||
if let Some(slot) = list.get_mut(index) {
|
||||
*slot = Object::Ref(r);
|
||||
}
|
||||
set_annots(doc, p, list)?;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ fn deleted_index(doc: &Document, page: usize, index: usize) -> Result<usize, Ann
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
let mut target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let mut index = index;
|
||||
let target_obj = target.as_ref().map_or_else(|| doc.resolve(target), |r| doc.get(super::info::final_ref(doc, r)));
|
||||
if !target_obj.as_dict().is_some_and(super::lookup::is_comment_dict) {
|
||||
return Err(AnnotError::NoSuchAnnotation { page, index });
|
||||
}
|
||||
if let Some(r) = target.as_ref() {
|
||||
unlocked(doc, r)?;
|
||||
}
|
||||
|
||||
@@ -2544,6 +2544,28 @@ fn non_markup_annotations_are_not_comments() {
|
||||
for s in ["Text", "FreeText", "Highlight", "Ink", "Stamp", "FileAttachment", "Redact", "Sound"] {
|
||||
assert!(is_comment_subtype(s), "{s}");
|
||||
}
|
||||
// #3916: comment edits must refuse non-comment annotations (form widgets, links, pop-ups)
|
||||
// when a stale `/Annots` index points at one.
|
||||
for subtype in ["Widget", "Link", "Popup", "Watermark"] {
|
||||
let mut doc = fixture();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
d.set(b"Rect".to_vec(), num_array(&[10.0, 10.0, 50.0, 30.0]));
|
||||
d.set(b"Contents".to_vec(), PdfString::text("keep me"));
|
||||
let w = doc.add(Object::Dict(d));
|
||||
let p = page_refs(&doc).unwrap()[0];
|
||||
let mut a = annots(&doc, p);
|
||||
a.push(Object::Ref(w));
|
||||
let idx = a.len() - 1;
|
||||
set_annots(&mut doc, p, a).unwrap();
|
||||
|
||||
assert_eq!(delete_annotation(&mut doc, 0, idx), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}");
|
||||
assert_eq!(set_contents(&mut doc, 0, idx, "overwritten", &meta("m")), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}");
|
||||
assert_eq!(move_annotation(&mut doc, 0, idx, 5.0, 5.0, &meta("m")), Err(AnnotError::NoSuchAnnotation { page: 0, index: idx }), "{subtype}");
|
||||
assert_eq!(props(&doc, 0, idx), None, "{subtype}");
|
||||
assert_eq!(text(&list(&doc, 0)[idx], b"Contents"), "keep me", "{subtype} contents must be untouched");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing (a drawn Fill & Sign signature is one) resizes by scaling its strokes into the new
|
||||
|
||||
@@ -207,8 +207,9 @@ mutually exclusive with `hide_text_block`.
|
||||
|
||||
When the renderer reports a warning for the page, `page_render` and `render_preview` return a
|
||||
JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences
|
||||
`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour
|
||||
that could not be computed was painted in a fallback colour". A page without warnings returns
|
||||
`pdfcraft-cli render` prints, such as "content past the page's safety budget, or a picture whose
|
||||
JBIG2 mask is past a decoder limit, was skipped" and "a colour that could not be computed was
|
||||
painted in a fallback colour". A page without warnings returns
|
||||
the PNG alone.
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic};
|
||||
|
||||
/// A script's request to the viewer as the JSON that running a script reports; a side panel
|
||||
/// goes by its fixed id.
|
||||
/// A script's request to the viewer as the JSON that running a script reports (page numbers
|
||||
/// 1-based); a side panel goes by its fixed id.
|
||||
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
use pdfcraft_engine::js::Request as R;
|
||||
match r {
|
||||
@@ -14,6 +14,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::Highlight(on) => json!({ "highlight": on }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option<Value> {
|
||||
match (&f.actions.calculate, &f.actions.scripts.calculate) {
|
||||
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
|
||||
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
|
||||
(Calculate::None, Some(js)) => Some(json!({ "script": js })),
|
||||
(Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })),
|
||||
(Calculate::None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +412,9 @@ pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize)
|
||||
fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str {
|
||||
use pdfcraft_render::RenderWarning;
|
||||
match warning {
|
||||
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
|
||||
RenderWarning::ContentTruncated => {
|
||||
"content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
|
||||
}
|
||||
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
|
||||
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all",
|
||||
RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print
|
||||
//! dialog options.
|
||||
|
||||
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
|
||||
use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
|
||||
@@ -62,8 +62,17 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
|
||||
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
|
||||
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells
|
||||
/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the
|
||||
/// caller chose in `options` stands.
|
||||
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize, sheets: &[print::Sheet]) -> Result<spool::Job> {
|
||||
/// caller chose in `options` stands. It asks the driver for grayscale only when the pages aren't
|
||||
/// grey pictures already ([`print::driver_needs_grayscale`]).
|
||||
fn spool_job(
|
||||
a: &Args,
|
||||
printer: &str,
|
||||
title: String,
|
||||
duplex: Option<spool::Duplex>,
|
||||
paper: usize,
|
||||
raster: Option<print::Raster>,
|
||||
sheets: &[print::Sheet],
|
||||
) -> Result<spool::Job> {
|
||||
let options = match a.get("options") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Object(m)) => m
|
||||
@@ -81,7 +90,7 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duple
|
||||
collate: a.opt_bool("collate")?.unwrap_or(true),
|
||||
duplex,
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
|
||||
grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster),
|
||||
title,
|
||||
options: {
|
||||
let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect();
|
||||
@@ -174,9 +183,6 @@ impl Automation {
|
||||
let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false))
|
||||
.map_err(|e| bad(e.to_string()))?;
|
||||
let layout = match a.opt_str("layout")?.unwrap_or("fit") {
|
||||
"fit" => Layout::Size(SizeMode::Fit),
|
||||
"actual" => Layout::Size(SizeMode::Actual),
|
||||
"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.
|
||||
@@ -221,13 +227,14 @@ impl Automation {
|
||||
s => return Err(bad(format!("unknown booklet_subset {s:?}"))),
|
||||
},
|
||||
binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left },
|
||||
creep: a.opt_num("creep")?.unwrap_or(0.0),
|
||||
},
|
||||
"poster" => Layout::Poster {
|
||||
scale: a.opt_num("scale")?.unwrap_or(200.0),
|
||||
overlap: a.opt_num("overlap")?.unwrap_or(18.0),
|
||||
cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true),
|
||||
},
|
||||
l => return Err(bad(format!("unknown layout {l:?}"))),
|
||||
l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| 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 { .. }) {
|
||||
@@ -242,13 +249,8 @@ impl Automation {
|
||||
"landscape" => Orientation::Landscape,
|
||||
o => return Err(bad(format!("unknown orientation {o:?}"))),
|
||||
};
|
||||
let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") {
|
||||
"document" => Content::Document,
|
||||
"document-and-markups" => Content::DocumentAndMarkups,
|
||||
"document-and-stamps" => Content::DocumentAndStamps,
|
||||
"form-fields-only" => Content::FormFieldsOnly,
|
||||
c => return Err(bad(format!("unknown comments_forms {c:?}"))),
|
||||
};
|
||||
let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups");
|
||||
let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?;
|
||||
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
|
||||
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
|
||||
// `grayscale` is also asked for.
|
||||
@@ -274,7 +276,7 @@ impl Automation {
|
||||
out["path"] = json!(path.to_string_lossy());
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?;
|
||||
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?;
|
||||
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
@@ -290,7 +292,7 @@ mod tests {
|
||||
/// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error.
|
||||
#[test]
|
||||
fn a_booklet_decides_two_sided_itself() {
|
||||
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left };
|
||||
let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 };
|
||||
for asked in ["printer", "off", "long-edge", "short-edge"] {
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}");
|
||||
assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}");
|
||||
@@ -320,7 +322,7 @@ mod tests {
|
||||
#[test]
|
||||
fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() {
|
||||
let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::<Vec<_>>();
|
||||
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options;
|
||||
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, None, s).unwrap().options;
|
||||
let media = ("media".to_string(), "Letter".to_string());
|
||||
let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]);
|
||||
let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] };
|
||||
@@ -337,7 +339,7 @@ mod tests {
|
||||
fn printing_to_a_printer_feeds_the_laid_out_paper() {
|
||||
let job = |args: Value, paper_name: Option<&str>| {
|
||||
let (index, _) = paper_named(paper_name).unwrap();
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap()
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, None, &[]).unwrap()
|
||||
};
|
||||
let media = |m: &str| ("media".to_string(), m.to_string());
|
||||
// US Letter when no paper is asked for, after the driver's own options.
|
||||
@@ -353,7 +355,21 @@ mod tests {
|
||||
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
|
||||
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
|
||||
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, &[]).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, &[]).is_err());
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, None, &[]).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, None, &[]).is_err());
|
||||
}
|
||||
|
||||
/// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for
|
||||
/// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused.
|
||||
#[test]
|
||||
fn grey_pictures_dont_ask_the_driver_for_grayscale() {
|
||||
let grey = print::Raster { dpi: 300.0, grayscale: true };
|
||||
let colour = print::Raster { dpi: 300.0, grayscale: false };
|
||||
let job =
|
||||
|args: Value, raster: Option<print::Raster>| spool_job(&Args(&args), "Office", String::new(), None, 0, raster, &[]).unwrap().grayscale;
|
||||
assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey");
|
||||
assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do");
|
||||
assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver");
|
||||
assert!(!job(json!({}), None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
)),
|
||||
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx:<name> when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a picture left out because its JBIG2 mask is past a decoder limit, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
|
||||
"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." },
|
||||
@@ -764,6 +764,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"auto_rotate": { "type": "boolean" },
|
||||
"booklet_subset": { "type": "string", "enum": ["both", "front", "back"] },
|
||||
"binding": { "type": "string", "enum": ["left", "right"] },
|
||||
"creep": { "type": "number", "minimum": 0, "maximum": 10, "description": "Booklet creep compensation: points each sheet's pages move toward the fold, times the sheet's place from the outside (default 0)." },
|
||||
"overlap": { "type": "number", "minimum": 0 },
|
||||
"cut_marks": { "type": "boolean" },
|
||||
"orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] },
|
||||
@@ -773,9 +774,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." },
|
||||
"grayscale": { "type": "boolean" },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." },
|
||||
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
|
||||
"options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." },
|
||||
}),
|
||||
@@ -1395,7 +1396,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
|
||||
@@ -6200,6 +6200,9 @@ fn printing_through_tools() {
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4)));
|
||||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" }));
|
||||
assert!(r["sheets"].as_u64().unwrap() > 1);
|
||||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "booklet", "creep": 1.5, "path": "booklet.pdf" }));
|
||||
assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4) * 2), "creep keeps the booklet's sheets");
|
||||
assert!(a.call("doc_print", &json!({ "doc": doc, "layout": "booklet", "creep": -1, "path": "x.pdf" })).is_err(), "negative creep");
|
||||
assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
@@ -123,6 +123,45 @@ fn page_render_reports_a_recovered_malformed_indexed_image() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A picture whose JBIG2 soft mask is past one of the decoder's limits isn't drawn (#6722), and
|
||||
/// `page_render` says why the page is incomplete, in the words `pdfcraft-cli render` prints.
|
||||
#[test]
|
||||
fn page_render_says_a_picture_was_left_out_for_its_jbig2_mask() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-jbig2-mask-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// One JBIG2 segment (ISO 14492 §7.4.8): the information of a page 65,536 columns wide, a
|
||||
// column past the decoder's bound on a side, as the mask's dictionary says too.
|
||||
let mut mask = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19];
|
||||
mask.extend_from_slice(&65536u32.to_be_bytes());
|
||||
mask.extend_from_slice(&2u32.to_be_bytes());
|
||||
mask.extend_from_slice(&[0; 11]); // resolution, flags, striping
|
||||
let content = "q 200 0 0 200 0 0 cm /Im Do Q";
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n\
|
||||
6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n",
|
||||
content.len(),
|
||||
mask.len()
|
||||
)
|
||||
.into_bytes();
|
||||
pdf.extend_from_slice(&mask);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
|
||||
std::fs::write(dir.join("mask.pdf"), pdf).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = open(&mut a, "mask.pdf");
|
||||
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
|
||||
assert_eq!(
|
||||
report["warnings"],
|
||||
json!(["content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"])
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
|
||||
#[cfg(feature = "mcp")]
|
||||
#[test]
|
||||
|
||||
+161
-2
@@ -246,12 +246,14 @@ pub struct DecodeBudget {
|
||||
bytes: usize,
|
||||
forms: usize,
|
||||
seen: HashSet<ObjRef>,
|
||||
/// Decodes refused for lack of allowance ([`DecodeBudget::refused`]).
|
||||
refusals: usize,
|
||||
}
|
||||
|
||||
impl DecodeBudget {
|
||||
/// An allowance of `bytes` decoded bytes and `forms` form entries.
|
||||
pub fn new(bytes: usize, forms: usize) -> Self {
|
||||
Self { limit: bytes, bytes, forms, seen: HashSet::new() }
|
||||
Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 }
|
||||
}
|
||||
|
||||
/// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms.
|
||||
@@ -264,6 +266,22 @@ impl DecodeBudget {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
/// How many decodes were refused for lack of allowance: one that came once it was spent, or
|
||||
/// ran past what was left. A decode that spent exactly what was left wasn't refused, so an
|
||||
/// allowance used up to the byte has none.
|
||||
pub fn refused(&self) -> usize {
|
||||
self.refusals
|
||||
}
|
||||
|
||||
/// Whether the allowance is spent and `stream`, which has data, would be refused, noting the
|
||||
/// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's
|
||||
/// parameters, work the allowance doesn't count. A stream without data still reads.
|
||||
pub fn refuses(&mut self, stream: &Stream) -> bool {
|
||||
let refuses = self.bytes == 0 && !stream.raw.is_empty();
|
||||
self.refusals += usize::from(refuses);
|
||||
refuses
|
||||
}
|
||||
|
||||
/// Whether a decode was refused for lack of allowance or a form entry for lack of visits.
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.bytes == 0 || self.forms == 0
|
||||
@@ -283,6 +301,51 @@ impl DecodeBudget {
|
||||
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
|
||||
}
|
||||
|
||||
/// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as
|
||||
/// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the
|
||||
/// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares
|
||||
/// its data and copies no more of its dictionary than its filters and parameters
|
||||
/// ([`DecodeBudget::decode_with_damage`] reads it so). A
|
||||
/// stream so reads here when it reads without an allowance and the allowance can pay for
|
||||
/// what its stages decode, the output of one stage on top of another's. A stage past `max`
|
||||
/// is an error that charges what the stages before it decoded and `max`, the work it took to
|
||||
/// find out; past the stages' total it charges that total, and past what is left it spends
|
||||
/// all of it, as [`DecodeBudget::decode_within`] does.
|
||||
pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
// Before the parameters are resolved, work the allowance doesn't count.
|
||||
if self.refuses(stream) {
|
||||
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
|
||||
}
|
||||
let stream = doc.decoding_view(stream);
|
||||
let total = max.saturating_mul(stream.filters().len().max(1));
|
||||
let room = total.min(self.bytes);
|
||||
match stream.decoded_within_each_counted(max, room) {
|
||||
Ok((data, _, spent)) => {
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
Ok(data)
|
||||
}
|
||||
// A stage stopped at `max`, below what was left of the room.
|
||||
Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => {
|
||||
self.bytes = self.bytes.saturating_sub(spent.saturating_add(max));
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(max).to_string()))
|
||||
}
|
||||
// At the stages' total, with more left.
|
||||
Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => {
|
||||
self.bytes = self.bytes.saturating_sub(total);
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(total).to_string()))
|
||||
}
|
||||
Err((FilterError::LimitExceeded(_), _)) => {
|
||||
self.bytes = 0;
|
||||
self.refusals += 1;
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
|
||||
}
|
||||
Err((e, spent)) => {
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
Err(CosError::Filter(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
|
||||
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
|
||||
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
|
||||
@@ -304,7 +367,7 @@ impl DecodeBudget {
|
||||
/// pays for the bytes decoded before the error, so referencing a stream again and again
|
||||
/// spends the allowance by the work each decode does.
|
||||
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec<u8>, bool), CosError> {
|
||||
if self.bytes == 0 && !stream.raw.is_empty() {
|
||||
if self.refuses(stream) {
|
||||
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
|
||||
}
|
||||
if max < self.bytes {
|
||||
@@ -324,6 +387,7 @@ impl DecodeBudget {
|
||||
}
|
||||
Err((FilterError::LimitExceeded(_), _)) => {
|
||||
self.bytes = 0;
|
||||
self.refusals += 1;
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
|
||||
}
|
||||
Err((e, spent)) => {
|
||||
@@ -1025,4 +1089,99 @@ mod tests {
|
||||
assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
|
||||
}
|
||||
|
||||
/// `decode_stages_within` holds every stage to the limit, as a decode without an allowance
|
||||
/// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and
|
||||
/// Flate whose stages each stay within it reads, the hex stage's output charged on top; one
|
||||
/// whose Flate stage would make more stops there, charged what the hex stage made and the
|
||||
/// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves
|
||||
/// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a
|
||||
/// stream past what is left spends all of it.
|
||||
#[test]
|
||||
fn each_stage_is_held_to_the_limit() {
|
||||
let chained = |data: &[u8], filters: [&str; 2]| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect()));
|
||||
Stream::from_raw(dict, data.to_vec())
|
||||
};
|
||||
let hexed = |data: &[u8]| {
|
||||
let flate = Stream::flate(Dict::new(), data);
|
||||
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
|
||||
(chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len())
|
||||
};
|
||||
let max = 1 << 16;
|
||||
let (fits, inner) = hexed(&vec![0u8; max]);
|
||||
let mut budget = DecodeBudget::new(1 << 20, 0);
|
||||
assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max));
|
||||
assert_eq!(budget.remaining(), (1 << 20) - inner - max);
|
||||
let (bomb, inner) = hexed(&vec![0u8; 8 * max]);
|
||||
let mut budget = DecodeBudget::new(1 << 20, 0);
|
||||
assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err());
|
||||
assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit");
|
||||
// Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together
|
||||
// make 1.8 times the limit, within twice it, but the Flate stage alone is past it.
|
||||
let text = "00".repeat(max * 6 / 10).into_bytes();
|
||||
let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]);
|
||||
assert!(wide.decoded_within(max).is_err());
|
||||
assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err());
|
||||
let mut budget = DecodeBudget::new(max, 0);
|
||||
assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err());
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
|
||||
/// What `decode_stages_within` charges, and which limit it reports, on each way a read stops:
|
||||
/// a stage at the stream's limit with more of the room left charges what came before and the
|
||||
/// limit; at the stages' total, with more of the allowance left, it charges that total; at
|
||||
/// what is left of the allowance it spends all of it and reports the allowance's limit, as
|
||||
/// `decode_within` does. Each boundary between them is read on both sides.
|
||||
#[test]
|
||||
fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() {
|
||||
let max = 1 << 12;
|
||||
let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
|
||||
let two = |raw: &[u8]| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
Stream::from_raw(dict, hex(raw))
|
||||
};
|
||||
let says = |read: Result<Vec<u8>, CosError>, limit: usize| {
|
||||
read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string())
|
||||
};
|
||||
// Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and
|
||||
// the Flate stage stops at the limit.
|
||||
let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec();
|
||||
let inner = bomb.len();
|
||||
// A byte more of the room than the hex stage and the limit: the Flate stage stops at the
|
||||
// limit, so that is what it charges.
|
||||
let mut budget = DecodeBudget::new(inner + max + 1, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max));
|
||||
assert_eq!(budget.remaining(), 1);
|
||||
// Exactly the hex stage and the limit: what was left stopped it as much as the limit did,
|
||||
// so it is past the allowance, spends it and reports the allowance's limit.
|
||||
let mut budget = DecodeBudget::new(inner + max, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// The same data padded to exactly the limit: the hex stage makes all of it, and the Flate
|
||||
// stage, at the limit, reaches the stages' total. With more of the allowance left, that
|
||||
// total is what it charges and reports.
|
||||
let mut padded = bomb.clone();
|
||||
padded.resize(max, 0);
|
||||
let mut budget = DecodeBudget::new(10 * max, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max));
|
||||
assert_eq!(budget.remaining(), 8 * max);
|
||||
// With exactly the total left (of an allowance of three times the limit), it is past the
|
||||
// allowance: all of it is spent, and the allowance's limit reported.
|
||||
let mut budget = DecodeBudget::new(3 * max, 0);
|
||||
budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap();
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// With less than the total left, the same.
|
||||
let mut budget = DecodeBudget::new(max, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// With nothing left, a stream with data is refused before it is decoded, even one that
|
||||
// decodes to nothing; an empty one reads.
|
||||
let mut budget = DecodeBudget::new(0, 0);
|
||||
assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0));
|
||||
assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,10 +351,12 @@ impl Stream {
|
||||
}
|
||||
|
||||
/// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
|
||||
/// decoded before the damage is returned). Streams with image codecs fail with
|
||||
/// decoded before the damage is returned). Decoding past
|
||||
/// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one
|
||||
/// stream, is an error. Streams with image codecs fail with
|
||||
/// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
|
||||
pub fn decoded(&self) -> Result<Vec<u8>, CosError> {
|
||||
self.decoded_within(MAX_DECODED)
|
||||
self.decoded_within(crate::budget::MAX_PAGE_DECODED)
|
||||
}
|
||||
|
||||
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
|
||||
@@ -382,6 +384,26 @@ impl Stream {
|
||||
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as
|
||||
/// [`Stream::decoded_within`] holds it, while `max` bounds the stages together
|
||||
/// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to
|
||||
/// both.
|
||||
pub(crate) fn decoded_within_each_counted(
|
||||
&self,
|
||||
each: usize,
|
||||
max: usize,
|
||||
) -> Result<(Vec<u8>, bool, usize), (pdfcraft_filters::FilterError, usize)> {
|
||||
let mut damaged = false;
|
||||
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
|
||||
pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| {
|
||||
damaged = partial;
|
||||
(v, spent)
|
||||
})
|
||||
};
|
||||
let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max };
|
||||
self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
|
||||
/// document's images ([`pdfcraft_filters::decode_tolerant_within`]).
|
||||
pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
|
||||
@@ -780,6 +802,13 @@ mod tests {
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_stops_at_renderer_cap_of_256_mib() {
|
||||
let data = vec![0u8; (256 << 20) + 1];
|
||||
let stream = Stream::flate(Dict::new(), &data);
|
||||
assert!(matches!(stream.decoded(), Err(CosError::Filter(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_filter_decodes_only_the_outer_wrapper() {
|
||||
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
|
||||
|
||||
@@ -16,6 +16,7 @@ md-5 = "0.11"
|
||||
sha2 = "0.11"
|
||||
stringprep = "0.1"
|
||||
thiserror.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
+58
-53
@@ -19,6 +19,7 @@ mod rc4;
|
||||
|
||||
use md5::{Digest as _, Md5};
|
||||
use sha2::{Sha256, Sha384, Sha512};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt};
|
||||
pub use rc4::rc4;
|
||||
@@ -147,9 +148,9 @@ pub enum StreamKind<'a> {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SecurityHandler {
|
||||
dict: EncryptDict,
|
||||
key: Vec<u8>,
|
||||
key: Zeroizing<Vec<u8>>,
|
||||
auth: Auth,
|
||||
recovered_user: Option<Vec<u8>>,
|
||||
recovered_user: Option<Zeroizing<Vec<u8>>>,
|
||||
methods: Methods,
|
||||
}
|
||||
|
||||
@@ -190,7 +191,7 @@ impl Methods {
|
||||
}
|
||||
|
||||
impl SecurityHandler {
|
||||
fn new(dict: EncryptDict, key: Vec<u8>, auth: Auth, recovered_user: Option<Vec<u8>>) -> Self {
|
||||
fn new(dict: EncryptDict, key: Zeroizing<Vec<u8>>, auth: Auth, recovered_user: Option<Zeroizing<Vec<u8>>>) -> Self {
|
||||
let methods = Methods::new(&dict);
|
||||
Self { dict, key, auth, recovered_user, methods }
|
||||
}
|
||||
@@ -248,7 +249,7 @@ impl SecurityHandler {
|
||||
if methods.string != Method::Identity || methods.stream != Method::Identity {
|
||||
return None;
|
||||
}
|
||||
Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods })
|
||||
Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods })
|
||||
}
|
||||
|
||||
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
|
||||
@@ -289,7 +290,7 @@ impl SecurityHandler {
|
||||
/// For R2–R4 documents opened with the owner password: the user password, which owner
|
||||
/// authentication recovers (Algorithm 7). Lets components that only accept user passwords
|
||||
/// open the document too.
|
||||
pub fn recovered_user_password(&self) -> Option<Vec<u8>> {
|
||||
pub fn recovered_user_password(&self) -> Option<Zeroizing<Vec<u8>>> {
|
||||
self.recovered_user.clone()
|
||||
}
|
||||
|
||||
@@ -306,7 +307,7 @@ impl SecurityHandler {
|
||||
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);
|
||||
let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false));
|
||||
let p = i32::from_le_bytes(*plain.first_chunk::<4>()?);
|
||||
(plain.get(9..12) == Some(&b"adb"[..])).then_some(p)
|
||||
}
|
||||
@@ -348,7 +349,7 @@ impl SecurityHandler {
|
||||
/// 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> {
|
||||
fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing<Vec<u8>> {
|
||||
if method == Method::Aes256 {
|
||||
return self.key.clone();
|
||||
}
|
||||
@@ -363,8 +364,9 @@ impl SecurityHandler {
|
||||
if method == Method::Aes128 {
|
||||
h.update(b"sAlT");
|
||||
}
|
||||
let digest = h.finalize();
|
||||
digest[..(key.len() + 5).min(16)].to_vec()
|
||||
let mut digest = Zeroizing::new(h.finalize().to_vec());
|
||||
digest.truncate((key.len() + 5).min(16));
|
||||
digest
|
||||
}
|
||||
|
||||
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
|
||||
@@ -456,31 +458,31 @@ pub fn pdfdoc_byte(c: char) -> Option<u8> {
|
||||
|
||||
/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes.
|
||||
/// Characters PDFDocEncoding lacks are dropped.
|
||||
fn legacy_password(pw: &str) -> Vec<u8> {
|
||||
pw.chars().filter_map(pdfdoc_byte).take(32).collect()
|
||||
fn legacy_password(pw: &str) -> Zeroizing<Vec<u8>> {
|
||||
Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect())
|
||||
}
|
||||
|
||||
/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification
|
||||
/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives
|
||||
/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords.
|
||||
fn legacy_candidates(pw: &str) -> Vec<Vec<u8>> {
|
||||
fn legacy_candidates(pw: &str) -> Vec<Zeroizing<Vec<u8>>> {
|
||||
let mut out = vec![legacy_password(pw)];
|
||||
if pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
|
||||
out.push(pw.bytes().take(32).collect());
|
||||
out.push(Zeroizing::new(pw.bytes().take(32).collect()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes.
|
||||
fn sasl_password(pw: &str) -> Vec<u8> {
|
||||
let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string());
|
||||
let mut bytes = prepped.into_bytes();
|
||||
fn sasl_password(pw: &str) -> Zeroizing<Vec<u8>> {
|
||||
let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()));
|
||||
let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec());
|
||||
bytes.truncate(127);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn pad(pw: &[u8]) -> [u8; 32] {
|
||||
let mut out = PADDING;
|
||||
fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> {
|
||||
let mut out = Zeroizing::new(PADDING);
|
||||
let n = pw.len().min(32);
|
||||
out[..n].copy_from_slice(&pw[..n]);
|
||||
out[n..].copy_from_slice(&PADDING[..32 - n]);
|
||||
@@ -494,20 +496,20 @@ fn key_len(d: &EncryptDict) -> usize {
|
||||
// ── Revisions 2–4 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Algorithm 2: file key from a (user) password.
|
||||
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec<u8> {
|
||||
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let n = key_len(d);
|
||||
let mut h = Md5::new();
|
||||
h.update(pad(pw));
|
||||
h.update(*pad(pw));
|
||||
h.update(&d.o[..d.o.len().min(32)]);
|
||||
h.update(d.p.to_le_bytes());
|
||||
h.update(id0);
|
||||
if d.r >= 4 && !d.encrypt_metadata {
|
||||
h.update([0xFF; 4]);
|
||||
}
|
||||
let mut digest = h.finalize().to_vec();
|
||||
let mut digest = Zeroizing::new(h.finalize().to_vec());
|
||||
if d.r >= 3 {
|
||||
for _ in 0..50 {
|
||||
digest = Md5::digest(&digest[..n]).to_vec();
|
||||
digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec());
|
||||
}
|
||||
}
|
||||
digest.truncate(n);
|
||||
@@ -524,7 +526,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
h.update(id0);
|
||||
let mut x = rc4(key, &h.finalize());
|
||||
for i in 1..=19u8 {
|
||||
let k: Vec<u8> = key.iter().map(|b| b ^ i).collect();
|
||||
let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::<Vec<u8>>());
|
||||
x = rc4(&k, &x);
|
||||
}
|
||||
x.extend_from_slice(&[0u8; 16]);
|
||||
@@ -536,7 +538,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
/// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the
|
||||
/// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation
|
||||
/// fails we retry the other way.
|
||||
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
|
||||
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
|
||||
let check = |d: &EncryptDict| {
|
||||
let key = file_key_r4(d, id0, pw);
|
||||
let u = u_value_r4(d, id0, &key);
|
||||
@@ -547,54 +549,57 @@ fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The RC4 key derived from an owner password (Algorithm 3, steps a–d).
|
||||
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec<u8> {
|
||||
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let n = key_len(d);
|
||||
let mut digest = Md5::digest(pad(owner_pw)).to_vec();
|
||||
let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec());
|
||||
if d.r >= 3 {
|
||||
for _ in 0..50 {
|
||||
digest = Md5::digest(&digest).to_vec();
|
||||
digest = Zeroizing::new(Md5::digest(&*digest).to_vec());
|
||||
}
|
||||
}
|
||||
digest.truncate(n);
|
||||
digest
|
||||
}
|
||||
|
||||
type SecretBytes = Zeroizing<Vec<u8>>;
|
||||
|
||||
/// Algorithm 7: authenticate an owner password (recover the user password from `/O`).
|
||||
/// Returns the file key and the recovered user password (padding removed).
|
||||
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> {
|
||||
let rk = owner_rc4_key(d, pw);
|
||||
let mut user = d.o[..d.o.len().min(32)].to_vec();
|
||||
let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec());
|
||||
if d.r == 2 {
|
||||
user = rc4(&rk, &user);
|
||||
user = Zeroizing::new(rc4(&rk, &user));
|
||||
} else {
|
||||
for i in (0..=19u8).rev() {
|
||||
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
|
||||
user = rc4(&k, &user);
|
||||
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
|
||||
user = Zeroizing::new(rc4(&k, &user));
|
||||
}
|
||||
}
|
||||
let key = user_key_r4(d, id0, &user)?;
|
||||
// The recovered value is the padded password: strip the padding suffix.
|
||||
let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32));
|
||||
Some((key, user[..len].to_vec()))
|
||||
user.truncate(len);
|
||||
Some((key, user))
|
||||
}
|
||||
|
||||
// ── Revisions 5–6 ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B).
|
||||
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
let mut k = {
|
||||
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let mut k = Zeroizing::new({
|
||||
let mut h = Sha256::new();
|
||||
h.update(pw);
|
||||
h.update(salt);
|
||||
h.update(udata);
|
||||
h.finalize().to_vec()
|
||||
};
|
||||
});
|
||||
if r == 5 {
|
||||
return k;
|
||||
}
|
||||
let mut round = 0u32;
|
||||
loop {
|
||||
let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()));
|
||||
let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())));
|
||||
for _ in 0..64 {
|
||||
k1.extend_from_slice(pw);
|
||||
k1.extend_from_slice(&k);
|
||||
@@ -603,13 +608,13 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
// `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16.
|
||||
let Some((key, rest)) = k.split_first_chunk::<16>() else { break };
|
||||
let Some(iv) = rest.first_chunk::<16>() else { break };
|
||||
let e = aes_cbc_encrypt(key, iv, &k1, false);
|
||||
let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false));
|
||||
let m = e.iter().take(16).map(|b| u32::from(*b)).sum::<u32>() % 3;
|
||||
k = match m {
|
||||
0 => Sha256::digest(&e).to_vec(),
|
||||
1 => Sha384::digest(&e).to_vec(),
|
||||
_ => Sha512::digest(&e).to_vec(),
|
||||
};
|
||||
k = Zeroizing::new(match m {
|
||||
0 => Sha256::digest(&*e).to_vec(),
|
||||
1 => Sha384::digest(&*e).to_vec(),
|
||||
_ => Sha512::digest(&*e).to_vec(),
|
||||
});
|
||||
round += 1;
|
||||
if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) {
|
||||
break;
|
||||
@@ -620,19 +625,19 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key.
|
||||
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Vec<u8>>, CryptError> {
|
||||
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Zeroizing<Vec<u8>>>, CryptError> {
|
||||
if d.u.len() < 48 || d.o.len() < 48 {
|
||||
return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into()));
|
||||
}
|
||||
let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) };
|
||||
if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
|
||||
if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
|
||||
return Ok(None);
|
||||
}
|
||||
if encrypted.len() < 32 {
|
||||
return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into()));
|
||||
}
|
||||
let k = hash_r6(d.r, pw, &hashed[40..48], udata);
|
||||
Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))
|
||||
Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))))
|
||||
}
|
||||
|
||||
// ── Creating encryption ────────────────────────────────────────────────────────────────────────
|
||||
@@ -654,7 +659,7 @@ pub enum Algorithm {
|
||||
/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed,
|
||||
/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8,
|
||||
/// at most 127 bytes.
|
||||
fn prepared_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
|
||||
fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing<Vec<u8>> {
|
||||
match algorithm {
|
||||
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password),
|
||||
Algorithm::Aes256 => sasl_password(password),
|
||||
@@ -672,7 +677,7 @@ pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
|
||||
let prepared = prepared_password(algorithm, password);
|
||||
match algorithm {
|
||||
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(),
|
||||
Algorithm::Aes256 => prepared,
|
||||
Algorithm::Aes256 => prepared.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,10 +742,10 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
|
||||
let owner = if params.owner_password.is_empty() { user.clone() } else { owner };
|
||||
// Algorithm 3: /O
|
||||
let rk = owner_rc4_key(&d, &owner);
|
||||
let mut o = rc4(&rk, &pad(&user));
|
||||
let mut o = rc4(&rk, &*pad(&user));
|
||||
if d.r >= 3 {
|
||||
for i in 1..=19u8 {
|
||||
let k: Vec<u8> = rk.iter().map(|b| b ^ i).collect();
|
||||
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
|
||||
o = rc4(&k, &o);
|
||||
}
|
||||
}
|
||||
@@ -754,15 +759,15 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
|
||||
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes256)];
|
||||
d.stm_f = b"StdCF".to_vec();
|
||||
d.str_f = b"StdCF".to_vec();
|
||||
let key = expand(¶ms.seed, b"file key", 32);
|
||||
let key = Zeroizing::new(expand(¶ms.seed, b"file key", 32));
|
||||
let salts = expand(¶ms.seed, b"salts", 32);
|
||||
// Algorithm 8: /U, /UE
|
||||
let mut u = hash_r6(6, &user, &salts[0..8], &[]);
|
||||
let mut u = hash_r6(6, &user, &salts[0..8], &[]).to_vec();
|
||||
u.extend_from_slice(&salts[0..16]);
|
||||
let uk = hash_r6(6, &user, &salts[8..16], &[]);
|
||||
d.ue = aes_cbc_encrypt(&uk, &[0; 16], &key, false);
|
||||
// Algorithm 9: /O, /OE
|
||||
let mut o = hash_r6(6, &owner, &salts[16..24], &u);
|
||||
let mut o = hash_r6(6, &owner, &salts[16..24], &u).to_vec();
|
||||
o.extend_from_slice(&salts[16..32]);
|
||||
let ok = hash_r6(6, &owner, &salts[24..32], &u);
|
||||
d.oe = aes_cbc_encrypt(&ok, &[0; 16], &key, false);
|
||||
|
||||
@@ -348,11 +348,13 @@ fn owner_authentication_recovers_the_user_password() {
|
||||
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||||
let d = new(alg, "user pw", "owner pw", -1).dict().clone();
|
||||
let h = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
|
||||
assert_eq!(h.recovered_user_password().as_deref(), Some(&b"user pw"[..]), "{alg:?}");
|
||||
let _: &Zeroizing<Vec<u8>> = &h.key;
|
||||
let _: &Option<Zeroizing<Vec<u8>>> = &h.recovered_user;
|
||||
assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}");
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None);
|
||||
}
|
||||
let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone();
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..]));
|
||||
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..]));
|
||||
}
|
||||
|
||||
/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys
|
||||
@@ -393,7 +395,7 @@ fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() {
|
||||
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f")));
|
||||
let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap();
|
||||
assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f")));
|
||||
assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0]));
|
||||
assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0])));
|
||||
// Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it.
|
||||
for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] {
|
||||
assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}");
|
||||
@@ -423,6 +425,6 @@ fn pdfdoc_encoding_round_trips() {
|
||||
for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] {
|
||||
assert_eq!(pdfdoc_byte(c), None, "{c}");
|
||||
}
|
||||
assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]);
|
||||
assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]);
|
||||
assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]);
|
||||
assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ impl PageImage {
|
||||
self.clips.iter().any(|clip| clip.app_crop)
|
||||
}
|
||||
|
||||
/// The editing frame, respecting explicit PDF/editor crops but not the page boundary.
|
||||
/// Selected handles remain reachable outside the page; body hits still use `contains`.
|
||||
pub fn frame_rect(&self) -> Option<[f64; 4]> {
|
||||
self.clips.iter().try_fold(self.rect, |rect, clip| {
|
||||
let b = clip.path.bounding_box();
|
||||
intersection(rect, [b.x0, b.y0, b.x1, b.y1])
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a user-space point belongs to the placed artwork and every active clip.
|
||||
pub fn contains(&self, point: [f64; 2]) -> bool {
|
||||
if !point.iter().all(|v| v.is_finite()) {
|
||||
|
||||
@@ -13628,6 +13628,25 @@ fn page_rules_refuse_unread_graphics_state() {
|
||||
assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_editing_frame_keeps_page_edge_outside_explicit_clips() {
|
||||
for (prefix, expected) in [
|
||||
("", [-30.0, -20.0, 70.0, 80.0]),
|
||||
("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
|
||||
("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
|
||||
] {
|
||||
let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q"));
|
||||
for doc in [&doc, &reopen(&doc)] {
|
||||
let im = images::page_images(doc, 0).unwrap().remove(0);
|
||||
assert_eq!(im.frame_rect(), Some(expected));
|
||||
assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop"));
|
||||
assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]);
|
||||
assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable");
|
||||
assert!(im.contains([10.0, 10.0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() {
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
|
||||
@@ -14295,6 +14314,29 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() {
|
||||
assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing");
|
||||
}
|
||||
|
||||
/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a
|
||||
/// read refused for want of it refuses the edit. The subset is read four times over its program
|
||||
/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read)
|
||||
/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of
|
||||
/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it.
|
||||
#[test]
|
||||
fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() {
|
||||
let mut program = legacy_thai_program();
|
||||
program.resize((16 << 20) - 192, 0);
|
||||
let edit = |unicode_len: usize| {
|
||||
let mut unicode = LEGACY_THAI_UNICODE.to_vec();
|
||||
unicode.resize(unicode_len, b' ');
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
|
||||
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &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("กขค"), &style).map(|_| ()).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(edit(256), Ok(()));
|
||||
let error = edit(257).unwrap_err();
|
||||
assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}");
|
||||
}
|
||||
|
||||
/// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is
|
||||
/// shaped with its own layout like any source font, not by the legacy shaper (which would ask
|
||||
/// for Windows PUA alternates such a face doesn't need).
|
||||
|
||||
@@ -116,8 +116,11 @@ struct LegacyFace {
|
||||
space: f64,
|
||||
}
|
||||
|
||||
/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets
|
||||
/// (each at most 16 MiB): a page may name many of them.
|
||||
/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most
|
||||
/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their
|
||||
/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its
|
||||
/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among
|
||||
/// the page's fonts aren't charged to it.
|
||||
const MAX_THAI_PROGRAMS: usize = 64 << 20;
|
||||
|
||||
struct PreparedCharacters {
|
||||
@@ -900,6 +903,9 @@ fn legacy_thai_font(
|
||||
}
|
||||
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
|
||||
let mut candidates = vec![source.font.clone()];
|
||||
// Every name on the page is read here for its face name alone, aliases of one font and other
|
||||
// faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would
|
||||
// pay again and other faces would spend what the face's subsets need.
|
||||
candidates.extend(fonts.iter().filter_map(|(name, font)| {
|
||||
let font = doc.dict(font)?;
|
||||
(pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned())
|
||||
@@ -914,9 +920,15 @@ fn legacy_thai_font(
|
||||
let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) {
|
||||
Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias },
|
||||
None => {
|
||||
let metrics = Metrics::from_dict(doc, &font);
|
||||
let space = source_space(doc, fonts, &metrics);
|
||||
let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new);
|
||||
// Read within the edit's allowance; past it the subset goes unread, and the edit is
|
||||
// refused below.
|
||||
let metrics = Metrics::from_dict_within(doc, &font, budget);
|
||||
let (space, glyphs) = if metrics.past_allowance {
|
||||
(0.0, None)
|
||||
} else {
|
||||
let space = source_space(doc, fonts, &metrics);
|
||||
(space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new))
|
||||
};
|
||||
let style = FontStyle::of(doc, &font, &metrics);
|
||||
LegacyFace { glyphs, metrics, style, object, space }
|
||||
}
|
||||
@@ -924,9 +936,11 @@ fn legacy_thai_font(
|
||||
cache.insert(name, face);
|
||||
}
|
||||
// A subset left unread would count as unusable, silently changing which subset draws the text.
|
||||
if budget.remaining() == 0 {
|
||||
// A read refused for want of the allowance is what refuses the edit: reads that spent it to the
|
||||
// byte refused nothing.
|
||||
if budget.refused() > 0 {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text",
|
||||
"the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text",
|
||||
source.base_font.rsplit('+').next().unwrap_or(&source.base_font),
|
||||
MAX_THAI_PROGRAMS >> 20
|
||||
)));
|
||||
|
||||
@@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
if let Some((added_idx, added_item)) = matched_added
|
||||
&& let AddedContent::Image(ai) = added_item.content.clone()
|
||||
{
|
||||
// Page visibility alone is not a crop: dragging an added image over the page edge
|
||||
// must not discard its editable added-item identity on the next resize.
|
||||
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|
||||
|| img.visible_rect.is_some_and(|v| {
|
||||
(v[0] - img.rect[0]).abs() > 1e-4
|
||||
|| (v[1] - img.rect[1]).abs() > 1e-4
|
||||
|| (v[2] - img.rect[2]).abs() > 1e-4
|
||||
|| (v[3] - img.rect[3]).abs() > 1e-4
|
||||
});
|
||||
|| img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4));
|
||||
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
|
||||
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
|
||||
let should_promote = has_active_crop
|
||||
|
||||
@@ -81,13 +81,13 @@ impl JsOutput {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// except a request to show a side panel, open Save As, or open or save an attached file,
|
||||
/// which is noted on the console and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
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.
|
||||
// button's may show or hide a side panel, open Save As, or open or save an attached file.
|
||||
// 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();
|
||||
@@ -95,6 +95,9 @@ impl JsOutput {
|
||||
match r {
|
||||
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()),
|
||||
Request::OpenAttachment { name, .. } => {
|
||||
notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file"))
|
||||
}
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
@@ -510,10 +513,12 @@ impl Session {
|
||||
}
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
// A button PdfCraft generated from an XFA template runs its XFA click script.
|
||||
// A button PdfCraft generated from an XFA template runs its XFA click script (the app runs
|
||||
// click scripts for buttons only, so other fields' click events don't run here either).
|
||||
if let Some(t) = target
|
||||
&& let Some(tpl) = doc.xfa_template.clone()
|
||||
&& let Some(field) = doc.form.iter().find(|f| f.name == t)
|
||||
&& field.kind == crate::FormFieldKind::PushButton
|
||||
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
|
||||
{
|
||||
// The click returns only the output of its own edit; output from opening or from earlier edits stays.
|
||||
|
||||
@@ -11338,6 +11338,61 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() {
|
||||
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}");
|
||||
}
|
||||
|
||||
/// `run_javascript` runs an XFA click script only for a push button, as the app does: a text
|
||||
/// field with a click event of its own, named as the target, asks to open no attached file.
|
||||
#[test]
|
||||
fn run_javascript_runs_xfa_click_scripts_only_for_buttons() {
|
||||
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>
|
||||
<field name="guide" w="2in" h="0.3in"><ui><button/></ui><caption><value><text>Instructions</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
<field name="note" w="2in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="click"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
s.take_js_output(id);
|
||||
let o = s.run_javascript(id, "", Some("guide")).unwrap();
|
||||
assert_eq!(o.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "the button's click: {o:?}");
|
||||
let o = s.run_javascript(id, "", Some("note")).unwrap();
|
||||
assert!(o.requests.is_empty(), "a text field's click script doesn't run here: {o:?}");
|
||||
}
|
||||
|
||||
/// In an XFA form a button's click script may open an attached file; the same request from a
|
||||
/// text field's exit script is noted in the console and not made.
|
||||
#[test]
|
||||
fn an_xfa_button_may_open_an_attached_file_and_other_events_may_not() {
|
||||
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>
|
||||
<field name="guide" w="2in" h="0.3in"><ui><button/></ui><caption><value><text>Instructions</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
<field name="note" w="2in" h="0.3in"><ui><textEdit/></ui>
|
||||
<event activity="exit"><script contentType="application/x-javascript">event.target.exportDataObject({cName: "Guide.pdf", nLaunch: 2});</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::XfaEvent { som: "form[0].guide[0]".into(), activity: "click".into() }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert_eq!(out.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "{out:?}");
|
||||
s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.requests.is_empty(), "only a click opens a file: {out:?}");
|
||||
assert!(out.console.iter().any(|c| c.contains("open an attached file")), "{:?}", out.console);
|
||||
}
|
||||
|
||||
/// A field whose layout:ready script only reads the page count keeps the value its script gives
|
||||
/// it: only a field the script gives the page call's result is numbered by the layout.
|
||||
#[test]
|
||||
@@ -13241,6 +13296,25 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() {
|
||||
assert_eq!(named, 0, "no image names the dictionary, which left with them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let original = s.get(id).unwrap().added()[0].obj;
|
||||
let target = [10.0, 10.0, 60.0, 60.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap();
|
||||
let added = s.get(id).unwrap().added();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0].obj, original);
|
||||
assert_eq!(added[0].content.rect(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
|
||||
let (mut session, id) = rotated_blank(0);
|
||||
|
||||
@@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> {
|
||||
XfaEffect::Print => Some("print"),
|
||||
XfaEffect::SaveAs => Some("save"),
|
||||
XfaEffect::LaunchUrl(_) => Some("open a link"),
|
||||
XfaEffect::OpenAttachment { .. } => Some("open an attached file"),
|
||||
XfaEffect::ShowPanel(_) => Some("show a side panel"),
|
||||
XfaEffect::SetFocus(_) => Some("move the focus"),
|
||||
_ => None,
|
||||
@@ -910,6 +911,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
XfaEffect::Print => self.run.out.requests.push(Request::Print),
|
||||
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
|
||||
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
|
||||
XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }),
|
||||
XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
|
||||
XfaEffect::SetFocus(som) => {
|
||||
if let Some(name) = self.by_som().get(som).cloned() {
|
||||
|
||||
@@ -15,13 +15,10 @@ pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout,
|
||||
/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the
|
||||
/// picture its signer's application drew.
|
||||
pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> {
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) {
|
||||
continue;
|
||||
}
|
||||
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
|
||||
}
|
||||
Ok(())
|
||||
let signed = |doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field| {
|
||||
f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V"))
|
||||
};
|
||||
pdfcraft_forms::redraw_fields(doc, |doc, f| !signed(doc, f)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
|
||||
|
||||
+130
-43
@@ -1,5 +1,5 @@
|
||||
use super::as_docx::{hex, rgb8};
|
||||
use super::layout::{Item, Page, items};
|
||||
use super::layout::{Item, Page, Run, Table, items};
|
||||
|
||||
/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab,
|
||||
/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them
|
||||
@@ -94,6 +94,64 @@ pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&
|
||||
html_with_mode(pages, title, true, language)
|
||||
}
|
||||
|
||||
fn style_color(color: [f64; 3], simple: bool) -> String {
|
||||
if simple { String::new() } else { hex(color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }
|
||||
}
|
||||
|
||||
fn wrap_inline(mut text: String, bold: bool, italic: bool) -> String {
|
||||
if text.trim().is_empty() {
|
||||
return text;
|
||||
}
|
||||
if italic {
|
||||
text = format!("<em>{text}</em>");
|
||||
}
|
||||
if bold {
|
||||
text = format!("<strong>{text}</strong>");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option<String> {
|
||||
let non_empty: Vec<_> = runs.iter().map(|r| (r.block, esc(&r.text))).filter(|(_, text)| !text.is_empty()).collect();
|
||||
let &(first, _) = non_empty.iter().find(|(_, text)| !text.trim().is_empty())?;
|
||||
let mut t = String::new();
|
||||
for (block, text) in non_empty {
|
||||
if text.trim().is_empty() {
|
||||
t.push_str(&text);
|
||||
continue;
|
||||
}
|
||||
let mut run = wrap_inline(text, block.bold && lvl == 0, block.italic);
|
||||
// The paragraph has its first visible run's colour; a run in another colour says so.
|
||||
let colour = rgb8(block.color);
|
||||
if !simple && colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
let style = style_color(first.color, simple);
|
||||
Some(match lvl.min(6) {
|
||||
0 => format!("<p{style}>{t}</p>\n"),
|
||||
l => format!("<h{l}{style}>{t}</h{l}>\n"),
|
||||
})
|
||||
}
|
||||
|
||||
fn table_html(t: &Table, simple: bool) -> String {
|
||||
let mut s = String::from("<table>\n");
|
||||
for row in &t.rows {
|
||||
s.push_str("<tr>");
|
||||
for c in row {
|
||||
let body = wrap_inline(esc(&c.text), c.bold, c.italic);
|
||||
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
||||
let style = style_color(c.color, simple);
|
||||
s.push_str(&format!("<td{span}{style}>{body}</td>"));
|
||||
}
|
||||
s.push_str("</tr>\n");
|
||||
}
|
||||
s.push_str("</table>\n");
|
||||
s
|
||||
}
|
||||
|
||||
fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String {
|
||||
let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default();
|
||||
let mut s = format!(
|
||||
@@ -108,50 +166,11 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s
|
||||
for it in items(pages) {
|
||||
match it {
|
||||
Item::Para(runs, lvl) => {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
let mut t = String::new();
|
||||
for r in &runs {
|
||||
let mut run = esc(&r.text);
|
||||
if r.block.italic {
|
||||
run = format!("<em>{run}</em>");
|
||||
}
|
||||
if r.block.bold && lvl == 0 {
|
||||
run = format!("<strong>{run}</strong>");
|
||||
}
|
||||
// The paragraph has its first run's colour; a run in another colour says so.
|
||||
let colour = rgb8(r.block.color);
|
||||
if !simple && colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
||||
match lvl {
|
||||
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
|
||||
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
|
||||
if let Some(p) = para_html(&runs, lvl, simple) {
|
||||
s.push_str(&p);
|
||||
}
|
||||
}
|
||||
Item::Table(t) => {
|
||||
s.push_str("<table>\n");
|
||||
for row in &t.rows {
|
||||
s.push_str("<tr>");
|
||||
for c in row {
|
||||
let mut body = esc(&c.text);
|
||||
if c.italic {
|
||||
body = format!("<em>{body}</em>");
|
||||
}
|
||||
if c.bold {
|
||||
body = format!("<strong>{body}</strong>");
|
||||
}
|
||||
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
||||
let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
||||
s.push_str(&format!("<td{span}{style}>{body}</td>"));
|
||||
}
|
||||
s.push_str("</tr>\n");
|
||||
}
|
||||
s.push_str("</table>\n");
|
||||
}
|
||||
Item::Table(t) => s.push_str(&table_html(&t, simple)),
|
||||
Item::Img(im) => {
|
||||
let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" };
|
||||
s.push_str(&format!("<p><img alt=\"\" src=\"data:{mime};base64,{}\"></p>\n", base64(&im.bytes)));
|
||||
@@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s
|
||||
s.push_str("</body>\n</html>\n");
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{html, para_html, table_html};
|
||||
use crate::layout::{Block, Cell, Page, Run, Table};
|
||||
|
||||
fn block(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block {
|
||||
Block {
|
||||
text: text.into(),
|
||||
rect: [x0, y0, x1, y1],
|
||||
size: 12.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_control_only_runs_and_blocks_do_not_emit_empty_tags() {
|
||||
let page = Page {
|
||||
width: 612.0,
|
||||
height: 792.0,
|
||||
blocks: vec![
|
||||
Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block("\u{1}\u{FFFF}", 72.0, 700.0, 90.0, 712.0) },
|
||||
Block { color: [0.0, 0.5, 0.0], ..block("Hello", 90.5, 700.0, 140.0, 712.0) },
|
||||
Block { bold: true, ..block("\u{0}\u{2}", 72.0, 660.0, 120.0, 672.0) },
|
||||
],
|
||||
images: Vec::new(),
|
||||
background: None,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let out = html(&[page], "Clean");
|
||||
assert!(out.contains("<p style=\"color:#008000\">Hello</p>"), "{out}");
|
||||
assert!(!out.contains("<strong></strong>"), "{out}");
|
||||
assert!(!out.contains("<em></em>"), "{out}");
|
||||
assert_eq!(out.matches("<p").count(), 1, "{out}");
|
||||
|
||||
// Styled whitespace-only paragraphs are omitted, and leading whitespace runs do not set
|
||||
// the paragraph colour or emit emphasis/colour wrappers.
|
||||
let ws_block = Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block(" ", 72.0, 700.0, 80.0, 712.0) };
|
||||
let vis_block = Block { color: [0.0, 0.5, 0.0], ..block("Visible", 80.0, 700.0, 120.0, 712.0) };
|
||||
assert_eq!(para_html(&[Run { text: " \t\n".into(), block: &ws_block }], 0, false), None);
|
||||
assert_eq!(
|
||||
para_html(&[Run { text: " ".into(), block: &ws_block }, Run { text: "Visible".into(), block: &vis_block }], 0, false).as_deref(),
|
||||
Some("<p style=\"color:#008000\"> Visible</p>\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_level_is_clamped_and_empty_styled_cells_stay_clean() {
|
||||
let b = block("Deep Heading", 72.0, 700.0, 200.0, 712.0);
|
||||
let runs = [Run { text: "Deep Heading".into(), block: &b }];
|
||||
assert_eq!(para_html(&runs, 9, true).as_deref(), Some("<h6>Deep Heading</h6>\n"));
|
||||
|
||||
let table = Table {
|
||||
rect: [0.0, 0.0, 100.0, 20.0],
|
||||
cols: vec![0.0, 50.0],
|
||||
rows: vec![vec![
|
||||
Cell { text: "\u{1}".into(), size: 11.0, bold: true, italic: true, color: [0.0; 3], span: 1 },
|
||||
Cell { text: "Val".into(), size: 11.0, bold: true, italic: false, color: [0.0; 3], span: 1 },
|
||||
]],
|
||||
ruled: None,
|
||||
};
|
||||
assert_eq!(table_html(&table, true), "<table>\n<tr><td></td><td><strong>Val</strong></td></tr>\n</table>\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp
|
||||
run(chain, data, max_output, Some(max_output), false, false, None)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as
|
||||
/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A
|
||||
/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it.
|
||||
pub fn decode_tolerant_counted_each(
|
||||
chain: &[(Filter, Params)],
|
||||
data: &[u8],
|
||||
max_stage: usize,
|
||||
max_output: usize,
|
||||
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
|
||||
run(chain, data, max_stage, Some(max_output), false, false, None)
|
||||
}
|
||||
|
||||
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
|
||||
/// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each
|
||||
/// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each
|
||||
@@ -451,6 +463,30 @@ mod tests {
|
||||
assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data));
|
||||
}
|
||||
|
||||
/// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages
|
||||
/// to their total: a Flate stage that would make more than that bound stops there, though
|
||||
/// the total has room, and the stage after it gets nothing; within both, the chain reads
|
||||
/// whole, one stage's output charged on top of another's.
|
||||
#[test]
|
||||
fn each_stage_is_held_to_its_own_bound_within_the_total() {
|
||||
let hex = "00".repeat(1 << 16).into_bytes();
|
||||
let data = super::flate::encode(&hex);
|
||||
let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
|
||||
// The total alone lets the Flate stage make all of the hex text, and the hex stage read it.
|
||||
STAGE_BYTES.with(|c| c.set(0));
|
||||
let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v);
|
||||
assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16]));
|
||||
assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len());
|
||||
// Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing.
|
||||
STAGE_BYTES.with(|c| c.set(0));
|
||||
let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16);
|
||||
assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}");
|
||||
assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len());
|
||||
// Within both, it reads whole.
|
||||
let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap();
|
||||
assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
+148
-35
@@ -14,7 +14,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream};
|
||||
|
||||
/// How the bytes of a string map to codes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -67,6 +67,11 @@ pub struct Metrics {
|
||||
/// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the
|
||||
/// embedded program. See [`cids_with_outlines`].
|
||||
outlined_cids: Vec<u64>,
|
||||
/// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph
|
||||
/// description) was left unread because the decode allowance [`Metrics::from_dict_within`]
|
||||
/// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a
|
||||
/// Type 3 font's height) is a guess.
|
||||
pub past_allowance: bool,
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
@@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option<Dict> {
|
||||
o.and_then(|o| doc.resolve(o).as_dict().cloned())
|
||||
}
|
||||
|
||||
/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference,
|
||||
/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect).
|
||||
fn identity_h(doc: &Document, font: &Dict) -> bool {
|
||||
font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice()))
|
||||
}
|
||||
|
||||
/// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in
|
||||
/// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…).
|
||||
fn legacy_indic_font(base_font: &str) -> bool {
|
||||
@@ -92,14 +103,17 @@ fn legacy_indic_font(base_font: &str) -> bool {
|
||||
/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export
|
||||
/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer.
|
||||
pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
|
||||
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0))
|
||||
// No total: one font's two programs, and the metrics read that may adapt one, are each
|
||||
// bounded by that stream's own limit.
|
||||
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0))
|
||||
}
|
||||
|
||||
/// The largest embedded sfnt program read for the editor.
|
||||
const MAX_SFNT: usize = 16 << 20;
|
||||
|
||||
/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares:
|
||||
/// each program decoded is charged whether or not it turns out to be usable.
|
||||
/// each program decoded is charged whether or not it turns out to be usable, and so are the
|
||||
/// streams of the metrics read that adapts a program without a Unicode cmap.
|
||||
pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref());
|
||||
@@ -108,7 +122,10 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue };
|
||||
// As in `Reads::decode`: each stage within the limit, the program, the last, too, read
|
||||
// through a view of the stream; once the allowance is spent a program with data is refused
|
||||
// before its parameters are resolved.
|
||||
let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
|
||||
continue;
|
||||
@@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) {
|
||||
return Some(bytes);
|
||||
}
|
||||
// A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`):
|
||||
// with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a
|
||||
// cmap of its own is kept as it is, as when the rebuild finds nothing.
|
||||
if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) {
|
||||
if own_cmap {
|
||||
return Some(bytes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only
|
||||
// cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged.
|
||||
let metrics = Metrics::from_dict(doc, font);
|
||||
// Metrics read past the allowance, its widths' read of the program again included,
|
||||
// adapt no copy.
|
||||
let metrics = Metrics::from_dict_within(doc, font, budget);
|
||||
if metrics.past_allowance {
|
||||
continue;
|
||||
}
|
||||
let mut mappings = std::collections::BTreeMap::new();
|
||||
if metrics.composite {
|
||||
let refused = budget.refused();
|
||||
if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) {
|
||||
mappings = map;
|
||||
} else if budget.refused() > refused {
|
||||
// The map the cmap is rebuilt from was left unread for want of the allowance: no
|
||||
// copy is listed, adapted or as it is, as for metrics read past it.
|
||||
continue;
|
||||
}
|
||||
if own_cmap {
|
||||
let charmap = face.charmap();
|
||||
@@ -166,7 +202,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
/// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has
|
||||
/// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read.
|
||||
/// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one.
|
||||
fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<u64> {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref())
|
||||
else {
|
||||
@@ -175,13 +211,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
|
||||
let program = doc.resolve(&program);
|
||||
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
|
||||
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
|
||||
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
|
||||
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
|
||||
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
|
||||
Some(Object::Stream(map)) => {
|
||||
let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() };
|
||||
let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() };
|
||||
bit_set_below_64k(
|
||||
map.as_chunks::<2>()
|
||||
.0
|
||||
@@ -200,7 +236,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
|
||||
/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
|
||||
/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
|
||||
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) {
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
|
||||
@@ -208,7 +244,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
if upem == 0.0 {
|
||||
@@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20;
|
||||
/// thousand.
|
||||
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.
|
||||
/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`).
|
||||
const MAX_CMAP_BYTES: usize = 16 << 20;
|
||||
/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536.
|
||||
const MAX_CID_TO_GID: usize = 1 << 17;
|
||||
|
||||
/// The streams one reading of a font decodes, against the caller's allowance, and whether one was
|
||||
/// left unread because that allowance was spent.
|
||||
struct Reads<'a> {
|
||||
doc: &'a Document,
|
||||
budget: &'a mut DecodeBudget,
|
||||
refused: bool,
|
||||
}
|
||||
|
||||
impl<'a> Reads<'a> {
|
||||
fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self {
|
||||
Self { doc, budget, refused: false }
|
||||
}
|
||||
|
||||
/// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given
|
||||
/// by reference too, through a view of the stream that copies no more of its dictionary than
|
||||
/// its filters and parameters; `None` when it can't be, noting when the allowance is why.
|
||||
///
|
||||
/// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and
|
||||
/// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a
|
||||
/// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes
|
||||
/// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream
|
||||
/// reads here when it reads without an allowance and what is left pays for it. A stream with a
|
||||
/// stage past `max` is unread. When what is left is more than what the stages before that one
|
||||
/// made and `max`, it is charged those two and isn't refused; otherwise, with more left than
|
||||
/// the stages' total (behind one filter, `max`), it is charged that total and isn't refused;
|
||||
/// with no more left than that total, it spends all of it and counts as refused.
|
||||
fn decode(&mut self, stream: &Stream, max: usize) -> Option<Vec<u8>> {
|
||||
match self.budget.decode_stages_within(self.doc, stream, max) {
|
||||
Ok(data) => Some(data),
|
||||
Err(_) => {
|
||||
self.refused |= self.budget.remaining() == 0;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6;
|
||||
/// 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)> {
|
||||
/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too.
|
||||
fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> 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.
|
||||
@@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> {
|
||||
} else {
|
||||
names.iter().filter_map(|n| procs.get(n)).collect()
|
||||
};
|
||||
let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0);
|
||||
// What is left of the font's own total: the descriptions decode against the reading's
|
||||
// allowance, which charges them, and each within what is left of this.
|
||||
let mut left = MAX_TYPE3_FONT_BYTES;
|
||||
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 {
|
||||
// Once the reading's allowance is spent the rest go unread, before their parameters are
|
||||
// resolved.
|
||||
if reads.budget.refuses(s) {
|
||||
reads.refused = true;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let before = reads.budget.remaining();
|
||||
let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data);
|
||||
left = left.saturating_sub(before.saturating_sub(reads.budget.remaining()));
|
||||
let Ok(data) = read else {
|
||||
if reads.budget.remaining() == 0 {
|
||||
// The rest go unread too, for want of the reading's allowance.
|
||||
reads.refused = true;
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
if left == 0 {
|
||||
// The rest go unread too.
|
||||
complete = false;
|
||||
break;
|
||||
@@ -785,11 +879,25 @@ impl Metrics {
|
||||
code_len: 1,
|
||||
explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(),
|
||||
outlined_cids: Vec::new(),
|
||||
past_allowance: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for
|
||||
/// a CMap or a program, 4 MiB for a Type 3 font's glyph descriptions together), and again on
|
||||
/// every call: a caller that reads many fonts, or one font many times, shares an allowance
|
||||
/// through [`Metrics::from_dict_within`].
|
||||
pub fn from_dict(doc: &Document, font: &Dict) -> Self {
|
||||
let mut m = Self::read_metrics(doc, font);
|
||||
// No total, so nothing is refused: each stream stays within its own limit, as it always has.
|
||||
Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0))
|
||||
}
|
||||
|
||||
/// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts
|
||||
/// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the
|
||||
/// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so.
|
||||
pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self {
|
||||
let mut reads = Reads::new(doc, budget);
|
||||
let mut m = Self::read_metrics(doc, font, &mut reads);
|
||||
let base = font
|
||||
.name(b"BaseFont")
|
||||
.map(|name| String::from_utf8_lossy(name).into_owned())
|
||||
@@ -806,17 +914,18 @@ impl Metrics {
|
||||
m.italic |= italic;
|
||||
m.base_font = base;
|
||||
if m.composite && m.subset {
|
||||
m.outlined_cids = cids_with_outlines(doc, font);
|
||||
m.outlined_cids = cids_with_outlines(doc, font, &mut reads);
|
||||
}
|
||||
m.code_len = match &m.codes {
|
||||
Codes::One => 1,
|
||||
Codes::Two => 2,
|
||||
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
|
||||
};
|
||||
m.unicode = unicode_map(doc, font, m.composite);
|
||||
m.unicode = unicode_map(doc, font, m.composite, &mut reads);
|
||||
if m.std14.is_some() {
|
||||
embedded_widths(doc, font, &mut m);
|
||||
embedded_widths(doc, font, &mut m, &mut reads);
|
||||
}
|
||||
m.past_allowance = reads.refused;
|
||||
let mut reverse = HashMap::new();
|
||||
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
|
||||
if let Some(ch) = only_char(text)
|
||||
@@ -837,7 +946,7 @@ impl Metrics {
|
||||
m
|
||||
}
|
||||
|
||||
fn read_metrics(doc: &Document, font: &Dict) -> Self {
|
||||
fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self {
|
||||
let mut m = Metrics::fallback();
|
||||
m.std14 = None;
|
||||
let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec();
|
||||
@@ -850,7 +959,7 @@ impl Metrics {
|
||||
m.codes = Codes::Two;
|
||||
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
|
||||
&& let Object::Stream(s) = &*e
|
||||
&& let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES)
|
||||
&& let Some(data) = reads.decode(s, MAX_CMAP_BYTES)
|
||||
{
|
||||
let (spaces, cids) = parse_cmap(&data);
|
||||
if !spaces.is_empty() {
|
||||
@@ -915,7 +1024,7 @@ impl Metrics {
|
||||
// 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);
|
||||
let glyphs = type3_glyph_box(doc, font, reads);
|
||||
// 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));
|
||||
@@ -1088,8 +1197,14 @@ impl Metrics {
|
||||
}
|
||||
|
||||
/// The bytes that show `text` in this font, or `None` if some character has no code or
|
||||
/// no glyph in it (the caller then substitutes another font).
|
||||
/// no glyph in it, or the metrics were read past their allowance (the caller then substitutes
|
||||
/// another font). Empty text needs no verified glyph and always encodes.
|
||||
pub fn encode(&self, text: &str) -> Option<Vec<u8>> {
|
||||
// A refused read cannot establish the mappings or glyphs needed to write new text.
|
||||
// Empty text needs neither and remains usable for erasing existing text.
|
||||
if self.past_allowance && !text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The lowest code showing each wanted character. Explicit entries and the encoding are
|
||||
// checked one by one; each range is asked for the wanted characters it can show.
|
||||
let wanted: HashSet<char> = text.chars().collect();
|
||||
@@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
|
||||
/// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads
|
||||
/// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and
|
||||
/// extraction agree: `€` for code 0x80, which Standard leaves without a character.
|
||||
fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> {
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() };
|
||||
@@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() };
|
||||
let Ok(cmap) = face.cmap() else { return Vec::new() };
|
||||
// A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft
|
||||
@@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str],
|
||||
|
||||
/// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name
|
||||
/// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding.
|
||||
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode {
|
||||
let mut out = Unicode::default();
|
||||
if !composite {
|
||||
let enc = font.get(b"Encoding").map(|e| doc.resolve(e));
|
||||
@@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
// A font whose `post` table names its glyphs reads by those names; one that names none
|
||||
// and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback.
|
||||
if base_name.is_none() && !symbolic_builtin {
|
||||
out.encoding.extend(symbolic_glyph_texts(doc, font));
|
||||
out.encoding.extend(symbolic_mac_roman_texts(doc, font));
|
||||
out.encoding.extend(symbolic_glyph_texts(doc, font, reads));
|
||||
out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads));
|
||||
}
|
||||
if let Some(Object::Dict(d)) = enc.as_deref()
|
||||
&& let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned())
|
||||
@@ -1606,10 +1721,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
}
|
||||
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
|
||||
Some(Object::Stream(s)) => {
|
||||
// With `/DecodeParms` given by reference, as every stream of the font is read here,
|
||||
// and as the redaction proof reads the map: the two must get the same text, or a copy
|
||||
// redaction rightly left counts as a survivor.
|
||||
if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) {
|
||||
// Reads resolves indirect parameters too, matching the redaction proof's text.
|
||||
if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) {
|
||||
parse_to_unicode(&data, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec<u8> {
|
||||
/// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at
|
||||
/// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text
|
||||
/// layer reads the same codes the same way (vendored hayro-interpret patch (101)).
|
||||
fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> {
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() };
|
||||
@@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
|
||||
let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() };
|
||||
let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() };
|
||||
// A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post`
|
||||
// table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it)
|
||||
|
||||
+510
-10
@@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui
|
||||
/// can't use; and that glyph.
|
||||
fn mac_subset() -> (Vec<u8>, skrifa::GlyphId) {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let face = skrifa::FontRef::new(original).unwrap();
|
||||
let expected = face.charmap().map('Ω').unwrap();
|
||||
@@ -194,6 +195,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes());
|
||||
subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes());
|
||||
subset.extend_from_slice(&cmap);
|
||||
(subset, expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let (subset, expected) = mac_subset();
|
||||
let face = skrifa::FontRef::new(&subset).unwrap();
|
||||
assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6");
|
||||
assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected));
|
||||
@@ -214,6 +222,331 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
|
||||
assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none());
|
||||
}
|
||||
|
||||
/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a
|
||||
/// step short of the program, its ToUnicode and the program again for its widths and for the
|
||||
/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance
|
||||
/// and no copy is adapted.
|
||||
#[test]
|
||||
fn adapting_a_programs_cmap_is_paid_for() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let (subset, expected) = mac_subset();
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let cmap = b"1 beginbfchar <41> <03A9> endbfchar";
|
||||
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap)));
|
||||
let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]);
|
||||
let cost = 4 * subset.len() + cmap.len();
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap();
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none());
|
||||
// A composite font whose codes aren't Identity-H can't be adapted, so its metrics aren't
|
||||
// read: the program is all it pays for.
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let mut composite = source.clone();
|
||||
composite.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none());
|
||||
assert_eq!(budget.remaining(), cost - subset.len());
|
||||
}
|
||||
|
||||
/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and
|
||||
/// what each decodes to.
|
||||
fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) {
|
||||
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange";
|
||||
let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let map = [0u8, 0, 0, 1, 0, 2];
|
||||
let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data))));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), stream(program));
|
||||
let mut descendant = Dict::new();
|
||||
descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
descendant.set(b"CIDToGIDMap".to_vec(), stream(&map));
|
||||
let entries = vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("BaseFont", Object::name("ABCDEF+Inter")),
|
||||
("Encoding", stream(encoding)),
|
||||
("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])),
|
||||
("ToUnicode", stream(to_unicode)),
|
||||
];
|
||||
(font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()])
|
||||
}
|
||||
|
||||
/// Each stream a font's metrics come from is paid for from the allowance they are read within,
|
||||
/// down to the byte; a stream it can't pay for is left unread, and the metrics say so.
|
||||
#[test]
|
||||
fn a_fonts_streams_are_paid_for_from_its_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar");
|
||||
let cost: usize = sizes.iter().sum();
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance);
|
||||
assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X")));
|
||||
assert!(!whole.outlined_cids.is_empty(), "the program and its map were read");
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
// A byte short, the ToUnicode, read last, is left unread; what came before is kept.
|
||||
let mut budget = DecodeBudget::new(cost - 1, 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
|
||||
assert!(m.past_allowance);
|
||||
assert_eq!((m.code_len, m.text_of(1)), (2, None));
|
||||
assert_eq!(m.outlined_cids, whole.outlined_cids);
|
||||
// With nothing left, none of them is read.
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
|
||||
assert!(m.past_allowance && m.outlined_cids.is_empty());
|
||||
assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread");
|
||||
// Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone.
|
||||
let mut whole_font = f.clone();
|
||||
whole_font.set(b"BaseFont".to_vec(), Object::name("Inter"));
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget);
|
||||
assert!(!m.past_allowance && m.outlined_cids.is_empty());
|
||||
assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]);
|
||||
}
|
||||
|
||||
/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to
|
||||
/// call the font read past it; a stream with data is left unread, and is.
|
||||
#[test]
|
||||
fn an_empty_stream_reads_with_nothing_left() {
|
||||
let past = |data: &[u8]| {
|
||||
let mut doc = Document::new_empty();
|
||||
let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]);
|
||||
Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance
|
||||
};
|
||||
assert!(!past(b""));
|
||||
assert!(past(b"1 beginbfchar <41> <0058> endbfchar"));
|
||||
}
|
||||
|
||||
/// A stream behind one filter past its own limit, with more left than its limit, is left unread as
|
||||
/// it always was, paying for the limit, and is no reason to call the font read past the allowance.
|
||||
#[test]
|
||||
fn a_cmap_past_its_own_limit_is_not_past_the_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec();
|
||||
cmap.resize(MAX_CMAP_BYTES + 1, b' ');
|
||||
let (f, sizes) = subset_type0(&mut doc, &cmap);
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut budget);
|
||||
assert!(!m.past_allowance);
|
||||
assert_eq!(m.text_of(1), None);
|
||||
assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::<usize>() + MAX_CMAP_BYTES);
|
||||
}
|
||||
|
||||
/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no
|
||||
/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex
|
||||
/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each.
|
||||
#[test]
|
||||
fn a_full_cid_map_reads_behind_one_or_two_filters() {
|
||||
let read = |entries: usize, filters: usize| {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, _) = subset_type0(&mut doc, b"");
|
||||
let map: Vec<u8> = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect();
|
||||
let flate = Stream::flate(Dict::new(), &map);
|
||||
let stream = match filters {
|
||||
0 => Stream::from_raw(Dict::new(), map),
|
||||
1 => flate,
|
||||
_ => {
|
||||
let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
Stream::from_raw(dict, hex.into_bytes())
|
||||
}
|
||||
};
|
||||
let map = Object::Ref(doc.add(Object::Stream(stream)));
|
||||
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
Metrics::from_dict(&doc, &f).outlined_cids
|
||||
};
|
||||
let full = read(1 << 16, 1);
|
||||
assert!(!full.is_empty());
|
||||
assert_eq!(read(1 << 16, 0), full);
|
||||
assert_eq!(read(1 << 16, 2), full);
|
||||
for filters in 0..=2 {
|
||||
assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters");
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor's program read holds every filter stage to the program's limit: a program behind
|
||||
/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what
|
||||
/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program
|
||||
/// within the limit behind the same two filters reads (see also
|
||||
/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`).
|
||||
#[test]
|
||||
fn an_editors_program_read_holds_each_stage_to_its_limit() {
|
||||
let mut doc = Document::new_empty();
|
||||
let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]);
|
||||
let hex: Vec<u8> = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
let program = doc.add(Object::Stream(Stream::from_raw(dict, hex)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none());
|
||||
assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT);
|
||||
}
|
||||
|
||||
/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a
|
||||
/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate
|
||||
/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it,
|
||||
/// though the two stages together make less than twice the limit; with its hex text within the
|
||||
/// limit it reads.
|
||||
#[test]
|
||||
fn each_stage_of_a_metrics_stream_is_held_to_its_limit() {
|
||||
let read = |cids: usize| {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, _) = subset_type0(&mut doc, b"");
|
||||
let map: Vec<u8> = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect();
|
||||
let hex: Vec<u8> = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
|
||||
let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec());
|
||||
let unread = stream.decoded_within(MAX_CID_TO_GID).is_err();
|
||||
let map = Object::Ref(doc.add(Object::Stream(stream)));
|
||||
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
(Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread)
|
||||
};
|
||||
// 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it.
|
||||
assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true));
|
||||
assert_eq!(read(MAX_CID_TO_GID / 4), (false, false));
|
||||
}
|
||||
|
||||
/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program
|
||||
/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though
|
||||
/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a
|
||||
/// byte past it is unread behind either.
|
||||
#[test]
|
||||
fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() {
|
||||
let program = |len: usize, noise: usize| {
|
||||
let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec();
|
||||
// Bytes Flate can't shrink, then zeros.
|
||||
let mut x = 1u32;
|
||||
program.extend((0..noise).map(|_| {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
x.to_be_bytes()[0]
|
||||
}));
|
||||
program.resize(len, 0);
|
||||
program
|
||||
};
|
||||
let read = |program: &[u8], hex: bool| {
|
||||
let mut doc = Document::new_empty();
|
||||
let flate = Stream::flate(Dict::new(), program);
|
||||
let stream = if hex {
|
||||
let digits = b"0123456789ABCDEF";
|
||||
let mut data: Vec<u8> = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect();
|
||||
data.push(b'>');
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
Stream::from_raw(dict, data)
|
||||
} else {
|
||||
flate
|
||||
};
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream))));
|
||||
let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]);
|
||||
embedded_sfnt(&doc, &simple)
|
||||
};
|
||||
let near = program(12 << 20, 8 << 20);
|
||||
let full = program(MAX_SFNT, 0);
|
||||
for hex in [false, true] {
|
||||
assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}");
|
||||
assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}");
|
||||
assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the
|
||||
/// difference from the row above), so read without the predictor it is garbled; with its
|
||||
/// `/DecodeParms` given by reference or direct.
|
||||
fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object {
|
||||
let mut above = vec![0u8; columns];
|
||||
let mut rows = Vec::new();
|
||||
for row in data.chunks(columns) {
|
||||
rows.push(2);
|
||||
rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a)));
|
||||
above[..row.len()].copy_from_slice(row);
|
||||
}
|
||||
let mut s = Stream::flate(Dict::new(), &rows);
|
||||
let parms: Dict =
|
||||
[(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect();
|
||||
let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) };
|
||||
s.dict.set(b"DecodeParms".to_vec(), parms);
|
||||
Object::Ref(doc.add(Object::Stream(s)))
|
||||
}
|
||||
|
||||
/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG
|
||||
/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in.
|
||||
#[test]
|
||||
fn parameters_given_by_reference_are_read() {
|
||||
let read = |by_reference: bool| {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, _) = subset_type0(&mut doc, b"");
|
||||
let map: Vec<u8> = (0..1000u16).flat_map(u16::to_be_bytes).collect();
|
||||
let map = predicted(&mut doc, &map, 2, by_reference);
|
||||
let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec();
|
||||
cmap.resize(48, b' ');
|
||||
let unicode = predicted(&mut doc, &cmap, 16, by_reference);
|
||||
let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
f.set(b"ToUnicode".to_vec(), unicode);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
(m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned))
|
||||
};
|
||||
let direct = read(false);
|
||||
assert!(!direct.0.is_empty());
|
||||
assert_eq!(direct.1.as_deref(), Some("X"));
|
||||
assert_eq!(read(true), direct);
|
||||
let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec();
|
||||
program.resize(program.len().next_multiple_of(4), 0);
|
||||
for by_reference in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let file = predicted(&mut doc, &program, 4, by_reference);
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), file);
|
||||
let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]);
|
||||
assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for
|
||||
/// as many readings as it holds, and the rest are left unread, where each reading used to decode
|
||||
/// it afresh.
|
||||
#[test]
|
||||
fn fonts_naming_one_cmap_share_one_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec();
|
||||
cmap.resize(1_000_000, b' ');
|
||||
let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap)));
|
||||
let fonts: Vec<Dict> = (0..100)
|
||||
.map(|_| {
|
||||
font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))])
|
||||
})
|
||||
.collect();
|
||||
let mut budget = DecodeBudget::new(64 << 20, 0);
|
||||
let read: Vec<Metrics> = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect();
|
||||
let whole = read.iter().filter(|m| !m.past_allowance).count();
|
||||
assert_eq!(whole, (64 << 20) / cmap.len());
|
||||
assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X")));
|
||||
assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance");
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
|
||||
/// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its
|
||||
/// own names (Inter's version 2.0 table), and none past the last glyph.
|
||||
#[test]
|
||||
@@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
|
||||
],
|
||||
);
|
||||
assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), "");
|
||||
// The names are read within the allowance the metrics are read within: the program is paid
|
||||
// for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and
|
||||
// reads nothing) and for the widths. A byte short of the first, the names go unread.
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(4));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
|
||||
let mut budget = DecodeBudget::new(3 * subset.len(), 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0));
|
||||
assert!(short.past_allowance);
|
||||
assert_ne!(short.decode(&codes), TEXT, "the names unread");
|
||||
}
|
||||
|
||||
/// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase
|
||||
@@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() {
|
||||
assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone");
|
||||
}
|
||||
|
||||
/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within,
|
||||
/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so.
|
||||
#[test]
|
||||
fn type3_glyph_boxes_are_paid_for_from_the_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let procs = ["1 0 0 -20 1 300 d1", "1 0 0 0 1 400 d1"];
|
||||
let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs);
|
||||
let cost: usize = procs.iter().map(|p| p.len()).sum();
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance);
|
||||
assert_eq!((whole.ascent, whole.descent), (400.0, -20.0));
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0));
|
||||
assert!(m.past_allowance, "a byte short, the second description is left unread");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -1142,7 +1508,7 @@ fn malformed_cmaps_never_panic() {
|
||||
let mut enc = Dict::new();
|
||||
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")]));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]);
|
||||
assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B"));
|
||||
assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1373,6 +1739,29 @@ fn low_codes_resolve_to_the_covering_range() {
|
||||
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_subset_reads_do_not_encode_unverified_glyphs() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut f, sizes) = subset_type0(&mut doc, b"");
|
||||
f.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
f.set(b"ToUnicode".to_vec(), Object::name("Identity-H"));
|
||||
if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts")
|
||||
&& let Some(Object::Dict(descendant)) = descendants.first_mut()
|
||||
{
|
||||
descendant.set(b"DW".to_vec(), Object::Int(1000));
|
||||
}
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance && !whole.outlined_cids.is_empty());
|
||||
assert!(whole.encode("\u{ffff}").is_none());
|
||||
// Refuse the program, then the CID map after reading the complete program.
|
||||
for allowance in [0, sizes[1]] {
|
||||
let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0));
|
||||
assert!(partial.past_allowance);
|
||||
assert_eq!(partial.encode(""), Some(Vec::new()));
|
||||
assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`cmap_only_sfnt_named`] without a `post` table.
|
||||
fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec<u8> {
|
||||
cmap_only_sfnt_named(mac, windows, false)
|
||||
@@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman(
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(flags));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]);
|
||||
let u = unicode_map(&doc, &f, false);
|
||||
let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0)));
|
||||
codes.map(|c| u.text(c).unwrap_or_default().into_owned())
|
||||
};
|
||||
// `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and
|
||||
@@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() {
|
||||
let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))];
|
||||
all.extend(entries);
|
||||
let f = font(&mut doc, all);
|
||||
let u = unicode_map(&doc, &f, false);
|
||||
let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0)));
|
||||
[0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned())
|
||||
};
|
||||
assert_eq!(read(vec![]), ["à", "é"]);
|
||||
@@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() {
|
||||
assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef");
|
||||
}
|
||||
|
||||
/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS
|
||||
/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is
|
||||
/// left unread, and the metrics say so.
|
||||
#[test]
|
||||
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
|
||||
fn the_symbolic_readings_of_a_program_are_paid_for() {
|
||||
let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]);
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &program)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(4));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(stream));
|
||||
// With `/Widths` the program isn't read for its advances: these are its only reads.
|
||||
let entries = vec![
|
||||
("Subtype", Object::name("TrueType")),
|
||||
("FontDescriptor", Object::Dict(descriptor)),
|
||||
("FirstChar", Object::Int(0x88)),
|
||||
("Widths", Object::Array(vec![Object::Int(500)])),
|
||||
];
|
||||
let f = font(&mut doc, entries);
|
||||
let whole = Metrics::from_dict(&doc, &f);
|
||||
assert!(!whole.past_allowance);
|
||||
assert_eq!(whole.text_of(0x88).as_deref(), Some("à"));
|
||||
let mut budget = DecodeBudget::new(2 * program.len(), 0);
|
||||
assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole);
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0));
|
||||
assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread");
|
||||
assert_eq!(m.text_of(0x88), None);
|
||||
}
|
||||
|
||||
/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends
|
||||
/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the
|
||||
/// program, Ω's glyph and what the ToUnicode and the map decode to.
|
||||
fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec<u8>, skrifa::GlyphId, [usize; 2]) {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap();
|
||||
@@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
subset[12 + index * 16..28 + index * 16].copy_from_slice(record);
|
||||
}
|
||||
assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err());
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
@@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
let mut descendant = Dict::new();
|
||||
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids));
|
||||
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar")));
|
||||
let cmap = b"1 beginbfchar <0011> <03a9> endbfchar";
|
||||
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap)));
|
||||
let source = font(
|
||||
&mut doc,
|
||||
doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("Encoding", Object::name("Identity-H")),
|
||||
@@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
("ToUnicode", Object::Ref(unicode)),
|
||||
],
|
||||
);
|
||||
(source, program, subset, expected, [cmap.len(), 36])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (source, program, subset, expected, _) = cmapless_composite(&mut doc);
|
||||
let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too");
|
||||
@@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without
|
||||
let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") };
|
||||
assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact");
|
||||
}
|
||||
|
||||
/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's
|
||||
/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the
|
||||
/// ToUnicode left unread no copy is adapted, though what is left would pay for the map.
|
||||
#[test]
|
||||
fn adapting_a_composite_subsets_cmap_is_paid_for() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc);
|
||||
let cost = subset.len() + cmap + map;
|
||||
let mut budget = DecodeBudget::new(cost, 0);
|
||||
let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap();
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
assert!(cmap > map);
|
||||
assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none());
|
||||
}
|
||||
|
||||
/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt
|
||||
/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no
|
||||
/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance.
|
||||
#[test]
|
||||
fn a_composite_font_that_cant_be_adapted_spends_only_its_program() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc);
|
||||
for encoding in ["Identity-V", "UniJIS-UCS2-H"] {
|
||||
let mut f = source.clone();
|
||||
f.set(b"Encoding".to_vec(), Object::name(encoding));
|
||||
let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0);
|
||||
assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}");
|
||||
assert_eq!(budget.remaining(), cmap + map, "{encoding}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite font's `/Encoding` given through a reference is the name it refers to, as the
|
||||
/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is.
|
||||
#[test]
|
||||
fn a_composite_fonts_encoding_given_by_reference_is_read() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut source, _, _, expected, _) = cmapless_composite(&mut doc);
|
||||
let identity = doc.add(Object::name("Identity-H"));
|
||||
source.set(b"Encoding".to_vec(), Object::Ref(identity));
|
||||
assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2);
|
||||
let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
}
|
||||
|
||||
/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams
|
||||
/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's
|
||||
/// output comes on top of the map.
|
||||
#[test]
|
||||
fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut source, _, _, expected, _) = cmapless_composite(&mut doc);
|
||||
let mut map = vec![0u8; MAX_CID_TO_GID];
|
||||
map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes());
|
||||
let flate = Stream::flate(Dict::new(), &map);
|
||||
let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")]));
|
||||
let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes()))));
|
||||
let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") };
|
||||
if let Some(Object::Dict(d)) = descendants.get_mut(0) {
|
||||
d.set(b"CIDToGIDMap".to_vec(), map);
|
||||
}
|
||||
source.set(b"DescendantFonts".to_vec(), Object::Array(descendants));
|
||||
let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! does (a mark takes none); correct maps and unrelated fonts are never changed. No font
|
||||
//! artwork is bundled.
|
||||
|
||||
use super::{Dict, Document, Metrics, dict, only_char, unicode_map};
|
||||
use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map};
|
||||
use pdfcraft_cos::{DecodeBudget, Object};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)
|
||||
let family = base.rsplit('+').next()?;
|
||||
if !matches!(family, "THSarabunPSK" | "THSarabunPSK-Bold" | "THSarabunPSK-Italic" | "THSarabunPSK-BoldItalic")
|
||||
|| font.name(b"Subtype") != Some(b"Type0")
|
||||
|| font.name(b"Encoding") != Some(b"Identity-H")
|
||||
|| !identity_h(doc, font)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let desc = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
if desc.name(b"CIDToGIDMap") != Some(b"Identity") {
|
||||
// A map given by reference is the name it refers to. A missing one is taken as Identity, the
|
||||
// default of PDF 1.x (ISO 32000-1), as `composite_glyph_maps` and the renderer take it; PDF 2.0
|
||||
// gives it no default and requires it with a `/FontFile2`.
|
||||
if desc.get(b"CIDToGIDMap").is_some_and(|m| doc.resolve(m).as_name() != Some(b"Identity".as_slice())) {
|
||||
return None;
|
||||
}
|
||||
let map = unicode_map(doc, font, true);
|
||||
let map = unicode_map(doc, font, true, &mut Reads::new(doc, &mut DecodeBudget::new(usize::MAX, 0)));
|
||||
// Five separated consonants and three contextual marks must agree, together with the
|
||||
// corrupt destinations and corrupt range. This is deliberately stricter than font-name
|
||||
// detection: a renamed/subsetted font with different glyph order must not be guessed.
|
||||
@@ -170,7 +173,7 @@ fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option<Vec<u8>> {
|
||||
let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?;
|
||||
let program = doc.resolve(descriptor.get(b"FontFile2")?);
|
||||
let Object::Stream(program) = program.as_ref() else { return None };
|
||||
DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok()
|
||||
DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data)
|
||||
}
|
||||
|
||||
/// Source glyph positions and the original PDF character codes that draw them. Empty codes
|
||||
@@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps(
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Option<(BTreeMap<u32, u32>, BTreeMap<u32, u16>)> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
if font.name(b"Encoding") != Some(b"Identity-H") {
|
||||
if !identity_h(doc, font) {
|
||||
return None;
|
||||
}
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() {
|
||||
None => None,
|
||||
Some(Object::Name(n)) if n == b"Identity" => None,
|
||||
Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?),
|
||||
// Read as the font's other streams are, a map behind several filters too.
|
||||
Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?),
|
||||
_ => return None,
|
||||
};
|
||||
let face = skrifa::FontRef::new(bytes).ok()?;
|
||||
@@ -294,13 +298,17 @@ impl LegacyThaiFont {
|
||||
/// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper
|
||||
/// assumes can't be trusted there, so it is shaped as an ordinary source font.
|
||||
///
|
||||
/// Its streams are decoded against `budget`, which the caller shares between every font it
|
||||
/// reads: what they keep is the programs decoded and the editor cmaps added to them.
|
||||
/// Its streams, its metrics' too, are decoded against `budget`, which the caller shares
|
||||
/// between every font it reads: what they keep is the programs decoded and the editor cmaps
|
||||
/// added to them. Metrics read past it adapt nothing: the font is refused.
|
||||
pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result<Option<Self>, String> {
|
||||
let m = Metrics::from_dict(doc, font);
|
||||
let m = Metrics::from_dict_within(doc, font, budget);
|
||||
if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") {
|
||||
return Ok(None);
|
||||
}
|
||||
if m.past_allowance {
|
||||
return Err("the source Thai font's metrics are past the edit's allowance".into());
|
||||
}
|
||||
if !space.is_finite() || space < 0.0 {
|
||||
return Err("the source space width is invalid".into());
|
||||
}
|
||||
@@ -528,6 +536,46 @@ mod tests {
|
||||
/// An intact legacy map: nominal Thai and two tone alternates.
|
||||
const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
|
||||
/// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its
|
||||
/// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor
|
||||
/// lists no copy, rather than the program without its Thai mappings.
|
||||
#[test]
|
||||
fn a_map_left_unread_for_the_allowance_lists_no_copy() {
|
||||
let mut doc = Document::new_empty();
|
||||
let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program))));
|
||||
let map: Vec<u8> = (0u16..512).flat_map(u16::to_be_bytes).collect();
|
||||
let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map))));
|
||||
let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT);
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some());
|
||||
let cost = usize::MAX / 2 - budget.remaining();
|
||||
// The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused.
|
||||
let mut short = DecodeBudget::new(cost - 1, 0);
|
||||
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings");
|
||||
assert_eq!(short.refused(), 1);
|
||||
}
|
||||
|
||||
/// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's
|
||||
/// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the
|
||||
/// editor's read of it pays. Metrics read past the allowance refuse the font before its
|
||||
/// program is read.
|
||||
#[test]
|
||||
fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let program = thai_ordered_program();
|
||||
let font = thai_font(&mut doc, &program, INTACT);
|
||||
let mut editor = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some());
|
||||
let editor_cost = usize::MAX / 2 - editor.remaining();
|
||||
let mut budget = DecodeBudget::new(usize::MAX / 2, 0);
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap();
|
||||
assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len());
|
||||
let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0);
|
||||
let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default();
|
||||
assert!(error.contains("past the edit's allowance"), "{error}");
|
||||
}
|
||||
|
||||
/// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai
|
||||
/// codes, and the oversized range first.
|
||||
const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange \
|
||||
@@ -547,6 +595,32 @@ mod tests {
|
||||
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
|
||||
assert_eq!(recovered[&262], 'ั');
|
||||
assert_eq!(recovered[&297], '๘');
|
||||
// `/Encoding` given through a reference is the name it refers to.
|
||||
let mut indirect = font.clone();
|
||||
indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H"))));
|
||||
assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font));
|
||||
// `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is
|
||||
// taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it.
|
||||
let with_map = |map: Option<Object>| {
|
||||
let mut f = font.clone();
|
||||
let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") };
|
||||
let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") };
|
||||
match map {
|
||||
Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map),
|
||||
None => {
|
||||
desc.remove(b"CIDToGIDMap");
|
||||
}
|
||||
}
|
||||
f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
|
||||
f
|
||||
};
|
||||
let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity")))));
|
||||
assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference");
|
||||
assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map");
|
||||
// Any other map is refused: one that moves glyphs, and another name.
|
||||
let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0]))));
|
||||
assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs");
|
||||
assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name");
|
||||
// 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that
|
||||
// put Mai Han-akat one glyph earlier passes every anchor and only this check sees it.
|
||||
let mut shifted = thai_ordered_program();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms`
|
||||
//! is given by reference is read through a view that shares its data and copies no more of its
|
||||
//! dictionary than its filters and parameters, not through a copy of the whole stream. A test
|
||||
//! binary of its own, so that the one test is all the process runs while its peak is measured.
|
||||
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within};
|
||||
|
||||
/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the
|
||||
/// peak can't be reset (`/proc/self/clear_refs` is Linux's).
|
||||
fn peak_growth<T>(f: impl FnOnce() -> T) -> Option<(T, u64)> {
|
||||
fn peak_kib() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
std::fs::write("/proc/self/clear_refs", "5").ok()?;
|
||||
let before = peak_kib()?;
|
||||
let out = f();
|
||||
Some((out, peak_kib()?.saturating_sub(before)))
|
||||
}
|
||||
|
||||
/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`.
|
||||
fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object {
|
||||
let mut s = Stream::flate(Dict::new(), data);
|
||||
s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
s.dict.set(b"Junk".to_vec(), junk.clone());
|
||||
Object::Ref(doc.add(Object::Stream(s)))
|
||||
}
|
||||
|
||||
/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their
|
||||
/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by
|
||||
/// the editor's program read and by the metrics read: neither copies an array. A read through a
|
||||
/// copy of the stream would add about as much as one array holds.
|
||||
#[test]
|
||||
fn reading_a_fonts_streams_copies_no_dictionary() {
|
||||
let junk = Object::Array(vec![Object::Int(0); 1 << 20]);
|
||||
let array_kib = (std::mem::size_of::<Object>() << 20) as u64 >> 10;
|
||||
let mut doc = Document::new_empty();
|
||||
let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))])));
|
||||
let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk);
|
||||
let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk);
|
||||
drop(junk);
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), program);
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
font.set(b"ToUnicode".to_vec(), unicode);
|
||||
let Some(((), growth)) = peak_growth(|| {
|
||||
for _ in 0..8 {
|
||||
let mut budget = DecodeBudget::new(64 << 20, 0);
|
||||
assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some());
|
||||
let m = Metrics::from_dict_within(&doc, &font, &mut budget);
|
||||
assert_eq!(m.text_of(0x41).as_deref(), Some("X"));
|
||||
}
|
||||
}) else {
|
||||
eprintln!("the peak can't be measured here");
|
||||
return;
|
||||
};
|
||||
assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB");
|
||||
}
|
||||
@@ -98,6 +98,12 @@ pub enum Calculate {
|
||||
Simple { op: CalcOp, fields: Vec<String> },
|
||||
/// Simplified field notation, e.g. `Price * Quantity`.
|
||||
Notation(String),
|
||||
/// A custom calculation script, written as given (Field Properties ▸ Calculate ▸ Custom
|
||||
/// calculation script). Reading a field never gives this: a script that isn't one of the
|
||||
/// forms above stays in [`Actions::unsupported`] and [`Scripts::calculate`], where the
|
||||
/// JavaScript engine runs it. Authoring writes it back byte for byte; a blank one writes no
|
||||
/// script.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// A field's recognised scripts.
|
||||
|
||||
@@ -445,6 +445,7 @@ impl Calculate {
|
||||
match self {
|
||||
Calculate::None => Vec::new(),
|
||||
Calculate::Simple { fields, .. } => unique(fields.iter().cloned()),
|
||||
Calculate::Custom(js) => calculation_reads(js),
|
||||
Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token {
|
||||
Notation::Name(name) => Some(name),
|
||||
_ => None,
|
||||
@@ -620,7 +621,8 @@ const MAX_EXPANDED_VALUES: usize = 1 << 20;
|
||||
pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<String> {
|
||||
let num = |s: &String| make_number(s).unwrap_or(0.0);
|
||||
match c {
|
||||
Calculate::None => None,
|
||||
// A custom script is the JavaScript engine's to run.
|
||||
Calculate::None | Calculate::Custom(_) => 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.)
|
||||
|
||||
@@ -383,9 +383,12 @@ fn notation_text(src: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/AA /C` JavaScript that stores `c`; `None` when it stores none (no calculation, or a
|
||||
/// blank custom script).
|
||||
pub fn calculate_js(c: &Calculate) -> Option<String> {
|
||||
match c {
|
||||
Calculate::None => None,
|
||||
Calculate::Custom(js) => (!js.trim().is_empty()).then(|| js.clone()),
|
||||
Calculate::Simple { op, fields } => Some(format!(
|
||||
"AFSimple_Calculate({}, new Array({}));",
|
||||
js_str(op.code()),
|
||||
|
||||
@@ -487,6 +487,20 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom calculation script (#6630) is written as given, a blank one as no script; it reads the
|
||||
/// fields it names, and running it is the JavaScript engine's, not the AF calculator's.
|
||||
#[test]
|
||||
fn a_custom_calculation_script_is_kept_as_written() {
|
||||
let js = "event.value = this.getField(\"a\").value * getField(\"b\").value; // getField(\"c\")";
|
||||
let c = Calculate::Custom(js.into());
|
||||
assert_eq!(calculate_js(&c).as_deref(), Some(js));
|
||||
for blank in ["", " \r\n\t"] {
|
||||
assert_eq!(calculate_js(&Calculate::Custom(blank.into())), None, "{blank:?}");
|
||||
}
|
||||
assert_eq!(c.reads(), ["a", "b"]);
|
||||
assert_eq!(calculate(&c, &|_| vec!["2".into()]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_calculations_fold_a_repeated_group_without_collecting_it() {
|
||||
let group = |_: &str| vec!["2".to_string(); 3];
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(crate) use tree::ancestor_sets;
|
||||
pub use edit::set_props;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon};
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon};
|
||||
pub(crate) use tree::{ensure_form, form_fonts_as_objects};
|
||||
|
||||
/// The smallest width and height of a field's widget, in points: a smaller one is refused.
|
||||
|
||||
@@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Obj
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `/AA /C` action that stores `c`; `None` when it stores no calculation. A custom script is
|
||||
/// the author's own text, so it is written as a text string, as `set_field_script` writes it, and
|
||||
/// a letter outside ASCII reads the same in every viewer. The AF calls are written as they are.
|
||||
fn calculate_action(c: &crate::af::Calculate) -> Option<Object> {
|
||||
let js = crate::af::calculate_js(c)?;
|
||||
let text = match c {
|
||||
crate::af::Calculate::Custom(_) => PdfString::text(&js),
|
||||
_ => PdfString::literal(js.into_bytes()),
|
||||
};
|
||||
let mut a = Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), text);
|
||||
Some(Object::Dict(a))
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
@@ -328,10 +343,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
if let Some(c) = &props.calculate {
|
||||
aa.remove(b"C");
|
||||
if let Some(js) = crate::af::calculate_js(c) {
|
||||
aa.set(b"C".to_vec(), action(&js));
|
||||
let action = calculate_action(c);
|
||||
let calculated = action.is_some();
|
||||
if let Some(action) = action {
|
||||
aa.set(b"C".to_vec(), action);
|
||||
}
|
||||
crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?;
|
||||
crate::calc_order::keep_listed(doc, f.obj, calculated)?;
|
||||
}
|
||||
doc.update_dict(f.obj, |d| {
|
||||
if aa.is_empty() {
|
||||
|
||||
@@ -315,6 +315,25 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
/// `FormError::NoSuchField` when no field is named `name`.
|
||||
pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
redraw_widgets_of(doc, &f, only)
|
||||
}
|
||||
|
||||
/// [`redraw_field`] for each field [`fields`] lists that `keep` accepts. The form's fields are
|
||||
/// read once: drawing a field changes only its own widgets' appearances, so the list stays true
|
||||
/// for the rest. Looking each one up by name instead reads the whole form again for every field,
|
||||
/// which grows with the square of the field count (a laid out XFA form with a thousand rows holds
|
||||
/// thousands of fields). A failure names the field it stopped at.
|
||||
pub fn redraw_fields(doc: &mut Document, mut keep: impl FnMut(&Document, &Field) -> bool) -> Result<(), FormError> {
|
||||
for f in fields(doc) {
|
||||
if keep(doc, &f) {
|
||||
redraw_widgets_of(doc, &f, None).map_err(|e| FormError::Invalid(format!("{}: {e}", f.name)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`redraw_widgets`] for the field `f`, as `fields(doc)` read it.
|
||||
fn redraw_widgets_of(doc: &mut Document, f: &Field, only: Option<usize>) -> Result<(), FormError> {
|
||||
let mut undrawn = false;
|
||||
for (index, w) in f.widgets.iter().enumerate() {
|
||||
if only.is_some_and(|only| only != index) {
|
||||
@@ -323,7 +342,7 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>
|
||||
let mut missing = None;
|
||||
let ap = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
|
||||
let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
|
||||
let (mut s, m) = appearance::field_appearance_drawn(doc, f, w, &f.value, true);
|
||||
missing = m;
|
||||
// Thai glyphs are streams of their own.
|
||||
pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict);
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
|
||||
@@ -1726,6 +1726,70 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
|
||||
assert_eq!(field(&fields(&doc), "total").value, ["ran"]);
|
||||
}
|
||||
|
||||
/// The `/JS` string of field `name`'s `/AA /C` action, as stored.
|
||||
fn calculate_action_js(doc: &Document, name: &str) -> Option<PdfString> {
|
||||
let f = field(&fields(doc), name).obj;
|
||||
let aa = doc.get(f).as_dict().and_then(|d| d.get(b"AA").cloned()).map(|a| doc.resolve(&a))?;
|
||||
let c = aa.as_dict().and_then(|aa| aa.get(b"C").cloned()).map(|c| doc.resolve(&c))?;
|
||||
Some(c.as_dict().and_then(|c| c.get(b"JS")).and_then(|s| s.as_string()).expect("/JS is a string").clone())
|
||||
}
|
||||
|
||||
/// Field Properties ▸ Calculate ▸ Custom calculation script (#6630): the script is written as
|
||||
/// given, as a text string, and reads back as the field's custom script, in the calculation
|
||||
/// order; a blank one removes the script and the field's place in the order.
|
||||
#[test]
|
||||
fn a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it() {
|
||||
use crate::af::Calculate;
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("codice")).unwrap();
|
||||
add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("barcode")).unwrap();
|
||||
let js = "// Code 39: tra asterischi, così\r\nevent.value = \"*\" + this.getField(\"codice\").value + \"*\";";
|
||||
let custom = |js: &str| FieldProps { calculate: Some(Calculate::Custom(js.into())), ..FieldProps::default() };
|
||||
set_props(&mut doc, "barcode", &custom(js)).unwrap();
|
||||
for doc in [doc.clone(), reopen(&doc)] {
|
||||
let all = fields(&doc);
|
||||
let actions = &field(&all, "barcode").actions;
|
||||
assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, Some(js)), "read back as a script");
|
||||
assert!(actions.unsupported.contains(&"calculate"));
|
||||
let stored = calculate_action_js(&doc, "barcode").expect("an /AA /C action");
|
||||
assert_eq!(stored.bytes, pdfcraft_cos::PdfString::text(js).bytes, "a text string, so ì isn't UTF-8 bytes another viewer reads as ì's pieces");
|
||||
let order = calculation_order(&doc, &all);
|
||||
assert_eq!(order.fields.iter().map(|f| (f.name.as_str(), f.listed)).collect::<Vec<_>>(), [("barcode", true)]);
|
||||
assert_eq!(order.fields[0].reads, ["codice"]);
|
||||
}
|
||||
for blank in ["", " \n\t"] {
|
||||
let mut doc = doc.clone();
|
||||
set_props(&mut doc, "barcode", &custom(blank)).unwrap();
|
||||
let all = fields(&doc);
|
||||
let actions = &field(&all, "barcode").actions;
|
||||
assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, None), "{blank:?}");
|
||||
assert!(calculate_action_js(&doc, "barcode").is_none(), "{blank:?} writes no /C");
|
||||
assert!(calculation_order(&doc, &all).fields.is_empty(), "{blank:?} leaves the calculation order");
|
||||
}
|
||||
}
|
||||
|
||||
/// A sum and a notation are still written as the AF calls they are, byte for byte, beside the
|
||||
/// custom script that is written as a text string (#6630).
|
||||
#[test]
|
||||
fn a_sum_and_a_notation_are_written_as_their_af_calls() {
|
||||
use crate::af::{CalcOp, Calculate, calculate_js};
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
for (i, name) in ["a", "b", "total"].into_iter().enumerate() {
|
||||
let y = 700.0 - 40.0 * i as f64;
|
||||
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
|
||||
}
|
||||
let sum = Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] };
|
||||
for c in [sum, Calculate::Notation("a * b".into())] {
|
||||
set_props(&mut doc, "total", &FieldProps { calculate: Some(c.clone()), ..FieldProps::default() }).unwrap();
|
||||
let js = calculate_js(&c).unwrap();
|
||||
assert_eq!(calculate_action_js(&doc, "total").map(|s| s.bytes), Some(js.into_bytes()), "{c:?}");
|
||||
let all = fields(&reopen(&doc));
|
||||
assert_eq!((&field(&all, "total").actions.calculate, &field(&all, "total").actions.scripts.calculate), (&c, &None));
|
||||
}
|
||||
}
|
||||
|
||||
/// An unsupported first AF call keeps the entire script and reaches the caller's interpreter.
|
||||
#[test]
|
||||
fn unsupported_first_validation_call_preserves_custom_script_dispatch() {
|
||||
@@ -5721,6 +5785,34 @@ fn set_values_reads_the_form_once_not_per_value() {
|
||||
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
|
||||
}
|
||||
|
||||
/// Redrawing every field of a large form reads the form once, not once per field: a laid out XFA
|
||||
/// form with a thousand rows holds thousands of fields, and reading them all again for each one
|
||||
/// took 15 s a relayout. The fields `keep` turns down keep the appearance they had.
|
||||
#[test]
|
||||
fn redraw_fields_reads_the_form_once_however_many_fields_it_holds() {
|
||||
for n in [250u32, 1000] {
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 5)).collect();
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")));
|
||||
objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" ")));
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /V (v{i}) /Rect [50 0 250 10] /P 3 0 R >>"));
|
||||
}
|
||||
let mut doc = document(&objs);
|
||||
let before = form_reads();
|
||||
redraw_fields(&mut doc, |_, f| f.name != "f0").unwrap();
|
||||
assert_eq!(form_reads() - before, 1, "{n} fields");
|
||||
let all = fields(&doc);
|
||||
let has_ap = |name: &str| field(&all, name).widgets.iter().all(|w| doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP")));
|
||||
assert!(!has_ap("f0"), "turned down: left as it was");
|
||||
assert!((1..n).all(|i| has_ap(&format!("f{i}"))), "{n} fields");
|
||||
assert!(ap(&doc, &field(&all, &format!("f{}", n - 1)).widgets[0]).contains(&format!("(v{})", n - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and
|
||||
/// the group's buttons in the calculation order; and a text field `name` with two widgets.
|
||||
fn radio_group_form() -> Document {
|
||||
|
||||
@@ -100,6 +100,9 @@ struct Interp<'h> {
|
||||
/// burning a core to the loop limit).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
deadline: Option<std::time::Instant>,
|
||||
/// `deadline` passed and stopped the script: the run reports as abandoned.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
out_of_time: bool,
|
||||
}
|
||||
|
||||
type R<T> = Result<T, String>;
|
||||
@@ -151,6 +154,8 @@ pub(crate) fn run_formcalc_at(
|
||||
exited: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
out_of_time: false,
|
||||
};
|
||||
let mut out = XfaOutcome::default();
|
||||
match interp.block(&body) {
|
||||
@@ -165,6 +170,19 @@ pub(crate) fn run_formcalc_at(
|
||||
}
|
||||
Err(e) => out.error = Some(e),
|
||||
}
|
||||
// The caller stops waiting at the same time limit, so which of the two notices first is
|
||||
// down to the scheduler. The outcome must not depend on it: stopped by the clock, the run
|
||||
// reports what an abandoned one does, the error and no changes. Keeping the changes made
|
||||
// it a race: a loop adding rows until the clock stopped it left the form unchanged on one
|
||||
// run and 1000 rows longer on the next (each laid out again, tens of seconds).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (true, Some(d)) = (interp.out_of_time, deadline) {
|
||||
return XfaOutcome {
|
||||
error: Some(format!("the script ran longer than {:.1} s and was abandoned", d.as_secs_f64())),
|
||||
abandoned: true,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
out.notes = host.notes();
|
||||
out.effects = host.effects;
|
||||
out.console = host.console;
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Interp<'_> {
|
||||
&& let Some(d) = self.deadline
|
||||
&& std::time::Instant::now() > d
|
||||
{
|
||||
self.out_of_time = true;
|
||||
return Err(RAN_TOO_LONG.into());
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::host::{arg, function, host, s, text, text_line};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
|
||||
use super::{
|
||||
Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer,
|
||||
js_string,
|
||||
error, js_string,
|
||||
};
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
@@ -120,6 +120,36 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// The attached file `exportDataObject` names, and whether to open it: from an object
|
||||
/// (`{cName, nLaunch}`) or from the arguments (`cName, cDIPath, bAllowAuth, nLaunch`). `nLaunch`
|
||||
/// 1 and 2 open the file; 0 (the default) and any other value save it.
|
||||
pub(crate) fn data_object(args: &[JsValue], ctx: &mut Context) -> JsResult<(String, bool)> {
|
||||
let first = arg(args, 0);
|
||||
// Any object of named parameters is read by key, one without `cName` too, so a missing name
|
||||
// is refused rather than read as the object's text.
|
||||
let named = first.as_object().filter(|o| !o.is_array() && !o.is_callable());
|
||||
let name = match &named {
|
||||
Some(o) => text_line(&o.get(js_string!("cName"), ctx)?, ctx)?,
|
||||
None => text_line(&first, ctx)?,
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(error("exportDataObject needs cName, the attached file's name"));
|
||||
}
|
||||
let launch = match &named {
|
||||
Some(o) => o.get(js_string!("nLaunch"), ctx)?,
|
||||
None => arg(args, 3),
|
||||
};
|
||||
let launch = if launch.is_undefined() || launch.is_null() { 0.0 } else { launch.to_number(ctx)? };
|
||||
Ok((name, launch == 1.0 || launch == 2.0))
|
||||
}
|
||||
|
||||
/// `this.exportDataObject(…)`: asks the viewer to open or save an attached file.
|
||||
fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let (name, open) = data_object(args, ctx)?;
|
||||
host(ctx)?.borrow_mut().request(Request::OpenAttachment { name, open });
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print
|
||||
/// and Save As. Other items do nothing.
|
||||
pub(crate) fn menu_item(name: &str) -> Option<Request> {
|
||||
@@ -199,11 +229,11 @@ fn color_object(ctx: &mut Context) -> JsObject {
|
||||
const XFA_VERSION: f64 = 3.5;
|
||||
|
||||
/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`,
|
||||
/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`,
|
||||
/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and
|
||||
/// `color` objects.
|
||||
/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`,
|
||||
/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`,
|
||||
/// `display` and `color` objects.
|
||||
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let globals: [(&str, usize, NativeFunction); 9] = [
|
||||
let globals: [(&str, usize, NativeFunction); 10] = [
|
||||
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
|
||||
("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)),
|
||||
("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)),
|
||||
@@ -213,6 +243,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
("calculateNow", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("dirty", 0, NativeFunction::from_fn_ptr(noop)),
|
||||
("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)),
|
||||
];
|
||||
for (name, len, f) in globals {
|
||||
ctx.register_global_callable(JsString::from(name), len, f)?;
|
||||
|
||||
@@ -68,6 +68,7 @@ impl Host {
|
||||
let bytes = match &r {
|
||||
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
|
||||
Request::OpenAttachment { name, .. } => name.len(),
|
||||
_ => 0,
|
||||
};
|
||||
let kept = self.room(bytes);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`.
|
||||
//! - the document (`this`, also the global object): `getField`, `numFields`,
|
||||
//! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`,
|
||||
//! `calculateNow`, `print`, `submitForm`, `mailDoc`.
|
||||
//! `calculateNow`, `print`, `submitForm`, `mailDoc`, `exportDataObject`.
|
||||
//! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`,
|
||||
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
|
||||
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
|
||||
|
||||
@@ -241,6 +241,12 @@ pub enum Request {
|
||||
Beep,
|
||||
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
|
||||
SaveAs,
|
||||
/// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch`
|
||||
/// 1 or 2) or to save (0).
|
||||
OpenAttachment {
|
||||
name: String,
|
||||
open: bool,
|
||||
},
|
||||
/// `app.runtimeHighlight = on`: highlight the form's fields, or stop.
|
||||
Highlight(bool),
|
||||
/// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing.
|
||||
|
||||
+80
-1
@@ -1637,7 +1637,21 @@ $host.gotoURL("https://example.org")"#,
|
||||
Limits { loop_iterations: u64::MAX, recursion: 32 },
|
||||
Some(std::time::Duration::ZERO),
|
||||
);
|
||||
assert!(o.error.unwrap().contains("too long"));
|
||||
assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}");
|
||||
// Stopped by its clock, a script keeps none of its changes, as when the caller stops
|
||||
// waiting first: the outcome is the same whichever side notices. A loop adding rows
|
||||
// until the clock stops it kept up to 1000 new rows when the script noticed first.
|
||||
let o = crate::formcalc::run_formcalc_at(
|
||||
"while (1) do table._row.addInstance(1) endwhile",
|
||||
&ev,
|
||||
&doc,
|
||||
std::sync::Arc::new(form()),
|
||||
false,
|
||||
Limits { loop_iterations: u64::MAX, recursion: 32 },
|
||||
Some(std::time::Duration::from_millis(50)),
|
||||
);
|
||||
assert!(o.abandoned && o.effects.is_empty() && o.notes.is_empty(), "{:?}, {} effects", o.error, o.effects.len());
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{:?}", o.error);
|
||||
assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
|
||||
assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
|
||||
assert!(run_hostile_formcalc("Space(1e12)").error.is_none());
|
||||
@@ -1885,6 +1899,16 @@ mod session {
|
||||
slow.timeout = std::time::Duration::from_millis(200);
|
||||
let o = s.run(slow);
|
||||
assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}");
|
||||
// Whether the script's clock or the session's wait notices first, a FormCalc loop
|
||||
// adding rows until the time is up is abandoned with none of its rows, and the session
|
||||
// is dead after it.
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
let mut slow = script("while (1) do table._row.addInstance(1) endwhile", true, "form1[0].page1[0].go[0]", "click");
|
||||
slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 };
|
||||
slow.timeout = std::time::Duration::from_millis(200);
|
||||
let o = s.run(slow);
|
||||
assert!(o.abandoned && o.effects.is_empty(), "{:?}, {} effects", o.error, o.effects.len());
|
||||
assert!(s.dead().is_some_and(|w| w.contains("abandoned")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1924,6 +1948,61 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() {
|
||||
assert!(read.changed.is_empty());
|
||||
}
|
||||
|
||||
mod data_objects {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
|
||||
|
||||
/// `exportDataObject`, as a document method or a bare global, asks to open the named attached
|
||||
/// file when `nLaunch` is 1 or 2 and to save it otherwise; without `cName` it is an error.
|
||||
#[test]
|
||||
fn export_data_object_asks_to_open_or_save_an_attached_file() {
|
||||
let o = go(
|
||||
"this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); exportDataObject('notes.txt'); \
|
||||
this.exportDataObject({cName: 'form.pdf', nLaunch: 1});",
|
||||
&Event::field("Mouse Up", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(
|
||||
o.requests,
|
||||
[
|
||||
Request::OpenAttachment { name: "Guide.pdf".into(), open: true },
|
||||
Request::OpenAttachment { name: "notes.txt".into(), open: false },
|
||||
Request::OpenAttachment { name: "form.pdf".into(), open: true },
|
||||
]
|
||||
);
|
||||
let o = go("this.exportDataObject({nLaunch: 2})", &Event::field("Mouse Up", "total", ""));
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("cName")), "{:?}", o.error);
|
||||
assert!(o.requests.is_empty());
|
||||
}
|
||||
|
||||
/// Only `nLaunch` 1 and 2 open the file: other values (1.5, 3, a huge number) save it, as the
|
||||
/// default 0 does.
|
||||
#[test]
|
||||
fn export_data_object_opens_only_for_n_launch_1_and_2() {
|
||||
for launch in ["1.5", "3", "1e9", "-1"] {
|
||||
let o = go(&format!("this.exportDataObject({{cName: 'Guide.pdf', nLaunch: {launch}}});"), &Event::field("Mouse Up", "total", ""));
|
||||
assert_eq!(o.requests, [Request::OpenAttachment { name: "Guide.pdf".into(), open: false }], "nLaunch {launch}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In an XFA script `event.target` is the document, so `exportDataObject` works on it, while
|
||||
/// `xfa.event.target` is still the node the event happened on.
|
||||
#[test]
|
||||
fn in_xfa_scripts_event_target_is_the_document_and_xfa_event_target_the_node() {
|
||||
let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
|
||||
let o = run_xfa(
|
||||
"event.target.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); xfa.event.target.name",
|
||||
&event,
|
||||
&XfaDoc::default(),
|
||||
&super::xfa_model::form(),
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("go"), "xfa.event.target is still the button");
|
||||
assert!(o.effects.contains(&XfaEffect::OpenAttachment { name: "Guide.pdf".into(), open: true }), "{:?}", o.effects);
|
||||
}
|
||||
}
|
||||
|
||||
mod runtime_highlight {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa};
|
||||
|
||||
@@ -15,6 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy};
|
||||
use boa_engine::property::Attribute;
|
||||
use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
|
||||
use crate::globals::data_object;
|
||||
use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
|
||||
mod globals;
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::instances::instance_manager;
|
||||
use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names};
|
||||
use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink};
|
||||
use super::{
|
||||
Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx,
|
||||
s, scand, text,
|
||||
Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, data_object, function, js_string, platform, printd,
|
||||
printf, printx, s, scand, text,
|
||||
};
|
||||
|
||||
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
|
||||
@@ -15,6 +15,13 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `event.target.exportDataObject(…)`: the document asks to open or save an attached file.
|
||||
fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let (name, open) = data_object(args, ctx)?;
|
||||
effect(ctx, XfaEffect::OpenAttachment { name, open })?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.runtimeHighlight`: whether the viewer highlights form fields.
|
||||
fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.highlight))
|
||||
@@ -67,13 +74,13 @@ fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.beep()`: asks to beep.
|
||||
/// `xfa.host.beep()` and `app.beep()`: ask to beep.
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Beep)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.gotoURL(url)`: asks to open the URL.
|
||||
/// `xfa.host.gotoURL(url)` and `app.launchURL(url)`: ask to open the URL.
|
||||
fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let u = text(&arg(args, 0), ctx)?;
|
||||
effect(ctx, XfaEffect::LaunchUrl(u))?;
|
||||
@@ -114,50 +121,63 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `recalculate(…)` on a node or on `xfa`: records a request to recalculate the form.
|
||||
pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Recalculate)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.layout.relayout()`, `relayoutPageArea()` and `remerge()`: record a request to lay the
|
||||
/// form out again.
|
||||
pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
effect(ctx, XfaEffect::Relayout)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `console.println(…)`: adds the line, cut to [`MAX_LINE`] characters, to the run's console
|
||||
/// output.
|
||||
fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = clip(text(&arg(args, 0), ctx)?);
|
||||
host(ctx)?.borrow_mut().print(m);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `xfa.host.response(…)` and `app.response(…)`: always return `null`, as for a canceled dialog.
|
||||
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::null())
|
||||
}
|
||||
|
||||
/// `xfa.layout.page(…)`: the current page, 1-based, whatever the argument.
|
||||
fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0))
|
||||
}
|
||||
|
||||
/// `xfa.layout.absPage(…)`: the current page, 0-based, whatever the argument.
|
||||
fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64))
|
||||
}
|
||||
|
||||
/// `xfa.layout.pageCount()` and `absPageCount()`: the document's page count.
|
||||
fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64))
|
||||
}
|
||||
|
||||
/// The `xfa.layout` measures `h`, `w`, `x`, `y` and `pageSpan`: always 0.
|
||||
fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
Ok(JsValue::from(0.0))
|
||||
}
|
||||
|
||||
/// `xfa.resolveNode(som)`, or `xfa.resolveNodes(som)` when `many`, resolving from the node the
|
||||
/// script runs on.
|
||||
fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult<JsValue> {
|
||||
let current = host(ctx)?.borrow().current;
|
||||
resolver(ctx, current, many)
|
||||
}
|
||||
|
||||
/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`,
|
||||
/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`,
|
||||
/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager.
|
||||
/// `resolveNode` and more; `xfa.event`'s target is that node), a separate global `event` whose
|
||||
/// target is a document offering `exportDataObject`, `app`, `console`, `util`, and the nodes in
|
||||
/// scope by name, with `_name` for each repeatable one's instance manager.
|
||||
pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> {
|
||||
let rw = Attribute::CONFIGURABLE;
|
||||
let form = node_object(ctx, 0)?;
|
||||
@@ -207,24 +227,33 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
.function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1)
|
||||
.property(js_string!("ready"), JsValue::from(true), Attribute::READONLY)
|
||||
.build();
|
||||
let ev = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&event.activity), Attribute::all())
|
||||
.property(js_string!("newText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("prevText"), s(&event.prev_text), Attribute::all())
|
||||
.property(js_string!("fullText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("change"), s(""), Attribute::all())
|
||||
.property(js_string!("target"), this_node.clone(), Attribute::all())
|
||||
.property(js_string!("selStart"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("cancelAction"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("reenter"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("shift"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("modifier"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("keyDown"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all())
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0)
|
||||
.build();
|
||||
// Two event objects, as in Acrobat's XFA scripting: `xfa.event`, whose target is the form
|
||||
// node the event happened on, and Acrobat's own `event`, whose target is the document
|
||||
// (`event.target.exportDataObject(…)` is how forms open their attached files).
|
||||
let acrobat_doc =
|
||||
ObjectInitializer::new(ctx).function(NativeFunction::from_fn_ptr(export_data_object), js_string!("exportDataObject"), 4).build();
|
||||
let event_object = |ctx: &mut Context, target: JsValue| {
|
||||
ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&event.activity), Attribute::all())
|
||||
.property(js_string!("newText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("prevText"), s(&event.prev_text), Attribute::all())
|
||||
.property(js_string!("fullText"), s(&event.new_text), Attribute::all())
|
||||
.property(js_string!("change"), s(""), Attribute::all())
|
||||
.property(js_string!("target"), target, Attribute::all())
|
||||
.property(js_string!("selStart"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all())
|
||||
.property(js_string!("cancelAction"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("reenter"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("shift"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("modifier"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("keyDown"), JsValue::from(false), Attribute::all())
|
||||
.property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all())
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0)
|
||||
.build()
|
||||
};
|
||||
let ev = event_object(ctx, this_node.clone());
|
||||
let acrobat_ev = event_object(ctx, acrobat_doc.into());
|
||||
let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build();
|
||||
let template = sink(ctx)?;
|
||||
let resolve_one = xfa_resolve(ctx, false)?;
|
||||
@@ -243,7 +272,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
.function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0)
|
||||
.build();
|
||||
ctx.register_global_property(js_string!("xfa"), xfa, rw)?;
|
||||
ctx.register_global_property(js_string!("event"), ev, rw)?;
|
||||
ctx.register_global_property(js_string!("event"), acrobat_ev, rw)?;
|
||||
ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?;
|
||||
let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight));
|
||||
let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight));
|
||||
|
||||
@@ -104,6 +104,11 @@ pub enum XfaEffect {
|
||||
MessageBox(Message),
|
||||
/// `xfa.host.resetData()`: the named fields, or every field when empty.
|
||||
ResetData(Vec<String>),
|
||||
/// `event.target.exportDataObject(…)`: the attached file `name`, to open or to save.
|
||||
OpenAttachment {
|
||||
name: String,
|
||||
open: bool,
|
||||
},
|
||||
/// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
|
||||
ShowPanel(crate::Panel),
|
||||
Print,
|
||||
@@ -217,6 +222,7 @@ pub(super) fn payload(e: &XfaEffect) -> usize {
|
||||
som.len().saturating_add(s.len())
|
||||
}
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
|
||||
XfaEffect::OpenAttachment { name, .. } => name.len(),
|
||||
XfaEffect::MessageBox(m) => m.bytes(),
|
||||
XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
|
||||
@@ -179,7 +179,15 @@ mod native {
|
||||
loop {
|
||||
let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now()));
|
||||
match worker.from.recv_timeout(left) {
|
||||
Ok(Reply::Outcome(o)) => return *o,
|
||||
Ok(Reply::Outcome(o)) => {
|
||||
// A FormCalc script its own clock stopped reports as abandoned: the
|
||||
// session ends as it does when the wait runs out first.
|
||||
if o.abandoned {
|
||||
self.dead = o.error.clone();
|
||||
self.worker = None;
|
||||
}
|
||||
return *o;
|
||||
}
|
||||
Ok(Reply::Started) => {
|
||||
// A second call changes nothing: the script's time has already begun.
|
||||
if !running {
|
||||
|
||||
+150
-37
@@ -521,6 +521,41 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize {
|
||||
read
|
||||
}
|
||||
|
||||
/// Records any device colour space named directly or used as the base or alternate space of a
|
||||
/// colour-space object (`Name` or `Array` such as `[/Indexed base …]`, `[/Separation _ alt …]`,
|
||||
/// or `[/DeviceN _ alt …]`).
|
||||
fn mark_cs_object(doc: &Document, cs: &Object, used: &mut DeviceColour, depth: u8) {
|
||||
if depth > MAX_DIRECT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match cs {
|
||||
Object::Ref(r) => mark_cs_object(doc, &doc.get(*r), used, depth + 1),
|
||||
Object::Name(n) => used.mark(n, false),
|
||||
Object::Array(a) => {
|
||||
let mut kind = a.first().map(|o| doc.resolve(o));
|
||||
for _ in 0..MAX_DIRECT_DEPTH {
|
||||
let Some(&Object::Ref(r)) = kind.as_deref() else { break };
|
||||
kind = Some(doc.get(r));
|
||||
}
|
||||
match kind.as_deref().and_then(Object::as_name) {
|
||||
Some(n) if a.len() == 1 => used.mark(n, false),
|
||||
Some(b"I" | b"Indexed" | b"Pattern") => {
|
||||
if let Some(base) = a.get(1) {
|
||||
mark_cs_object(doc, base, used, depth + 1);
|
||||
}
|
||||
}
|
||||
Some(b"Separation" | b"DeviceN") => {
|
||||
if let Some(alt) = a.get(2) {
|
||||
mark_cs_object(doc, alt, used, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any content or object uses a device colour space.
|
||||
fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> DeviceColour {
|
||||
let mut used = DeviceColour::default();
|
||||
@@ -536,15 +571,15 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
|
||||
{
|
||||
used.default_rgb |= spaces.get(b"DefaultRGB").is_some();
|
||||
used.default_cmyk |= spaces.get(b"DefaultCMYK").is_some();
|
||||
for (k, entry) in spaces.iter() {
|
||||
if !matches!(k.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") {
|
||||
mark_cs_object(doc, entry, &mut used, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cs) = d.get(b"ColorSpace").map(|c| doc.resolve(c)) {
|
||||
match &*cs {
|
||||
Object::Name(n) if n == b"DeviceGray" => used.gray = true,
|
||||
Object::Name(n) if n == b"DeviceRGB" => used.rgb = true,
|
||||
Object::Name(n) if n == b"DeviceCMYK" => used.cmyk = true,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(cs) = d.get(b"ColorSpace") {
|
||||
mark_cs_object(doc, cs, &mut used, 0);
|
||||
}
|
||||
// Content streams only: a font program or an ICC profile is data, whatever bytes it holds.
|
||||
// Parsing is the expensive part, so once content has used all three spaces no more is read.
|
||||
@@ -947,8 +982,20 @@ fn check_objects(
|
||||
let Some(d) = dict_of(o) else { continue };
|
||||
check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue);
|
||||
}
|
||||
for (i, p) in pdfcraft_model::pages(doc).into_iter().enumerate() {
|
||||
if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a))
|
||||
&& let Some(a) = a.as_array()
|
||||
{
|
||||
for entry in a {
|
||||
if let Object::Dict(d) = entry {
|
||||
check_one_object(doc, level, d, entry, false, Some(i), issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a single object against the PDF/A rules for `level`.
|
||||
fn check_one_object(
|
||||
doc: &Document,
|
||||
level: Level,
|
||||
@@ -961,13 +1008,14 @@ fn check_one_object(
|
||||
let ty = dict.name(b"Type");
|
||||
let subtype = dict.name(b"Subtype");
|
||||
check_actions(doc, dict, issue);
|
||||
if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) {
|
||||
let is_annot = matches!(object, Object::Dict(_)) && is_annotation(doc, dict, ty, subtype, page.is_some());
|
||||
if is_annot && check_annotation(doc, dict, subtype, page, issue) {
|
||||
// A forbidden annotation stops every later check for its object, as the scan did before
|
||||
// it was split into helpers: the rest of its own checks and the form field, font, stream
|
||||
// and embedded file below all came after that `continue`.
|
||||
return;
|
||||
}
|
||||
if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") {
|
||||
if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && !is_annot {
|
||||
issue("6.5.2", "A form field has additional actions", None, None, true);
|
||||
}
|
||||
// A Type 0 font's descendant is checked through it.
|
||||
@@ -992,8 +1040,11 @@ fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static st
|
||||
}
|
||||
}
|
||||
|
||||
fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool {
|
||||
ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some())
|
||||
/// Whether `dict` is an annotation dictionary (`/Type /Annot`, or a dictionary with the required
|
||||
/// `/Subtype` and `/Rect` array entries that either sits in a page's `/Annots` list or carries `/P`).
|
||||
fn is_annotation(doc: &Document, dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>, on_page: bool) -> bool {
|
||||
ty == Some(b"Annot")
|
||||
|| (subtype.is_some() && dict.get(b"Rect").is_some_and(|r| doc.resolve(r).as_array().is_some()) && (on_page || dict.get(b"P").is_some()))
|
||||
}
|
||||
|
||||
fn check_annotation(
|
||||
@@ -1044,6 +1095,7 @@ fn check_annotation_appearance(
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a stream's compression filters, external file keys, and image/PostScript subtype rules.
|
||||
fn check_stream(
|
||||
doc: &Document,
|
||||
stream: &pdfcraft_cos::Stream,
|
||||
@@ -1053,12 +1105,12 @@ fn check_stream(
|
||||
let filters: Vec<Vec<u8>> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) {
|
||||
Some(filter) => match &*filter {
|
||||
Object::Name(name) => vec![name.clone()],
|
||||
Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(),
|
||||
Object::Array(filters) => filters.iter().filter_map(|filter| doc.resolve(filter).as_name().map(<[u8]>::to_vec)).collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
if filters.iter().any(|filter| filter == b"LZWDecode") {
|
||||
if filters.iter().any(|filter| filter == b"LZWDecode" || filter == b"LZW") {
|
||||
issue("6.1.7.2", "A stream uses LZW compression", None, None, false);
|
||||
}
|
||||
if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() {
|
||||
@@ -1223,6 +1275,86 @@ fn strip_actions(doc: &Document, d: &mut Dict) -> bool {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Collects every indirect annotation reference listed in any page's `/Annots` array.
|
||||
fn page_annot_refs(doc: &Document) -> std::collections::HashSet<ObjRef> {
|
||||
let mut refs = std::collections::HashSet::new();
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a))
|
||||
&& let Some(a) = a.as_array()
|
||||
{
|
||||
refs.extend(a.iter().filter_map(Object::as_ref));
|
||||
}
|
||||
}
|
||||
refs
|
||||
}
|
||||
|
||||
/// Removes annotation `r` from every page's `/Annots` list and replaces its indirect object with `Null`.
|
||||
fn remove_annot_from_pages(doc: &mut Document, r: ObjRef) -> Result<(), pdfcraft_cos::CosError> {
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
if annots.iter().any(|a| a.as_ref() == Some(r)) {
|
||||
let kept: Vec<Object> = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect();
|
||||
doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?;
|
||||
}
|
||||
}
|
||||
doc.set(r, Object::Null);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fix_annot_or_field(doc: &Document, nd: &mut Dict, is_annot: bool, sub: Option<&[u8]>, actions: &mut usize, flags: &mut usize) -> bool {
|
||||
let mut changed = strip_actions(doc, nd);
|
||||
*actions += changed as usize;
|
||||
if is_annot && sub != Some(b"Popup") {
|
||||
let f = nd.int(b"F").unwrap_or(0);
|
||||
let want = (f | 4) & !(1 | 2 | 32);
|
||||
if want != f {
|
||||
nd.set(b"F".to_vec(), Object::Int(want));
|
||||
*flags += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn convert_direct_page_annots(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
actions: &mut usize,
|
||||
flags: &mut usize,
|
||||
removed: &mut usize,
|
||||
) -> Result<(), pdfcraft_cos::CosError> {
|
||||
let Some(annots) = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !annots.iter().any(|a| a.as_dict().is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut kept = Vec::with_capacity(annots.len());
|
||||
let mut changed = false;
|
||||
for entry in annots {
|
||||
let Object::Dict(mut nd) = entry else {
|
||||
kept.push(entry);
|
||||
continue;
|
||||
};
|
||||
let ty = nd.name(b"Type").map(<[u8]>::to_vec);
|
||||
let sub = nd.name(b"Subtype").map(<[u8]>::to_vec);
|
||||
let is_annot = is_annotation(doc, &nd, ty.as_deref(), sub.as_deref(), true);
|
||||
if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
|
||||
*removed += 1;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if is_annot || nd.get(b"FT").is_some() {
|
||||
changed |= fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), actions, flags);
|
||||
}
|
||||
kept.push(Object::Dict(nd));
|
||||
}
|
||||
if changed {
|
||||
doc.update_dict(page.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make `doc` conform to `level` as far as possible, then re-check it.
|
||||
pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos::CosError> {
|
||||
let mut fixed = Vec::new();
|
||||
@@ -1293,45 +1425,26 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
|
||||
|
||||
// Pages, annotations, fields, images, embedded files.
|
||||
let (mut actions, mut flags, mut images, mut removed, mut files) = (0, 0, 0, 0, 0);
|
||||
let page_annots = page_annot_refs(doc);
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let mut changed = false;
|
||||
doc.update_dict(p.obj, |d| changed = d.remove(b"AA").is_some())?;
|
||||
actions += changed as usize;
|
||||
convert_direct_page_annots(doc, &p, &mut actions, &mut flags, &mut removed)?;
|
||||
}
|
||||
for (r, o) in objects(doc) {
|
||||
let Some(d) = dict_of(&o).cloned() else { continue };
|
||||
let ty = d.name(b"Type").map(<[u8]>::to_vec);
|
||||
let sub = d.name(b"Subtype").map(<[u8]>::to_vec);
|
||||
let is_annot = ty.as_deref() == Some(b"Annot");
|
||||
let is_annot = matches!(&*o, Object::Dict(_)) && is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r));
|
||||
if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
|
||||
// Take it off its page.
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
if annots.iter().any(|a| a.as_ref() == Some(r)) {
|
||||
let kept: Vec<Object> = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect();
|
||||
doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?;
|
||||
}
|
||||
}
|
||||
// The object stays in the file unless it is replaced: verify scans every object, so a
|
||||
// forbidden annotation left behind would still be reported (clause 6.3.1).
|
||||
doc.set(r, Object::Null);
|
||||
remove_annot_from_pages(doc, r)?;
|
||||
removed += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(&*o, Object::Dict(_)) && (is_annot || d.get(b"FT").is_some()) {
|
||||
let mut nd = d.clone();
|
||||
let mut changed = strip_actions(doc, &mut nd);
|
||||
actions += changed as usize;
|
||||
if is_annot && sub.as_deref() != Some(b"Popup") {
|
||||
let f = nd.int(b"F").unwrap_or(0);
|
||||
let want = (f | 4) & !(1 | 2 | 32);
|
||||
if want != f {
|
||||
nd.set(b"F".to_vec(), Object::Int(want));
|
||||
flags += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), &mut actions, &mut flags) {
|
||||
doc.set_existing(r, Object::Dict(nd))?;
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -495,6 +495,89 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typeless_annotations_indexed_cmyk_and_indirect_filter_elements_are_handled() {
|
||||
// Annotations in /Annots that omit optional /Type /Annot and /P (while keeping required /Subtype
|
||||
// and /Rect), whether indirect or direct dictionaries, are still verified and fixed by convert.
|
||||
// A malformed /Annots dictionary without /Type /Annot and without /Rect is not treated as an
|
||||
// annotation or destructively removed by convert.
|
||||
let mut doc = build(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R << /Subtype /Movie /Rect [60 10 70 30] >> << /Subtype /Sound >> << /Subtype /Sound /Rect null >> << /Subtype /Sound /Rect 9 0 R >>] >>",
|
||||
"<< /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 7 0 R >> >>",
|
||||
"<< /Subtype /Sound /Rect [50 10 60 30] >>",
|
||||
"<< /Subtype /Widget /FT /Tx /T (f) /Rect [70 10 90 30] /F 4 /AA << /E << /S /URI /URI (https://example.com) >> >> /AP << /N 7 0 R >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream",
|
||||
"<< /Subtype /Movie >>",
|
||||
"null",
|
||||
],
|
||||
"",
|
||||
);
|
||||
let issues = verify(&doc, Level::A2b);
|
||||
let msgs = messages(&issues);
|
||||
assert!(msgs.contains(&"A Text annotation isn't set to print, or is hidden"), "{msgs:#?}");
|
||||
assert!(msgs.contains(&"Sound annotations aren't allowed"), "{msgs:#?}");
|
||||
assert!(msgs.contains(&"Movie annotations aren't allowed"), "{msgs:#?}");
|
||||
assert!(msgs.contains(&"A Widget annotation has additional actions"), "{msgs:#?}");
|
||||
assert!(!msgs.contains(&"A form field has additional actions"), "{msgs:#?}");
|
||||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||||
assert!(r.remaining.is_empty(), "{:#?}", r.remaining);
|
||||
// The two /Rect-bearing forbidden annotations (5 0 R and the inline /Movie with /Rect) were
|
||||
// removed, while the /Rect-less or null-/Rect dictionaries (8 0 R and the inline /Sound
|
||||
// entries) without /Type /Annot are left untouched rather than destructively stripped.
|
||||
assert_eq!(&*doc.get(ObjRef::new(5, 0)), &Object::Null);
|
||||
assert!(doc.get(ObjRef::new(8, 0)).as_dict().is_some());
|
||||
let page_annots = doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap();
|
||||
assert_eq!(page_annots.len(), 6, "{page_annots:#?}");
|
||||
assert!(page_annots.iter().any(|entry| entry.as_ref() == Some(ObjRef::new(8, 0))));
|
||||
assert_eq!(page_annots.iter().filter(|entry| entry.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Sound"))).count(), 3);
|
||||
|
||||
// Indexed and Separation colour spaces over DeviceCMYK on image XObjects or /ColorSpace resources count as CMYK.
|
||||
let indexed_img = build(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>",
|
||||
"<< /Length 7 >>\nstream\n/Im0 Do\nendstream",
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceCMYK 0 <00000000>] /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream",
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert!(uses_cmyk(&indexed_img));
|
||||
|
||||
let res_cmyk = drawing("/CS0 cs 0 sc 0 0 10 10 re f", "/ColorSpace << /CS0 [/Indexed /DeviceCMYK 0 <00000000>] >>", &[]);
|
||||
assert!(uses_cmyk(&res_cmyk));
|
||||
let pattern_cmyk = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 [/Pattern /DeviceCMYK] >>", &[]);
|
||||
assert!(uses_cmyk(&pattern_cmyk));
|
||||
let chained_cs = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 5 0 R >>", &["6 0 R", "[7 0 R 8 0 R]", "/Pattern", "/DeviceCMYK"]);
|
||||
assert!(uses_cmyk(&chained_cs));
|
||||
|
||||
// An indirect filter name inside a /Filter array or the /LZW abbreviation is still checked for LZWDecode.
|
||||
let indirect_lzw = build(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>",
|
||||
"<< /Filter [5 0 R] /Length 0 >>\nstream\n\nendstream",
|
||||
"/LZWDecode",
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert!(messages(&verify(&indirect_lzw, Level::A2b)).contains(&"A stream uses LZW compression"));
|
||||
let abbrev_lzw = build(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>",
|
||||
"<< /Filter /LZW /Length 0 >>\nstream\n\nendstream",
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert!(messages(&verify(&abbrev_lzw, Level::A2b)).contains(&"A stream uses LZW compression"));
|
||||
}
|
||||
|
||||
/// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1):
|
||||
/// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name
|
||||
/// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-print = { workspace = true }
|
||||
log = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
@@ -17,3 +18,5 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-render = { workspace = true }
|
||||
# Reads the renamed programs back with a parser that isn't ours.
|
||||
skrifa = "0.44"
|
||||
|
||||
+38
-1
@@ -26,14 +26,29 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default()
|
||||
Headless: `doc_print` with `layout: "multiple"`, `order: "cut-stack"` and
|
||||
`per_sheet: 4` (or any supported grid size), plus `path` or `printer`.
|
||||
- **Booklet**: saddle-stitch imposition padded to a multiple of 4; both sides, front or back
|
||||
only; left or right binding.
|
||||
only; left or right binding; creep compensation, which moves each sheet's pages toward the
|
||||
fold by its place from the outside times the creep (points per sheet, up to `MAX_CREEP`),
|
||||
clipped at the fold. Headless: `doc_print` with `layout: "booklet"` and `creep`.
|
||||
- **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks.
|
||||
- **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print
|
||||
only with their Print flag, drawn from their appearance streams (Algorithm 8.1).
|
||||
Existing Link appearances are document content in the first three modes; Link appearances
|
||||
are not synthesized when absent, and form fields only excludes them.
|
||||
- **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off
|
||||
the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves
|
||||
the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within`
|
||||
kills and reaps the command at the deadline).
|
||||
- **Remembered choices** (#2001): the dialog keeps two-sided (including leaving it to the
|
||||
printer), grayscale, collate, page sizing (with its custom percent), Comments & forms and a
|
||||
booklet's binding side across restarts, in the settings file under `print` with the names
|
||||
`doc_print` takes (`names::duplex`, `names::content`, `names::size_mode` and their `parse_*`
|
||||
counterparts are the one vocabulary). The paper is saved by its own setting (#2156).
|
||||
- **Each job starts afresh**: copies, pages (the choice, the range, odd/even and reverse), the
|
||||
layout with its own options (pages per sheet, order, borders, booklet subset, poster scale,
|
||||
overlap and cut marks) and orientation reset to the defaults each time Print opens, in the
|
||||
same session too; ISO 32000-2 Table 147 initialises them per document when the print dialog
|
||||
opens. Picked thumbnails still open it on Selected pages. The printer picked last carries over
|
||||
within a session (#3537) but is not saved.
|
||||
|
||||
Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable
|
||||
annotations); sheets place them with a clip. The result is a fresh, unencrypted,
|
||||
@@ -59,6 +74,28 @@ orientation, zero added margins and hardware-edge clipping. Long-edge and short-
|
||||
use the corresponding Windows binding edges. Random, exclusively created Windows job files
|
||||
are removed on success and failure. Other targets offer the print-ready PDF instead.
|
||||
|
||||
On Windows, **Printer default** reads the selected driver's current `DEVMODEW` duplex
|
||||
setting immediately before submission and carries that binding explicitly into the job
|
||||
(#5130). This also applies to `doc_print` with `duplex: "printer"`; its omitted-argument
|
||||
default remains `off`. Explicit Off/Long Edge/Short Edge choices, booklet binding and
|
||||
single-sided cut-and-stack bypass this lookup. Drivers without `DM_DUPLEX` retain the
|
||||
unspecified setting. Two-sided defaults also stay unspecified when the driver does not
|
||||
confirm automatic two-sided support, preserving manual duplex preferences (#5772).
|
||||
A failed lookup, settings read or invalid declared duplex value is
|
||||
logged and falls back to leaving duplex unspecified, so the driver can still choose its default.
|
||||
If the queue was resolved before the settings failure, the job remains pinned to that queue. CUPS still leaves the choice to the queue. The Windows backend
|
||||
selects `JobDuplexAllDocumentsContiguously`, falling back to `DocumentDuplex` when
|
||||
the driver exposes only document-level duplex, and validates the retained binding before submission
|
||||
([craft-libs #411](https://github.com/storytold/craft-libs/pull/411)).
|
||||
|
||||
Before a job reaches the Windows spooler, every embedded TrueType or OpenType program is
|
||||
renamed `PdfCraftPrint<n>` in its `name` table and in the PDF (`/BaseFont`, `/FontName`; a
|
||||
subset keeps its tag). Windows otherwise draws a subset's renumbered glyphs with the installed
|
||||
font of the same name, so ReportLab's Arial subsets printed as shifted letters (#3132). Glyphs,
|
||||
encodings and `/ToUnicode` are unchanged, and CUPS and Save as PDF get the PDF as it was. A
|
||||
program PdfCraft can't read and rewrite keeps its old name in the PDF too, so a viewer that
|
||||
can't load it either still picks its substitute by that name.
|
||||
|
||||
The shared dependency requires Rust 1.95. Transport and native Windows regression tests
|
||||
live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage.
|
||||
The desktop UI discovers printers and submits jobs on workers, so neither operation blocks
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Embedded TrueType and OpenType programs renamed before a print-ready PDF goes to the Windows
|
||||
//! spooler, so no installed font answers to their names (#3132).
|
||||
//!
|
||||
//! A subset program numbers its glyphs anew: ReportLab's Arial subsets draw a space with glyph 11
|
||||
//! (Arial Bold: glyph 8), where the installed Arial's space is glyph 3. Windows' print path
|
||||
//! (Windows.Data.Pdf drawing into Direct2D's print control, through craft-print) keeps the
|
||||
//! subset's glyph numbers but draws them with the installed font of the same name, so every letter
|
||||
//! prints as the installed glyph with that number: `Revisión de clientes,` in the bold subset comes
|
||||
//! out as `Wj{nxn s%ij%hqnjsyjx1`, its `z` as `Ä`. Both Brother queues and the Adobe PDF printer
|
||||
//! show it; PdfCraft's renderer, CUPS and Chrome on Windows draw the embedded program and are right.
|
||||
//!
|
||||
//! [`distinct`] renames each program to `PdfCraftPrint<n>`: the identity records of its `name`
|
||||
//! table, `/FontName` of its descriptors and `/BaseFont` of the fonts drawn with it (a subset keeps
|
||||
//! its tag, `AAAAAA+PdfCraftPrint1`). Glyphs, glyph numbers, cmaps, encodings, widths and
|
||||
//! `/ToUnicode` are not touched, so pages draw and extract as before. A program that can't be
|
||||
//! read and rewritten keeps its old name everywhere.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
|
||||
use crate::PrintError;
|
||||
|
||||
/// Whether [`crate::spool`] renames the programs before spooling: on Windows only.
|
||||
pub const RENAME_FOR_SPOOLER: bool = cfg!(windows);
|
||||
|
||||
/// Decoded size past which a program is left as it is (a whole CJK font is about 20 MB).
|
||||
const MAX_PROGRAM: usize = 64 << 20;
|
||||
|
||||
/// Decoded bytes the whole renaming may read, across every program. A document's fonts rarely
|
||||
/// come to 100 MB; a file of many distinct compressed bombs is read only this far.
|
||||
const MAX_DECODED_IN_ALL: usize = 512 << 20;
|
||||
|
||||
/// More tables than any font has; a larger count is a damaged directory.
|
||||
const MAX_TABLES: usize = 256;
|
||||
|
||||
/// The `name` records that say which font this is: family, unique ID, full name, PostScript name,
|
||||
/// typographic family, Mac compatible full name, PostScript CID name, WWS family and the
|
||||
/// variations PostScript name prefix.
|
||||
const IDENTITY: [u16; 9] = [1, 3, 4, 6, 16, 18, 20, 21, 25];
|
||||
|
||||
/// The font subtypes whose descriptor can hold a TrueType or OpenType program.
|
||||
const DRAWN_WITH_A_PROGRAM: [&[u8]; 5] = [b"TrueType", b"Type1", b"MMType1", b"CIDFontType0", b"CIDFontType2"];
|
||||
|
||||
/// An embedded program and what it will be called.
|
||||
struct Program {
|
||||
name: String,
|
||||
/// A `/FontFile2` program, whose `/Length1` is the decoded length.
|
||||
true_type: bool,
|
||||
}
|
||||
|
||||
/// The fonts drawn with embedded TrueType or OpenType programs, found by scanning every object.
|
||||
#[derive(Default)]
|
||||
struct Found {
|
||||
/// Each program, numbered in object order.
|
||||
programs: BTreeMap<ObjRef, Program>,
|
||||
/// Simple fonts and CIDFonts, with their program.
|
||||
fonts: BTreeMap<ObjRef, ObjRef>,
|
||||
/// Indirect font descriptors, with their program.
|
||||
descriptors: BTreeMap<ObjRef, ObjRef>,
|
||||
/// Type 0 fonts whose CIDFont is an object of its own, with that CIDFont.
|
||||
type0: BTreeMap<ObjRef, ObjRef>,
|
||||
/// Type 0 fonts whose CIDFont is written inline, with the array object holding it when
|
||||
/// `/DescendantFonts` names one, and the CIDFont's program.
|
||||
inline: BTreeMap<ObjRef, (Option<ObjRef>, ObjRef)>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// Note the program `font` draws with, and its descriptor when that is an object of its own;
|
||||
/// the program, if it has one to rename.
|
||||
fn add(&mut self, doc: &Document, font: &Dict) -> Option<ObjRef> {
|
||||
if !font.name(b"Subtype").is_some_and(|s| DRAWN_WITH_A_PROGRAM.contains(&s)) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = font.get(b"FontDescriptor")?;
|
||||
let (program, true_type) = doc.dict(descriptor).and_then(|d| program(doc, &d))?;
|
||||
let number = self.programs.len() + 1;
|
||||
self.programs.entry(program).or_insert_with(|| Program { name: format!("PdfCraftPrint{number}"), true_type });
|
||||
if let Object::Ref(descriptor) = descriptor {
|
||||
self.descriptors.insert(*descriptor, program);
|
||||
}
|
||||
Some(program)
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdf` with every embedded TrueType or OpenType program renamed; `None` when it has none that
|
||||
/// can be.
|
||||
pub fn distinct(pdf: &[u8]) -> Result<Option<Vec<u8>>, PrintError> {
|
||||
distinct_within(pdf, &mut DecodeBudget::new(MAX_DECODED_IN_ALL, 0))
|
||||
}
|
||||
|
||||
/// [`distinct`], decoding the programs against `budget`: once it is spent, the programs not yet
|
||||
/// read keep their names, as one that can't be read does.
|
||||
pub(crate) fn distinct_within(pdf: &[u8], budget: &mut DecodeBudget) -> Result<Option<Vec<u8>>, PrintError> {
|
||||
let mut doc = Document::open(Arc::new(pdf.to_vec()))?;
|
||||
let mut found = find(&doc);
|
||||
// A program that can't be rewritten keeps its names in the PDF too: a viewer that can't load
|
||||
// it either then still picks its substitute by the original name. The lookups below skip it.
|
||||
found.programs.retain(|program, about| rename_program(&mut doc, *program, about, budget));
|
||||
if found.programs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
for (descriptor, program) in &found.descriptors {
|
||||
let Some(about) = found.programs.get(program) else { continue };
|
||||
doc.update_dict(*descriptor, |d| rename(d, b"FontName", &about.name))?;
|
||||
}
|
||||
for (font, program) in &found.fonts {
|
||||
let Some(about) = found.programs.get(program) else { continue };
|
||||
doc.update_dict(*font, |d| rename_font(d, &about.name))?;
|
||||
}
|
||||
// A Type 0 font is named after its CIDFont; renaming only the CIDFont would leave the old
|
||||
// name on the font the page uses.
|
||||
for (font, cid) in &found.type0 {
|
||||
let Some(about) = found.fonts.get(cid).and_then(|p| found.programs.get(p)) else { continue };
|
||||
doc.update_dict(*font, |d| rename(d, b"BaseFont", &about.name))?;
|
||||
}
|
||||
for (font, (array, program)) in &found.inline {
|
||||
let Some(about) = found.programs.get(program) else { continue };
|
||||
doc.update_dict(*font, |d| {
|
||||
rename(d, b"BaseFont", &about.name);
|
||||
if let Some(Object::Array(descendants)) = d.get_mut(b"DescendantFonts") {
|
||||
rename_first(descendants, &about.name);
|
||||
}
|
||||
})?;
|
||||
if let Some(array) = array {
|
||||
let Object::Array(mut descendants) = doc.get(*array).as_ref().clone() else { continue };
|
||||
rename_first(&mut descendants, &about.name);
|
||||
doc.set(*array, Object::Array(descendants));
|
||||
}
|
||||
}
|
||||
Ok(Some(write_full(&doc, &SaveOptions::default())?))
|
||||
}
|
||||
|
||||
fn find(doc: &Document) -> Found {
|
||||
let mut found = Found::default();
|
||||
for (reference, object) in doc.scan_objects() {
|
||||
let Object::Dict(font) = object.as_ref() else { continue };
|
||||
if font.name(b"Subtype") == Some(b"Type0") {
|
||||
find_descendant(doc, reference, font, &mut found);
|
||||
} else if let Some(program) = found.add(doc, font) {
|
||||
found.fonts.insert(reference, program);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Note the CIDFont a Type 0 font is named after: an object of its own, renamed when the scan
|
||||
/// reaches it, or written inline, which no scan reaches, so its program is noted here.
|
||||
fn find_descendant(doc: &Document, font: ObjRef, d: &Dict, found: &mut Found) {
|
||||
let Some(descendants) = d.get(b"DescendantFonts") else { return };
|
||||
let array = match descendants {
|
||||
Object::Ref(array) => Some(*array),
|
||||
_ => None,
|
||||
};
|
||||
let descendants = doc.resolve(descendants);
|
||||
match descendants.as_array().and_then(|a| a.first()) {
|
||||
Some(Object::Ref(cid)) => {
|
||||
found.type0.insert(font, *cid);
|
||||
}
|
||||
Some(Object::Dict(cid)) => {
|
||||
if let Some(program) = found.add(doc, cid) {
|
||||
found.inline.insert(font, (array, program));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename a simple font or CIDFont, and its descriptor when that is written inline.
|
||||
fn rename_font(d: &mut Dict, name: &str) {
|
||||
rename(d, b"BaseFont", name);
|
||||
if let Some(Object::Dict(descriptor)) = d.get_mut(b"FontDescriptor") {
|
||||
rename(descriptor, b"FontName", name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the CIDFont written inline at the head of `/DescendantFonts`.
|
||||
fn rename_first(descendants: &mut [Object], name: &str) {
|
||||
if let Some(Object::Dict(cid)) = descendants.first_mut() {
|
||||
rename_font(cid, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor's TrueType program (`/FontFile2`), else its OpenType one (`/FontFile3` with
|
||||
/// `/Subtype /OpenType`), with whether it is TrueType.
|
||||
fn program(doc: &Document, descriptor: &Dict) -> Option<(ObjRef, bool)> {
|
||||
if let Some(Object::Ref(program)) = descriptor.get(b"FontFile2") {
|
||||
return Some((*program, true));
|
||||
}
|
||||
let Some(Object::Ref(program)) = descriptor.get(b"FontFile3") else { return None };
|
||||
let open_type = matches!(doc.get(*program).as_ref(), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"OpenType"));
|
||||
open_type.then_some((*program, false))
|
||||
}
|
||||
|
||||
/// Set `key` to `name`, keeping the subset tag the old name had.
|
||||
fn rename(d: &mut Dict, key: &[u8], name: &str) {
|
||||
let mut renamed = d.name(key).and_then(subset_tag).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
renamed.extend_from_slice(name.as_bytes());
|
||||
d.set(key.to_vec(), Object::Name(renamed));
|
||||
}
|
||||
|
||||
/// `ABCDEF+` at the start of a subset font's name (ISO 32000-2 §9.9.2).
|
||||
fn subset_tag(name: &[u8]) -> Option<&[u8]> {
|
||||
let tag = name.get(..7)?;
|
||||
let (letters, plus) = tag.split_at(6);
|
||||
(letters.iter().all(u8::is_ascii_uppercase) && plus == b"+").then_some(tag)
|
||||
}
|
||||
|
||||
/// Rewrite the program with its `name` table renamed; whether it was. A program that can't be
|
||||
/// decoded within `budget` or isn't an sfnt with a readable `name` table stays as it is.
|
||||
fn rename_program(doc: &mut Document, program: ObjRef, about: &Program, budget: &mut DecodeBudget) -> bool {
|
||||
let object = doc.get(program);
|
||||
let Object::Stream(stream) = object.as_ref() else { return false };
|
||||
let Ok(bytes) = budget.decode_within(stream, MAX_PROGRAM) else { return false };
|
||||
let Some(renamed) = renamed_sfnt(&bytes, &about.name) else { return false };
|
||||
let mut dict = stream.dict.clone();
|
||||
dict.remove(b"DL");
|
||||
if about.true_type || dict.get(b"Length1").is_some() {
|
||||
dict.set(b"Length1".to_vec(), Object::Int(i64::try_from(renamed.len()).unwrap_or(i64::MAX)));
|
||||
}
|
||||
doc.set(program, Object::Stream(Stream::flate(dict, &renamed)));
|
||||
true
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn checksum(data: &[u8]) -> u32 {
|
||||
data.chunks(4).fold(0_u32, |sum, c| {
|
||||
let mut word = [0; 4];
|
||||
for (a, b) in word.iter_mut().zip(c) {
|
||||
*a = *b;
|
||||
}
|
||||
sum.wrapping_add(u32::from_be_bytes(word))
|
||||
})
|
||||
}
|
||||
|
||||
/// `program` with the identity records of its `name` table saying `name`. The new table goes
|
||||
/// after the others, as `pdfcraft_fonts` writes its editor cmap, and the directory entry and
|
||||
/// `head`'s checksum adjustment are brought up to date. `None` when `program` is not a single
|
||||
/// sfnt font with a `name` table that can be read.
|
||||
pub(crate) fn renamed_sfnt(program: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
let version = program.get(..4)?;
|
||||
if version != [0, 1, 0, 0] && version != b"true" && version != b"OTTO" {
|
||||
return None;
|
||||
}
|
||||
let count = usize::from(u16_at(program, 4)?);
|
||||
if count > MAX_TABLES {
|
||||
return None;
|
||||
}
|
||||
let (mut entry, mut head) = (None, None);
|
||||
for at in (0..count).map(|i| 12 + i * 16) {
|
||||
match program.get(at..at + 4)? {
|
||||
b"name" => entry = Some(at),
|
||||
b"head" => head = Some(usize::try_from(u32_at(program, at + 8)?).ok()?),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let entry = entry?;
|
||||
let offset = usize::try_from(u32_at(program, entry + 8)?).ok()?;
|
||||
let length = usize::try_from(u32_at(program, entry + 12)?).ok()?;
|
||||
let table = renamed_table(program.get(offset..offset.checked_add(length)?)?, name)?;
|
||||
let mut out = program.to_vec();
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
let at = u32::try_from(out.len()).ok()?;
|
||||
out.get_mut(entry + 4..entry + 8)?.copy_from_slice(&checksum(&table).to_be_bytes());
|
||||
out.get_mut(entry + 8..entry + 12)?.copy_from_slice(&at.to_be_bytes());
|
||||
out.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(table.len()).ok()?.to_be_bytes());
|
||||
out.extend_from_slice(&table);
|
||||
if let Some(head) = head {
|
||||
let adjustment = head.checked_add(8)?..head.checked_add(12)?;
|
||||
out.get_mut(adjustment.clone())?.fill(0);
|
||||
let sum = checksum(&out);
|
||||
out.get_mut(adjustment)?.copy_from_slice(&0xb1b0afba_u32.wrapping_sub(sum).to_be_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Where a record's string comes from: the old table's storage (offset and length), or the new
|
||||
/// name in UTF-16BE or in single bytes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Text {
|
||||
Old(u16, u16),
|
||||
Wide,
|
||||
Narrow,
|
||||
}
|
||||
|
||||
/// A `name` table (format 0 or 1) with its identity records saying `name`. The records stay in
|
||||
/// their order, which the format requires to be sorted. An identity record in an encoding that
|
||||
/// can't spell `name`, or any record whose string lies outside the table, is left out; a broken
|
||||
/// language tag fails the table, because records refer to the tags by position.
|
||||
///
|
||||
/// Nothing is copied until the whole table has been read, and then each distinct string once:
|
||||
/// records that shared a string still share it, and the strings stay within what 16-bit offsets
|
||||
/// reach, so a small table can't make the copy large (#4098).
|
||||
fn renamed_table(table: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
let format = u16_at(table, 0)?;
|
||||
if format > 1 {
|
||||
return None;
|
||||
}
|
||||
let count = usize::from(u16_at(table, 2)?);
|
||||
let storage = usize::from(u16_at(table, 4)?);
|
||||
let old = |offset: u16, length: u16| table.get(storage.checked_add(usize::from(offset))?..)?.get(..usize::from(length));
|
||||
let tags_at = 6 + count * 12;
|
||||
let tag_count = if format == 1 { usize::from(u16_at(table, tags_at)?) } else { 0 };
|
||||
// The records and tags come before the strings, whose offset is 16 bits: a table that claims
|
||||
// more of them overlaps its own strings and is refused.
|
||||
u16::try_from(tags_at + if format == 1 { 2 + tag_count * 4 } else { 0 }).ok()?;
|
||||
let mut records: Vec<([u16; 4], Text)> = Vec::with_capacity(count);
|
||||
for at in (0..count).map(|i| 6 + i * 12) {
|
||||
let field = |i: usize| u16_at(table, at + 2 * i);
|
||||
let key = [field(0)?, field(1)?, field(2)?, field(3)?];
|
||||
let [platform, encoding, _, id] = key;
|
||||
let text = if IDENTITY.contains(&id) {
|
||||
spelling(platform, encoding)
|
||||
} else {
|
||||
let (length, offset) = (field(4)?, field(5)?);
|
||||
old(offset, length).map(|_| Text::Old(offset, length))
|
||||
};
|
||||
if let Some(text) = text {
|
||||
records.push((key, text));
|
||||
}
|
||||
}
|
||||
let mut tags = Vec::with_capacity(tag_count);
|
||||
for at in (0..tag_count).map(|i| tags_at + 2 + 4 * i) {
|
||||
let (length, offset) = (u16_at(table, at)?, u16_at(table, at + 2)?);
|
||||
old(offset, length)?;
|
||||
tags.push(Text::Old(offset, length));
|
||||
}
|
||||
let wide: Vec<u8> = name.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let mut placed: BTreeMap<Text, [u16; 2]> = BTreeMap::new();
|
||||
let mut strings = Vec::new();
|
||||
// A string's length and offset in the new storage, storing it the first time it is asked for.
|
||||
let mut place = |text: Text| -> Option<[u16; 2]> {
|
||||
if let Some(at) = placed.get(&text) {
|
||||
return Some(*at);
|
||||
}
|
||||
let bytes = match text {
|
||||
Text::Old(offset, length) => old(offset, length)?,
|
||||
Text::Wide => &wide,
|
||||
Text::Narrow => name.as_bytes(),
|
||||
};
|
||||
let at = [u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?];
|
||||
strings.extend_from_slice(bytes);
|
||||
placed.insert(text, at);
|
||||
Some(at)
|
||||
};
|
||||
let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 };
|
||||
let mut out = Vec::with_capacity(header);
|
||||
for v in [format, u16::try_from(records.len()).ok()?, u16::try_from(header).ok()?] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for (key, text) in &records {
|
||||
for v in key.iter().chain(&place(*text)?) {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
if format == 1 {
|
||||
out.extend_from_slice(&u16::try_from(tags.len()).ok()?.to_be_bytes());
|
||||
for tag in &tags {
|
||||
for v in place(*tag)? {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&strings);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// How `name` (ASCII) is written in a `name` record of this platform and encoding: UTF-16BE for
|
||||
/// Unicode and the Windows Unicode and symbol encodings, single bytes for Macintosh (every Mac
|
||||
/// script encoding starts with ASCII); `None` for the legacy Windows CJK encodings and the ISO and
|
||||
/// custom platforms.
|
||||
fn spelling(platform: u16, encoding: u16) -> Option<Text> {
|
||||
match (platform, encoding) {
|
||||
(0, _) | (3, 0 | 1 | 10) => Some(Text::Wide),
|
||||
(1, _) => Some(Text::Narrow),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,9 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool {
|
||||
let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b"");
|
||||
match subtype {
|
||||
b"Widget" => true,
|
||||
b"Link" | b"Popup" => false,
|
||||
// Existing Link appearances are part of the document, not markup or fields.
|
||||
b"Link" => content != Content::FormFieldsOnly,
|
||||
b"Popup" => false,
|
||||
b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly,
|
||||
// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table
|
||||
// 171) is not printed even with its Print flag set.
|
||||
|
||||
+115
-2
@@ -20,6 +20,7 @@
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
|
||||
mod font_names;
|
||||
pub mod range;
|
||||
pub mod spool;
|
||||
#[cfg(test)]
|
||||
@@ -38,10 +39,122 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents,
|
||||
pub use imposition::{impose, keep_printed, sheet_pages};
|
||||
pub use settings::{
|
||||
Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex,
|
||||
cups_media, media_option,
|
||||
cups_media, driver_needs_grayscale, media_option,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use sheets::text_direction;
|
||||
pub use sheets::{
|
||||
LABEL_MARGIN, MARGIN, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up,
|
||||
LABEL_MARGIN, MARGIN, MAX_CREEP, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up,
|
||||
};
|
||||
|
||||
/// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings
|
||||
/// file, so a saved preference reads like a tool argument and there is one vocabulary to keep.
|
||||
/// Each `parse_*` returns `None` for an unknown name (settings and tool arguments are
|
||||
/// untrusted input; the caller keeps its default).
|
||||
pub mod names {
|
||||
use super::{Binding, Content, SizeMode, spool::Duplex};
|
||||
|
||||
/// Two-sided printing as `doc_print` names it: `None` leaves it to the printer (`printer`).
|
||||
pub fn duplex(duplex: Option<Duplex>) -> &'static str {
|
||||
match duplex {
|
||||
None => "printer",
|
||||
Some(Duplex::Off) => "off",
|
||||
Some(Duplex::LongEdge) => "long-edge",
|
||||
Some(Duplex::ShortEdge) => "short-edge",
|
||||
}
|
||||
}
|
||||
|
||||
/// [`duplex`] read back; `None` for a name it doesn't give.
|
||||
pub fn parse_duplex(name: &str) -> Option<Option<Duplex>> {
|
||||
match name {
|
||||
"printer" => Some(None),
|
||||
"off" => Some(Some(Duplex::Off)),
|
||||
"long-edge" => Some(Some(Duplex::LongEdge)),
|
||||
"short-edge" => Some(Some(Duplex::ShortEdge)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Acrobat's Comments & Forms choices.
|
||||
pub fn content(content: Content) -> &'static str {
|
||||
match content {
|
||||
Content::Document => "document",
|
||||
Content::DocumentAndMarkups => "document-and-markups",
|
||||
Content::DocumentAndStamps => "document-and-stamps",
|
||||
Content::FormFieldsOnly => "form-fields-only",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_content(name: &str) -> Option<Content> {
|
||||
match name {
|
||||
"document" => Some(Content::Document),
|
||||
"document-and-markups" => Some(Content::DocumentAndMarkups),
|
||||
"document-and-stamps" => Some(Content::DocumentAndStamps),
|
||||
"form-fields-only" => Some(Content::FormFieldsOnly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page sizing choices; a custom scale is named `custom` and carries its percent
|
||||
/// separately (see [`parse_size_mode`]).
|
||||
pub fn size_mode(mode: SizeMode) -> &'static str {
|
||||
match mode {
|
||||
SizeMode::Fit => "fit",
|
||||
SizeMode::Actual => "actual",
|
||||
SizeMode::Shrink => "shrink",
|
||||
SizeMode::Custom(_) => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// `custom` takes `scale` as its percent; the caller has validated it.
|
||||
pub fn parse_size_mode(name: &str, scale: f64) -> Option<SizeMode> {
|
||||
match name {
|
||||
"fit" => Some(SizeMode::Fit),
|
||||
"actual" => Some(SizeMode::Actual),
|
||||
"shrink" => Some(SizeMode::Shrink),
|
||||
"custom" => Some(SizeMode::Custom(scale)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A booklet's binding side.
|
||||
pub fn binding(binding: Binding) -> &'static str {
|
||||
match binding {
|
||||
Binding::Left => "left",
|
||||
Binding::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_binding(name: &str) -> Option<Binding> {
|
||||
match name {
|
||||
"left" => Some(Binding::Left),
|
||||
"right" => Some(Binding::Right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_choice_round_trips_through_its_name_and_unknown_names_are_none() {
|
||||
for d in [None, Some(Duplex::Off), Some(Duplex::LongEdge), Some(Duplex::ShortEdge)] {
|
||||
assert_eq!(parse_duplex(duplex(d)), Some(d));
|
||||
}
|
||||
for c in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] {
|
||||
assert_eq!(parse_content(content(c)), Some(c));
|
||||
}
|
||||
for m in [SizeMode::Fit, SizeMode::Actual, SizeMode::Shrink, SizeMode::Custom(62.5)] {
|
||||
assert_eq!(parse_size_mode(size_mode(m), 62.5), Some(m));
|
||||
}
|
||||
for b in [Binding::Left, Binding::Right] {
|
||||
assert_eq!(parse_binding(binding(b)), Some(b));
|
||||
}
|
||||
assert_eq!(parse_binding("Right"), None);
|
||||
assert_eq!(parse_duplex("Long-Edge"), None);
|
||||
assert_eq!(parse_content(""), None);
|
||||
assert_eq!(parse_size_mode("fit-to-page", 100.0), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +75,12 @@ pub enum Layout {
|
||||
/// portrait sheet that turns to landscape when that suits it.
|
||||
custom: bool,
|
||||
},
|
||||
/// `creep` (points, per sheet) moves sheet k's pages toward the fold by `k × creep`, sheet 0
|
||||
/// being the outermost, so the margins match once the nested sheets are folded and trimmed.
|
||||
Booklet {
|
||||
subset: BookletSubset,
|
||||
binding: Binding,
|
||||
creep: f64,
|
||||
},
|
||||
/// Tile scale (percent), overlap (points), cut marks.
|
||||
Poster {
|
||||
@@ -178,6 +181,14 @@ impl Default for Raster {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a print asked to be `grayscale` needs the printer's driver to print in grayscale.
|
||||
/// Not when the pages go as grey pictures (`raster` with [`Raster::grayscale`]): they are grey
|
||||
/// already, so a driver that keeps no grayscale setting (Microsoft Print to PDF, many inkjets)
|
||||
/// still prints them instead of the job being refused (#5806).
|
||||
pub fn driver_needs_grayscale(grayscale: bool, raster: Option<Raster>) -> bool {
|
||||
grayscale && !raster.is_some_and(|r| r.grayscale)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Crop {
|
||||
pub rect: [f64; 4],
|
||||
@@ -199,7 +210,7 @@ impl Default for Settings {
|
||||
}
|
||||
|
||||
/// Common paper sizes (points, portrait).
|
||||
pub const PAPERS: [(&str, (f64, f64)); 21] = [
|
||||
pub const PAPERS: [(&str, (f64, f64)); 23] = [
|
||||
("US Letter", (612.0, 792.0)),
|
||||
("US Legal", (612.0, 1008.0)),
|
||||
("Tabloid", (792.0, 1224.0)),
|
||||
@@ -221,6 +232,8 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [
|
||||
("A0", (2383.94, 3370.39)),
|
||||
("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)),
|
||||
("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)),
|
||||
("Folio (8.5 x 13 in)", (612.0, 936.0)),
|
||||
("F4 (210 x 330 mm)", (595.28, 935.43)),
|
||||
];
|
||||
|
||||
/// `PAPERS` index of the 4 × 6 in shipping label.
|
||||
@@ -255,6 +268,8 @@ pub fn cups_media(index: usize) -> Option<&'static str> {
|
||||
"A1" => "iso_a1_594x841mm",
|
||||
"A0" => "iso_a0_841x1189mm",
|
||||
"US #10 Envelope (4.125 x 9.5 in)" => "Env10",
|
||||
"Folio (8.5 x 13 in)" => "na_foolscap_8.5x13in",
|
||||
"F4 (210 x 330 mm)" => "om_folio_210x330mm",
|
||||
_ if index == LABEL_PAPER => "w288h432",
|
||||
// A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's
|
||||
// paper stands, as it did for every size before #1583.
|
||||
|
||||
@@ -168,6 +168,25 @@ fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: b
|
||||
Placement { page, matrix, clip: [0.0, 0.0, dw, dh] }
|
||||
}
|
||||
|
||||
/// The most creep per sheet, in points. Paper adds a fraction of a point per sheet; more is a mistake.
|
||||
pub const MAX_CREEP: f64 = 10.0;
|
||||
|
||||
/// `placed` moved `shift` points across the sheet (positive is rightward) and clipped at the fold
|
||||
/// at `fold`, so a page moved toward it never prints on the facing page.
|
||||
fn creep_toward_fold(mut placed: Placement, shift: f64, fold: f64, s: f64) -> Placement {
|
||||
if shift == 0.0 || s <= 0.0 {
|
||||
return placed;
|
||||
}
|
||||
placed.matrix.0[4] += shift;
|
||||
let at_fold = (fold - placed.matrix.0[4]) / s;
|
||||
if shift > 0.0 {
|
||||
placed.clip[2] = placed.clip[2].min(at_fold);
|
||||
} else {
|
||||
placed.clip[0] = placed.clip[0].max(at_fold);
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 {
|
||||
if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) }
|
||||
}
|
||||
@@ -288,13 +307,19 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
|
||||
sheets.push(sheet);
|
||||
}
|
||||
}
|
||||
Layout::Booklet { subset, binding } => {
|
||||
Layout::Booklet { subset, binding, creep } => {
|
||||
// Pad to a multiple of 4 with blank pages (None).
|
||||
let n4 = pages.len().div_ceil(4) * 4;
|
||||
let at = |i: usize| pages.get(i).copied();
|
||||
let size = oriented(paper, Orientation::Landscape, true);
|
||||
let half = (size.0 - 2.0 * MARGIN) / 2.0;
|
||||
let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]];
|
||||
// The innermost sheet moves furthest; it must stay within half its cell. `pages` is not
|
||||
// empty (checked above), so there is at least one sheet.
|
||||
let innermost = creep * (n4 / 4 - 1) as f64;
|
||||
if !((0.0..=MAX_CREEP).contains(&creep) && innermost < half / 2.0) {
|
||||
return Err(PrintError::Invalid("invalid booklet creep".into()));
|
||||
}
|
||||
for k in 0..n4 / 4 {
|
||||
let front = (at(n4 - 1 - 2 * k), at(2 * k));
|
||||
let back = (at(2 * k + 1), at(n4 - 2 - 2 * k));
|
||||
@@ -308,11 +333,12 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
|
||||
for (l, r) in sides {
|
||||
let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) };
|
||||
let mut sheet = Sheet { size, ..Sheet::default() };
|
||||
for (p, cell) in [(l, cells[0]), (r, cells[1])] {
|
||||
for (p, cell, toward) in [(l, cells[0], 1.0), (r, cells[1], -1.0)] {
|
||||
if let Some(p) = p {
|
||||
let d = sizes[p];
|
||||
let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1]));
|
||||
sheet.placed.push(place_in(p, d, cell, s, false));
|
||||
let placed = place_in(p, d, cell, s, false);
|
||||
sheet.placed.push(creep_toward_fold(placed, toward * creep * k as f64, MARGIN + half, s));
|
||||
}
|
||||
}
|
||||
sheets.push(sheet);
|
||||
|
||||
+191
-13
@@ -6,6 +6,9 @@ pub use craft_print::{
|
||||
parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed,
|
||||
};
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
mod windows_duplex;
|
||||
|
||||
/// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load
|
||||
/// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list)
|
||||
/// so a Windows installation without them still starts (#2630); a call into a missing
|
||||
@@ -24,8 +27,9 @@ thread_local! {
|
||||
/// Tests: the folder the print DLLs are looked for in, instead of the system's, so the
|
||||
/// public entry points can be checked against a Windows that lacks them.
|
||||
pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell<Option<std::path::PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
/// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell
|
||||
/// the guard answered without relying on the machine having no printers.
|
||||
/// Tests: how many times [`printers`], [`printer_queues`] or [`two_sided`] asked the system's
|
||||
/// printer backend, so a test can tell the guard answered without relying on the machine
|
||||
/// having no printers.
|
||||
pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
@@ -59,6 +63,29 @@ pub fn printers() -> Vec<Printer> {
|
||||
craft_print::printers()
|
||||
}
|
||||
|
||||
/// The system's print queues without asking any driver, so a Print dialog's list never waits on
|
||||
/// a printer (#2192); none on a Windows installation without the print DLLs, as [`printers`].
|
||||
pub fn printer_queues() -> Vec<Printer> {
|
||||
if missing_print_dll().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
#[cfg(all(windows, test))]
|
||||
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
craft_print::printer_queues()
|
||||
}
|
||||
|
||||
/// Whether `printer`'s driver takes two-sided settings, read for that one printer; it can wait
|
||||
/// on the device, so call it off the UI thread. Unknown on a Windows installation without the
|
||||
/// print DLLs: reading it goes through them.
|
||||
pub fn two_sided(printer: &str) -> Option<bool> {
|
||||
if missing_print_dll().is_some() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(all(windows, test))]
|
||||
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
craft_print::two_sided(printer)
|
||||
}
|
||||
|
||||
/// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults);
|
||||
/// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it
|
||||
/// when the user asks, not every frame.
|
||||
@@ -164,10 +191,14 @@ pub(crate) fn printer_options_via_within(
|
||||
) -> Vec<PrinterOption> {
|
||||
let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i)));
|
||||
// Retain the conservative name checks, applied to each component. Neither component is
|
||||
// a path: reject dot components, backslashes and any additional separator before I/O.
|
||||
// a path: reject dot components, backslashes and any additional separator before I/O. A
|
||||
// leading `-` is refused too: `lpoptions` parses its argv with getopt, so `-p -E -l` would
|
||||
// read `-E` as an option rather than as the destination, and `--` cannot help because it
|
||||
// ends option parsing before the `-p` that names the queue.
|
||||
let valid = |name: &str| {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& !name.starts_with('-')
|
||||
&& !matches!(name, "." | "..")
|
||||
&& name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"'))
|
||||
};
|
||||
@@ -181,11 +212,20 @@ pub(crate) fn printer_options_via_within(
|
||||
f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes)
|
||||
});
|
||||
let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default();
|
||||
let lpoptions_out = output_within(lpoptions.args(["-p", printer, "-l"]), limit).ok();
|
||||
let current = lpoptions_out.as_deref().map(|out| parse_lpoptions(&String::from_utf8_lossy(out))).unwrap_or_default();
|
||||
if options.is_empty() {
|
||||
// The PPD is missing or unreadable (#4699). The same `lpoptions -l` listing carries every
|
||||
// option with its choices and default; build a fallback list from it.
|
||||
if let Some(out) = &lpoptions_out {
|
||||
options = parse_lpoptions_list(&String::from_utf8_lossy(out));
|
||||
}
|
||||
}
|
||||
// After the fallback: a readable PPD whose `*OpenUI` blocks yielded nothing still supplies the
|
||||
// translations, and `localize` leaves options it cannot translate untouched.
|
||||
if let Some(b) = &ppd {
|
||||
localize(&mut options, b, lang);
|
||||
}
|
||||
let current =
|
||||
output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).unwrap_or_default();
|
||||
for (key, value) in current {
|
||||
if let Some(o) = options.iter_mut().find(|o| o.key == key)
|
||||
&& o.choices.iter().any(|(c, _)| *c == value)
|
||||
@@ -196,6 +236,108 @@ pub(crate) fn printer_options_via_within(
|
||||
options
|
||||
}
|
||||
|
||||
/// Caps that match [`craft_print::parse_ppd`]: a malformed spooler answer is not allowed to
|
||||
/// dominate memory or the dialog.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
const MAX_LPOPTIONS_OPTIONS: usize = 400;
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
const MAX_LPOPTIONS_CHOICES: usize = 400;
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
const MAX_LPOPTIONS_LABEL: usize = 256;
|
||||
|
||||
/// A PPD keyword: printable ASCII without spaces, `/`, `:`, `=` or `"`.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
// The three name helpers below mirror `is_ppd_keyword`, `name_label` and `ppd_label` in craft-print,
|
||||
// which are private there: keep them in step with that crate, or they will accept in one reader what
|
||||
// the other refuses.
|
||||
fn is_lpoptions_keyword(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"'))
|
||||
}
|
||||
|
||||
/// `Name/Label` → (`Name`, `Label`), the label falling back to the name.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
fn lpoptions_name_label(s: &str) -> (&str, &str) {
|
||||
match s.split_once('/') {
|
||||
Some((n, l)) if !l.trim().is_empty() => (n.trim(), l.trim()),
|
||||
Some((n, _)) => (n.trim(), n.trim()),
|
||||
None => (s.trim(), s.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `label`, cut to [`MAX_LPOPTIONS_LABEL`] characters.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
fn lpoptions_label(label: &str) -> String {
|
||||
label.chars().take(MAX_LPOPTIONS_LABEL).collect()
|
||||
}
|
||||
|
||||
/// Whether `choice` is a custom-value template. For a choice the user has to type, `lpoptions -l`
|
||||
/// prints `Custom`, `Custom.<TYPE>` (`Custom.PASSCODE`, `Custom.STRING`, …) or `Custom.WIDTHxHEIGHT`
|
||||
/// in place of the choices, and the value is not something Craft can collect: it would be sent to
|
||||
/// the driver as the literal text. The prefix is reserved by CUPS, case-insensitively.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
fn is_lpoptions_custom_template(choice: &str) -> bool {
|
||||
match choice.split_once('.') {
|
||||
Some((name, _)) => name.eq_ignore_ascii_case("Custom"),
|
||||
None => choice.eq_ignore_ascii_case("Custom"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build [`PrinterOption`]s from the `lpoptions -p NAME -l` listing (`Key/Label: a *b c`). This is
|
||||
/// the fallback when the PPD cannot be read, so there is no group information: every option lands in
|
||||
/// one unnamed group, and the listing cannot say which options sit under `InstallableOptions`, so an
|
||||
/// optional-hardware entry that [`parse_ppd`] drops may appear here. The starred choice is the
|
||||
/// driver's default. Options Craft handles itself ([`HANDLED_OPTIONS`]) and custom-value templates
|
||||
/// ([`is_lpoptions_custom_template`]) are skipped.
|
||||
///
|
||||
/// The line is split at its **last** colon, not its first: `list_group()` prints the option as
|
||||
/// `"%s/%s:"` from the PPD's keyword and translated text, then every choice as a bare `" %s"`, so
|
||||
/// text such as `Supporto: Tipo` carries colons while a choice keyword cannot. Splitting at the
|
||||
/// first colon would turn the rest of the label into invented choices.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
fn parse_lpoptions_list(out: &str) -> Vec<PrinterOption> {
|
||||
let mut options = Vec::new();
|
||||
for line in out.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let Some((head, choices)) = line.rsplit_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let (key, label) = lpoptions_name_label(head);
|
||||
if !is_lpoptions_keyword(key) || HANDLED_OPTIONS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
let mut choice_list = Vec::new();
|
||||
let mut default = String::new();
|
||||
// Set when the choice CUPS marks is a template the dialog cannot collect. The printer keeps
|
||||
// that value, so standing in the first retained choice would show a default the job does not
|
||||
// use: only options changed away from the default are submitted.
|
||||
let mut marked_template = false;
|
||||
for token in choices.split_whitespace() {
|
||||
let starred = token.starts_with('*');
|
||||
let (choice_key, choice_label) = lpoptions_name_label(token.strip_prefix('*').unwrap_or(token));
|
||||
if !is_lpoptions_keyword(choice_key) || is_lpoptions_custom_template(choice_key) {
|
||||
marked_template |= starred && is_lpoptions_custom_template(choice_key);
|
||||
continue;
|
||||
}
|
||||
if starred {
|
||||
default = choice_key.to_string();
|
||||
}
|
||||
if choice_list.len() < MAX_LPOPTIONS_CHOICES && choice_list.iter().all(|(c, _)| c != choice_key) {
|
||||
choice_list.push((choice_key.to_string(), lpoptions_label(choice_label)));
|
||||
}
|
||||
}
|
||||
if choice_list.is_empty() || options.len() >= MAX_LPOPTIONS_OPTIONS {
|
||||
continue;
|
||||
}
|
||||
if default.is_empty() && !marked_template {
|
||||
// No choice marked at all: the dialog needs something selected, and the listing has
|
||||
// nothing better than the first one to offer.
|
||||
default = choice_list.first().map(|(c, _)| c.clone()).unwrap_or_default();
|
||||
}
|
||||
options.push(PrinterOption { key: key.to_string(), label: lpoptions_label(label), group: String::new(), choices: choice_list, default });
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// The keys the macOS print dialog names a job's title and owner under.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName";
|
||||
@@ -248,7 +390,12 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
|
||||
if let Some(dll) = missing_print_dll() {
|
||||
return Err(unavailable(dll));
|
||||
}
|
||||
craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
#[cfg(windows)]
|
||||
let resolved = windows_duplex::job(job);
|
||||
#[cfg(windows)]
|
||||
let job = resolved.as_ref();
|
||||
let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER);
|
||||
craft_print::submit(&pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the
|
||||
@@ -257,7 +404,26 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz
|
||||
if let Some(dll) = missing_print_dll() {
|
||||
return Err(unavailable(dll));
|
||||
}
|
||||
craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
#[cfg(windows)]
|
||||
let resolved = windows_duplex::job(job);
|
||||
#[cfg(windows)]
|
||||
let job = resolved.as_ref();
|
||||
let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER);
|
||||
craft_print::submit_with_progress(&pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
/// The PDF as the system spooler takes it: with `rename` (Windows), its embedded TrueType and
|
||||
/// OpenType programs renamed, so Windows can't draw a subset's glyph numbers with an installed
|
||||
/// font of the same name (#3132, [`crate::font_names`]). A PDF that can't be read back goes as it
|
||||
/// is: the renaming only protects the text from Windows, and the job shouldn't fail over it.
|
||||
pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8]> {
|
||||
if !rename {
|
||||
return std::borrow::Cow::Borrowed(pdf);
|
||||
}
|
||||
match crate::font_names::distinct(pdf) {
|
||||
Ok(Some(renamed)) => std::borrow::Cow::Owned(renamed),
|
||||
Ok(None) | Err(_) => std::borrow::Cow::Borrowed(pdf),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> {
|
||||
@@ -271,7 +437,9 @@ pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError>
|
||||
fn ppd_languages(lang: &str) -> Vec<String> {
|
||||
let lang = lang.to_ascii_lowercase();
|
||||
let mut parts = lang.split(['-', '_']).filter(|p| !p.is_empty());
|
||||
let Some(primary) = parts.next() else { return Vec::new() };
|
||||
let Some(primary) = parts.next() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let region = match (primary, parts.next()) {
|
||||
("zh", Some("hans")) => Some("CN".to_string()),
|
||||
("zh", Some("hant")) => Some("TW".to_string()),
|
||||
@@ -303,7 +471,9 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) {
|
||||
let mut choices: HashMap<(String, String), String> = HashMap::new();
|
||||
let mut groups: HashMap<String, String> = HashMap::new();
|
||||
for line in ppd.split(|b| *b == b'\n') {
|
||||
let Some(text) = std::str::from_utf8(line).ok() else { continue };
|
||||
let Some(text) = std::str::from_utf8(line).ok() else {
|
||||
continue;
|
||||
};
|
||||
let text = text.trim_end_matches('\r');
|
||||
if let Some(rest) = text.strip_prefix("*OpenGroup:") {
|
||||
// Group label → keyword, since options carry their group's label.
|
||||
@@ -313,10 +483,18 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = text.strip_prefix(prefix.as_str()) else { continue };
|
||||
let Some((head, _)) = rest.split_once(':') else { continue };
|
||||
let Some((main, spec)) = head.split_once(' ') else { continue };
|
||||
let Some((key, label)) = spec.split_once('/') else { continue };
|
||||
let Some(rest) = text.strip_prefix(prefix.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some((head, _)) = rest.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let Some((main, spec)) = head.split_once(' ') else {
|
||||
continue;
|
||||
};
|
||||
let Some((key, label)) = spec.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
let label = label.trim();
|
||||
if label.is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Resolve Printer default from the Windows driver's current DEVMODE. Omitting duplex
|
||||
//! from a PrintTicket leaves its interpretation to the XPS driver (#5130).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::{Duplex, Job};
|
||||
use crate::PrintError;
|
||||
|
||||
/// Preserve an explicit duplex choice by borrowing `job` without calling `read`.
|
||||
/// Otherwise call `read` once with the selected printer (or `None` for the system default),
|
||||
/// returning an owned copy with the resolved printer and duplex value. Log lookup failures
|
||||
/// and leave duplex unspecified so the spooler can use its previous default behavior;
|
||||
/// the original job remains unchanged even when the driver has no duplex setting.
|
||||
fn resolve<'a>(job: &'a Job, read: impl FnOnce(Option<&str>) -> Result<(String, Option<Duplex>), PrintError>) -> Cow<'a, Job> {
|
||||
if job.duplex.is_some() {
|
||||
return Cow::Borrowed(job);
|
||||
}
|
||||
let (printer, duplex) = match read(job.printer.as_deref()) {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
driver_default(Err(error));
|
||||
return Cow::Borrowed(job);
|
||||
}
|
||||
};
|
||||
let mut resolved = job.clone();
|
||||
resolved.printer = Some(printer);
|
||||
resolved.duplex = duplex;
|
||||
Cow::Owned(resolved)
|
||||
}
|
||||
|
||||
/// A driver default is best-effort: record the fallback and let the driver choose.
|
||||
fn driver_default(result: Result<Option<Duplex>, PrintError>) -> Option<Duplex> {
|
||||
match result {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
log::warn!("could not resolve the printer's two-sided default; leaving duplex unspecified: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve manual two-sided defaults when PrintCapabilities say the driver cannot take
|
||||
/// explicit two-sided settings (#5772). Unknown capabilities also keep the driver's default:
|
||||
/// turning a best-effort preference into a strict request could otherwise refuse a working job.
|
||||
fn supported_default(value: Option<Duplex>, support: impl FnOnce() -> Option<bool>) -> Option<Duplex> {
|
||||
if matches!(value, Some(Duplex::LongEdge | Duplex::ShortEdge)) && support() != Some(true) { None } else { value }
|
||||
}
|
||||
|
||||
/// DEVMODEW's public dmFields (offset 72) marks dmDuplex (offset 94) as valid with
|
||||
/// DM_DUPLEX. Values are DMDUP_SIMPLEX, DMDUP_VERTICAL and DMDUP_HORIZONTAL, respectively.
|
||||
/// Return `None` when DM_DUPLEX is absent, or a spool error for a malformed field or an
|
||||
/// unrecognized declared value. Layout: <https://learn.microsoft.com/windows/win32/api/wingdi/ns-wingdi-devmodew>.
|
||||
fn duplex(settings: &craft_print::rgb::DevMode) -> Result<Option<Duplex>, PrintError> {
|
||||
let bytes = settings.as_bytes();
|
||||
let invalid = || PrintError::Spool("the printer driver returned an invalid two-sided default; check its printing preferences".into());
|
||||
let fields = bytes.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).ok_or_else(invalid)?;
|
||||
if fields & 0x1000 == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
match bytes.get(94..96).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(i16::from_le_bytes) {
|
||||
Some(1) => Ok(Some(Duplex::Off)),
|
||||
Some(2) => Ok(Some(Duplex::LongEdge)),
|
||||
Some(3) => Ok(Some(Duplex::ShortEdge)),
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
/// Resolve an unspecified duplex setting from the selected Windows printer immediately
|
||||
/// before submission. An unnamed printer is resolved to the system default and pinned to
|
||||
/// the returned job. Explicit choices bypass the driver lookup. Lookup failures are logged
|
||||
/// and leave duplex unspecified; a settings failure retains the resolved queue.
|
||||
pub(super) fn job(job: &Job) -> Cow<'_, Job> {
|
||||
resolve(job, |selected| {
|
||||
let failed = |e| PrintError::Spool(format!("could not read the printer's two-sided default: {e}"));
|
||||
let printer = match selected {
|
||||
Some(name) => name.to_owned(),
|
||||
None => craft_print::rgb::default_printer()
|
||||
.map_err(failed)?
|
||||
.ok_or_else(|| PrintError::Spool("there is no default printer; choose a printer".into()))?,
|
||||
};
|
||||
let value = craft_print::rgb::default_settings(&printer).map_err(failed).and_then(|settings| duplex(&settings));
|
||||
// Only this printer's driver is asked (craft-print `two_sided`), not every queue's, as
|
||||
// `printers()` would; a networked printer in power save could hold the job (#2192).
|
||||
let value = supported_default(driver_default(value), || craft_print::two_sided(&printer));
|
||||
Ok((printer, value))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(fields: u32, value: i16) -> craft_print::rgb::DevMode {
|
||||
let mut bytes = vec![0; 220];
|
||||
bytes[68..70].copy_from_slice(&220u16.to_le_bytes());
|
||||
bytes[72..76].copy_from_slice(&fields.to_le_bytes());
|
||||
bytes[94..96].copy_from_slice(&value.to_le_bytes());
|
||||
craft_print::rgb::DevMode::from_bytes(bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_default_reaches_the_windows_job_for_each_binding() {
|
||||
for (value, expected) in [(1, Duplex::Off), (2, Duplex::LongEdge), (3, Duplex::ShortEdge)] {
|
||||
let original = Job {
|
||||
printer: Some("Office".into()),
|
||||
copies: 3,
|
||||
collate: false,
|
||||
grayscale: true,
|
||||
title: "report".into(),
|
||||
options: vec![("media".into(), "A4".into())],
|
||||
..Job::default()
|
||||
};
|
||||
let resolved = resolve(&original, |selected| {
|
||||
assert_eq!(selected, Some("Office"));
|
||||
Ok(("Office".into(), duplex(&settings(0x1000, value))?))
|
||||
});
|
||||
assert_eq!(resolved.duplex, Some(expected));
|
||||
assert_eq!(resolved.printer.as_deref(), Some("Office"));
|
||||
assert_eq!(resolved.copies, 3);
|
||||
assert_eq!(resolved.collate, original.collate);
|
||||
assert_eq!(resolved.grayscale, original.grayscale);
|
||||
assert_eq!(resolved.scaling, original.scaling);
|
||||
assert_eq!(resolved.options, original.options);
|
||||
assert_eq!(resolved.title, "report");
|
||||
assert_eq!(original.duplex, None, "Printer default remains selected for the next job");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_choices_do_not_read_or_change_the_printer_default() {
|
||||
for choice in [Duplex::Off, Duplex::LongEdge, Duplex::ShortEdge] {
|
||||
let original = Job { duplex: Some(choice), ..Job::default() };
|
||||
let resolved = resolve(&original, |_| panic!("explicit choice must skip driver settings"));
|
||||
assert!(matches!(resolved, Cow::Borrowed(_)));
|
||||
assert_eq!(resolved.duplex, Some(choice));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_default_queue_is_resolved_once_and_pinned_to_the_job() {
|
||||
let original = Job::default();
|
||||
let resolved = resolve(&original, |selected| {
|
||||
assert_eq!(selected, None);
|
||||
Ok(("System default".into(), Some(Duplex::ShortEdge)))
|
||||
});
|
||||
assert_eq!(resolved.printer.as_deref(), Some("System default"));
|
||||
assert_eq!(resolved.duplex, Some(Duplex::ShortEdge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_duplex_field_is_left_to_the_driver_and_invalid_values_fail() {
|
||||
assert_eq!(duplex(&settings(0, 99)).unwrap(), None);
|
||||
for value in [0, -1, 4, i16::MAX] {
|
||||
assert!(duplex(&settings(0x1000, value)).is_err());
|
||||
}
|
||||
let original = Job::default();
|
||||
let resolved = resolve(&original, |_| Ok(("Simplex".into(), None)));
|
||||
assert_eq!(resolved.duplex, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queue_lookup_failure_preserves_the_original_job() {
|
||||
let original = Job::default();
|
||||
let resolved = resolve(&original, |_| Err(PrintError::Spool("driver unavailable".into())));
|
||||
assert!(matches!(resolved, Cow::Borrowed(_)));
|
||||
assert_eq!(resolved.duplex, None);
|
||||
assert_eq!(resolved.printer, original.printer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue() {
|
||||
for value in [Err(PrintError::Spool("driver unavailable".into())), duplex(&settings(0x1000, 99))] {
|
||||
let original = Job { copies: 3, ..Job::default() };
|
||||
let resolved = resolve(&original, |_| Ok(("Resolved queue".into(), driver_default(value))));
|
||||
assert_eq!(resolved.duplex, None);
|
||||
assert_eq!(resolved.printer.as_deref(), Some("Resolved queue"));
|
||||
assert_eq!(resolved.copies, 3);
|
||||
assert_eq!(original.printer, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_duplex_defaults_remain_unspecified_without_automatic_support() {
|
||||
for binding in [2, 3] {
|
||||
for support in [Some(false), None, Some(true)] {
|
||||
let original = Job { printer: Some("Manual duplex".into()), ..Job::default() };
|
||||
let value = duplex(&settings(0x1000, binding)).unwrap();
|
||||
let resolved = resolve(&original, |_| Ok(("Manual duplex".into(), supported_default(value, || support))));
|
||||
assert_eq!(resolved.duplex, if support == Some(true) { value } else { None });
|
||||
assert_eq!(resolved.printer.as_deref(), Some("Manual duplex"));
|
||||
assert_eq!(original.duplex, None);
|
||||
}
|
||||
}
|
||||
assert_eq!(supported_default(Some(Duplex::Off), || panic!("simplex needs no capability query")), Some(Duplex::Off));
|
||||
assert_eq!(supported_default(None, || panic!("unspecified needs no capability query")), None);
|
||||
}
|
||||
|
||||
/// Reads settings only: never submits a job or changes the printer's preferences.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "needs PDFCRAFT_PRINT_TEST_PRINTER naming an installed Windows printer; sends no job"]
|
||||
fn installed_windows_printer_default_is_carried_explicitly() {
|
||||
let printer = std::env::var("PDFCRAFT_PRINT_TEST_PRINTER").unwrap();
|
||||
let original = Job { printer: Some(printer.clone()), ..Job::default() };
|
||||
let expected = supported_default(duplex(&craft_print::rgb::default_settings(&printer).unwrap()).unwrap(), || {
|
||||
super::super::printers().into_iter().find(|p| p.name == printer).and_then(|p| p.two_sided)
|
||||
});
|
||||
let resolved = job(&original);
|
||||
assert_eq!(resolved.printer.as_deref(), Some(printer.as_str()));
|
||||
assert_eq!(resolved.duplex, expected);
|
||||
eprintln!("native Windows default: {expected:?}; resolved job: {:?}", resolved.duplex);
|
||||
}
|
||||
}
|
||||
+194
-7
@@ -288,15 +288,58 @@ fn imposing_lists_the_pages_once() {
|
||||
#[test]
|
||||
fn booklets_pair_pages_for_folding() {
|
||||
let sizes = vec![(200.0, 300.0); 6];
|
||||
let sheets = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })).unwrap();
|
||||
let sheets =
|
||||
layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }))
|
||||
.unwrap();
|
||||
// 6 pages pad to 8: sheet 1 front [8,1] → [blank, 0], back [1, 6]; sheet 2 front [5, 2], back [3, 4].
|
||||
assert_eq!(sheet_pages(&sheets), [vec![0], vec![1], vec![5, 2], vec![3, 4]]);
|
||||
assert!(sheets[0].placed[0].matrix.0[4] >= 395.9, "page 1 on the right half");
|
||||
assert_eq!(sheets[0].size, (792.0, 612.0));
|
||||
let front = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right })).unwrap();
|
||||
let front =
|
||||
layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right, creep: 0.0 }))
|
||||
.unwrap();
|
||||
assert_eq!(sheet_pages(&front), [vec![0], vec![2, 5]], "right binding mirrors the spread");
|
||||
}
|
||||
|
||||
/// #6777: creep moves each sheet's pages toward the fold by its place from the outside, and clips
|
||||
/// them at the fold, for either binding.
|
||||
#[test]
|
||||
fn booklet_creep_moves_inner_sheets_toward_the_fold() {
|
||||
let sizes = vec![(200.0, 300.0); 8];
|
||||
let booklet =
|
||||
|binding, creep| layout(&sizes, &settings((0..8).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding, creep })).unwrap();
|
||||
for binding in [Binding::Left, Binding::Right] {
|
||||
let (flat, crept) = (booklet(binding, 0.0), booklet(binding, 2.0));
|
||||
assert_eq!(sheet_pages(&flat), sheet_pages(&crept));
|
||||
let fold = 792.0 / 2.0;
|
||||
for (side, (a, b)) in flat.iter().zip(&crept).enumerate() {
|
||||
let shift = if side < 2 { 0.0 } else { 2.0 };
|
||||
let (left, right) = ((&a.placed[0], &b.placed[0]), (&a.placed[1], &b.placed[1]));
|
||||
assert!((left.1.matrix.0[4] - left.0.matrix.0[4] - shift).abs() < 1e-9, "{binding:?} side {side}: the left page moves right");
|
||||
assert!((right.1.matrix.0[4] - right.0.matrix.0[4] + shift).abs() < 1e-9, "{binding:?} side {side}: the right page moves left");
|
||||
let s = left.1.matrix.0[0];
|
||||
assert!(left.1.matrix.0[4] + left.1.clip[2] * s <= fold + 1e-9, "{binding:?} side {side}: the left page stops at the fold");
|
||||
assert!(right.1.matrix.0[4] + right.1.clip[0] * s >= fold - 1e-9, "{binding:?} side {side}: the right page starts at the fold");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booklet_creep_is_refused_when_it_is_not_a_small_positive_amount() {
|
||||
let sizes = vec![(200.0, 300.0); 8];
|
||||
let booklet = |sizes: &[(f64, f64)], creep| {
|
||||
layout(sizes, &settings((0..sizes.len()).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep }))
|
||||
};
|
||||
for creep in [f64::NAN, f64::INFINITY, -1.0, MAX_CREEP + 0.1] {
|
||||
assert!(matches!(booklet(&sizes, creep), Err(PrintError::Invalid(_))), "{creep}");
|
||||
}
|
||||
assert!(booklet(&sizes, MAX_CREEP).is_ok());
|
||||
// 400 pages fold into 100 sheets: 5 pt each would move the innermost past half its cell.
|
||||
let thick = vec![(200.0, 300.0); 400];
|
||||
assert!(booklet(&thick, 1.0).is_ok());
|
||||
assert!(matches!(booklet(&thick, 5.0), Err(PrintError::Invalid(_))), "the innermost sheet moves too far");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posters_tile_with_overlap() {
|
||||
// A 200×300 page at 400% = 800×1200 on Letter with 18 pt margins (576×756 tiles), overlap 36.
|
||||
@@ -709,9 +752,11 @@ fn landscape_sheets_ask_cups_to_turn_the_page() {
|
||||
// A landscape Size sheet, and a booklet (always landscape sheets), are both turned.
|
||||
let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap();
|
||||
assert_eq!(cups_orientation(&page), ask);
|
||||
let booklet =
|
||||
layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left }))
|
||||
.unwrap();
|
||||
let booklet = layout(
|
||||
&[(200.0, 300.0); 4],
|
||||
&settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cups_orientation(&booklet), ask);
|
||||
// Portrait sheets, mixed jobs and nothing at all say nothing.
|
||||
let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap();
|
||||
@@ -759,7 +804,9 @@ mod cups_options {
|
||||
/// The child process stands in for lpoptions, recording its real arguments.
|
||||
#[test]
|
||||
fn stand_in_for_lpoptions() {
|
||||
let Some(record) = std::env::var_os(RECORD) else { return };
|
||||
let Some(record) = std::env::var_os(RECORD) else {
|
||||
return;
|
||||
};
|
||||
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
|
||||
std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap();
|
||||
let answer = std::env::var(ANSWER).unwrap();
|
||||
@@ -877,6 +924,13 @@ mod cups_options {
|
||||
long_queue.as_str(),
|
||||
long_queue_instance.as_str(),
|
||||
long_instance.as_str(),
|
||||
// A destination cannot be an argument: `lpoptions` would take these as options.
|
||||
"-Office",
|
||||
"-E",
|
||||
"-",
|
||||
"--",
|
||||
"Office/-l",
|
||||
"Office/--",
|
||||
] {
|
||||
assert!(
|
||||
spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT, "en").is_empty(),
|
||||
@@ -886,6 +940,120 @@ mod cups_options {
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// #4699: when the queue's PPD is not readable, build the option list from `lpoptions -l`
|
||||
/// instead of returning an empty dialog.
|
||||
#[test]
|
||||
fn unreadable_ppd_falls_back_to_lpoptions_list() {
|
||||
let dir = folder();
|
||||
// No PPD file: the PPD path is unreadable, just as on Arch where cupsd keeps it at
|
||||
// 0640 root:cups.
|
||||
let lpoptions_out = "MediaType/Media Type: *Stationery PhotographicGlossy StationeryInkjet Com.brotherBp71\ncupsPrintQuality/cupsPrintQuality: Draft *Normal High\nPageSize/Page Size: A4 Letter\nDuplex/Duplex: *None DuplexNoTumble\nno colon on this line\nEmpty/Empty:\n";
|
||||
let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en");
|
||||
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
|
||||
assert_eq!(keys, ["MediaType", "cupsPrintQuality"], "PageSize is handled by the dialog and must be skipped");
|
||||
|
||||
let media_type = options.iter().find(|o| o.key == "MediaType").unwrap();
|
||||
assert_eq!(media_type.label, "Media Type");
|
||||
assert_eq!(media_type.default, "Stationery");
|
||||
assert_eq!(
|
||||
media_type.choices,
|
||||
[
|
||||
("Stationery".into(), "Stationery".into()),
|
||||
("PhotographicGlossy".into(), "PhotographicGlossy".into()),
|
||||
("StationeryInkjet".into(), "StationeryInkjet".into()),
|
||||
("Com.brotherBp71".into(), "Com.brotherBp71".into()),
|
||||
]
|
||||
);
|
||||
|
||||
let quality = options.iter().find(|o| o.key == "cupsPrintQuality").unwrap();
|
||||
assert_eq!(quality.label, "cupsPrintQuality");
|
||||
assert_eq!(quality.default, "Normal");
|
||||
assert_eq!(quality.choices, [("Draft".into(), "Draft".into()), ("Normal".into(), "Normal".into()), ("High".into(), "High".into())]);
|
||||
|
||||
// The starring decides the fallback default, wherever the starred choice sits.
|
||||
let lpoptions_out = "MediaType/Media Type: Plain *Glossy Matte\n";
|
||||
let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en");
|
||||
assert_eq!(options.len(), 1);
|
||||
assert_eq!(options[0].default, "Glossy", "the starred choice is the fallback default");
|
||||
assert_eq!(options[0].choices, [("Plain".into(), "Plain".into()), ("Glossy".into(), "Glossy".into()), ("Matte".into(), "Matte".into())]);
|
||||
|
||||
// A PPD path that exists but cannot be opened at all, for a reason other than NotFound:
|
||||
// ENOTDIR fails for root as well, where a 0o000 file would still be readable.
|
||||
let blocker = dir.join("not-a-directory");
|
||||
std::fs::write(&blocker, b"x").unwrap();
|
||||
let options = spool::printer_options_via_within("Office", &blocker, command(&dir, lpoptions_out), LIMIT, "en");
|
||||
assert_eq!(options.len(), 1, "an unopenable PPD falls back as an unreadable one does");
|
||||
|
||||
// A readable PPD whose *OpenUI blocks yield nothing (driverless queues): the fallback still
|
||||
// fills the list, and the PPD's translations apply to it.
|
||||
std::fs::write(dir.join("Empty.ppd"), b"*PPD-Adobe: \"4.3\"\n*cupsLanguages: \"it\"\n*it.Translation MediaType/Tipo di supporto: \"\"\n")
|
||||
.unwrap();
|
||||
let options = spool::printer_options_via_within("Empty", &dir, command(&dir, lpoptions_out), LIMIT, "it");
|
||||
assert_eq!(options.len(), 1, "a readable PPD without options falls back too");
|
||||
assert_eq!(options[0].label, "Tipo di supporto", "the readable PPD still translates the fallback list");
|
||||
|
||||
// A queue instance reaches lpoptions as the destination, and falls back the same way.
|
||||
let options = spool::printer_options_via_within("Office/Instance", &dir, command(&dir, lpoptions_out), LIMIT, "en");
|
||||
assert_eq!(options.len(), 1);
|
||||
let recorded = std::fs::read_to_string(dir.join("args")).unwrap();
|
||||
assert!(recorded.contains("Office/Instance"), "the instance is the destination: {recorded}");
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// CUPS prints a custom option's template instead of its choices — `Custom`, `Custom.PASSCODE`,
|
||||
/// `Custom.STRING`, `Custom.WIDTHxHEIGHT`: the dialog has no field to collect the value, so
|
||||
/// offering the template would send that literal text to the driver.
|
||||
#[test]
|
||||
fn custom_value_templates_are_not_offered() {
|
||||
let dir = folder();
|
||||
let lpoptions_out = "Pin/Pin: *Custom.PASSCODE\nJobPassword/Job Password: 1234 *5678 Custom.PASSWORD\nScale/Scale: *Custom.REAL\nSolo/Solo: *Custom\nMediaType/Media Type: *Plain Custom.Glossy Matte\n";
|
||||
let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en");
|
||||
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
|
||||
assert_eq!(keys, ["JobPassword", "MediaType"], "an option whose choices are all templates is dropped");
|
||||
|
||||
let password = options.iter().find(|o| o.key == "JobPassword").unwrap();
|
||||
assert_eq!(password.choices, [("1234".into(), "1234".into()), ("5678".into(), "5678".into())]);
|
||||
assert_eq!(password.default, "5678", "a template is never a choice");
|
||||
|
||||
let media_type = options.iter().find(|o| o.key == "MediaType").unwrap();
|
||||
assert_eq!(media_type.choices, [("Plain".into(), "Plain".into()), ("Matte".into(), "Matte".into())]);
|
||||
assert_eq!(media_type.default, "Plain");
|
||||
|
||||
// A starred template is never offered as a choice, and never replaced by an invented
|
||||
// default: the printer keeps the template, so standing the first real choice in its
|
||||
// place would show a value the job does not use.
|
||||
let options = spool::printer_options_via_within("Office", &dir, command(&dir, "Pin/Pin: *Custom.PASSCODE 1234\n"), LIMIT, "en");
|
||||
assert_eq!(options.len(), 1);
|
||||
assert_eq!(options[0].choices, [("1234".into(), "1234".into())]);
|
||||
assert_eq!(options[0].default, "", "an uncollectable default is not a choice");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `list_group()` prints `Keyword/Text:` and then bare choice keywords, so the text may carry a
|
||||
/// colon while the choices cannot: the split belongs at the last one.
|
||||
#[test]
|
||||
fn an_option_text_may_contain_a_colon() {
|
||||
let dir = folder();
|
||||
let lpoptions_out = "Media/Supporto: Tipo: *A B\n";
|
||||
let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en");
|
||||
assert_eq!(options.len(), 1);
|
||||
assert_eq!(options[0].key, "Media");
|
||||
assert_eq!(options[0].label, "Supporto: Tipo", "the text keeps its colon");
|
||||
assert_eq!(options[0].choices, [("A".into(), "A".into()), ("B".into(), "B".into())], "no choice is invented from the text");
|
||||
assert_eq!(options[0].default, "A");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_is_capped_the_way_the_ppd_is() {
|
||||
let dir = folder();
|
||||
let listing: String = (0..500).map(|i| format!("Option{i}/Option {i}: *A B\n")).collect();
|
||||
let options = spool::printer_options_via_within("Office", &dir, command(&dir, &listing), LIMIT, "en");
|
||||
assert_eq!(options.len(), 400, "the fallback honours the same cap as parse_ppd");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -985,10 +1153,26 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() {
|
||||
("A0", "iso_a0_841x1189mm"),
|
||||
("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"),
|
||||
("US #10 Envelope (4.125 x 9.5 in)", "Env10"),
|
||||
("Folio (8.5 x 13 in)", "na_foolscap_8.5x13in"),
|
||||
("F4 (210 x 330 mm)", "om_folio_210x330mm"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// #4777: Folio (8.5 × 13 in) and F4 (210 × 330 mm) presets are offered, fed by their
|
||||
/// PWG 5101.1 media names (`na_foolscap_8.5x13in` and `om_folio_210x330mm`) and laid out at their
|
||||
/// proper dimensions.
|
||||
#[test]
|
||||
fn folio_and_f4_paper_sizes_are_available() {
|
||||
let folio = PAPERS.iter().position(|(name, _)| *name == "Folio (8.5 x 13 in)").expect("Folio present");
|
||||
assert_eq!(cups_media(folio), Some("na_foolscap_8.5x13in"));
|
||||
assert_eq!(PAPERS[folio].1, (612.0, 936.0));
|
||||
|
||||
let f4 = PAPERS.iter().position(|(name, _)| *name == "F4 (210 x 330 mm)").expect("F4 present");
|
||||
assert_eq!(cups_media(f4), Some("om_folio_210x330mm"));
|
||||
assert_eq!(PAPERS[f4].1, (595.28, 935.43));
|
||||
}
|
||||
|
||||
/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at
|
||||
/// its own size.
|
||||
#[test]
|
||||
@@ -1193,6 +1377,7 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() {
|
||||
}
|
||||
|
||||
mod appearances;
|
||||
mod font_names;
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
@@ -1407,7 +1592,7 @@ fn missing_print_dlls_are_named() {
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn printing_without_the_print_dlls_is_refused_before_the_spooler() {
|
||||
use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress};
|
||||
use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printer_queues, printers, submit, submit_with_progress, two_sided};
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -1424,6 +1609,8 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() {
|
||||
// Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked.
|
||||
PRINTER_BACKEND_CALLS.with(|c| c.set(0));
|
||||
assert!(printers().is_empty(), "no printers are listed");
|
||||
assert!(printer_queues().is_empty(), "no queues are listed");
|
||||
assert_eq!(two_sided("PdfCraft test: no such printer"), None, "two-sided support is unknown");
|
||||
assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked");
|
||||
SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -85,6 +85,44 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef {
|
||||
objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap()
|
||||
}
|
||||
|
||||
/// Existing Link artwork prints as document content only when its flags allow printing.
|
||||
#[test]
|
||||
fn printable_link_appearance_survives_vector_print() {
|
||||
for flags in [0, 2, 4, 6, 5] {
|
||||
for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] {
|
||||
let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None);
|
||||
doc.update_dict(annot, |d| {
|
||||
d.set(b"Subtype".to_vec(), Object::name("Link"));
|
||||
d.set(b"F".to_vec(), Object::Int(flags));
|
||||
})
|
||||
.unwrap();
|
||||
let before = saved(&doc);
|
||||
let expected = flags & 4 != 0 && flags & 2 == 0 && content != Content::FormFieldsOnly;
|
||||
let printed = render(impose(&doc, &print_settings(content)).unwrap());
|
||||
let color = if expected { [0, 255, 0, 255] } else { [255, 255, 255, 255] };
|
||||
assert_eq!(pixel(&printed, 30, 70), color, "flags={flags}, content={content:?}");
|
||||
let mut raster_source = doc.clone();
|
||||
keep_printed(&mut raster_source, content);
|
||||
let page = &pdfcraft_model::pages(&raster_source)[0];
|
||||
let annots = raster_source.resolve(page.dict.get(b"Annots").unwrap());
|
||||
assert_eq!(annots.as_array().unwrap().len(), usize::from(expected), "raster flags={flags}, content={content:?}");
|
||||
assert_eq!(saved(&doc), before, "source remains unchanged");
|
||||
}
|
||||
}
|
||||
for subtype in ["Link", "Popup"] {
|
||||
let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None);
|
||||
doc.update_dict(annot, |d| {
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
if subtype == "Link" {
|
||||
d.remove(b"AP");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let printed = render(impose(&doc, &print_settings(Content::DocumentAndMarkups)).unwrap());
|
||||
assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "{subtype} must not acquire an appearance");
|
||||
}
|
||||
}
|
||||
|
||||
/// Printing keeps occupied page resource names and distinct annotation appearances intact,
|
||||
/// whether the page resources are direct or inherited from the page tree.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! #3132: embedded TrueType programs go to the Windows spooler under names no installed font has.
|
||||
|
||||
use super::*;
|
||||
use crate::font_names::{distinct, distinct_within, renamed_sfnt};
|
||||
use crate::spool::spooled_pdf;
|
||||
use pdfcraft_cos::DecodeBudget;
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
use skrifa::raw::TableProvider as _;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{FontRef, MetadataProvider as _};
|
||||
|
||||
/// Inter (OFL, `ATTRIBUTION.toml`) stands in for the subset ReportLab embeds as `AAAAAA+ArialMT`.
|
||||
const INTER: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
|
||||
fn name_of(program: &[u8], id: StringId) -> Option<String> {
|
||||
FontRef::new(program).unwrap().localized_strings(id).english_or_first().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// A TrueType font over `program`, named `base_font`, as ReportLab writes one; its descriptor is
|
||||
/// indirect.
|
||||
fn true_type_font(doc: &mut Document, base_font: &str, program: ObjRef) -> ObjRef {
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(base_font));
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(32));
|
||||
descriptor.set(b"FontBBox".to_vec(), Object::Array([-500, -300, 1500, 1000].into_iter().map(Object::Int).collect()));
|
||||
for (key, value) in [(b"ItalicAngle".as_slice(), 0), (b"Ascent", 900), (b"Descent", -200), (b"CapHeight", 700), (b"StemV", 80)] {
|
||||
descriptor.set(key.to_vec(), Object::Int(value));
|
||||
}
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let descriptor = doc.add(Object::Dict(descriptor));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor));
|
||||
doc.add(Object::Dict(font))
|
||||
}
|
||||
|
||||
fn program_stream(doc: &mut Document) -> ObjRef {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Length1".to_vec(), Object::Int(INTER.len() as i64));
|
||||
doc.add(Object::Stream(Stream::flate(d, INTER)))
|
||||
}
|
||||
|
||||
/// One 200×300 page writing "Hola" in a regular and a bold Arial subset, the way the report's
|
||||
/// proposal is set, and its print-ready PDF.
|
||||
fn print_ready() -> Vec<u8> {
|
||||
let mut doc = fixture(1);
|
||||
let (regular, bold) = (program_stream(&mut doc), program_stream(&mut doc));
|
||||
let regular = true_type_font(&mut doc, "AAAAAA+ArialMT", regular);
|
||||
let bold = true_type_font(&mut doc, "AAAAAA+Arial-BoldMT", bold);
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(regular));
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(bold));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 40 Tf 10 200 Td (Hola) Tj ET BT /F2 40 Tf 10 100 Td (Hola) Tj ET")));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| {
|
||||
d.remove(b"Annots");
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
})
|
||||
.unwrap();
|
||||
let settings = Settings { pages: vec![0], paper: (200.0, 300.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() };
|
||||
impose(&doc, &settings).unwrap()
|
||||
}
|
||||
|
||||
fn pixels(pdf: &[u8]) -> Vec<u8> {
|
||||
let page = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
preview: false,
|
||||
});
|
||||
assert_eq!(page.error, None);
|
||||
page.rgba[..].to_vec()
|
||||
}
|
||||
|
||||
/// Each TrueType font in `pdf`: its `/BaseFont`, its descriptor's `/FontName`, its decoded
|
||||
/// program and that program's `/Length1`.
|
||||
fn true_type_fonts(pdf: &[u8]) -> Vec<(String, String, Vec<u8>, i64)> {
|
||||
let doc = Document::open(Arc::new(pdf.to_vec())).unwrap();
|
||||
let mut fonts = Vec::new();
|
||||
for (_, object) in doc.scan_objects() {
|
||||
let Object::Dict(font) = object.as_ref() else { continue };
|
||||
if font.name(b"Subtype") != Some(b"TrueType") {
|
||||
continue;
|
||||
}
|
||||
let descriptor = doc.dict(font.get(b"FontDescriptor").unwrap()).unwrap();
|
||||
let program = doc.resolve(descriptor.get(b"FontFile2").unwrap());
|
||||
let Object::Stream(program) = program.as_ref() else { panic!("no program") };
|
||||
let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap();
|
||||
fonts.push((name(font, b"BaseFont"), name(&descriptor, b"FontName"), program.decoded().unwrap(), program.dict.int(b"Length1").unwrap()));
|
||||
}
|
||||
fonts.sort();
|
||||
fonts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_gets_embedded_subsets_under_names_no_installed_font_has() {
|
||||
let pdf = print_ready();
|
||||
let before = true_type_fonts(&pdf);
|
||||
assert_eq!(before.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["AAAAAA+Arial-BoldMT", "AAAAAA+ArialMT"]);
|
||||
|
||||
let spooled = spooled_pdf(&pdf, true);
|
||||
let after = true_type_fonts(&spooled);
|
||||
|
||||
// Each program has a name of its own, in the PDF and in its `name` table; the subset tag stays.
|
||||
let names: Vec<_> = after.iter().map(|f| (f.0.as_str(), f.1.as_str())).collect();
|
||||
assert_eq!(names, [("AAAAAA+PdfCraftPrint1", "AAAAAA+PdfCraftPrint1"), ("AAAAAA+PdfCraftPrint2", "AAAAAA+PdfCraftPrint2")]);
|
||||
for (n, (_, _, program, length1)) in after.iter().enumerate() {
|
||||
let name = format!("PdfCraftPrint{}", n + 1);
|
||||
for id in [StringId::FAMILY_NAME, StringId::FULL_NAME, StringId::POSTSCRIPT_NAME, StringId::UNIQUE_ID] {
|
||||
assert_eq!(name_of(program, id).as_deref(), Some(name.as_str()), "{id:?}");
|
||||
}
|
||||
assert_eq!(name_of(program, StringId::SUBFAMILY_NAME).as_deref(), Some("Regular"));
|
||||
assert_eq!(*length1, program.len() as i64);
|
||||
// Nothing that draws a glyph changed: the same glyph for every character, and the same
|
||||
// tables but `name` and `head`'s checksum adjustment.
|
||||
let (old, new) = (FontRef::new(INTER).unwrap(), FontRef::new(program).unwrap());
|
||||
assert_eq!(old.charmap().mappings().collect::<Vec<_>>(), new.charmap().mappings().collect::<Vec<_>>());
|
||||
let table = |font: &FontRef, tag| {
|
||||
let mut data = font.table_data(tag).unwrap().as_bytes().to_vec();
|
||||
if &tag == b"head" {
|
||||
data[8..12].fill(0);
|
||||
}
|
||||
data
|
||||
};
|
||||
for tag in old.table_directory.table_records().iter().map(|r| r.tag()).filter(|t| t != b"name") {
|
||||
assert_eq!(table(&old, tag), table(&new, tag), "{tag}");
|
||||
}
|
||||
// `head`'s checksum adjustment holds for the new file.
|
||||
let sum = program.chunks(4).fold(0_u32, |s, c| s.wrapping_add(u32::from_be_bytes([0, 1, 2, 3].map(|i| c.get(i).copied().unwrap_or(0)))));
|
||||
assert_eq!(sum, 0xb1b0afba);
|
||||
}
|
||||
assert!(before.iter().all(|f| name_of(&f.2, StringId::FAMILY_NAME).as_deref() == Some("Inter")));
|
||||
|
||||
// The page draws exactly as before, and draws something.
|
||||
let (old, new) = (pixels(&pdf), pixels(&spooled));
|
||||
assert!(old.chunks(4).any(|p| p[0] < 128), "the text is drawn");
|
||||
assert!(old == new, "renaming changed what prints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_and_pdfs_without_embedded_programs_spool_as_they_are() {
|
||||
let pdf = print_ready();
|
||||
assert!(matches!(spooled_pdf(&pdf, false), std::borrow::Cow::Borrowed(p) if p == pdf.as_slice()));
|
||||
let helvetica = impose(&fixture(1), &Settings { pages: vec![0], paper: (200.0, 300.0), ..Settings::default() }).unwrap();
|
||||
assert_eq!(distinct(&helvetica).unwrap(), None);
|
||||
assert!(matches!(spooled_pdf(&helvetica, true), std::borrow::Cow::Borrowed(_)));
|
||||
// Not a PDF: it goes as it is, and the spooler reports what it makes of it.
|
||||
assert!(matches!(spooled_pdf(b"not a pdf", true), std::borrow::Cow::Borrowed(b"not a pdf")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too() {
|
||||
// A viewer that can't load the program either picks a substitute by the PDF's name, so that
|
||||
// name must still say Arial.
|
||||
let mut doc = fixture(1);
|
||||
let good = program_stream(&mut doc);
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Length1".to_vec(), Object::Int(10));
|
||||
let broken = doc.add(Object::Stream(Stream::flate(d, b"not a font")));
|
||||
let good = true_type_font(&mut doc, "AAAAAA+ArialMT", good);
|
||||
let broken = true_type_font(&mut doc, "BAAAAA+Arial-BoldMT", broken);
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(good));
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(broken));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let pdf = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
|
||||
let names: Vec<_> = true_type_fonts(&spooled_pdf(&pdf, true)).into_iter().map(|f| (f.0, f.1, f.2 == b"not a font")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
("AAAAAA+PdfCraftPrint1".to_owned(), "AAAAAA+PdfCraftPrint1".to_owned(), false),
|
||||
("BAAAAA+Arial-BoldMT".to_owned(), "BAAAAA+Arial-BoldMT".to_owned(), true),
|
||||
]
|
||||
);
|
||||
// With nothing it can rewrite, the PDF goes as it is.
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(broken));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
assert_eq!(distinct(&write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_past_the_shared_decode_budget_keep_their_names() {
|
||||
// Each program decodes to Inter's whole length; the budget covers one and a half.
|
||||
let pdf = print_ready();
|
||||
let mut budget = DecodeBudget::new(INTER.len() * 3 / 2, 0);
|
||||
let renamed = distinct_within(&pdf, &mut budget).unwrap().unwrap();
|
||||
let names: Vec<_> = true_type_fonts(&renamed).into_iter().map(|f| (f.0, name_of(&f.2, StringId::FAMILY_NAME).unwrap())).collect();
|
||||
let (new, old): (Vec<_>, Vec<_>) = names.iter().partition(|(base_font, _)| base_font.contains("PdfCraftPrint"));
|
||||
assert_eq!((new.len(), old.len()), (1, 1), "{names:?}");
|
||||
assert!(new[0].0.starts_with("AAAAAA+PdfCraftPrint") && new[0].1.starts_with("PdfCraftPrint"), "{names:?}");
|
||||
assert!(old[0].0.starts_with("AAAAAA+Arial") && old[0].1 == "Inter", "{names:?}");
|
||||
// With the default allowance both are renamed.
|
||||
assert_eq!(true_type_fonts(&distinct(&pdf).unwrap().unwrap()).iter().filter(|f| f.0.contains("PdfCraftPrint")).count(), 2);
|
||||
}
|
||||
|
||||
/// A CIDFontType2 named `base_font` over `program`, written as a dictionary with its descriptor
|
||||
/// inline, as some producers put it straight into `/DescendantFonts`.
|
||||
fn inline_cid_font(base_font: &str, program: ObjRef) -> Dict {
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(base_font));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut cid = Dict::new();
|
||||
cid.set(b"Type".to_vec(), Object::name("Font"));
|
||||
cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
cid.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
cid
|
||||
}
|
||||
|
||||
fn type0_font(doc: &mut Document, base_font: &str, descendants: Object) -> ObjRef {
|
||||
let mut type0 = Dict::new();
|
||||
type0.set(b"Type".to_vec(), Object::name("Font"));
|
||||
type0.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
type0.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
type0.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
type0.set(b"DescendantFonts".to_vec(), descendants);
|
||||
doc.add(Object::Dict(type0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cidfont_written_inside_its_type0_font_is_renamed_with_it() {
|
||||
let mut doc = fixture(1);
|
||||
let (shared, own) = (program_stream(&mut doc), program_stream(&mut doc));
|
||||
let simple = true_type_font(&mut doc, "AAAAAA+ArialMT", shared);
|
||||
// In `/DescendantFonts` itself, sharing the simple font's program; and in an array object of
|
||||
// its own, over a program nothing else uses.
|
||||
let direct = type0_font(&mut doc, "BAAAAA+ArialMT-Identity-H", Object::Array(vec![Object::Dict(inline_cid_font("BAAAAA+ArialMT", shared))]));
|
||||
let array = doc.add(Object::Array(vec![Object::Dict(inline_cid_font("CAAAAA+ArialMT", own))]));
|
||||
let in_array = type0_font(&mut doc, "CAAAAA+ArialMT-Identity-H", Object::Ref(array));
|
||||
let mut fonts = Dict::new();
|
||||
for (key, font) in [(b"F1", simple), (b"F2", direct), (b"F3", in_array)] {
|
||||
fonts.set(key.to_vec(), Object::Ref(font));
|
||||
}
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let pdf = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
|
||||
let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap();
|
||||
let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap();
|
||||
let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap();
|
||||
let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap();
|
||||
let simple = renamed.dict(fonts.get(b"F1").unwrap()).unwrap();
|
||||
for (key, tag) in [(b"F2", "BAAAAA+"), (b"F3", "CAAAAA+")] {
|
||||
let type0 = renamed.dict(fonts.get(key).unwrap()).unwrap();
|
||||
let descendants = renamed.resolve(type0.get(b"DescendantFonts").unwrap());
|
||||
let Some(Object::Dict(cid)) = descendants.as_array().and_then(|a| a.first()) else { panic!("inline CIDFont") };
|
||||
let Some(Object::Dict(descriptor)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") };
|
||||
let program = renamed.resolve(descriptor.get(b"FontFile2").unwrap());
|
||||
let Object::Stream(program) = program.as_ref() else { panic!("program") };
|
||||
let family = name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).unwrap();
|
||||
// The Type 0 font, its CIDFont and the descriptor all say the program's new name.
|
||||
let expected = format!("{tag}{family}");
|
||||
assert!(family.starts_with("PdfCraftPrint"), "{family}");
|
||||
assert_eq!(
|
||||
(name(&type0, b"BaseFont"), name(cid, b"BaseFont"), name(descriptor, b"FontName")),
|
||||
(expected.clone(), expected.clone(), expected)
|
||||
);
|
||||
}
|
||||
// The shared program has one name, whichever font led to it.
|
||||
let shared_name = name(&simple, b"BaseFont").replace("AAAAAA+", "");
|
||||
assert_eq!(name(&renamed.dict(fonts.get(b"F2").unwrap()).unwrap(), b"BaseFont"), format!("BAAAAA+{shared_name}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_sharing_a_program_share_its_name_and_type0_fonts_follow_their_cidfont() {
|
||||
let mut doc = fixture(1);
|
||||
let program = program_stream(&mut doc);
|
||||
let simple = true_type_font(&mut doc, "BCDEFG+Calibri", program);
|
||||
let again = true_type_font(&mut doc, "Calibri", program);
|
||||
// A Type 0 font over a CIDFontType2 whose descriptor is written inline.
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name("HIJKLM+Calibri"));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut cid = Dict::new();
|
||||
cid.set(b"Type".to_vec(), Object::name("Font"));
|
||||
cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
cid.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri"));
|
||||
cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
let cid = doc.add(Object::Dict(cid));
|
||||
let mut type0 = Dict::new();
|
||||
type0.set(b"Type".to_vec(), Object::name("Font"));
|
||||
type0.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
type0.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri-Identity-H"));
|
||||
type0.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
type0.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)]));
|
||||
let type0 = doc.add(Object::Dict(type0));
|
||||
let mut fonts = Dict::new();
|
||||
for (key, font) in [(b"F1", simple), (b"F2", again), (b"F3", type0)] {
|
||||
fonts.set(key.to_vec(), Object::Ref(font));
|
||||
}
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let pdf = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
|
||||
// The file is written anew, so the fonts are found again through the page.
|
||||
let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap();
|
||||
let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap();
|
||||
let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap();
|
||||
let font = |key: &[u8]| renamed.dict(fonts.get(key).unwrap()).unwrap();
|
||||
let base_font = |d: &Dict| String::from_utf8(d.name(b"BaseFont").unwrap().to_vec()).unwrap();
|
||||
assert_eq!(base_font(&font(b"F1")), "BCDEFG+PdfCraftPrint1");
|
||||
assert_eq!(base_font(&font(b"F2")), "PdfCraftPrint1");
|
||||
assert_eq!(base_font(&font(b"F3")), "HIJKLM+PdfCraftPrint1");
|
||||
let Some(Object::Array(descendants)) = font(b"F3").get(b"DescendantFonts").cloned() else { panic!("descendants") };
|
||||
let cid = renamed.dict(&descendants[0]).unwrap();
|
||||
assert_eq!(base_font(&cid), "HIJKLM+PdfCraftPrint1");
|
||||
let Some(Object::Dict(inline)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") };
|
||||
assert_eq!(inline.name(b"FontName"), Some(b"HIJKLM+PdfCraftPrint1".as_slice()));
|
||||
// Renamed once, not once per font.
|
||||
let program = renamed.resolve(inline.get(b"FontFile2").unwrap());
|
||||
let Object::Stream(program) = program.as_ref() else { panic!("program") };
|
||||
assert_eq!(name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1"));
|
||||
}
|
||||
|
||||
/// A `name` table of `format` with the given records `(platform, encoding, language, id, text)`
|
||||
/// and language tags, inside a minimal sfnt with only that table.
|
||||
fn sfnt_with_name(format: u16, records: &[(u16, u16, u16, u16, &[u8])], tags: &[&[u8]]) -> Vec<u8> {
|
||||
let mut table = Vec::new();
|
||||
let mut strings = Vec::new();
|
||||
table.extend_from_slice(&format.to_be_bytes());
|
||||
table.extend_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 };
|
||||
table.extend_from_slice(&(header as u16).to_be_bytes());
|
||||
for (platform, encoding, language, id, text) in records {
|
||||
for v in [*platform, *encoding, *language, *id, text.len() as u16, strings.len() as u16] {
|
||||
table.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend_from_slice(text);
|
||||
}
|
||||
if format == 1 {
|
||||
table.extend_from_slice(&(tags.len() as u16).to_be_bytes());
|
||||
for tag in tags {
|
||||
table.extend_from_slice(&(tag.len() as u16).to_be_bytes());
|
||||
table.extend_from_slice(&(strings.len() as u16).to_be_bytes());
|
||||
strings.extend_from_slice(tag);
|
||||
}
|
||||
}
|
||||
table.extend_from_slice(&strings);
|
||||
sfnt_with_table(&table)
|
||||
}
|
||||
|
||||
/// A minimal sfnt with `table` as its only table, `name`.
|
||||
fn sfnt_with_table(table: &[u8]) -> Vec<u8> {
|
||||
let mut sfnt = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
sfnt.extend_from_slice(b"name");
|
||||
sfnt.extend_from_slice(&0_u32.to_be_bytes());
|
||||
sfnt.extend_from_slice(&28_u32.to_be_bytes());
|
||||
sfnt.extend_from_slice(&(table.len() as u32).to_be_bytes());
|
||||
sfnt.extend_from_slice(table);
|
||||
sfnt
|
||||
}
|
||||
|
||||
/// A format-0 `name` table whose `copies` copyright records all point at one `length`-byte
|
||||
/// string, then a family record after it; `storage` overrides where the table says its strings
|
||||
/// start.
|
||||
fn shared_string_table(copies: usize, length: usize, storage: Option<u16>) -> Vec<u8> {
|
||||
let count = copies + 1;
|
||||
let mut table = Vec::new();
|
||||
for v in [0, count as u16, storage.unwrap_or((6 + count * 12) as u16)] {
|
||||
table.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let copyright = [3, 1, 0x409, 0, length as u16, 0];
|
||||
for v in (0..copies).flat_map(|_| copyright).chain([3, 1, 0x409, 1, 2, length as u16]) {
|
||||
table.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
table.extend(std::iter::repeat_n(b'c', length));
|
||||
table.extend_from_slice(&[0, b'A']);
|
||||
table
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_that_share_one_string_keep_sharing_it() {
|
||||
// A thousand records over one 60,000-byte string. Each given its own copy they would take
|
||||
// 60 MB and lie past what 16-bit offsets reach; stored once, the table is as small as before.
|
||||
let table = shared_string_table(1024, 60_000, None);
|
||||
let renamed = renamed_sfnt(&sfnt_with_table(&table), "PdfCraftPrint1").unwrap();
|
||||
let font = FontRef::new(&renamed).unwrap();
|
||||
let name = font.name().unwrap();
|
||||
let copyright: std::collections::BTreeSet<_> =
|
||||
name.name_record().iter().filter(|r| r.name_id().to_u16() == 0).map(|r| (r.string_offset().to_u32(), r.length())).collect();
|
||||
assert_eq!(copyright.len(), 1, "every copyright record points at the one string");
|
||||
assert!(font.table_data(skrifa::raw::types::Tag::new(b"name")).unwrap().len() <= table.len() + 64);
|
||||
assert_eq!(name_of(&renamed, StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1"));
|
||||
// 60,000 bytes of UTF-16BE: 30,000 characters.
|
||||
assert_eq!(name_of(&renamed, StringId::COPYRIGHT_NOTICE).map(|c| c.chars().count()), Some(60_000 / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused() {
|
||||
// The case reported on #4098: 65,535 records over one 65,535-byte string, the strings said to
|
||||
// start inside the records. Copying each record's string before checking took about 4 GiB.
|
||||
let table = shared_string_table(65_534, 65_535, Some(6));
|
||||
assert_eq!(renamed_sfnt(&sfnt_with_table(&table), "X"), None);
|
||||
}
|
||||
|
||||
/// A `name` record: platform, encoding, language, name ID and the string's bytes.
|
||||
type Record = (u16, u16, u16, u16, Vec<u8>);
|
||||
|
||||
/// The records and language tags of the renamed font's `name` table.
|
||||
fn records_of(sfnt: &[u8]) -> (Vec<Record>, Vec<Vec<u8>>) {
|
||||
let font = FontRef::new(sfnt).unwrap();
|
||||
let name = font.name().unwrap();
|
||||
let data = name.string_data();
|
||||
let text = |length: u16, offset: u16| data.as_bytes()[offset as usize..][..length as usize].to_vec();
|
||||
let records = name
|
||||
.name_record()
|
||||
.iter()
|
||||
.map(|r| (r.platform_id(), r.encoding_id(), r.language_id(), r.name_id().to_u16(), text(r.length(), r.string_offset().to_u32() as u16)))
|
||||
.collect();
|
||||
let tags = name.lang_tag_record().unwrap_or_default().iter().map(|t| text(t.length(), t.lang_tag_offset().to_u32() as u16)).collect();
|
||||
(records, tags)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_records_are_respelled_per_platform_and_the_rest_are_kept() {
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let arial = utf16("Arial");
|
||||
let copyright = utf16("© The Monotype Corporation");
|
||||
let sjis_family: &[u8] = &[0x82, 0xa0];
|
||||
let sfnt = sfnt_with_name(
|
||||
1,
|
||||
&[
|
||||
(1, 0, 0, 1, b"Arial"),
|
||||
(1, 0, 0, 2, b"Bold"),
|
||||
(3, 1, 0x409, 0, ©right),
|
||||
(3, 1, 0x409, 1, &arial),
|
||||
(3, 1, 0x409, 6, &utf16("Arial-BoldMT")),
|
||||
(3, 2, 0x411, 1, sjis_family),
|
||||
(3, 1, 0x8000, 4, &utf16("Arial Bold")),
|
||||
],
|
||||
&[b"\0e\0n"],
|
||||
);
|
||||
let renamed = renamed_sfnt(&sfnt, "PdfCraftPrint7").unwrap();
|
||||
let (records, tags) = records_of(&renamed);
|
||||
assert_eq!(
|
||||
records,
|
||||
[
|
||||
(1, 0, 0, 1, b"PdfCraftPrint7".to_vec()),
|
||||
(1, 0, 0, 2, b"Bold".to_vec()),
|
||||
(3, 1, 0x409, 0, copyright),
|
||||
(3, 1, 0x409, 1, utf16("PdfCraftPrint7")),
|
||||
(3, 1, 0x409, 6, utf16("PdfCraftPrint7")),
|
||||
// The Shift-JIS family can't be respelled in ASCII here, so it goes.
|
||||
(3, 1, 0x8000, 4, utf16("PdfCraftPrint7")),
|
||||
]
|
||||
);
|
||||
assert_eq!(tags, [b"\0e\0n".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_programs_are_left_alone_and_never_panic() {
|
||||
let good = sfnt_with_name(0, &[(3, 1, 0x409, 1, &[0, b'A'])], &[]);
|
||||
assert!(renamed_sfnt(&good, "X").is_some());
|
||||
// Every truncation, of the small font and of the start of a real one.
|
||||
for n in 0..good.len() {
|
||||
let _ = renamed_sfnt(&good[..n], "X");
|
||||
}
|
||||
for n in (0..2048).chain([INTER.len() - 1]) {
|
||||
let _ = renamed_sfnt(&INTER[..n], "X");
|
||||
}
|
||||
// A collection, a CFF-in-name-only header, an absurd table count, a `name` entry past the
|
||||
// end, a format-2 `name` table and a string past the table's end.
|
||||
let mut collection = good.clone();
|
||||
collection[..4].copy_from_slice(b"ttcf");
|
||||
assert_eq!(renamed_sfnt(&collection, "X"), None);
|
||||
let mut many = good.clone();
|
||||
many[4..6].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&many, "X"), None);
|
||||
let mut past = good.clone();
|
||||
past[20..24].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&past, "X"), None);
|
||||
let mut format2 = good.clone();
|
||||
format2[28..30].copy_from_slice(&2_u16.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&format2, "X"), None);
|
||||
let mut long = sfnt_with_name(0, &[(3, 1, 0x409, 0, &[0, b'C']), (3, 1, 0x409, 1, &[0, b'A'])], &[]);
|
||||
let first_length = 28 + 6 + 8;
|
||||
long[first_length..first_length + 2].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
let (records, _) = records_of(&renamed_sfnt(&long, "X").unwrap());
|
||||
assert_eq!(records, [(3, 1, 0x409, 1, vec![0, b'X'])], "the broken copyright record is left out");
|
||||
// A broken language tag fails the table, since records point at tags by position.
|
||||
let mut tagged = sfnt_with_name(1, &[(3, 1, 0x409, 1, &[0, b'A'])], &[b"\0e"]);
|
||||
let tag_length = 28 + 6 + 12 + 2;
|
||||
tagged[tag_length..tag_length + 2].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&tagged, "X"), None);
|
||||
}
|
||||
@@ -932,11 +932,7 @@ pub(crate) fn remove_counted(
|
||||
let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() };
|
||||
if fields {
|
||||
// Fields without appearances get one first, so their values stay visible.
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) {
|
||||
pdfcraft_forms::redraw_field(doc, &f.name)?;
|
||||
}
|
||||
}
|
||||
pdfcraft_forms::redraw_fields(doc, |doc, f| f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))))?;
|
||||
let n = pdfcraft_model::pages(doc).len();
|
||||
pdfcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), false, true)?;
|
||||
doc.update_dict(root, |d| {
|
||||
|
||||
@@ -269,7 +269,9 @@ pub struct RenderedPage {
|
||||
/// A non-fatal condition reported while interpreting a page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderWarning {
|
||||
/// Content was skipped after the per-page decoded-content budget was exhausted.
|
||||
/// Content was left out at one of the renderer's limits: content past the page's
|
||||
/// decoded-content budget or its JBIG2 allowance, or a picture whose JBIG2 mask is past a
|
||||
/// decoder limit.
|
||||
ContentTruncated,
|
||||
/// A colour could not be computed (a colour space that cannot be read, or a Separation or
|
||||
/// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour.
|
||||
|
||||
@@ -4234,7 +4234,7 @@ fn inline_images_with_ei_in_their_data_parse_in_linear_time() {
|
||||
/// From the nightly `cargo xtask fuzz`: a JBIG2 image whose stream declares a page and a
|
||||
/// generic region of 65535 × 65535 pixels (each side within JBIG2's limit) behind a small
|
||||
/// /Width and /Height: decoding its 4.3 billion pixels one by one took minutes. Vendored
|
||||
/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and 2^26 per symbol dictionary.
|
||||
/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and as many per symbol dictionary.
|
||||
#[test]
|
||||
fn jbig2_regions_of_billions_of_pixels_are_refused() {
|
||||
// Embedded JBIG2 segments (ISO 14492 §7.2): page information, then an immediate generic
|
||||
@@ -4302,9 +4302,14 @@ fn jbig2_segment(out: &mut Vec<u8>, number: u32, kind: u8, refs: &[u8], data: &[
|
||||
/// The page information of a 16 × 2 JBIG2 page whose default pixel is black, so that the image, if
|
||||
/// it decodes, draws black whatever its other segments hold.
|
||||
fn black_jbig2_page() -> Vec<u8> {
|
||||
black_jbig2_page_of(16, 2)
|
||||
}
|
||||
|
||||
/// [`black_jbig2_page`], `width` × `height`.
|
||||
fn black_jbig2_page_of(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut page = Vec::new();
|
||||
page.extend_from_slice(&16u32.to_be_bytes());
|
||||
page.extend_from_slice(&2u32.to_be_bytes());
|
||||
page.extend_from_slice(&width.to_be_bytes());
|
||||
page.extend_from_slice(&height.to_be_bytes());
|
||||
page.extend_from_slice(&[0; 8]); // resolution
|
||||
page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping
|
||||
let mut stream = Vec::new();
|
||||
@@ -4495,6 +4500,289 @@ fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() {
|
||||
assert_eq!(other, (true, false), "and a page with nothing left says nothing of a mask that isn't JBIG2");
|
||||
}
|
||||
|
||||
/// A symbol dictionary may hold as many pixels as the largest page bitmap. It was held to a
|
||||
/// quarter of that, and the soft mask of a scanned drawing, one dictionary of 7,559 symbols, passed
|
||||
/// it: refused, the mask left its picture drawn whole, which covered the page in noise (#6722).
|
||||
#[test]
|
||||
fn a_symbol_dictionary_holds_as_many_pixels_as_a_page() {
|
||||
let stream = |heights: &[u32]| {
|
||||
let mut stream = black_jbig2_page();
|
||||
jbig2_segment(&mut stream, 1, 0, &[], &wide_jbig2_dictionary(65535, heights));
|
||||
stream
|
||||
};
|
||||
assert!(jbig2_image_draws(&stream(&[1100])), "72,088,500 pixels, past 2^26");
|
||||
assert!(jbig2_image_draws(&stream(&[2047, 2048])), "268,365,825 pixels, within 2^28");
|
||||
assert!(!jbig2_image_draws(&stream(&[2048, 2049])), "268,496,895 pixels, past 2^28");
|
||||
}
|
||||
|
||||
/// A JBIG2 mask one of the decoder's limits refuses takes its picture with it, as one left out for
|
||||
/// the page's allowance does: the file isn't to blame, and a scan's colour layer drawn without its
|
||||
/// mask covers the page in noise (#6722). A mask the file damaged leaves its picture whole, as
|
||||
/// upstream draws it. A size the file damaged can read as past a limit, and is taken for one.
|
||||
#[test]
|
||||
fn a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn() {
|
||||
let draws = |entry: &str, (width, height): (u32, u32), mask: &[u8]| {
|
||||
let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q";
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
|
||||
pdf.extend_from_slice(content);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\n");
|
||||
pdf.extend_from_slice(
|
||||
format!("5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray {entry} 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n").as_bytes(),
|
||||
);
|
||||
let kind = if entry == "/Mask" { "/ImageMask true" } else { "/ColorSpace /DeviceGray" };
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 {kind} /Filter /JBIG2Decode /Length {} >> stream\n",
|
||||
mask.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(mask);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
let shown = match &page.rgba[((25 * 40 + 15) * 4)..][..4] {
|
||||
[0, 0, 0, 255] => true,
|
||||
[255, 255, 255, 255] => false,
|
||||
other => panic!("neither the picture nor the page: {other:?}"),
|
||||
};
|
||||
(shown, page.warnings.contains(&RenderWarning::ContentTruncated))
|
||||
};
|
||||
let out = (false, true);
|
||||
// A page a column wider than the decoder's bound on a side, as the mask's dictionary says too.
|
||||
let wide = black_jbig2_page_of(65536, 2);
|
||||
assert_eq!(draws("/SMask", (65536, 2), &wide), out, "a soft mask past the decoder's bound on a side");
|
||||
assert_eq!(draws("/Mask", (65536, 2), &wide), out, "a stencil mask past it");
|
||||
// A page of 65,535 rows and columns, past the pixels the renderer decodes, as the dictionary
|
||||
// says too.
|
||||
let large = black_jbig2_page_of(65535, 65535);
|
||||
assert_eq!(draws("/SMask", (65535, 65535), &large), out, "a soft mask past the renderer's bound on pixels");
|
||||
assert_eq!(draws("/Mask", (65535, 65535), &large), out, "a stencil mask past it");
|
||||
// A dictionary of more symbol pixels than a page holds.
|
||||
let mut dictionary = black_jbig2_page();
|
||||
jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049]));
|
||||
assert_eq!(draws("/SMask", (16, 2), &dictionary), out, "a soft mask whose dictionary is past its bound");
|
||||
// Cut short in its dictionary: the file's damage, not a limit.
|
||||
let mut damaged = black_jbig2_page();
|
||||
jbig2_segment(&mut damaged, 1, 0, &[], &wide_jbig2_dictionary(100, &[100])[..12]);
|
||||
assert_eq!(draws("/SMask", (16, 2), &damaged), (true, false), "a damaged mask is no mask");
|
||||
// Control: the stream's page is far larger than the dictionary says. That is damage, which
|
||||
// reads as past the decoder's bound on pixels, and is taken for it: it can't be told from a
|
||||
// hostile size, and leaving the picture out is the safe side.
|
||||
let disagreeing = black_jbig2_page_of(65535, 65535);
|
||||
assert_eq!(draws("/SMask", (16, 2), &disagreeing), out, "a size damaged past a limit is taken for one");
|
||||
|
||||
// A stencil mask a limit refused is drawn on its own: it takes nothing else out, and is a
|
||||
// failed image, not content left out (`a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`).
|
||||
// Nor does it leave anything behind for the next picture: one whose mask is never decoded (it
|
||||
// has no /Width) is drawn whole, as upstream draws it.
|
||||
let content = b"q 4 0 0 4 30 30 cm /St Do Q q 20 0 0 20 5 5 cm /Im1 Do Q";
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R /St 7 0 R >> >> >> endobj\n".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
|
||||
pdf.extend_from_slice(content);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n");
|
||||
let mask = black_jbig2_page();
|
||||
pdf.extend_from_slice(format!("6 0 obj << /Type /XObject /Subtype /Image /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", mask.len()).as_bytes());
|
||||
pdf.extend_from_slice(&mask);
|
||||
pdf.extend_from_slice(format!("\nendstream endobj\n7 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /ImageMask true /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()).as_bytes());
|
||||
pdf.extend_from_slice(&wide);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!(&page.rgba[((25 * 40 + 15) * 4)..][..4], [0, 0, 0, 255], "the picture after the stencil is drawn whole");
|
||||
assert!(page.warnings.is_empty(), "the stencil left nothing out: {:?}", page.warnings);
|
||||
}
|
||||
|
||||
/// A JBIG2 image drawn on its own that a limit refuses, a stencil mask included, takes nothing else
|
||||
/// out, so it is a failed image, as it was before #6722: only a picture's mask refused by a limit
|
||||
/// is reported as content left out, with the picture it takes along (patch 73). Refused by the
|
||||
/// decoder's bound on a side or by the renderer's bound on pixels, each image's `/Width` and
|
||||
/// `/Height` those of its JBIG2 page.
|
||||
#[test]
|
||||
fn a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image() {
|
||||
use hayro::hayro_interpret::font::Glyph;
|
||||
use hayro::hayro_interpret::hayro_syntax::Pdf;
|
||||
use hayro::hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
TransformExt, interpret_page,
|
||||
};
|
||||
use kurbo::{Affine, BezPath, Rect};
|
||||
|
||||
/// Asks each image for its pixels, which decodes it, and draws nothing.
|
||||
struct Pixels;
|
||||
impl<'a> Device<'a> for Pixels {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, _: Affine) {
|
||||
match image {
|
||||
Image::Raster(r) => r.with_rgba(|_, _| {}, Some((20, 20))),
|
||||
Image::Stencil(s) => s.with_stencil(|_, _| {}, Some((20, 20))),
|
||||
}
|
||||
}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
// The warnings drawing one image reports.
|
||||
let warnings = |kind: &str, (width, height): (u32, u32)| {
|
||||
let stream = black_jbig2_page_of(width, height);
|
||||
let content = "q 20 0 0 20 5 5 cm /Im Do Q";
|
||||
let mut pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} {kind} /Filter /JBIG2Decode /Length {} >> stream\n",
|
||||
content.len(),
|
||||
stream.len()
|
||||
)
|
||||
.into_bytes();
|
||||
pdf.extend_from_slice(&stream);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let pdf = Pdf::new(Arc::new(pdf)).expect("parses");
|
||||
let page = &pdf.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let reported = Arc::new(Mutex::new(Vec::new()));
|
||||
let settings = InterpreterSettings {
|
||||
warning_sink: {
|
||||
let reported = reported.clone();
|
||||
Arc::new(move |warning| reported.lock().unwrap().push(format!("{warning:?}")))
|
||||
},
|
||||
..InterpreterSettings::default()
|
||||
};
|
||||
let mut ctx = Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 40.0), &cache, page.xref(), settings);
|
||||
interpret_page(page, &mut ctx, &mut Pixels);
|
||||
reported.lock().unwrap().clone()
|
||||
};
|
||||
let stencil = "/ImageMask true";
|
||||
let raster = "/BitsPerComponent 1 /ColorSpace /DeviceGray";
|
||||
let failed = ["ImageDecodeFailure"];
|
||||
assert_eq!(warnings(stencil, (65536, 2)), failed, "a stencil mask past the decoder's bound on a side");
|
||||
assert_eq!(warnings(stencil, (65535, 65535)), failed, "a stencil mask past the renderer's bound on pixels");
|
||||
assert_eq!(warnings(raster, (65536, 2)), failed, "an image past the decoder's bound on a side");
|
||||
assert_eq!(warnings(raster, (65535, 65535)), failed, "an image past the renderer's bound on pixels");
|
||||
}
|
||||
|
||||
/// What refused one image says nothing of the next: after a picture one of the decoder's limits
|
||||
/// refused, a stencil mask and a soft mask whose filters fail before JBIG2 is reached (LZW data that
|
||||
/// doesn't decode) are bad, not refused by a limit, on that page and on the next. The decoder's
|
||||
/// note of the refusal was read before a decode had set it, and outlived its page: the stencil
|
||||
/// said content was left out, and the picture under the soft mask wasn't drawn.
|
||||
#[test]
|
||||
fn a_limit_refusal_says_nothing_of_the_next_image() {
|
||||
let refused = black_jbig2_page_of(65536, 2);
|
||||
let bad = [0xff; 64];
|
||||
let page = |number: u8, content: u8| {
|
||||
format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << /Big 7 0 R /St 8 0 R /Im 9 0 R >> >> >> endobj\n").into_bytes()
|
||||
};
|
||||
let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes();
|
||||
let object = |number: u8, dict: &str, data: &[u8]| {
|
||||
let mut object = format!("{number} 0 obj << {dict} /Length {} >> stream\n", data.len()).into_bytes();
|
||||
object.extend_from_slice(data);
|
||||
object.extend_from_slice(b"\nendstream endobj\n");
|
||||
object
|
||||
};
|
||||
let mut pdf =
|
||||
b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec();
|
||||
pdf.extend(page(3, 5));
|
||||
pdf.extend(page(4, 6));
|
||||
pdf.extend(content(5, "q 10 0 0 10 30 30 cm /Big Do Q q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q"));
|
||||
pdf.extend(content(6, "q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q"));
|
||||
pdf.extend(object(
|
||||
7,
|
||||
"/Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode",
|
||||
&refused,
|
||||
));
|
||||
pdf.extend(object(8, "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ImageMask true /Filter [/LZWDecode /JBIG2Decode]", &bad));
|
||||
pdf.extend(object(9, "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 10 0 R", &[0]));
|
||||
pdf.extend(object(
|
||||
10,
|
||||
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/LZWDecode /JBIG2Decode]",
|
||||
&bad,
|
||||
));
|
||||
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
// Both pages from one renderer, on one thread.
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let [first, second] =
|
||||
[0, 1].map(|page| renderer.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
|
||||
let drawn = |page: &RenderedPage| {
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
(page.rgba[((25 * 40 + 15) * 4)..][..4] == [0, 0, 0, 255], page.warnings.contains(&RenderWarning::ContentTruncated))
|
||||
};
|
||||
// The next page first: on the page of the refusal, the stale note shows whether or not it
|
||||
// outlives the page.
|
||||
assert_eq!(drawn(&second), (true, false), "on the next page, the picture is drawn and nothing was left out");
|
||||
assert_eq!(drawn(&first), (true, false), "on its page, the picture is drawn and nothing was left out");
|
||||
}
|
||||
|
||||
/// A JPX picture is decoded again for each size a page draws it at, while a soft mask far larger
|
||||
/// than where it is drawn is averaged down once and kept for the sizes it serves (patch 82). A
|
||||
/// JBIG2 mask a decoder limit refused is kept refused, so the picture drawn at another size is left
|
||||
/// out again, not drawn whole because the mask wasn't decoded that time. The scans of #6722 are
|
||||
/// JPX under such a mask.
|
||||
#[test]
|
||||
fn a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size() {
|
||||
// A page a column wider than the decoder's bound on a side, as the mask's dictionary says too.
|
||||
let wide = black_jbig2_page_of(65536, 2);
|
||||
// A 64 × 64 grey JPX codestream, every sample 128 (as `jpx_midpoint_pdf`'s, larger): larger
|
||||
// than where it is drawn, it is decoded for each size it is drawn at.
|
||||
let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ, Rsiz.
|
||||
for value in [64_u32, 64, 0, 0, 64, 64, 0, 0] {
|
||||
codestream.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
codestream.extend_from_slice(&[0, 1, 7, 1, 1]); // One component, eight bits.
|
||||
// COD: one decomposition level, so it decodes at half its size for a small target; QCD: no
|
||||
// quantization, the four subbands' exponents.
|
||||
codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 1, 4, 4, 0, 1]);
|
||||
codestream.extend_from_slice(&[0xff, 0x5c, 0, 7, 0x40, 0x40, 0x48, 0x48, 0x50]);
|
||||
codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 16, 0, 1, 0xff, 0x93, 0, 0]); // SOT, SOD, two empty packets.
|
||||
codestream.extend_from_slice(&[0xff, 0xd9]); // EOC.
|
||||
// At 10, 20 and 10 units square: the second draw is a new size for the picture.
|
||||
let content = b"q 10 0 0 10 2 2 cm /Im1 Do Q q 20 0 0 20 16 16 cm /Im1 Do Q q 10 0 0 10 2 28 cm /Im1 Do Q";
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
|
||||
pdf.extend_from_slice(content);
|
||||
pdf.extend_from_slice(
|
||||
format!("\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Filter /JPXDecode /Length {} >> stream\n", codestream.len())
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(&codestream);
|
||||
pdf.extend_from_slice(
|
||||
format!("\nendstream endobj\n6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", wide.len())
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(&wide);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
// The middle of each draw, in rows from the top.
|
||||
let shown = [(33, 7), (14, 26), (7, 7)].map(|(row, column)| page.rgba[(row * 40 + column) * 4..][..4] != [255, 255, 255, 255]);
|
||||
assert_eq!(shown, [false; 3], "drawn at 10, 20 and 10");
|
||||
assert!(page.warnings.contains(&RenderWarning::ContentTruncated));
|
||||
}
|
||||
|
||||
/// A page's allowance ends with it, so what decodes on its thread afterwards has no page to run out
|
||||
/// on, and a `Context` used again starts afresh.
|
||||
#[test]
|
||||
@@ -4747,6 +5035,40 @@ fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec<u8> {
|
||||
dictionary
|
||||
}
|
||||
|
||||
/// A Huffman-coded symbol dictionary of one white symbol of `width` columns in each height class
|
||||
/// of `heights` (rising), none exported: wide symbols hold many pixels in few retained words.
|
||||
fn wide_jbig2_dictionary(width: u32, heights: &[u32]) -> Vec<u8> {
|
||||
let mut bits = Bits::default();
|
||||
let mut previous = 0;
|
||||
for &height in heights {
|
||||
match height - previous {
|
||||
// Height class delta (table B.4): 1, or the upper line.
|
||||
1 => bits.put(0, 1),
|
||||
delta => {
|
||||
bits.put(0b11111, 5);
|
||||
bits.put(delta - 76, 32);
|
||||
}
|
||||
}
|
||||
previous = height;
|
||||
bits.put(0b111110, 6); // width delta, the upper line of table B.2
|
||||
bits.put(width - 75, 32);
|
||||
bits.put(0b111111, 6); // end of the class (B.2)
|
||||
let rows = height.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row
|
||||
bits.table_b1(rows); // BMSIZE (B.1)
|
||||
bits.align();
|
||||
for _ in 0..rows {
|
||||
bits.put(0xFF, 8);
|
||||
}
|
||||
}
|
||||
let symbols = u32::try_from(heights.len()).unwrap();
|
||||
bits.table_b1(symbols); // the first export run, not exported, is all of them
|
||||
let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1
|
||||
dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported
|
||||
dictionary.extend_from_slice(&symbols.to_be_bytes()); // new
|
||||
dictionary.extend_from_slice(&bits.bytes);
|
||||
dictionary
|
||||
}
|
||||
|
||||
/// An intermediate generic region, white, every row typical: it decodes in a few bytes however
|
||||
/// large it is.
|
||||
fn jbig2_white_region(width: u32, height: u32) -> Vec<u8> {
|
||||
|
||||
@@ -237,3 +237,114 @@ fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() {
|
||||
assert!(black(&second, 10) && black(&second, 30), "A, and B left out of the first page, draw on the second");
|
||||
assert!(second.1.is_empty(), "{:?}", second.1);
|
||||
}
|
||||
|
||||
/// A JBIG2 mask its decoding work refuses: four typical-row regions of 16,384 × 16,384 take more
|
||||
/// than a stream may.
|
||||
fn jbig2_mask_past_its_work() -> Vec<u8> {
|
||||
use super::{black_jbig2_page, jbig2_segment, jbig2_white_region};
|
||||
let mut mask = black_jbig2_page();
|
||||
for number in 1..=4 {
|
||||
jbig2_segment(&mut mask, number, 38, &[], &jbig2_white_region(16384, 16384)); // immediate generic region
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// What drawing 64 × 64 pictures under JBIG2 soft masks did, with the page's JBIG2 allowance
|
||||
/// `work`: `/P` under `mask` (`/Width` × `/Height` `size`) and `/W` under
|
||||
/// [`jbig2_mask_past_its_work`], each of `draws` a name and a size (units square). The decodes the
|
||||
/// cache ran, how many times content was said to be left out, and what the device was handed.
|
||||
fn draw_under_jbig2_masks(mask: &[u8], size: (u32, u32), draws: &[(&str, u32)], work: u64) -> (usize, usize, Draws) {
|
||||
let pixels = vec![0; 64 * 64 * 3];
|
||||
let picture = |mask: u8| {
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask {mask} 0 R /Length {} >>",
|
||||
pixels.len()
|
||||
)
|
||||
};
|
||||
let soft_mask = |(width, height): (u32, u32), data: &[u8]| {
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /Length {} >>",
|
||||
data.len()
|
||||
)
|
||||
};
|
||||
let past_its_work = jbig2_mask_past_its_work();
|
||||
let content = draws.iter().map(|(name, s)| format!("q {s} 0 0 {s} 10 10 cm /{name} Do Q")).collect::<Vec<_>>().join(" ");
|
||||
let bytes = image_cache_pdf(
|
||||
&content,
|
||||
"<< /XObject << /P 5 0 R /W 7 0 R >> >>",
|
||||
&[(&picture(6), &pixels), (&soft_mask(size, mask), mask), (&picture(8), &pixels), (&soft_mask((16, 2), &past_its_work), &past_its_work)],
|
||||
);
|
||||
let parsed = Pdf::new(Arc::new(bytes)).expect("parses");
|
||||
let page = &parsed.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let truncated = Arc::new(AtomicUsize::new(0));
|
||||
let others = Arc::new(Mutex::new(Vec::new()));
|
||||
let settings = InterpreterSettings {
|
||||
warning_sink: {
|
||||
let (truncated, others) = (truncated.clone(), others.clone());
|
||||
Arc::new(move |warning| match warning {
|
||||
InterpreterWarning::ContentTruncated => {
|
||||
truncated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
other => others.lock().unwrap().push(format!("{other:?}")),
|
||||
})
|
||||
},
|
||||
jbig2_work: work,
|
||||
..InterpreterSettings::default()
|
||||
};
|
||||
let initial = page.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), settings);
|
||||
let mut device = Draws::default();
|
||||
interpret_page(page, &mut ctx, &mut device);
|
||||
assert!(others.lock().unwrap().is_empty(), "reported {:?}", others.lock().unwrap());
|
||||
(cache.decoded_image_decodes(), truncated.load(Ordering::Relaxed), device)
|
||||
}
|
||||
|
||||
/// A picture whose JBIG2 soft mask a decoder limit refuses isn't drawn (patch 73), and the limit
|
||||
/// is the stream's own: decoding it again refuses it again. So the refusal is kept with the picture
|
||||
/// that went with it (patch 91), and a page drawing it a hundred times decodes them once, as for
|
||||
/// one draw. Kept as content left out, every draw still says so. The refusal wasn't kept, and the
|
||||
/// picture, of any size, was decoded again at every draw: 2.45 s for a hundred draws of a 4,096 ×
|
||||
/// 4,096 JPX picture, against 0.23 s under a mask the file damaged. A mask larger than its picture
|
||||
/// is averaged down for each size it is drawn at, from one kept at an intermediate size (patch 82),
|
||||
/// so a second size finds the refusal kept there and keeps it for that size too, also after
|
||||
/// another picture's mask was refused for its work, which isn't kept.
|
||||
#[test]
|
||||
fn a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn() {
|
||||
use super::{black_jbig2_page, black_jbig2_page_of, jbig2_segment, wide_jbig2_dictionary};
|
||||
let mut dictionary = black_jbig2_page();
|
||||
jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049]));
|
||||
let masks = [
|
||||
("past the decoder's bound on a side", black_jbig2_page_of(65536, 2), (65536, 2)),
|
||||
("past the renderer's bound on pixels", black_jbig2_page_of(65535, 65535), (65535, 65535)),
|
||||
("whose dictionary is past its bound", dictionary, (16, 2)),
|
||||
];
|
||||
let work = InterpreterSettings::default().jbig2_work;
|
||||
for (what, mask, size) in masks {
|
||||
let (once, _, _) = draw_under_jbig2_masks(&mask, size, &[("P", 20)], work);
|
||||
let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, size, &[("P", 20); 100], work);
|
||||
assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out");
|
||||
assert_eq!(decodes, once, "a mask {what}: a hundred draws decode as one does");
|
||||
assert_eq!(truncated, 100, "a mask {what}: every draw says content was left out");
|
||||
// With no page allowance to run out of, so that the mask past its work is refused by its
|
||||
// own limit and the page's allowance doesn't stop what is kept.
|
||||
let each = [("P", 20), ("W", 20), ("P", 40)];
|
||||
let (once_each, _, _) = draw_under_jbig2_masks(&mask, size, &each, u64::MAX);
|
||||
let (decodes, _, draws) = draw_under_jbig2_masks(&mask, size, &[&each[..], &[("P", 40); 2]].concat(), u64::MAX);
|
||||
assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out at either size");
|
||||
assert_eq!(decodes, once_each, "a mask {what}: drawn again at its second size, decoded as once at each");
|
||||
}
|
||||
}
|
||||
|
||||
/// A JBIG2 mask refused for the work its decoding takes isn't kept: that work may be what its page
|
||||
/// had left, which another page has afresh. So the picture under it is decoded at every draw, here
|
||||
/// with no page allowance to run out of, where the stream's own limit is what refuses it.
|
||||
#[test]
|
||||
fn a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw() {
|
||||
let mask = super::black_jbig2_page();
|
||||
let (once, _, _) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20)], u64::MAX);
|
||||
let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20); 3], u64::MAX);
|
||||
assert_eq!(draws.rasters, 0, "the picture is left out");
|
||||
assert_eq!(decodes, 3 * once, "decoded at every draw");
|
||||
assert_eq!(truncated, 3, "every draw says content was left out");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pdfcraft-content = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
zeroize.workspace = true
|
||||
# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code).
|
||||
chrono = { workspace = true }
|
||||
base64 = "0.23"
|
||||
|
||||
+14
-10
@@ -4,6 +4,8 @@
|
||||
//! (constant-time; ADR-0009: the `rsa` crate is never used for private keys there). ECDSA
|
||||
//! signing uses RustCrypto with deterministic nonces (RFC 6979).
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::SignError;
|
||||
use crate::der::{self, Tlv, tag};
|
||||
|
||||
@@ -588,7 +590,7 @@ pub struct PrivateKey {
|
||||
inner: Inner,
|
||||
public: PublicKey,
|
||||
/// The PKCS#8 `PrivateKeyInfo` it came from (to write it back into a PKCS#12 file).
|
||||
pkcs8: Vec<u8>,
|
||||
pub(crate) pkcs8: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PrivateKey {
|
||||
@@ -601,7 +603,7 @@ impl PrivateKey {
|
||||
/// A key held outside PdfCraft's memory (its public half is `public`). It can't be
|
||||
/// exported to a PKCS #12 file.
|
||||
pub fn external(public: PublicKey, key: std::sync::Arc<dyn ExternalKey>) -> PrivateKey {
|
||||
PrivateKey { inner: Inner::External(key), public, pkcs8: Vec::new() }
|
||||
PrivateKey { inner: Inner::External(key), public, pkcs8: Zeroizing::new(Vec::new()) }
|
||||
}
|
||||
|
||||
/// Whether the key lives outside the app (and so can't be exported).
|
||||
@@ -631,7 +633,7 @@ impl PrivateKey {
|
||||
);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let inner = Inner::Rsa;
|
||||
Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: info.to_vec() })
|
||||
Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: Zeroizing::new(info.to_vec()) })
|
||||
}
|
||||
oid::EC => {
|
||||
// ECPrivateKey: version, privateKey OCTET STRING, [0] parameters, [1] publicKey.
|
||||
@@ -650,12 +652,12 @@ impl PrivateKey {
|
||||
oid::P256 => {
|
||||
let k = p256::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-256 key".into()))?;
|
||||
let public = PublicKey::P256(k.verifying_key().to_sec1_point(false).as_bytes().to_vec());
|
||||
Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: info.to_vec() })
|
||||
Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: Zeroizing::new(info.to_vec()) })
|
||||
}
|
||||
oid::P384 => {
|
||||
let k = p384::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-384 key".into()))?;
|
||||
let public = PublicKey::P384(k.verifying_key().to_sec1_point(false).as_bytes().to_vec());
|
||||
Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: info.to_vec() })
|
||||
Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: Zeroizing::new(info.to_vec()) })
|
||||
}
|
||||
other => Err(SignError::Unsupported(format!("elliptic curve {other}"))),
|
||||
}
|
||||
@@ -689,13 +691,15 @@ impl PrivateKey {
|
||||
/// A new P-256 key.
|
||||
pub fn generate_p256() -> Result<PrivateKey, SignError> {
|
||||
loop {
|
||||
let mut d = [0u8; 32];
|
||||
getrandom::fill(&mut d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?;
|
||||
if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&d) {
|
||||
let mut d = Zeroizing::new([0u8; 32]);
|
||||
getrandom::fill(&mut *d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?;
|
||||
if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&*d) {
|
||||
let point = k.verifying_key().to_sec1_point(false).as_bytes().to_vec();
|
||||
// PKCS#8 with an ECPrivateKey (RFC 5915) carrying the public key.
|
||||
let ec = der::seq(&[&der::int(1), &der::octets(&d), &der::explicit(1, &der::bit_string(&point))]);
|
||||
let info = der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &der::octets(&ec)]);
|
||||
let d_octets = Zeroizing::new(der::octets(&*d));
|
||||
let ec = Zeroizing::new(der::seq(&[&der::int(1), &d_octets, &der::explicit(1, &der::bit_string(&point))]));
|
||||
let ec_octets = Zeroizing::new(der::octets(&ec));
|
||||
let info = Zeroizing::new(der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &ec_octets]));
|
||||
return PrivateKey::from_pkcs8(&info);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-21
@@ -20,6 +20,7 @@ use base64::Engine as _;
|
||||
use cbc::cipher::block_padding::Pkcs7;
|
||||
use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyIvInit};
|
||||
use hmac::Mac;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::SignError;
|
||||
use crate::der::{self, Tlv, tag};
|
||||
@@ -100,14 +101,14 @@ fn bad(what: &str) -> SignError {
|
||||
}
|
||||
|
||||
/// The password as a BMPString with its terminating NUL (RFC 7292 Appendix B.1).
|
||||
fn bmp(password: &str) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = password.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
fn bmp(password: &str) -> Zeroizing<Vec<u8>> {
|
||||
let mut v = Zeroizing::new(password.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>());
|
||||
v.extend([0, 0]);
|
||||
v
|
||||
}
|
||||
|
||||
/// The PKCS #12 key derivation function (RFC 7292 Appendix B.2).
|
||||
fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Vec<u8> {
|
||||
fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Zeroizing<Vec<u8>> {
|
||||
// Callers only pass the SHA-1/SHA-2 digests PKCS #12 files use (`open` restricts the MAC digest).
|
||||
let (u, v) = match alg {
|
||||
DigestAlg::Sha1 => (20, 64),
|
||||
@@ -115,25 +116,25 @@ fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations:
|
||||
DigestAlg::Sha256 => (32, 64),
|
||||
DigestAlg::Sha384 => (48, 128),
|
||||
DigestAlg::Sha512 => (64, 128),
|
||||
_ => return Vec::new(),
|
||||
_ => return Zeroizing::new(Vec::new()),
|
||||
};
|
||||
let _ = u;
|
||||
let fill = |s: &[u8]| -> Vec<u8> {
|
||||
let fill = |s: &[u8]| -> Zeroizing<Vec<u8>> {
|
||||
if s.is_empty() {
|
||||
return Vec::new();
|
||||
return Zeroizing::new(Vec::new());
|
||||
}
|
||||
let len = v * s.len().div_ceil(v);
|
||||
s.iter().cycle().take(len).copied().collect()
|
||||
Zeroizing::new(s.iter().cycle().take(len).copied().collect())
|
||||
};
|
||||
let d = vec![id; v];
|
||||
let mut i: Vec<u8> = [fill(salt), fill(password)].concat();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut i = Zeroizing::new([fill(salt).as_slice(), fill(password).as_slice()].concat());
|
||||
let mut out = Zeroizing::new(Vec::with_capacity(n));
|
||||
while out.len() < n {
|
||||
let mut a = alg.digest(&[&d, &i]);
|
||||
let mut a = Zeroizing::new(alg.digest(&[&d, &i]));
|
||||
for _ in 1..iterations.max(1) {
|
||||
a = alg.digest(&[&a]);
|
||||
a = Zeroizing::new(alg.digest(&[&a]));
|
||||
}
|
||||
let b: Vec<u8> = a.iter().cycle().take(v).copied().collect();
|
||||
let b = Zeroizing::new(a.iter().cycle().take(v).copied().collect::<Vec<u8>>());
|
||||
for chunk in i.chunks_mut(v) {
|
||||
// chunk = (chunk + b + 1) mod 2^(8v)
|
||||
let mut carry = 1u16;
|
||||
@@ -169,7 +170,7 @@ impl Cipher {
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result<Vec<u8>, SignError> {
|
||||
fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result<Zeroizing<Vec<u8>>, SignError> {
|
||||
let wrong = || SignError::WrongPassword;
|
||||
let r = match self {
|
||||
Cipher::Aes128 => cbc::Decryptor::<aes::Aes128>::new_from_slices(key, iv).map_err(|_| wrong())?.decrypt_padded_vec::<Pkcs7>(ct),
|
||||
@@ -181,7 +182,7 @@ impl Cipher {
|
||||
cbc::Decryptor::<rc2::Rc2>::inner_iv_slice_init(c, iv).map_err(|_| wrong())?.decrypt_padded_vec::<Pkcs7>(ct)
|
||||
}
|
||||
};
|
||||
r.map_err(|_| wrong())
|
||||
r.map(Zeroizing::new).map_err(|_| wrong())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +206,8 @@ fn hmac(alg: DigestAlg, key: &[u8], data: &[u8]) -> Result<Vec<u8>, SignError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result<Vec<u8>, SignError> {
|
||||
let mut out = vec![0u8; len];
|
||||
fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result<Zeroizing<Vec<u8>>, SignError> {
|
||||
let mut out = Zeroizing::new(vec![0u8; len]);
|
||||
match prf {
|
||||
DigestAlg::Sha1 => pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password, salt, rounds, &mut out),
|
||||
DigestAlg::Sha224 => pbkdf2::pbkdf2_hmac::<sha2::Sha224>(password, salt, rounds, &mut out),
|
||||
@@ -219,7 +220,7 @@ fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize)
|
||||
}
|
||||
|
||||
/// Decrypt `ct` with the password-based scheme `alg` (an AlgorithmIdentifier).
|
||||
fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result<Vec<u8>, SignError> {
|
||||
fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result<Zeroizing<Vec<u8>>, SignError> {
|
||||
let parts = alg.children()?;
|
||||
let o = parts.first().ok_or_else(|| bad("algorithm"))?.oid()?;
|
||||
let params = parts.get(1).ok_or_else(|| bad("algorithm parameters"))?;
|
||||
@@ -280,8 +281,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) ->
|
||||
let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len);
|
||||
if key.len() == 16 && matches!(cipher, Cipher::TripleDes) {
|
||||
// Two-key 3DES: K1 K2 K1.
|
||||
let k1 = key[..8].to_vec();
|
||||
key.extend(k1);
|
||||
key.extend_from_within(..8);
|
||||
}
|
||||
let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 2, rounds, iv_len);
|
||||
cipher.decrypt(&key, &iv, ct)
|
||||
@@ -297,7 +297,7 @@ struct Bag {
|
||||
}
|
||||
|
||||
enum BagKind {
|
||||
Key(Vec<u8>),
|
||||
Key(Zeroizing<Vec<u8>>),
|
||||
Cert(Vec<u8>),
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>, budget: &mut B
|
||||
}
|
||||
}
|
||||
let kind = match id.as_str() {
|
||||
KEY_BAG => BagKind::Key(value.raw.to_vec()),
|
||||
KEY_BAG => BagKind::Key(Zeroizing::new(value.raw.to_vec())),
|
||||
SHROUDED_KEY_BAG => {
|
||||
let p = value.children()?;
|
||||
let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) };
|
||||
@@ -549,4 +549,16 @@ mod tests {
|
||||
assert_eq!(budget.spend(DigestAlg::Sha1, 0, 20).ok(), Some(0));
|
||||
assert_eq!(budget.0, OPEN_BUDGET - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_and_kdf_buffers_are_zeroizing() {
|
||||
let key = PrivateKey::generate_p256().unwrap();
|
||||
let _: &Zeroizing<Vec<u8>> = &key.pkcs8;
|
||||
let pw: Zeroizing<Vec<u8>> = bmp("secret12");
|
||||
assert_eq!(&*pw, &[0, b's', 0, b'e', 0, b'c', 0, b'r', 0, b'e', 0, b't', 0, b'1', 0, b'2', 0, 0]);
|
||||
let kdf: Zeroizing<Vec<u8>> = pkcs12_kdf(DigestAlg::Sha256, &pw, &[1, 2, 3, 4], 1, 1, 32);
|
||||
assert_eq!(kdf.len(), 32);
|
||||
let derived: Zeroizing<Vec<u8>> = pbkdf2(DigestAlg::Sha256, b"secret12", &[1, 2, 3, 4], 1, 32).unwrap();
|
||||
assert_eq!(derived.len(), 32);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "acces
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
# UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono
|
||||
# (no hand-rolled calendar code); std-only, so it stays wasm-safe.
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -133,6 +133,7 @@ impl PdfCraftApp {
|
||||
printer_source: None,
|
||||
options_source: None,
|
||||
spooler: None,
|
||||
two_sided_source: None,
|
||||
print_tasks: print_ui::PrintTasks::default(),
|
||||
palette_open: false,
|
||||
palette_query: String::new(),
|
||||
|
||||
@@ -61,6 +61,21 @@ impl PdfCraftApp {
|
||||
pub(crate) fn select_tool(&mut self, id: &'static str) {
|
||||
// The tool's panel shows, not the expanded All tools view over it.
|
||||
self.all_tools_full = false;
|
||||
// Edit a PDF turns on Edit text & images at once, as the Edit tab does (`enter_mode`), so
|
||||
// the page is editable without a second click. Leaving it for another tool from All tools
|
||||
// puts editing down again; under the Edit tab the workspace keeps it, as before. Read mode
|
||||
// hides the quick toolbar and keeps every tool down (`enter_mode`), so the control
|
||||
// channel's `tool edit` there leaves editing off.
|
||||
if id == "edit" {
|
||||
if self.mode != Mode::Read {
|
||||
self.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
} else if self.mode != Mode::Edit
|
||||
&& self.left == LeftPanel::Tool("edit")
|
||||
&& matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText)
|
||||
{
|
||||
self.put_down_edit_text();
|
||||
}
|
||||
self.left = LeftPanel::Tool(id);
|
||||
self.reveal_tools();
|
||||
if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) {
|
||||
|
||||
@@ -161,6 +161,7 @@ impl PdfCraftApp {
|
||||
if let Some(Dialog::Marks(kind)) = self.dialog {
|
||||
self.marks_draft.opened(kind);
|
||||
}
|
||||
dialogs::discard_inactive_password_drafts(self);
|
||||
}
|
||||
("tools", _) if value == "full" => self.expand_all_tools(),
|
||||
("tools", _) => {
|
||||
|
||||
@@ -484,7 +484,9 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an attachment to disk, or open a PDF attachment in a new tab.
|
||||
/// Save an attachment to disk, or open a PDF attachment in a new tab (or go to the tab it is
|
||||
/// already open in). Only bytes that are a PDF open, whatever the file's name says; anything
|
||||
/// else asked to open is offered to save.
|
||||
pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) {
|
||||
let Some(d) = self.session.get(doc) else { return };
|
||||
let Some(att) = d.info.attachments.get(index).cloned() else { return };
|
||||
@@ -493,13 +495,30 @@ impl PdfCraftApp {
|
||||
let source = d.path.clone();
|
||||
match (data, open) {
|
||||
(Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]),
|
||||
(Ok(bytes), true) => {
|
||||
(Ok(bytes), true) if looks_like_pdf(&bytes) => {
|
||||
// The tab name later names files written from the tab (Split, Extract pages).
|
||||
if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]);
|
||||
let name = crate::files::attachment_file_name(&att.name);
|
||||
// Already open: the tab opened from this attachment of this document (its bytes
|
||||
// may have grown since, as a form laid out on open appends its layout).
|
||||
let origin = (doc, att.name.clone());
|
||||
let open_in = self.views.iter().position(|v| v.from_attachment.as_ref() == Some(&origin) && self.session.get(v.id).is_some());
|
||||
if let Some(i) = open_in {
|
||||
self.active = Some(i);
|
||||
return;
|
||||
}
|
||||
let before = self.views.len();
|
||||
match self.open_bytes(&name, None, bytes) {
|
||||
// Opening bytes adds their tab at once (unless a password is asked for first).
|
||||
Ok(()) if self.views.len() > before => {
|
||||
if let Some(v) = self.views.last_mut() {
|
||||
v.from_attachment = Some(origin);
|
||||
}
|
||||
}
|
||||
Ok(()) => {}
|
||||
Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]),
|
||||
}
|
||||
}
|
||||
(Ok(bytes), false) => {
|
||||
(Ok(bytes), _) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.ask_one(
|
||||
pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))),
|
||||
@@ -627,11 +646,12 @@ impl PdfCraftApp {
|
||||
|
||||
/// Answer the password prompt (`None` cancels).
|
||||
pub fn submit_password(&mut self, password: Option<String>) {
|
||||
let Some(p) = self.password_prompt.take() else { return };
|
||||
let Some(pw) = password else {
|
||||
let Some(mut p) = self.password_prompt.take() else { return };
|
||||
zeroize::Zeroize::zeroize(&mut p.input);
|
||||
let Some(pw) = password.map(zeroize::Zeroizing::new) else {
|
||||
// A cancelled recovery goes back on offer; its snapshot stays in the store and must
|
||||
// not attach itself to whatever document is unlocked next (#813).
|
||||
if let Some(meta) = p.recovered {
|
||||
if let Some(meta) = p.recovered.take() {
|
||||
self.recoverable.push(meta);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -669,7 +689,9 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp);
|
||||
let after_open = p.after_open.clone();
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) {
|
||||
let opened = self.try_open(&p.name, p.path.take(), p.bytes.clone(), Some(&pw), p.activate);
|
||||
drop(pw);
|
||||
match opened {
|
||||
Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
|
||||
// A recovered encrypted document is open once its prompt is gone.
|
||||
Ok(()) if self.password_prompt.is_none() => {
|
||||
@@ -706,7 +728,7 @@ impl PdfCraftApp {
|
||||
// and the tool waiting for it.
|
||||
Ok(()) => {
|
||||
if let Some(prompt) = self.password_prompt.as_mut() {
|
||||
prompt.recovered = p.recovered;
|
||||
prompt.recovered = p.recovered.take();
|
||||
prompt.then = p.then;
|
||||
prompt.after_open = after_open;
|
||||
prompt.display_override = p.display_override;
|
||||
@@ -726,8 +748,11 @@ impl PdfCraftApp {
|
||||
/// Answer the permissions password prompt (`None` cancels).
|
||||
pub fn submit_unlock(&mut self, password: Option<String>) {
|
||||
let Some(mut p) = self.unlock_prompt.take() else { return };
|
||||
let Some(pw) = password else { return };
|
||||
match self.session.unlock(p.doc, &pw) {
|
||||
zeroize::Zeroize::zeroize(&mut p.input);
|
||||
let Some(mut pw) = password else { return };
|
||||
let unlocked = self.session.unlock(p.doc, &pw);
|
||||
zeroize::Zeroize::zeroize(&mut pw);
|
||||
match unlocked {
|
||||
// Carry on with what asked for the password, if its document is still the one shown.
|
||||
Ok(()) => match p.then {
|
||||
Some(command) if self.active_ids().is_some_and(|(_, id)| id == p.doc) => {
|
||||
@@ -736,7 +761,6 @@ impl PdfCraftApp {
|
||||
_ => self.notify_tr("Restrictions lifted"),
|
||||
},
|
||||
Err(pdfcraft_engine::EditError::WrongPassword) => {
|
||||
p.input.clear();
|
||||
p.error = Some("Incorrect password. Try again.".to_string());
|
||||
self.unlock_prompt = Some(p);
|
||||
}
|
||||
@@ -1023,3 +1047,8 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a PDF: a `%PDF-` header within the first 1024 bytes, as readers accept.
|
||||
fn looks_like_pdf(bytes: &[u8]) -> bool {
|
||||
bytes.get(..1024).unwrap_or(bytes).windows(5).any(|w| w == b"%PDF-")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use pdfcraft_engine::print::names as print_names;
|
||||
|
||||
/// [`PdfCraftApp::date_text`] for callers that already borrow other parts of the app.
|
||||
pub(crate) fn date_text(session: &Session, fmt: Option<&str>) -> Result<String, String> {
|
||||
@@ -75,6 +76,19 @@ impl PdfCraftApp {
|
||||
/// Serialize the user's persistent state (recent files, theme). Local only.
|
||||
pub fn persist(&self) -> String {
|
||||
let trusted: Vec<String> = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect();
|
||||
// The Print dialog's choices that describe how this computer prints (#2001), named as
|
||||
// `doc_print` names them. Copies, pages, layout and orientation are one job's.
|
||||
let print = serde_json::json!({
|
||||
"duplex": print_names::duplex(self.print_draft.duplex),
|
||||
"grayscale": self.print_draft.grayscale,
|
||||
"collate": self.print_draft.collate,
|
||||
"size": print_names::size_mode(self.print_draft.size),
|
||||
"custom_scale": self.print_draft.custom_scale,
|
||||
"comments_forms": print_names::content(self.print_draft.content),
|
||||
"binding": print_names::binding(self.print_draft.binding),
|
||||
"as_image": self.print_draft.as_image,
|
||||
"dpi": self.print_draft.dpi,
|
||||
});
|
||||
let mut v = serde_json::json!({
|
||||
"side_panels": self.side_panels.to_json(self.left_open),
|
||||
"docking_right": self.right,
|
||||
@@ -173,6 +187,10 @@ impl PdfCraftApp {
|
||||
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
|
||||
// Canonical names survive changes to the order of PAPERS and do not depend on UI language.
|
||||
v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name));
|
||||
// Also set outside json! above, which is at serde_json's macro recursion limit.
|
||||
v["print"] = print;
|
||||
// Each printer's paper, sizing and two-sided choice, the one printed to last first (#2001).
|
||||
v["print_printers"] = crate::print_ui::setups_to_json(&self.print_draft.setups);
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
@@ -300,6 +318,9 @@ impl PdfCraftApp {
|
||||
{
|
||||
self.print_draft.paper = paper;
|
||||
}
|
||||
if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) {
|
||||
self.print_draft.setups = setups;
|
||||
}
|
||||
if let Some(on) = v["highlight_fields"].as_bool() {
|
||||
self.view_defaults.highlight_fields = on;
|
||||
}
|
||||
@@ -309,6 +330,41 @@ impl PdfCraftApp {
|
||||
if let Some(closed) = v["right_panel_closed"].as_bool() {
|
||||
self.right_panel_closed = closed;
|
||||
}
|
||||
// The Print dialog's remembered choices (#2001). Settings are untrusted: an unknown name
|
||||
// or a scale outside the dialog's range keeps the default. The scale is read before the
|
||||
// size mode, which carries it.
|
||||
let print = &v["print"];
|
||||
let draft = &mut self.print_draft;
|
||||
if let Some(duplex) = print["duplex"].as_str().and_then(print_names::parse_duplex) {
|
||||
draft.duplex = duplex;
|
||||
}
|
||||
if let Some(on) = print["grayscale"].as_bool() {
|
||||
draft.grayscale = on;
|
||||
}
|
||||
if let Some(on) = print["collate"].as_bool() {
|
||||
draft.collate = on;
|
||||
}
|
||||
if let Some(scale) = print["custom_scale"].as_f64().filter(|s| print_ui::CUSTOM_SCALE.contains(s)) {
|
||||
draft.custom_scale = scale;
|
||||
}
|
||||
if let Some(size) = print["size"].as_str().and_then(|name| print_names::parse_size_mode(name, draft.custom_scale)) {
|
||||
draft.size = size;
|
||||
}
|
||||
if let Some(content) = print["comments_forms"].as_str().and_then(print_names::parse_content) {
|
||||
draft.content = content;
|
||||
}
|
||||
// A booklet's binding side, as Acrobat keeps it (#2707).
|
||||
if let Some(binding) = print["binding"].as_str().and_then(print_names::parse_binding) {
|
||||
draft.binding = binding;
|
||||
}
|
||||
// Print as image and its resolution (#6392), so a printer that needs pictures keeps getting
|
||||
// them; a resolution the dialog doesn't offer keeps the default.
|
||||
if let Some(on) = print["as_image"].as_bool() {
|
||||
draft.as_image = on;
|
||||
}
|
||||
if let Some(dpi) = print["dpi"].as_f64().filter(|d| print_ui::IMAGE_DPI.contains(d)) {
|
||||
draft.dpi = dpi;
|
||||
}
|
||||
// Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip
|
||||
// flags); unknown or malformed values keep the defaults.
|
||||
self.ocr_draft.apply_pref(&v["ocr"]);
|
||||
|
||||
@@ -102,39 +102,15 @@ impl PainterExt for egui::Painter {
|
||||
}
|
||||
}
|
||||
|
||||
/// User-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
|
||||
/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not
|
||||
/// truncate a selected image's handles. Body hit testing still respects page visibility.
|
||||
pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
|
||||
if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
im.visible_rect.unwrap_or(im.rect)
|
||||
im.frame_rect().unwrap_or(im.rect)
|
||||
}
|
||||
|
||||
/// Screen-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// the final selectable `egui::Rect` is the mathematical intersection of the image's
|
||||
/// full CTM-derived rect and the clipping path's rect.
|
||||
/// Screen-space editing frame, including selected handles outside the page.
|
||||
pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
|
||||
let full = xf.user_rect_f64(info, page, im.rect);
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let clip_r = xf.user_rect_f64(info, page, clip);
|
||||
let inter = full.intersect(clip_r);
|
||||
if inter.is_positive() {
|
||||
return inter;
|
||||
}
|
||||
}
|
||||
if let Some(vr) = im.visible_rect {
|
||||
let r = xf.user_rect_f64(info, page, vr);
|
||||
if r.is_positive() {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
full
|
||||
xf.user_rect_f64(info, page, image_selectable_bounds(im))
|
||||
}
|
||||
|
||||
/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
|
||||
@@ -507,6 +483,9 @@ pub struct DocView {
|
||||
/// views of one document apart (`crate::panes`).
|
||||
pub(crate) pane: crate::panes::ViewPane,
|
||||
pub id: DocId,
|
||||
/// The document and attached file this tab was opened from, when a form or the Attachments
|
||||
/// panel opened it: asking for that attachment again comes back to this tab.
|
||||
pub from_attachment: Option<(DocId, String)>,
|
||||
pub zoom: f32,
|
||||
pub fit: Fit,
|
||||
pub layout: PageLayout,
|
||||
@@ -1036,6 +1015,7 @@ impl DocView {
|
||||
Self {
|
||||
pane: Default::default(),
|
||||
id,
|
||||
from_attachment: None,
|
||||
zoom: defaults.zoom,
|
||||
fit: defaults.fit,
|
||||
layout: defaults.layout,
|
||||
@@ -1158,7 +1138,7 @@ impl DocView {
|
||||
page_widget: None,
|
||||
pan_hold_until: 0.0,
|
||||
slow_pages: HashSet::new(),
|
||||
comments: Default::default(),
|
||||
comments: crate::comments::CommentView::from_info(info),
|
||||
measure: Default::default(),
|
||||
forms: Default::default(),
|
||||
prepare: Default::default(),
|
||||
@@ -1216,6 +1196,7 @@ impl DocView {
|
||||
// Thumbnail drags hold page indexes from before the change.
|
||||
self.panel_drag = None;
|
||||
self.org_drag = None;
|
||||
self.comments.document_changed(info);
|
||||
self.reset_coarse_state();
|
||||
}
|
||||
|
||||
@@ -1860,7 +1841,12 @@ fn draw_pages(
|
||||
if delta != Vec2::ZERO && delta.is_finite() {
|
||||
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
|
||||
// A resized handle can sit in the window beside a page that does not fill it. The
|
||||
// response has to cover that visible area or the next press never starts: the pointer
|
||||
// is reported only inside this rectangle once the previous drag has been released.
|
||||
// Page positions stay on `content_w`/`content_h`; the extra area is not a new page.
|
||||
let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height()));
|
||||
let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag());
|
||||
view.page_widget = Some(resp.id);
|
||||
// A drag lasts only while egui reports it, so the mark can't outlive one.
|
||||
if !resp.dragged_by(egui::PointerButton::Primary) {
|
||||
@@ -3484,7 +3470,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// One crop, then back to selecting (as Acrobat does).
|
||||
let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. }));
|
||||
let mut tool = app.quick_tool;
|
||||
comments::keys(ui.ctx(), view, &mut tool, allowed);
|
||||
comments::keys(ui.ctx(), view, info, &mut tool, allowed);
|
||||
if preparing {
|
||||
crate::prepare::keys(ui.ctx(), view);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ impl PdfCraftApp {
|
||||
/// Last-resort guard (AGENTS.md §4): a command that panics is reported and the app, with its
|
||||
/// open documents, keeps running. Edits are applied to a copy, so the document is unchanged.
|
||||
pub fn execute(&mut self, id: &str) -> bool {
|
||||
match pdfcraft_engine::guard(|| self.execute_unguarded(id)) {
|
||||
let done = match pdfcraft_engine::guard(|| self.execute_unguarded(id)) {
|
||||
Ok(done) => done,
|
||||
Err(m) => {
|
||||
// A command stopped part way through closing or replacing a tab must not leave
|
||||
@@ -122,7 +122,9 @@ impl PdfCraftApp {
|
||||
self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]);
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::dialogs::discard_inactive_password_drafts(self);
|
||||
done
|
||||
}
|
||||
|
||||
/// Why the command can't run right now, for the user.
|
||||
|
||||
@@ -26,11 +26,22 @@ pub(super) mod word_count_ui;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use prompts::{PromptButton, save_prompt_key};
|
||||
pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle};
|
||||
use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock};
|
||||
|
||||
const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
|
||||
|
||||
/// Drop (and zeroize) password drafts as soon as their owning dialog is inactive.
|
||||
/// Commands and control requests also call this: close/reopen can happen between frames.
|
||||
pub(crate) fn discard_inactive_password_drafts(app: &mut PdfCraftApp) {
|
||||
if app.dialog != Some(Dialog::Protect) {
|
||||
app.protect_draft = Default::default();
|
||||
}
|
||||
if app.dialog != Some(Dialog::Sign) {
|
||||
app.sign_draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog's widgets asked for this frame; acted on after the modal is drawn.
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
@@ -106,6 +117,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if app.dialog != Some(Dialog::FieldTable) {
|
||||
app.field_table_draft = None;
|
||||
}
|
||||
discard_inactive_password_drafts(app);
|
||||
// A movable window, not this modal: the page under it shows the table as it changes.
|
||||
if app.dialog == Some(Dialog::FieldTable) {
|
||||
crate::field_table_ui::show(app, ctx);
|
||||
@@ -2214,6 +2226,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
} else {
|
||||
app.dialog = Some(req.next.unwrap_or(dialog));
|
||||
}
|
||||
discard_inactive_password_drafts(app);
|
||||
if let Some(cmd) = req.link_command {
|
||||
app.execute(cmd);
|
||||
}
|
||||
@@ -2282,3 +2295,134 @@ fn draft_changes(app: &PdfCraftApp) -> Option<Vec<Edit>> {
|
||||
fn yes(b: bool) -> String {
|
||||
tl!(if b { "Yes" } else { "No" }).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod password_draft_tests {
|
||||
use super::*;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
fn seed_password_drafts(app: &mut PdfCraftApp) {
|
||||
app.start_signing(0, None, None, None);
|
||||
let sign = app.sign_draft.as_mut().unwrap();
|
||||
sign.step = crate::sign_ui::SignStep::Choose;
|
||||
sign.password = "sign password".into();
|
||||
sign.new_id.password = "new ID password".into();
|
||||
sign.new_id.confirm = "new ID password".into();
|
||||
sign.new_id.file_password = "import password".into();
|
||||
app.protect_draft.password = "view password".into();
|
||||
app.protect_draft.confirm = "view password".into();
|
||||
app.protect_draft.protection.open_password = Some("open password".into());
|
||||
app.protect_draft.protection.permissions_password = Some("owner password".into());
|
||||
}
|
||||
|
||||
fn assert_protect_cleared(app: &PdfCraftApp) {
|
||||
assert_eq!(app.protect_draft, crate::protect::ProtectDraft::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_password_drafts_are_cleared_before_early_returns() {
|
||||
// None and FieldTable return before the ordinary modal-close path; About replaces it.
|
||||
for dialog in [None, Some(Dialog::FieldTable), Some(Dialog::About)] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
seed_password_drafts(&mut app);
|
||||
app.dialog = dialog;
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_protect_cleared(h.state());
|
||||
assert!(h.state().sign_draft.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_preserves_only_the_active_password_draft() {
|
||||
for dialog in [Dialog::Protect, Dialog::Sign] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
seed_password_drafts(&mut app);
|
||||
app.dialog = Some(dialog);
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
if dialog == Dialog::Protect {
|
||||
assert_eq!(h.state().protect_draft.password, "view password");
|
||||
assert_eq!(h.state().protect_draft.confirm, "view password");
|
||||
assert!(h.state().sign_draft.is_none());
|
||||
} else {
|
||||
assert_protect_cleared(h.state());
|
||||
let sign = h.state().sign_draft.as_ref().unwrap();
|
||||
assert_eq!(sign.password, "sign password");
|
||||
assert_eq!(sign.new_id.password, "new ID password");
|
||||
assert_eq!(sign.new_id.confirm, "new ID password");
|
||||
assert_eq!(sign.new_id.file_password, "import password");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_close_and_reopen_clears_passwords_without_a_render_between_requests() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
// The control queue can handle several ui.set requests before the next rendered frame.
|
||||
// Repeat both close and replacement flows so stale drafts cannot return on later opens.
|
||||
for _ in 0..2 {
|
||||
for replacement in ["none", "about", "sign", "certify"] {
|
||||
app.set_option("dialog", "protect").unwrap();
|
||||
app.protect_draft.password = "old password".into();
|
||||
app.protect_draft.confirm = "old password".into();
|
||||
app.set_option("dialog", replacement).unwrap();
|
||||
assert_protect_cleared(&app);
|
||||
app.set_option("dialog", "protect").unwrap();
|
||||
assert_protect_cleared(&app);
|
||||
assert!(app.sign_draft.is_none());
|
||||
}
|
||||
for replacement in ["none", "about", "protect"] {
|
||||
seed_password_drafts(&mut app);
|
||||
app.set_option("dialog", replacement).unwrap();
|
||||
assert!(app.sign_draft.is_none(), "inactive Sign draft retained after {replacement}");
|
||||
app.set_option("dialog", "sign").unwrap();
|
||||
let sign = app.sign_draft.as_ref().unwrap();
|
||||
assert!(sign.password.is_empty());
|
||||
assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_the_active_protect_dialog_keeps_its_password() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("dialog", "protect").unwrap();
|
||||
app.protect_draft.password = "current password".into();
|
||||
app.protect_draft.confirm = "current password".into();
|
||||
app.set_option("dialog", "protect").unwrap();
|
||||
assert_eq!(app.protect_draft.password, "current password");
|
||||
assert_eq!(app.protect_draft.confirm, "current password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_discard_replaced_password_drafts_before_the_next_control_request() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
for _ in 0..2 {
|
||||
seed_password_drafts(&mut app);
|
||||
app.dialog = Some(Dialog::Protect);
|
||||
assert!(app.execute("help.about"));
|
||||
assert_eq!(app.dialog, Some(Dialog::About));
|
||||
assert_protect_cleared(&app);
|
||||
assert!(app.sign_draft.is_none());
|
||||
app.set_option("dialog", "protect").unwrap();
|
||||
assert_protect_cleared(&app);
|
||||
|
||||
seed_password_drafts(&mut app);
|
||||
assert!(app.execute("help.shortcuts"));
|
||||
assert_eq!(app.dialog, Some(Dialog::Shortcuts));
|
||||
assert_protect_cleared(&app);
|
||||
assert!(app.sign_draft.is_none());
|
||||
app.set_option("dialog", "sign").unwrap();
|
||||
let sign = app.sign_draft.as_ref().unwrap();
|
||||
assert!(sign.password.is_empty());
|
||||
assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and
|
||||
/// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one.
|
||||
fn shorten_middle(name: &str, max: usize) -> String {
|
||||
pub(crate) fn shorten_middle(name: &str, max: usize) -> String {
|
||||
let count = name.chars().count();
|
||||
if count <= max {
|
||||
return name.to_string();
|
||||
@@ -351,7 +351,7 @@ pub(super) fn password(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let message = crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)]);
|
||||
match password_modal(ctx, "password", tl!("Password required"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Open")) {
|
||||
Some(PasswordAnswer::Submit) => {
|
||||
let pw = prompt.input.clone();
|
||||
let pw = std::mem::take(&mut prompt.input);
|
||||
app.submit_password(Some(pw));
|
||||
}
|
||||
Some(PasswordAnswer::Cancel) => app.submit_password(None),
|
||||
@@ -366,7 +366,7 @@ pub(super) fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
crate::i18n::fmt(tl!("“{name}” has security restrictions. Enter its permissions password to lift them."), &[("name", &prompt.name)]);
|
||||
match password_modal(ctx, "unlock", tl!("Permissions password"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Unlock")) {
|
||||
Some(PasswordAnswer::Submit) => {
|
||||
let pw = prompt.input.clone();
|
||||
let pw = std::mem::take(&mut prompt.input);
|
||||
app.submit_unlock(Some(pw));
|
||||
}
|
||||
Some(PasswordAnswer::Cancel) => app.submit_unlock(None),
|
||||
|
||||
@@ -25,6 +25,20 @@ impl Default for ProtectDraft {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProtectDraft {
|
||||
fn drop(&mut self) {
|
||||
use zeroize::Zeroize as _;
|
||||
self.password.zeroize();
|
||||
self.confirm.zeroize();
|
||||
if let Some(pw) = &mut self.protection.open_password {
|
||||
pw.zeroize();
|
||||
}
|
||||
if let Some(pw) = &mut self.protection.permissions_password {
|
||||
pw.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtectDraft {
|
||||
/// Why Apply is disabled, if it is.
|
||||
pub fn problem(&self) -> Option<&'static str> {
|
||||
@@ -251,4 +265,40 @@ mod tests {
|
||||
assert_eq!(strength("Abcdefg1").0, "Medium");
|
||||
assert_eq!(strength("Correct-Horse-9").0, "Strong");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_protect_dialog_clears_the_password_draft() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.dialog = Some(crate::Dialog::Protect);
|
||||
app.protect_draft.password = "TopSecret123!".into();
|
||||
app.protect_draft.confirm = "TopSecret123!".into();
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
|
||||
// Clicking Cancel closes Dialog::Protect and clears the draft passwords.
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!(h.state().protect_draft.password.is_empty(), "password must not linger after cancelling Dialog::Protect");
|
||||
assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after cancelling Dialog::Protect");
|
||||
|
||||
// Pressing Escape on Dialog::Protect also clears the draft passwords.
|
||||
{
|
||||
let app = h.state_mut();
|
||||
app.dialog = Some(crate::Dialog::Protect);
|
||||
app.protect_draft.password = "AnotherSecret!".into();
|
||||
app.protect_draft.confirm = "AnotherSecret!".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!(h.state().protect_draft.password.is_empty(), "password must not linger after closing Dialog::Protect");
|
||||
assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after closing Dialog::Protect");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ fn new_text_typed(v: &crate::DocView) -> bool {
|
||||
v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Clear an unfinished page-anchored drawing or composer when a page edit shifts or removes `page`.
|
||||
fn clear_shifted_page_comment_state(view: &mut crate::DocView, shifted_from: usize) {
|
||||
if matches!(view.comments.gesture, Some(crate::comments::Gesture::Draw { page, .. }) if page >= shifted_from) {
|
||||
view.comments.gesture = None;
|
||||
}
|
||||
if view.comments.composer.as_ref().is_some_and(|c| !matches!(c.kind, crate::comments::ComposerKind::Edit(_)) && c.page >= shifted_from) {
|
||||
view.comments.composer = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before`
|
||||
/// is the page's annotations before a delete or a paste of comments (empty for other edits).
|
||||
fn select_after_edit(
|
||||
@@ -39,17 +49,27 @@ fn select_after_edit(
|
||||
) {
|
||||
match edit {
|
||||
Edit::MovePages { pages, to } => {
|
||||
clear_shifted_page_comment_state(view, 0);
|
||||
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||||
let to = (*to).min(info.pages.len() - n);
|
||||
view.select_pages(&(to..to + n).collect::<Vec<_>>());
|
||||
}
|
||||
Edit::DuplicatePages { pages, at: Some(at) } => {
|
||||
clear_shifted_page_comment_state(view, *at);
|
||||
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||||
let at = (*at).min(info.pages.len().saturating_sub(n));
|
||||
view.select_pages(&(at..at + n).collect::<Vec<_>>());
|
||||
}
|
||||
Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]),
|
||||
Edit::DeletePages { .. } => view.select_pages(&[]),
|
||||
Edit::InsertBlankPage { at, .. } => {
|
||||
clear_shifted_page_comment_state(view, *at);
|
||||
view.select_pages(&[(*at).min(info.pages.len() - 1)]);
|
||||
}
|
||||
Edit::DeletePages { pages } => {
|
||||
if let Some(&first) = pages.iter().min() {
|
||||
clear_shifted_page_comment_state(view, first);
|
||||
}
|
||||
view.select_pages(&[]);
|
||||
}
|
||||
// A stroke drawn with the pen stays unselected, so the selection box and
|
||||
// author popup don't sit over the next stroke (#429).
|
||||
Edit::AddAnnotation(a)
|
||||
@@ -76,7 +96,6 @@ fn select_after_edit(
|
||||
view.comments.open_editor(a.page, n, String::new());
|
||||
}
|
||||
}
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
|
||||
// A group moved together stays selected (its positions don't change), so it
|
||||
// can be moved or copied again; anything else in a batch clears the selection.
|
||||
Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {}
|
||||
@@ -207,25 +226,15 @@ impl PdfCraftApp {
|
||||
.map(|before| form_edit_pages(&before, &doc.form));
|
||||
let one_page = comment_page(&edit).or_else(|| content_page(&edit));
|
||||
if signatures_before.is_some_and(|before| crate::canvas::signature_states_changed(&before, &doc.signatures)) {
|
||||
// A signature shows its state on its own page, which an edit elsewhere can
|
||||
// change (a certification forbidding it, say): every page redraws. An edit that
|
||||
// moves no page (a form edit, or one to a page's comments or content) keeps the
|
||||
// values typed, in it and before it, showing until their pages redraw.
|
||||
let waiting = (form_edit.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked));
|
||||
view.document_changed(info);
|
||||
if let Some(waiting) = waiting {
|
||||
view.forms.unbaked = waiting;
|
||||
}
|
||||
if let Some((_, unbaked)) = form_edit {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
}
|
||||
signatures_changed_after_edit(view, &edit, info, &annots_before, form_edit.map(|(_, u)| u), one_page);
|
||||
} else if let Some(page) = one_page {
|
||||
one_page_changed(view, &edit, page);
|
||||
one_page_changed(view, &edit, page, &annots_before, info);
|
||||
} else if let Some((changed_pages, unbaked)) = form_edit {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
for page in changed_pages {
|
||||
view.page_changed(page);
|
||||
}
|
||||
view.comments.document_changed(info);
|
||||
} else {
|
||||
view.document_changed(info);
|
||||
}
|
||||
@@ -1109,13 +1118,51 @@ fn content_page(edit: &Edit) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature shows its state on its own page, which an edit elsewhere can change (a
|
||||
/// certification forbidding it, say): every page redraws. An edit that moves no page (a form edit,
|
||||
/// or one to a page's comments or content) keeps the values typed, in it and before it, showing
|
||||
/// until their pages redraw.
|
||||
fn signatures_changed_after_edit(
|
||||
view: &mut crate::DocView,
|
||||
edit: &Edit,
|
||||
info: &pdfcraft_render::DocInfo,
|
||||
annots_before: &[pdfcraft_render::Annotation],
|
||||
form_unbaked: Option<Vec<(String, usize)>>,
|
||||
one_page: Option<usize>,
|
||||
) {
|
||||
let waiting = (form_unbaked.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked));
|
||||
if let Edit::DeleteAnnotation { page, index } = edit {
|
||||
// Remap surviving comments before generic slot pruning loses their state.
|
||||
view.comments.annotation_deleted(annots_before, info, (*page, *index));
|
||||
}
|
||||
view.document_changed(info);
|
||||
if let Some(waiting) = waiting {
|
||||
view.forms.unbaked = waiting;
|
||||
}
|
||||
if let Some(unbaked) = form_unbaked {
|
||||
view.forms.unbaked.extend(unbaked);
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing
|
||||
/// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation
|
||||
/// paints nothing in the raster (ISO 32000-2 §12.5.6.14).
|
||||
fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) {
|
||||
fn one_page_changed(
|
||||
view: &mut crate::DocView,
|
||||
edit: &Edit,
|
||||
page: usize,
|
||||
annots_before: &[pdfcraft_render::Annotation],
|
||||
info: &pdfcraft_render::DocInfo,
|
||||
) {
|
||||
if !matches!(edit, Edit::SetAnnotationPopup { .. }) {
|
||||
view.page_changed(page);
|
||||
}
|
||||
if let Edit::DeleteAnnotation { page, index } = edit {
|
||||
// Remap surviving comments before generic slot pruning loses their state.
|
||||
view.comments.annotation_deleted(annots_before, info, (*page, *index));
|
||||
} else {
|
||||
view.comments.document_changed(info);
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
|
||||
@@ -375,6 +375,40 @@ fn save_commits_the_open_text_box() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_pages_cancels_an_in_progress_drawing() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>",
|
||||
];
|
||||
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());
|
||||
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("a.pdf", None, pdf).unwrap();
|
||||
let drawing =
|
||||
|| Some(crate::comments::Gesture::Draw { page: 0, tool: crate::comments::CommentTool::Ink, points: vec![[10.0, 10.0], [30.0, 20.0]] });
|
||||
app.views[0].comments.gesture = drawing();
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }));
|
||||
assert!(app.views[0].comments.gesture.is_some(), "rotating the page keeps the drawing on it");
|
||||
app.views[0].comments.gesture = drawing();
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::MovePages { pages: vec![1], to: 0 }));
|
||||
assert!(app.views[0].comments.gesture.is_none(), "the drawing's page index would now be a different page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() {
|
||||
let dir = staging_dir("mail-temp");
|
||||
|
||||
@@ -59,7 +59,10 @@ mod home;
|
||||
mod hotbar;
|
||||
mod panels;
|
||||
pub mod panes;
|
||||
pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers};
|
||||
pub use print_ui::{
|
||||
Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, TwoSidedSource, Which as PrintWhich, system_printers,
|
||||
system_two_sided,
|
||||
};
|
||||
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
|
||||
pub mod i18n;
|
||||
pub mod theme;
|
||||
@@ -420,6 +423,9 @@ pub struct PdfCraftApp {
|
||||
pub options_source: Option<OptionsSource>,
|
||||
/// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]).
|
||||
pub spooler: Option<Spooler>,
|
||||
/// Reads the picked printer's two-sided support in the background (the desktop app sets it;
|
||||
/// see [`TwoSidedSource`]). Without one the dialog goes by the printer list alone.
|
||||
pub two_sided_source: Option<TwoSidedSource>,
|
||||
// A browser has no background print work: the type is empty there.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) print_tasks: print_ui::PrintTasks,
|
||||
|
||||
@@ -37,7 +37,7 @@ struct Job {
|
||||
context: OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
password: Option<String>,
|
||||
password: Option<zeroize::Zeroizing<String>>,
|
||||
input: Input,
|
||||
reply: Sender<ResultMessage>,
|
||||
#[cfg(test)]
|
||||
@@ -80,7 +80,7 @@ impl OpenWorker {
|
||||
context: OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
password: Option<String>,
|
||||
password: Option<zeroize::Zeroizing<String>>,
|
||||
input: Input,
|
||||
) -> Result<Receiver<ResultMessage>, String> {
|
||||
let (reply, receiver) = mpsc::channel();
|
||||
@@ -178,7 +178,7 @@ fn prepare(job: Job) -> ResultKind {
|
||||
if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) {
|
||||
return ResultKind::Conversion(bytes);
|
||||
}
|
||||
let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref());
|
||||
let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref().map(String::as_str));
|
||||
ResultKind::Prepared { bytes, result: Box::new(result) }
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ pub(crate) struct Updates {
|
||||
/// How many times `on_exit` called [`Updates::exiting`].
|
||||
#[cfg(test)]
|
||||
exits: u32,
|
||||
/// Stands in for the close that a finished installation queues in the `Updater`.
|
||||
#[cfg(test)]
|
||||
close_queued: bool,
|
||||
}
|
||||
|
||||
impl Default for Updates {
|
||||
@@ -44,6 +47,8 @@ impl Default for Updates {
|
||||
check_requested: false,
|
||||
#[cfg(test)]
|
||||
exits: 0,
|
||||
#[cfg(test)]
|
||||
close_queued: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +63,34 @@ impl Updates {
|
||||
self.exits += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the startup prompt and returns what it asks for. `can_start` is false while unsaved
|
||||
/// work holds back a ready update.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn show_prompt(&mut self, ctx: &egui::Context, can_start: bool) -> Action {
|
||||
#[cfg(test)]
|
||||
if can_start && std::mem::take(&mut self.close_queued) {
|
||||
return Action::Close;
|
||||
}
|
||||
self.updater.show_if(ctx, can_start)
|
||||
}
|
||||
|
||||
/// Asks the next frame that may close to close the app, so that a ready update installs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn queue_close(&mut self) {
|
||||
self.updater.restart_to_update();
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.close_queued = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the close that a finished installation queued, so a save after Cancel does not close
|
||||
/// the app. `show_prompt` draws an open startup prompt, so this is for when none is open.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn take_queued_close(&mut self, ctx: &egui::Context) {
|
||||
let _ = self.show_prompt(ctx, true);
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
@@ -110,7 +143,7 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
{
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
let clean = app.first_dirty().is_none();
|
||||
match app.updates.updater.show_if(ctx, clean) {
|
||||
match app.updates.show_prompt(ctx, clean) {
|
||||
Action::None => {}
|
||||
Action::OpenUrl(url) => app.open_url(&url),
|
||||
Action::Close => {
|
||||
@@ -123,27 +156,167 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
if app.updates.open {
|
||||
let dialog = CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION"));
|
||||
match dialog.show(ctx, app.updates.updater.status()) {
|
||||
CheckResultResponse::None => {}
|
||||
CheckResultResponse::Close => {
|
||||
app.updates.open = false;
|
||||
app.updates.updater.dismiss_status();
|
||||
}
|
||||
CheckResultResponse::CheckAgain => app.updates.check_requested = true,
|
||||
CheckResultResponse::RestartToUpdate => {
|
||||
app.updates.updater.restart_to_update();
|
||||
}
|
||||
}
|
||||
let response = dialog.show(ctx, app.updates.updater.status());
|
||||
answer_result(app, ctx, response);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = (app, ctx);
|
||||
}
|
||||
|
||||
/// Carries out the answer to the Updates dialog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn answer_result(app: &mut PdfCraftApp, ctx: &egui::Context, response: CheckResultResponse) {
|
||||
match response {
|
||||
CheckResultResponse::None => {}
|
||||
CheckResultResponse::Close => {
|
||||
app.updates.open = false;
|
||||
app.updates.updater.dismiss_status();
|
||||
}
|
||||
CheckResultResponse::CheckAgain => app.updates.check_requested = true,
|
||||
CheckResultResponse::RestartToUpdate => restart_to_update(app, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart to Update. With unsaved changes the Updates dialog closes and the quit path asks about
|
||||
/// them; Cancel there leaves the update ready. An open startup prompt keeps its own Restart to
|
||||
/// Update and save-first note, so the app stays open.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn restart_to_update(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if app.first_dirty().is_none() {
|
||||
app.updates.queue_close();
|
||||
return;
|
||||
}
|
||||
app.updates.open = false;
|
||||
app.updates.updater.dismiss_status();
|
||||
if app.updates.updater.prompt_open() {
|
||||
return;
|
||||
}
|
||||
app.updates.take_queued_close(ctx);
|
||||
app.request_quit(ctx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CloseRequest;
|
||||
use crate::editing::SaveTarget;
|
||||
use eframe::App as _;
|
||||
use pdfcraft_engine::{Edit, FieldValue};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An app with one open document holding a form, and with `dirty` an unsaved change to it.
|
||||
fn app(dirty: bool) -> PdfCraftApp {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("source.pdf", None, include_bytes!("../../tests/data/form.pdf").to_vec()).unwrap();
|
||||
if dirty {
|
||||
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) }));
|
||||
assert!(app.first_dirty().is_some());
|
||||
}
|
||||
app.updates.open = true;
|
||||
app
|
||||
}
|
||||
|
||||
/// An app with an unsaved change to a document in a new temporary folder, which Save writes
|
||||
/// without asking where. Returns the document's path too.
|
||||
fn app_with_file(test: &str) -> (PdfCraftApp, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-updates-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("source.pdf");
|
||||
let bytes = include_bytes!("../../tests/data/form.pdf").to_vec();
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("source.pdf", Some(path.to_string_lossy().into_owned()), bytes).unwrap();
|
||||
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) }));
|
||||
app.updates.open = true;
|
||||
(app, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_to_update_with_unsaved_changes_opens_the_save_prompt() {
|
||||
let mut app = app(true);
|
||||
let ctx = egui::Context::default();
|
||||
answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate);
|
||||
assert!(!app.updates.open, "the modal Updates dialog closes");
|
||||
assert_eq!(app.close_request, Some(CloseRequest::Quit), "the save prompt opens");
|
||||
assert!(!app.allow_quit);
|
||||
app.resolve_close(&ctx, None);
|
||||
assert!(app.close_request.is_none(), "Cancel closes the prompt");
|
||||
assert!(!app.allow_quit, "Cancel keeps the app open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_to_update_with_unsaved_changes_dismisses_the_check_result() {
|
||||
let mut app = app(true);
|
||||
app.set_update_source(Arc::new(|| Ok(None)));
|
||||
let ctx = egui::Context::default();
|
||||
app.updates.updater.check_now(&ctx);
|
||||
assert!(!matches!(app.updates.updater.status(), Status::Idle));
|
||||
answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate);
|
||||
assert!(matches!(app.updates.updater.status(), Status::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_at_the_restart_prompt_writes_the_file_and_quits() {
|
||||
let (mut app, path) = app_with_file("save");
|
||||
let ctx = egui::Context::default();
|
||||
answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate);
|
||||
app.resolve_close(&ctx, Some(true));
|
||||
assert!(app.allow_quit, "the app quits once the document is saved");
|
||||
|
||||
let mut reopened = PdfCraftApp::new();
|
||||
reopened.open_bytes("source.pdf", Some(path.to_string_lossy().into_owned()), std::fs::read(&path).unwrap()).unwrap();
|
||||
let doc = reopened.session.get(reopened.views[0].id).unwrap();
|
||||
let name = doc.form.iter().find(|f| f.name == "name").and_then(|f| f.value.first().cloned());
|
||||
assert_eq!(name.as_deref(), Some("Changed"), "the change is in the file");
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_after_canceling_the_restart_keeps_the_app_open() {
|
||||
let (mut app, path) = app_with_file("cancel");
|
||||
app.updates.close_queued = true;
|
||||
let ctx = egui::Context::default();
|
||||
answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate);
|
||||
app.resolve_close(&ctx, None);
|
||||
assert!(app.save_active(SaveTarget::InPlace));
|
||||
assert!(app.first_dirty().is_none());
|
||||
dialog(&mut app, &ctx);
|
||||
assert!(!app.allow_quit, "the save does not close the app");
|
||||
assert!(app.close_request.is_none());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_to_update_with_nothing_unsaved_leaves_the_close_to_the_updater() {
|
||||
let mut app = app(false);
|
||||
let ctx = egui::Context::default();
|
||||
answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate);
|
||||
assert!(app.updates.open, "the dialog stays until the updater closes the app");
|
||||
assert!(app.close_request.is_none());
|
||||
assert!(!app.allow_quit);
|
||||
dialog(&mut app, &ctx);
|
||||
assert!(app.allow_quit, "the next frame closes the app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_to_update_with_the_startup_prompt_open_leaves_the_app_open() {
|
||||
let mut app = app(true);
|
||||
app.updates.updater.offer(craft_update::Release {
|
||||
tag: "v99.0.0".into(),
|
||||
version: craft_update::parse_version("99.0.0"),
|
||||
name: "PdfCraft 99.0.0".into(),
|
||||
html_url: "https://github.com/storytold/pdfcraft/releases/tag/v99.0.0".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(app.updates.updater.prompt_open());
|
||||
answer_result(&mut app, &egui::Context::default(), CheckResultResponse::RestartToUpdate);
|
||||
assert!(!app.updates.open, "the result dialog closes");
|
||||
assert!(app.updates.updater.prompt_open(), "the prompt stays");
|
||||
assert!(app.close_request.is_none(), "the prompt carries the restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_exit_calls_the_update_exit_hook_once() {
|
||||
|
||||
@@ -356,13 +356,19 @@ pub struct PasswordPrompt {
|
||||
pub(crate) display_override: Option<(canvas::PageLayout, bool)>,
|
||||
}
|
||||
|
||||
impl Drop for PasswordPrompt {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.input);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) struct OpenRequest {
|
||||
pub(crate) name: String,
|
||||
pub(crate) path: Option<String>,
|
||||
pub(crate) stamp: Option<file_watch::Stamp>,
|
||||
pub(crate) input: open_worker::Input,
|
||||
pub(crate) password: Option<String>,
|
||||
pub(crate) password: Option<zeroize::Zeroizing<String>>,
|
||||
pub(crate) activate: bool,
|
||||
pub(crate) after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
pub(crate) recovered: Option<RecoveryMeta>,
|
||||
@@ -380,6 +386,12 @@ pub struct UnlockPrompt {
|
||||
pub then: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Drop for UnlockPrompt {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.input);
|
||||
}
|
||||
}
|
||||
|
||||
impl UnlockPrompt {
|
||||
/// Ask for document `doc`'s permissions password, then run command `then`, if any.
|
||||
pub(crate) fn new(doc: &pdfcraft_engine::Document, then: Option<&'static str>) -> Self {
|
||||
|
||||
@@ -116,7 +116,9 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
));
|
||||
let text = if typewriter_edit { c.text } else { c.text.trim_end().to_string() };
|
||||
match c.kind {
|
||||
ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }),
|
||||
ComposerKind::Edit(index) => {
|
||||
info.annotations.iter().any(|a| a.page == page && a.index == index).then_some(Edit::SetAnnotationContents { page, index, text })
|
||||
}
|
||||
ComposerKind::Note => {
|
||||
view.comments.tool_done = true;
|
||||
Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text))
|
||||
|
||||
@@ -740,7 +740,7 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
/// Delete / Escape handling for comments (only while no text field has focus).
|
||||
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) {
|
||||
pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, tool: &mut QuickTool, allowed: bool) {
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
@@ -756,21 +756,19 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
|
||||
*tool = QuickTool::Select;
|
||||
}
|
||||
}
|
||||
if allowed
|
||||
&& cv.selected.is_some()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& let Some(edit) = delete_selected(cv)
|
||||
{
|
||||
view.pending_edit = Some(edit);
|
||||
if allowed && cv.selected.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
|
||||
view.pending_edit = delete_selected(cv, info);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit that deletes every selected comment (one undo step for several), and clears the
|
||||
/// selection. Later positions go first, so each deletion leaves the earlier ones where they are.
|
||||
pub(crate) fn delete_selected(cv: &mut CommentView) -> Option<Edit> {
|
||||
pub(crate) fn delete_selected(cv: &mut CommentView, info: &DocInfo) -> Option<Edit> {
|
||||
// A group's member picked on the page, with its group the selection alone, goes by itself, as
|
||||
// Acrobat deletes the member clicked ([`CommentView::target`]).
|
||||
let mut keys = if cv.also.is_empty() { cv.target().into_iter().collect() } else { cv.selection() };
|
||||
let raw = if cv.also.is_empty() { cv.target().into_iter().collect() } else { cv.selection() };
|
||||
let mut keys: Vec<(usize, usize)> =
|
||||
raw.into_iter().filter(|(page, index)| info.annotations.iter().any(|a| a.page == *page && a.index == *index && !a.locked)).collect();
|
||||
cv.clear_selection();
|
||||
keys.sort_unstable_by(|a, b| b.cmp(a));
|
||||
keys.dedup();
|
||||
|
||||
@@ -148,7 +148,7 @@ pub(crate) fn context_menu(
|
||||
let (dpage, dindex) = view.comments.target().unwrap_or((page, index));
|
||||
let deletes = deletable(dpage, dindex) && view.comments.also.iter().all(|&(p, i)| deletable(p, i));
|
||||
if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() {
|
||||
action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e)));
|
||||
action = delete_selected(&mut view.comments, info).map(|e| CanvasAction::Edit(Box::new(e)));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() {
|
||||
|
||||
@@ -207,6 +207,17 @@ pub struct CommentView {
|
||||
pub tool_done: bool,
|
||||
/// What the inline editor (`editing`) does with the keyboard the next time it is drawn.
|
||||
pub(crate) editor_focus: Option<EditorFocus>,
|
||||
/// Snapshot of `(page, index)` comment slot identity from the last `DocInfo`, used to detect
|
||||
/// when an edit removes or shifts `/Annots` entries under an active selection or editor.
|
||||
pub(crate) slots: std::collections::HashMap<(usize, usize), CommentSlot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CommentSlot {
|
||||
subtype: String,
|
||||
name: Option<String>,
|
||||
page_ord: usize,
|
||||
page_count: usize,
|
||||
}
|
||||
|
||||
/// A one-off change to the keyboard focus of the Comments panel's inline editor. The editor
|
||||
@@ -221,6 +232,78 @@ pub(crate) enum EditorFocus {
|
||||
}
|
||||
|
||||
impl CommentView {
|
||||
/// Initial commenting state for `info`, recording its current annotation slots.
|
||||
pub(crate) fn from_info(info: &DocInfo) -> Self {
|
||||
Self { slots: Self::snapshot_slots(info), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Captures `(page, index)` comment slot identity for every comment in `info`.
|
||||
fn snapshot_slots(info: &DocInfo) -> std::collections::HashMap<(usize, usize), CommentSlot> {
|
||||
let mut by_page = std::collections::HashMap::<usize, Vec<usize>>::new();
|
||||
for a in &info.annotations {
|
||||
by_page.entry(a.page).or_default().push(a.index);
|
||||
}
|
||||
for indices in by_page.values_mut() {
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
}
|
||||
let mut slots = std::collections::HashMap::with_capacity(info.annotations.len());
|
||||
for a in &info.annotations {
|
||||
let indices = by_page.get(&a.page).map(Vec::as_slice).unwrap_or_default();
|
||||
let page_ord = indices.binary_search(&a.index).unwrap_or(0);
|
||||
let page_count = indices.len();
|
||||
slots.insert((a.page, a.index), CommentSlot { subtype: a.subtype.clone(), name: a.name.clone(), page_ord, page_count });
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// Prunes comment state whose `(page, index)` slot was removed or shifted by a document change.
|
||||
pub(crate) fn document_changed(&mut self, info: &DocInfo) {
|
||||
let next = Self::snapshot_slots(info);
|
||||
let page_count = info.pages.len();
|
||||
let same_slot = |page: usize, index: usize| -> bool {
|
||||
let Some(cur) = next.get(&(page, index)) else {
|
||||
return false;
|
||||
};
|
||||
if self.slots.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.slots.get(&(page, index)).is_some_and(|old| {
|
||||
cur.subtype == old.subtype && cur.name == old.name && cur.page_ord == old.page_ord && cur.page_count >= old.page_count
|
||||
})
|
||||
};
|
||||
if self.selected.is_some_and(|(p, i)| !same_slot(p, i)) {
|
||||
self.selected = None;
|
||||
}
|
||||
self.also.retain(|&(p, i)| same_slot(p, i));
|
||||
if self.selected.is_none() {
|
||||
self.selected = self.also.pop();
|
||||
}
|
||||
if self.member.is_some_and(|((mp, mi), (gp, gi))| Some((gp, gi)) != self.selected || !same_slot(mp, mi)) {
|
||||
self.member = None;
|
||||
}
|
||||
if self.editing.as_ref().is_some_and(|(p, i, _)| !same_slot(*p, *i)) {
|
||||
self.editing = None;
|
||||
self.editor_focus = None;
|
||||
}
|
||||
if self.composer.as_ref().is_some_and(|c| match c.kind {
|
||||
ComposerKind::Edit(i) => !same_slot(c.page, i),
|
||||
_ => c.page >= page_count,
|
||||
}) {
|
||||
self.composer = None;
|
||||
}
|
||||
if self.gesture.as_ref().is_some_and(|g| match g {
|
||||
Gesture::Draw { page, .. } => *page >= page_count,
|
||||
Gesture::Move { page, index, .. } | Gesture::Resize { page, index, .. } | Gesture::Reshape { page, index, .. } => {
|
||||
!same_slot(*page, *index)
|
||||
}
|
||||
Gesture::Place { .. } => false,
|
||||
}) {
|
||||
self.gesture = None;
|
||||
}
|
||||
self.slots = next;
|
||||
}
|
||||
|
||||
/// Every selected comment, the one the handles belong to first.
|
||||
pub fn selection(&self) -> Vec<(usize, usize)> {
|
||||
let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect();
|
||||
@@ -333,24 +416,33 @@ impl CommentView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The annotation at `gone` was just deleted; `before` and `after` are the document's
|
||||
/// The annotation at `gone` was just deleted; `before` and `info` hold the document's
|
||||
/// annotations until then and now. The selection and the inline editor stay on what they
|
||||
/// were on, where it now is (deleting a reply keeps its comment selected), and close when it
|
||||
/// went too.
|
||||
pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) {
|
||||
self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k));
|
||||
self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect();
|
||||
self.member = None;
|
||||
self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text)));
|
||||
if self.editing.is_none() {
|
||||
self.editor_focus = None;
|
||||
}
|
||||
// What is typed in a pop-up, and a window being dragged or resized, follow theirs too.
|
||||
pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], info: &DocInfo, gone: (usize, usize)) {
|
||||
let after = &info.annotations;
|
||||
let moved = |k: (usize, usize)| after_delete(before, after, gone, k);
|
||||
self.popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
self.popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
self.popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at)));
|
||||
self.popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size)));
|
||||
let selected = self.selected.and_then(moved);
|
||||
let also: Vec<(usize, usize)> = std::mem::take(&mut self.also).into_iter().filter_map(moved).collect();
|
||||
let editing = self.editing.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
let focus = self.editor_focus.take();
|
||||
// What is typed in a pop-up, and a window being dragged or resized, follow theirs too.
|
||||
let popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
let popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text)));
|
||||
let popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at)));
|
||||
let popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size)));
|
||||
// Other transient state still needs conservative pruning; refresh the slot snapshot too.
|
||||
self.document_changed(info);
|
||||
self.selected = selected;
|
||||
self.also = also;
|
||||
self.member = None;
|
||||
self.editor_focus = editing.as_ref().and(focus);
|
||||
self.editing = editing;
|
||||
self.popup_text = popup_text;
|
||||
self.popup_reply = popup_reply;
|
||||
self.popup_drag = popup_drag;
|
||||
self.popup_resize = popup_resize;
|
||||
}
|
||||
|
||||
/// Undo or Redo just ran. Either may have put annotations back or taken them away, on any
|
||||
|
||||
@@ -165,11 +165,17 @@ fn a_kept_comment_is_found_where_a_delete_moved_it() {
|
||||
let changed = vec![listed(0, 0, "something else", None), listed(0, 1, "B", Some((0, 0)))];
|
||||
assert_eq!(after_delete(&before, &changed, gone, (0, 2)), None);
|
||||
|
||||
let mut view = CommentView { selected: Some((0, 2)), editing: Some((0, 3, "typed".into())), ..Default::default() };
|
||||
view.annotation_deleted(&before, &after, gone);
|
||||
let info = DocInfo { annotations: before.clone(), ..Default::default() };
|
||||
let mut view = CommentView::from_info(&info);
|
||||
view.selected = Some((0, 2));
|
||||
view.editing = Some((0, 3, "typed".into()));
|
||||
view.annotation_deleted(&before, &DocInfo { annotations: after.clone(), ..Default::default() }, gone);
|
||||
view.document_changed(&DocInfo { annotations: after.clone(), ..Default::default() });
|
||||
assert_eq!((view.selected, view.editing), (Some((0, 0)), Some((0, 1, "typed".into()))), "both follow");
|
||||
let mut view = CommentView { editing: Some((0, 4, "typed".into())), editor_focus: Some(EditorFocus::Take), ..Default::default() };
|
||||
view.annotation_deleted(&before, &after, gone);
|
||||
let mut view = CommentView::from_info(&info);
|
||||
view.editing = Some((0, 4, "typed".into()));
|
||||
view.editor_focus = Some(EditorFocus::Take);
|
||||
view.annotation_deleted(&before, &DocInfo { annotations: after.clone(), ..Default::default() }, gone);
|
||||
assert_eq!((view.editing, view.editor_focus), (None, None), "the editor closes with the reply it was open on");
|
||||
}
|
||||
|
||||
@@ -554,6 +560,137 @@ fn text_boxes_fit_their_text() {
|
||||
assert!(long[3] - long[1] > 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_comment_state_is_cleared_and_delete_never_hits_non_comment_slots() {
|
||||
let annot = |index: usize, name: &str| Annotation {
|
||||
page: 0,
|
||||
index,
|
||||
subtype: "Text".into(),
|
||||
rect: [10.0, 10.0, 30.0, 30.0],
|
||||
color: None,
|
||||
author: None,
|
||||
contents: Some(name.into()),
|
||||
modified: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
name: Some(name.into()),
|
||||
state: None,
|
||||
quads: Vec::new(),
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign: false,
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
};
|
||||
// Page 0 has a Widget at index 0 (not in `info.annotations`), and Text comments at 1 and 2.
|
||||
let info = DocInfo {
|
||||
pages: vec![PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }],
|
||||
annotations: vec![annot(1, "c1"), annot(2, "c2")],
|
||||
..Default::default()
|
||||
};
|
||||
let mut view = DocView::new(pdfcraft_engine::DocId(1), &info, crate::canvas::ViewDefaults::default());
|
||||
view.comments.selected = Some((0, 2));
|
||||
view.comments.open_editor(0, 2, "draft".into());
|
||||
view.comments.composer = Some(Composer { page: 0, at: [10.0, 10.0], kind: ComposerKind::Edit(2), text: "body".into(), focus: false });
|
||||
view.comments.gesture = Some(Gesture::Move { page: 0, index: 2, from: pos2(0.0, 0.0) });
|
||||
|
||||
// Moving/editing in place or appending a comment at index 3 preserves (0, 2) selection/editing/gesture.
|
||||
let mut appended = info.clone();
|
||||
appended.annotations.push(annot(3, "c3"));
|
||||
view.document_changed(&appended);
|
||||
assert_eq!(view.comments.selected, Some((0, 2)));
|
||||
assert!(view.comments.editing.is_some());
|
||||
assert!(view.comments.composer.is_some());
|
||||
assert!(view.comments.gesture.is_some());
|
||||
|
||||
// Deleting comment 1 shifts comment 2 down to index 1: stale state at (0, 2) and shifted (0, 1) must clear.
|
||||
let mut shifted = info.clone();
|
||||
shifted.annotations = vec![annot(1, "c2")];
|
||||
view.comments.selected = Some((0, 1));
|
||||
view.comments.open_editor(0, 1, "for c1, not c2".into());
|
||||
view.comments.composer = Some(Composer { page: 0, at: [10.0, 10.0], kind: ComposerKind::Edit(1), text: "for c1".into(), focus: false });
|
||||
view.comments.gesture = Some(Gesture::Move { page: 0, index: 1, from: pos2(0.0, 0.0) });
|
||||
view.document_changed(&shifted);
|
||||
assert_eq!(view.comments.selected, None);
|
||||
assert_eq!(view.comments.editing, None);
|
||||
assert_eq!(view.comments.composer, None);
|
||||
assert_eq!(view.comments.gesture, None);
|
||||
|
||||
// Pressing Delete when `selected` holds a widget index (0, 0) or stale index (0, 2) emits no edit.
|
||||
let ctx = egui::Context::default();
|
||||
for bad_index in [0, 2] {
|
||||
view.comments.selected = Some((0, bad_index));
|
||||
let mut input = egui::RawInput::default();
|
||||
input.events.push(egui::Event::Key {
|
||||
key: egui::Key::Delete,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
let mut tool = QuickTool::Select;
|
||||
keys(ui.ctx(), &mut view, &shifted, &mut tool, true);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(view.comments.selected, None);
|
||||
assert_eq!(view.pending_edit, None, "index {bad_index} must not emit DeleteAnnotation");
|
||||
}
|
||||
}
|
||||
|
||||
/// An unnamed comment has no object name to tell it from the one that slid into its index.
|
||||
/// Deleting the earlier one still drops selection, editing, the composer and a gesture that
|
||||
/// pointed at that index.
|
||||
#[test]
|
||||
fn an_unnamed_shift_clears_state_for_the_old_index() {
|
||||
let annot = |index: usize| Annotation {
|
||||
page: 0,
|
||||
index,
|
||||
subtype: "Text".into(),
|
||||
rect: [10.0, 10.0, 30.0, 30.0],
|
||||
color: None,
|
||||
author: None,
|
||||
contents: None,
|
||||
modified: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
name: None,
|
||||
state: None,
|
||||
quads: Vec::new(),
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign: false,
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
text_box: None,
|
||||
popup: None,
|
||||
};
|
||||
let info = DocInfo {
|
||||
pages: vec![
|
||||
PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 },
|
||||
PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 },
|
||||
],
|
||||
annotations: vec![annot(1), annot(2)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut view = DocView::new(pdfcraft_engine::DocId(1), &info, crate::canvas::ViewDefaults::default());
|
||||
view.comments.selected = Some((0, 1));
|
||||
view.comments.open_editor(0, 1, "the first note".into());
|
||||
view.comments.composer = Some(Composer { page: 0, at: [10.0, 10.0], kind: ComposerKind::Edit(1), text: "the first note".into(), focus: false });
|
||||
view.comments.gesture = Some(Gesture::Move { page: 0, index: 1, from: pos2(0.0, 0.0) });
|
||||
let mut shifted = info.clone();
|
||||
shifted.annotations = vec![annot(1)];
|
||||
view.document_changed(&shifted);
|
||||
assert_eq!(view.comments.selected, None);
|
||||
assert_eq!(view.comments.editing, None);
|
||||
assert_eq!(view.comments.composer, None);
|
||||
assert_eq!(view.comments.gesture, None);
|
||||
}
|
||||
|
||||
/// Review on #4358: a Replace Text group is selected by its caret, the group's primary (ISO
|
||||
/// 32000-2 §12.5.6.2), and Make Current Properties Default on it updates the Replace Text tool. A
|
||||
/// caret of its own (Insert Text) still updates the Insert Text tool.
|
||||
|
||||
@@ -275,29 +275,30 @@ impl MoveBackground {
|
||||
return;
|
||||
}
|
||||
let r = screen_rect(xf, info, page, from);
|
||||
let base = self.settling.map_or(r, |s| screen_rect(xf, info, page, s));
|
||||
let to = if let Some((_, grab, start)) = cv.grab {
|
||||
let Some(p) = pointer else { return };
|
||||
dragged(r, grab, p - start, self.picture)
|
||||
} else if let Some(to) = self.settling {
|
||||
let to = screen_rect(xf, info, page, to);
|
||||
dragged(base, grab, p - start, self.picture)
|
||||
} else if self.settling.is_some() {
|
||||
// A moved text box keeps its top edge (its height follows its lines).
|
||||
if self.picture { to } else { r.translate(to.left_top() - r.left_top()) }
|
||||
if self.picture { base } else { r.translate(base.left_top() - r.left_top()) }
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
|
||||
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
// A pixel around the box, so its edges come along.
|
||||
// A pixel around the box, so its edges come along. Clip the source to the page raster so
|
||||
// an item extending past the page border never samples clamped edge texels (#3938, #4477).
|
||||
let (r, to) = (r.expand(1.0), to.expand(1.0));
|
||||
let clipped = r.intersect(xf.rect);
|
||||
if !clipped.is_positive() || r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let map = |p: Pos2| Pos2::new(to.min.x + (p.x - r.min.x) / r.width() * to.width(), to.min.y + (p.y - r.min.y) / r.height() * to.height());
|
||||
let mut mesh = egui::Mesh::with_texture(item.id());
|
||||
for (a, b) in [
|
||||
(r.left_top(), to.left_top()),
|
||||
(r.right_top(), to.right_top()),
|
||||
(r.right_bottom(), to.right_bottom()),
|
||||
(r.left_bottom(), to.left_bottom()),
|
||||
] {
|
||||
for a in [clipped.left_top(), clipped.right_top(), clipped.right_bottom(), clipped.left_bottom()] {
|
||||
let (u, v) = xf.screen_to_norm(a);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: map(a), uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
@@ -344,7 +345,11 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
|
||||
Grab::Corner(left, top) => {
|
||||
let (mut dx, mut dy) = (if left { -d.x } else { d.x }, if top { -d.y } else { d.y });
|
||||
if keep_aspect && r.width() > 0.0 && r.height() > 0.0 {
|
||||
let k = ((r.width() + dx) / r.width()).max((r.height() + dy) / r.height()).max(0.05);
|
||||
// Use the axis with the larger relative pointer change so shrinking along one axis
|
||||
// does not freeze when the other axis has not moved inward (#4477).
|
||||
let (rx, ry) = (dx / r.width(), dy / r.height());
|
||||
let delta = if rx.abs() > ry.abs() { rx } else { ry };
|
||||
let k = (1.0 + delta).max(0.05);
|
||||
dx = r.width() * k - r.width();
|
||||
dy = r.height() * k - r.height();
|
||||
}
|
||||
@@ -373,7 +378,8 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added],
|
||||
let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
|
||||
let selected = view.content.selected.filter(|s| s.0 == page).and_then(|(_, si)| items.iter().find(|(i, _)| *i == si));
|
||||
if let Some((index, a)) = selected
|
||||
&& let Some((_, left, top)) = handles(screen_rect(xf, info, page, a.content.rect())).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
|
||||
&& let Some((_, left, top)) =
|
||||
handles(screen_rect(xf, info, page, a.content.rect()).expand(1.0)).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
|
||||
{
|
||||
let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw };
|
||||
return Some(Hit::new(Owner::Added { index: *index, corner: Some((left, top)) }, Layer::Handle, icon));
|
||||
@@ -1596,4 +1602,14 @@ mod tests {
|
||||
assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too");
|
||||
assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits");
|
||||
}
|
||||
|
||||
/// #4477: dragging a corner handle predominantly inward on one axis must still shrink an image
|
||||
/// proportionally instead of freezing because the other axis did not shrink.
|
||||
#[test]
|
||||
fn corner_resize_shrinks_image_when_dragged_inward_along_one_axis() {
|
||||
let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0));
|
||||
let shrunk = dragged(r, Grab::Corner(false, false), egui::vec2(-40.0, 2.0), true);
|
||||
assert!((shrunk.width() - 60.0).abs() < 1e-3, "width shrinks by 40%: {shrunk:?}");
|
||||
assert!((shrunk.height() - 30.0).abs() < 1e-3, "height shrinks proportionally by 40%: {shrunk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user