mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
Merge remote-tracking branch 'upstream/main' into fix/paper-size-driver-5861
This commit is contained in:
commit
ebc9de101b
51 files changed
+3077
-187
No files matched your search
@@ -454,6 +454,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
|
||||
- **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
|
||||
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
|
||||
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
|
||||
|
||||
@@ -74,6 +74,7 @@ impl Automation {
|
||||
"text_extract" => self.text_extract(&a)?,
|
||||
"text_word_count" => self.text_word_count(&a)?,
|
||||
"text_find" => self.text_find(&a)?,
|
||||
"text_replace" => self.text_replace(&a)?,
|
||||
"page_rotate" => {
|
||||
let degrees = a.int("degrees")?;
|
||||
if degrees % 90 != 0 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::dispatch::Content;
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, one_based};
|
||||
use super::json::{Args, one_based, summary};
|
||||
use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools};
|
||||
|
||||
/// Default and maximum resolution for `page_render`.
|
||||
@@ -356,6 +356,103 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Find and replace (Edit text ▸ Replace): every match (or the first) rewritten inside its
|
||||
/// own paragraph through the same engine plan as the window's Replace all, one undo step.
|
||||
/// The page text is searched too (as `text_find` does), so matches no paragraph holds are
|
||||
/// counted as not editable and the counts add up to what `text_find` reports.
|
||||
pub(crate) fn text_replace(&mut self, a: &Args) -> Result<Value> {
|
||||
self.text_replace_with(a, &mut extract_parallel)
|
||||
}
|
||||
|
||||
pub(crate) fn text_replace_with(
|
||||
&mut self,
|
||||
a: &Args,
|
||||
extract: &mut impl FnMut(&Arc<Vec<u8>>, Option<Arc<str>>, &[usize]) -> PageTexts,
|
||||
) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, n) = (doc.id, doc.info.pages.len());
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let query = pdfcraft_engine::find_replace::Replace {
|
||||
find: a.str("find")?.to_owned(),
|
||||
with: a.str("replace")?.to_owned(),
|
||||
case_sensitive: a.opt_bool("case_sensitive")?.unwrap_or(false),
|
||||
whole_words: a.opt_bool("whole_words")?.unwrap_or(false),
|
||||
};
|
||||
let all = a.opt_bool("all")?.unwrap_or(true);
|
||||
let mut pages = pages;
|
||||
pages.sort_unstable();
|
||||
pages.dedup();
|
||||
// Pages whose text the find bar couldn't read, reported with the plan, not dropped.
|
||||
let mut unreadable: Vec<(usize, String)> = Vec::new();
|
||||
let (which, found) = if all {
|
||||
let mut found = std::collections::BTreeMap::new();
|
||||
for (p, text) in self.page_text_results(id, &pages, &mut *extract)? {
|
||||
match text {
|
||||
Ok(text) => {
|
||||
found.insert(p, text.find_opts(&query.find, query.case_sensitive, query.whole_words).len());
|
||||
}
|
||||
Err(e) => unreadable.push((p, e)),
|
||||
}
|
||||
}
|
||||
unreadable.sort();
|
||||
(Some(pdfcraft_engine::find_replace::Which::All), Some(found))
|
||||
} else {
|
||||
// The first match the find bar shows, and that one only: pages are read in order until
|
||||
// one holds a match, and a page that can't be read stops the search there.
|
||||
let mut texts = Vec::new();
|
||||
let (mut start, mut batch) = (0, FIND_FIRST_BATCH.max(1));
|
||||
'search: while start < pages.len() {
|
||||
let end = pages.len().min(start.saturating_add(batch));
|
||||
let mut results: std::collections::HashMap<usize, _> =
|
||||
self.page_text_results(id, &pages[start..end], &mut *extract)?.into_iter().collect();
|
||||
for p in &pages[start..end] {
|
||||
match results.remove(p) {
|
||||
Some(Ok(text)) if text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).is_empty() => {}
|
||||
Some(Ok(text)) => {
|
||||
texts.push((*p, text));
|
||||
break 'search;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
unreadable.push((*p, e));
|
||||
break 'search;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
batch = batch.saturating_mul(2).min(FIND_MAX_BATCH);
|
||||
}
|
||||
(pdfcraft_engine::find_replace::Which::first_in(texts.iter().map(|(p, t)| (*p, t.as_ref())), &query), None)
|
||||
};
|
||||
let mut plan = match which {
|
||||
Some(which) => self.doc(a)?.plan_replace(&pages, &query, which, found.as_ref()).map_err(failed)?,
|
||||
None => {
|
||||
// Nothing to replace: still the permission and query checks of a plan.
|
||||
self.doc(a)?.plan_replace(&[], &query, pdfcraft_engine::find_replace::Which::All, None).map_err(failed)?
|
||||
}
|
||||
};
|
||||
for (p, e) in &unreadable {
|
||||
plan.unreadable_page(*p, e);
|
||||
}
|
||||
let mut out = match plan.edit.clone() {
|
||||
Some(edit) => self.apply(a, edit)?,
|
||||
None => summary(self.doc(a)?),
|
||||
};
|
||||
let skipped: serde_json::Map<String, Value> = plan.skipped.iter().map(|(why, k)| (why.key().to_owned(), json!(k))).collect();
|
||||
out["replaced"] = json!(plan.replaced);
|
||||
out["changed"] = json!(plan.edit.is_some());
|
||||
out["pages"] = json!(plan.pages.iter().map(|p| p + 1).collect::<Vec<_>>());
|
||||
out["skipped"] = json!(plan.skipped_total());
|
||||
out["skipped_by"] = Value::Object(skipped);
|
||||
if !plan.refusals.is_empty() {
|
||||
out["refusals"] = json!(plan.refusals);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn command_list(&self, a: &Args) -> Result<Value> {
|
||||
let active = match a.opt_int("doc")? {
|
||||
Some(_) => Some(self.doc(a)?.id),
|
||||
|
||||
@@ -52,7 +52,7 @@ fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> {
|
||||
|
||||
/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions.
|
||||
/// Returns `((width, height), media_name)`.
|
||||
fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), String)> {
|
||||
fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), Option<String>)> {
|
||||
if let Some(p) = name {
|
||||
// 1. Match standard PAPERS presets.
|
||||
for (index, (n, size)) in PAPERS.iter().enumerate() {
|
||||
@@ -60,7 +60,7 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|label| label.eq_ignore_ascii_case(p))
|
||||
{
|
||||
let media = print::cups_media(index).unwrap_or("Letter").to_string();
|
||||
let media = print::cups_media(index).map(String::from);
|
||||
return Ok((*size, media));
|
||||
}
|
||||
}
|
||||
@@ -68,15 +68,19 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64
|
||||
if let Some(printer_name) = printer {
|
||||
let driver_papers = spool::printer_papers(printer_name);
|
||||
if let Some((name, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) {
|
||||
return Ok((*size, print::paper_media(Some(name), *size)));
|
||||
return Ok((*size, Some(print::paper_media(Some(name), *size))));
|
||||
}
|
||||
}
|
||||
// 3. Match custom dimensions ("<w>x<h>").
|
||||
if let Some(size) = parse_custom_dimensions(p) {
|
||||
return Ok((size, print::paper_media(None, size)));
|
||||
return Ok((size, Some(print::paper_media(None, size))));
|
||||
}
|
||||
// 4. A size of its own: "8.5x14in", "210x297mm", "10x15cm". It names no media.
|
||||
if let Some(size) = paper_size(p) {
|
||||
return Ok((size, None));
|
||||
}
|
||||
return Err(bad(format!(
|
||||
"unknown paper {p:?}; choose one of {}, a paper from printer_papers, or dimensions \"<w>x<h>\"",
|
||||
"unknown paper {p:?}; choose one of {}, a paper from printer_papers, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
|
||||
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
|
||||
)));
|
||||
}
|
||||
@@ -85,11 +89,12 @@ fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64
|
||||
if let Some(printer_name) = printer
|
||||
&& let Some((name, size)) = spool::printer_default_paper(printer_name)
|
||||
{
|
||||
return Ok((size, print::paper_media(Some(&name), size)));
|
||||
return Ok((size, Some(print::paper_media(Some(&name), size))));
|
||||
}
|
||||
|
||||
// Default fallback: US Letter.
|
||||
Ok((PAPERS[0].1, "Letter".to_string()))
|
||||
let (name, size) = PAPERS[0];
|
||||
let media = print::paper_media(Some(name), size);
|
||||
Ok((size, Some(media)))
|
||||
}
|
||||
|
||||
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
|
||||
@@ -334,7 +339,8 @@ impl Automation {
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
|
||||
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, Some(&media), settings.raster, &laid_out)?;
|
||||
let job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
|
||||
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\")")),
|
||||
@@ -343,6 +349,19 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200
|
||||
/// inches (PDF's own page limit).
|
||||
fn paper_size(spec: &str) -> Option<(f64, f64)> {
|
||||
let s = spec.trim().to_ascii_lowercase();
|
||||
let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)]
|
||||
.into_iter()
|
||||
.find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?;
|
||||
let (w, h) = body.split_once('x')?;
|
||||
let side = |t: &str| t.trim().parse::<f64>().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v));
|
||||
let (w, h) = (side(w)?, side(h)?);
|
||||
Some((w.min(h), w.max(h)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -397,7 +416,7 @@ mod tests {
|
||||
fn printing_to_a_printer_feeds_the_laid_out_paper() {
|
||||
let job = |args: Value, paper_name: Option<&str>| {
|
||||
let (_, media) = resolve_paper(paper_name, None).unwrap();
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some(&media), None, &[]).unwrap()
|
||||
spool_job(&Args(&args), "Office", "a.pdf".into(), None, media.as_deref(), 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.
|
||||
@@ -406,7 +425,7 @@ mod tests {
|
||||
assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf"));
|
||||
// Every paper, by its full name, goes out as its own media name.
|
||||
for (i, (name, size)) in PAPERS.iter().enumerate() {
|
||||
assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, print::cups_media(i).unwrap().to_string())), "{name}");
|
||||
assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, Some(print::cups_media(i).unwrap().to_string()))), "{name}");
|
||||
assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::<Vec<_>>(), "{name}");
|
||||
}
|
||||
// Short forms find the same paper.
|
||||
@@ -421,6 +440,9 @@ mod tests {
|
||||
assert_eq!(job(json!({}), Some("Custom.300x500pt")).options, [media("Custom.300x500")]);
|
||||
|
||||
assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
|
||||
// A size of its own is laid out as asked and names no media.
|
||||
assert_eq!(resolve_paper(Some("8.5x14in"), None).ok(), Some(((612.0, 1008.0), None)));
|
||||
assert!(job(json!({}), Some("8.5x14in")).options.is_empty());
|
||||
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter"), None, &[]).ok().map(|j| j.printer), Some(None));
|
||||
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter"), None, &[]).is_err());
|
||||
}
|
||||
@@ -439,4 +461,13 @@ mod tests {
|
||||
assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver");
|
||||
assert!(!job(json!({}), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paper_size_reads_its_numbers_and_needs_a_unit() {
|
||||
assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0)));
|
||||
assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0)));
|
||||
for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] {
|
||||
assert_eq!(paper_size(spec), None, "{spec}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,3 +707,27 @@ fn text_extract_html_has_one_budget_across_pages() {
|
||||
assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}");
|
||||
assert_eq!(capped["pages"][1]["styles_incomplete"], true);
|
||||
}
|
||||
|
||||
/// Replace through the tool when a page's text can't be read for the find bar: the page is named
|
||||
/// and counted as refused, in the result, and the readable pages are still replaced.
|
||||
#[test]
|
||||
fn replace_all_reports_a_page_whose_text_cannot_be_read() {
|
||||
let mut a = Automation::new();
|
||||
let id = search_document(&mut a, 3);
|
||||
let mut extract = |bytes: &Arc<Vec<u8>>, password, pages: &[usize]| {
|
||||
extract_parallel(bytes, password, pages)
|
||||
.into_iter()
|
||||
.map(|(p, result)| (p, if p == 1 { Err("synthetic page 2 failure".to_owned()) } else { result }))
|
||||
.collect()
|
||||
};
|
||||
let r = a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet" })), &mut extract).unwrap();
|
||||
assert_eq!(r["replaced"], 3, "{r}");
|
||||
assert_eq!((r["skipped"].clone(), r["skipped_by"]["refused"].clone()), (json!(1), json!(1)), "{r}");
|
||||
let why = r["refusals"][0].as_str().unwrap();
|
||||
assert!(why.starts_with("page 2:") && why.contains("synthetic page 2 failure"), "{why}");
|
||||
// Replacing the first match stops at an unreadable page before it: nothing is replaced.
|
||||
let r =
|
||||
a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet", "all": false, "pages": [2, 3] })), &mut extract).unwrap();
|
||||
assert_eq!((r["replaced"].clone(), r["changed"].clone(), r["skipped_by"]["refused"].clone()), (json!(0), json!(false), json!(1)), "{r}");
|
||||
assert!(r["refusals"][0].as_str().unwrap().starts_with("page 2:"), "{r}");
|
||||
}
|
||||
@@ -276,6 +276,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
|
||||
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." },
|
||||
"stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])),
|
||||
t("text_replace", "Replace text", "Find and replace (Edit text ▸ Replace): each match of find, with the find bar's rules (whitespace-normalised; case-insensitive unless case_sensitive; whole_words: no letter or digit may adjoin a match), is rewritten inside its own paragraph, which keeps its fonts, sizes and colours and rewraps; the replacement takes the style of the run it lands in. A longer replacement is measured in the paragraph's fonts and is not shrunk or hyphenated: one row grows right to the page's margin, then wraps; several rows keep their width. New rows go below and nothing else moves, so they can overlap what follows, unreported; a word wider than the width overruns it. replace may be empty (deletes the match). all (default true) replaces every match on pages (default all) as one undo step; false only the first match the find bar shows (page text order; when no paragraph holds it nothing is replaced and it is reported as not_editable). Matches are left alone and counted in skipped_by when they span two paragraphs (spans_paragraphs), are invisible text over a scan (scanned_text), would need another font because the paragraph's own can't draw the new text (missing_glyphs), the paragraph can't be rewritten (refused, with refusals), or the page text holds them outside any editable paragraph, such as form fields, comments or text inside a form XObject (not_editable, counted when all). A page whose text can't be read is counted once as refused and named in refusals. Returns replaced, skipped, skipped_by, the 1-based pages changed and whether the document changed.")
|
||||
.cmd("edit.replace")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "find": { "type": "string", "minLength": 1 }, "replace": { "type": "string" },
|
||||
"pages": pages("to search (default: all)"),
|
||||
"case_sensitive": { "type": "boolean" }, "whole_words": { "type": "boolean" },
|
||||
"all": { "type": "boolean", "description": "Every match (default) or only the first." },
|
||||
}),
|
||||
&["doc", "find", "replace"],
|
||||
)),
|
||||
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
|
||||
.cmd("page.rotate")
|
||||
.with(schema(
|
||||
@@ -776,7 +787,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"cut_marks": { "type": "boolean" },
|
||||
"orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] },
|
||||
"comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] },
|
||||
"paper": { "type": "string" },
|
||||
"paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." },
|
||||
"path": { "type": "string" },
|
||||
"printer": { "type": "string" },
|
||||
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
|
||||
|
||||
@@ -4397,6 +4397,37 @@ fn accessibility_review_sidecar_tracks_human_decisions_for_exact_pdf_revision()
|
||||
}
|
||||
}
|
||||
|
||||
/// Find and replace through text_replace: every match (one undo step), the first only, the find
|
||||
/// options, and counts that add up to what text_find reports.
|
||||
#[test]
|
||||
fn replacing_text_through_tools() {
|
||||
let dir = workdir("replace-text");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page" }))["count"].as_u64();
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet" }));
|
||||
assert_eq!((r["replaced"].as_u64(), r["skipped"].as_u64(), r["changed"].as_bool()), (Some(3), Some(0), Some(true)));
|
||||
assert_eq!(found, r["replaced"].as_u64(), "what text_find found is what was replaced");
|
||||
assert_eq!(r["pages"], json!([1, 2, 3]));
|
||||
assert_eq!(page_text(&mut a, doc), ["Sheet 1", "Sheet 2", "Sheet 3"]);
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Replace all");
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"], "one undo step");
|
||||
// The first match only, on the pages asked for.
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet", "all": false, "pages": [2, 3] }));
|
||||
assert_eq!((r["replaced"].as_u64(), r["pages"].clone()), (Some(1), json!([2])));
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Sheet 2", "Page 3"]);
|
||||
// Case-sensitive and whole words follow the find bar's options; nothing found changes nothing.
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "x", "case_sensitive": true }));
|
||||
assert_eq!((r["replaced"].as_u64(), r["changed"].as_bool()), (Some(0), Some(false)));
|
||||
let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Pag", "replace": "x", "whole_words": true }));
|
||||
assert_eq!(r["replaced"].as_u64(), Some(0));
|
||||
// An empty replacement deletes the match.
|
||||
ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Page ", "replace": "" }));
|
||||
assert_eq!(page_text(&mut a, doc), ["1", "Sheet 2", "3"]);
|
||||
assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x" })), Err(ToolError::InvalidArgs(_))), "replace is required");
|
||||
assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x", "replace": "y", "pages": [9] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_text_through_tools() {
|
||||
let dir = workdir("edit-text");
|
||||
@@ -12197,6 +12228,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres,
|
||||
/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn doc_print_takes_a_paper_size_of_its_own() {
|
||||
let dir = workdir("print-paper-size");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (paper, want) in
|
||||
[("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))]
|
||||
{
|
||||
let file = format!("{}.pdf", want.0);
|
||||
ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file }));
|
||||
let bytes = std::fs::read(dir.join(&file)).unwrap();
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&out)[0].display_size(&out);
|
||||
assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}");
|
||||
}
|
||||
for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] {
|
||||
match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) {
|
||||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"),
|
||||
other => panic!("{paper}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside
|
||||
/// 72–600 is refused.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! A replacement through the run sources must leave the rows it does not reach as they were.
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn page(content: &str) -> Document {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn content(doc: &Document) -> String {
|
||||
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let p = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
let list = match doc.resolve(p.dict.get(b"Contents").unwrap()).as_ref() {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![p.dict.get(b"Contents").unwrap().clone()],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for o in &list {
|
||||
if let Object::Stream(s) = &*doc.resolve(o) {
|
||||
out.extend(s.decoded().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_keeps_untouched_rows() {
|
||||
let mut doc = page("BT /F1 10 Tf 12 TL 100 700 Td [(Hello) -300 (World)] TJ T* (second row here) Tj ET");
|
||||
let block = text_blocks(&doc, 0).unwrap().swap_remove(0);
|
||||
let start = block.editor_text.find("here").expect("the second row's word is in the paragraph");
|
||||
let (text, sources) = block.replaced(std::slice::from_ref(&(start..start + 4)), "there").expect("the range is on boundaries");
|
||||
let style = BlockStyle { sources, ..Default::default() };
|
||||
rewrite_block(&mut doc, 0, 0, Some(&text), &style).unwrap();
|
||||
let after = content(&doc);
|
||||
assert!(after.contains("-300"), "the first row keeps its TJ kerning: {after}");
|
||||
}
|
||||
|
||||
/// The rewrite keeps every space typed, so a deleted word must not leave its two neighbouring
|
||||
/// spaces behind; a run of spaces the paragraph had is left as it was.
|
||||
#[test]
|
||||
fn a_deleted_word_leaves_one_space() {
|
||||
let doc = page("BT /F1 10 Tf 100 700 Td (one two three) Tj ET");
|
||||
let block = text_blocks(&doc, 0).unwrap().swap_remove(0);
|
||||
assert_eq!(block.editor_text, "one two three");
|
||||
let two = block.editor_text.find("two").unwrap();
|
||||
let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "").unwrap();
|
||||
assert_eq!(text, "one three");
|
||||
let one = block.editor_text.find("one").unwrap();
|
||||
let (text, _) = block.replaced(std::slice::from_ref(&(one..one + 3)), "").unwrap();
|
||||
assert_eq!(text, " two three");
|
||||
let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "2").unwrap();
|
||||
assert_eq!(text, "one 2 three");
|
||||
}
|
||||
@@ -61,8 +61,9 @@ mod linebreak;
|
||||
pub mod paragraph_align;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
|
||||
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, RunSources, TextBlock, TextLine, TextSession,
|
||||
block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block,
|
||||
text_blocks, text_lines,
|
||||
};
|
||||
pub mod printer_marks;
|
||||
pub use printer_marks::{PrinterMarks, add_printer_marks};
|
||||
@@ -93,6 +94,8 @@ use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_
|
||||
use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint};
|
||||
pub use place::{add_type3_fonts, stamp, stamp_with_fonts, undrawable_message};
|
||||
|
||||
#[cfg(test)]
|
||||
mod find_replace_rows_tests;
|
||||
#[cfg(test)]
|
||||
mod marked_isolation_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8358,6 +8358,72 @@ fn unchanged_runs_between_two_edits_keep_their_styles() {
|
||||
assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer"));
|
||||
}
|
||||
|
||||
/// Find and replace: two words replaced in one paragraph of several runs. Each replacement takes
|
||||
/// the run of the first character it replaces, and every run between them keeps its own style.
|
||||
#[test]
|
||||
fn replacing_words_apart_in_a_paragraph_keeps_every_run_between_them() {
|
||||
let original = mixed_region_page();
|
||||
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
|
||||
assert_eq!(block.editor_text, "About\nPlain bold text.\nItalic footer");
|
||||
let about = block.editor_text.find("About").unwrap();
|
||||
let text_word = block.editor_text.find("text").unwrap();
|
||||
let (draft, sources) = block.replaced(&[about..about + 5, text_word..text_word + 4], "Info").unwrap();
|
||||
assert_eq!(draft, "Info\nPlain bold Info.\nItalic footer");
|
||||
let pieces: Vec<&str> = sources.iter().map(|(r, _)| &draft[r.clone()]).collect();
|
||||
assert_eq!(pieces, ["Info", "\nPlain ", "bold", " Info.", "\nItalic footer"]);
|
||||
let before = text::text_lines(&original, 0).unwrap();
|
||||
let source_of = |word: &str| before.iter().position(|l| l.text.contains(word)).unwrap();
|
||||
let runs: Vec<usize> = sources.iter().map(|(_, s)| *s).collect();
|
||||
assert_eq!(runs, [source_of("About"), source_of("Plain"), source_of("bold"), source_of(" text."), source_of("Italic")]);
|
||||
let mut doc = original.clone();
|
||||
let style = text::BlockStyle { sources, ..Default::default() };
|
||||
assert_eq!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).unwrap().substituted, None);
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[1].editor_text, draft);
|
||||
let lines = text::text_lines(&reopened, 0).unwrap();
|
||||
let look = |l: &text::TextLine| (l.base_font.clone(), l.color);
|
||||
let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("{word}: {lines:?}"));
|
||||
assert_eq!(look(run("Info")), look(&before[source_of("About")]), "the first replacement takes About's run");
|
||||
assert_eq!(look(run("Plain")), look(&before[source_of("Plain")]));
|
||||
assert_eq!(look(run("bold")), look(&before[source_of("bold")]), "the run between the two replacements keeps its style");
|
||||
assert_eq!(look(run("Info.")), look(&before[source_of(" text.")]));
|
||||
assert_eq!(look(run("Italic")), look(&before[source_of("Italic")]));
|
||||
// A replaced word that is a run of its own keeps that run's style, where aligning the old and
|
||||
// new text (the inline editor's way) gives a changed word the run before it.
|
||||
let bold = block.editor_text.find("bold").unwrap();
|
||||
let (draft, sources) = block.replaced(std::slice::from_ref(&(bold..bold + 4)), "heavy").unwrap();
|
||||
let mut doc = original.clone();
|
||||
text::rewrite_block(&mut doc, 0, 1, Some(&draft), &text::BlockStyle { sources, ..Default::default() }).unwrap();
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
let heavy = lines.iter().find(|l| l.text.contains("heavy")).unwrap_or_else(|| panic!("{lines:?}"));
|
||||
assert_eq!(look(heavy), look(&before[source_of("bold")]), "the replacement keeps the bold run's style");
|
||||
// Out of order, overlapping or inside a character: no draft.
|
||||
assert!(block.replaced(&[text_word..text_word + 4, about..about + 5], "x").is_none());
|
||||
assert!(block.replaced(&[0..3, 2..5], "x").is_none());
|
||||
assert!(block.replaced(&[0..0, 3..4], "x").is_none(), "an empty range");
|
||||
assert_eq!(block.replaced(&[], "x").map(|(d, _)| d), Some(block.editor_text.clone()));
|
||||
}
|
||||
|
||||
/// Sources that don't cover the text in order, or name a run of another paragraph, are refused
|
||||
/// and change nothing.
|
||||
#[test]
|
||||
fn style_sources_must_cover_the_text_with_the_paragraphs_runs() {
|
||||
let original = mixed_region_page();
|
||||
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
|
||||
let draft = block.editor_text.replace("About", "Info");
|
||||
let own = block.lines[0];
|
||||
let other = text::text_blocks(&original, 0).unwrap()[0].lines[0];
|
||||
let len = draft.len();
|
||||
for sources in
|
||||
[vec![(0..4, own)], vec![(0..4, own), (5..len, own)], vec![(0..len, other)], vec![(0..len + 1, own)], vec![(4..len, own), (0..4, own)]]
|
||||
{
|
||||
let mut doc = original.clone();
|
||||
let style = text::BlockStyle { sources: sources.clone(), ..Default::default() };
|
||||
assert!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).is_err(), "{sources:?}");
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].editor_text, block.editor_text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
|
||||
let mut doc = mixed_region_page();
|
||||
|
||||
@@ -39,7 +39,7 @@ mod rewrite;
|
||||
#[cfg(test)]
|
||||
use blocks::wrap;
|
||||
pub use blocks::{
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, RunSources, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
replace_block, text_blocks,
|
||||
};
|
||||
use blocks::{kept_words, paragraph_separator, row_break};
|
||||
|
||||
@@ -61,14 +61,7 @@ impl TextBlock {
|
||||
let old: Vec<_> = self.editor_text.chars().collect();
|
||||
let new: Vec<_> = draft.char_indices().collect();
|
||||
let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect();
|
||||
let mut original = Vec::with_capacity(old.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) {
|
||||
span += 1;
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
let original = self.char_sources();
|
||||
let mut sources = Vec::with_capacity(new.len());
|
||||
let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::<Vec<_>>();
|
||||
let (old_words, new_words) = (slices(&old), slices(&chars));
|
||||
@@ -229,6 +222,79 @@ fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte ranges of a paragraph's text and the source run (an index into [`text_lines`]) each one
|
||||
/// keeps the style of, in order ([`TextBlock::spans`], [`BlockStyle::sources`]).
|
||||
pub type RunSources = Vec<(std::ops::Range<usize>, usize)>;
|
||||
|
||||
impl TextBlock {
|
||||
/// The source run (an index into [`text_lines`]) of each character of `editor_text`. A
|
||||
/// character no span covers (the line break between two rows, a space put between two runs)
|
||||
/// belongs to the run that follows it.
|
||||
fn char_sources(&self) -> Vec<usize> {
|
||||
let mut original = Vec::with_capacity(self.editor_text.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) {
|
||||
span += 1;
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
original
|
||||
}
|
||||
|
||||
/// `editor_text` with each of `ranges` (byte ranges in it, in order and not overlapping) put
|
||||
/// in `with`, and the source run each byte range of the result keeps the style of
|
||||
/// ([`BlockStyle::sources`]): every character outside the ranges its own, a replacement the
|
||||
/// run of the first character it replaces. Spaces a replacement puts side by side become
|
||||
/// one (deleting a word leaves one space between its neighbours), while a run of spaces the
|
||||
/// paragraph already had stays, since the rewrite keeps every space typed. `None` when a
|
||||
/// range is out of order, overlaps another or does not fall on character boundaries.
|
||||
pub fn replaced(&self, ranges: &[std::ops::Range<usize>], with: &str) -> Option<(String, RunSources)> {
|
||||
let mut end = 0;
|
||||
for r in ranges {
|
||||
if r.start < end || r.start >= r.end || self.editor_text.get(r.clone()).is_none() {
|
||||
return None;
|
||||
}
|
||||
end = r.end;
|
||||
}
|
||||
// One run per stretch of the result: a piece from the same run right after it joins it.
|
||||
fn extend(spans: &mut Vec<(std::ops::Range<usize>, usize)>, start: usize, len: usize, source: usize) {
|
||||
match spans.last_mut() {
|
||||
_ if len == 0 => {}
|
||||
Some((range, last)) if *last == source && range.end == start => range.end = start.saturating_add(len),
|
||||
_ => spans.push((start..start.saturating_add(len), source)),
|
||||
}
|
||||
}
|
||||
let mut draft = String::with_capacity(self.editor_text.len());
|
||||
let mut spans = Vec::new();
|
||||
let mut next = ranges.iter().peekable();
|
||||
// Right after a replacement: a space here would join one the draft ends with.
|
||||
let mut joined = false;
|
||||
let spacing = |c: char| c != '\n' && c != '\r' && paragraph_separator(c);
|
||||
for ((byte, ch), source) in self.editor_text.char_indices().zip(self.char_sources()) {
|
||||
if let Some(r) = next.peek()
|
||||
&& byte >= r.start
|
||||
{
|
||||
if byte == r.start {
|
||||
extend(&mut spans, draft.len(), with.len(), source);
|
||||
draft.push_str(with);
|
||||
}
|
||||
if byte.saturating_add(ch.len_utf8()) >= r.end {
|
||||
next.next();
|
||||
joined = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if std::mem::take(&mut joined) && spacing(ch) && draft.chars().next_back().is_some_and(spacing) {
|
||||
continue;
|
||||
}
|
||||
extend(&mut spans, draft.len(), ch.len_utf8(), source);
|
||||
draft.push(ch);
|
||||
}
|
||||
Some((draft, spans))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a listing of [`text_blocks`] records a paragraph so that it can be found again.
|
||||
///
|
||||
/// The index of a paragraph is its position in a list regrouped from geometry on every read, so
|
||||
@@ -461,6 +527,12 @@ pub struct BlockStyle {
|
||||
/// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour
|
||||
/// (RGB 0–1) before the new text, hiding the scanned word beneath it.
|
||||
pub cover: Option<[f64; 3]>,
|
||||
/// The source run each byte range of the new text keeps the style of (indexes into
|
||||
/// [`text_lines`], as in [`TextBlock::spans`]), covering the text in order. Empty:
|
||||
/// [`TextBlock::edited_spans`] works it out by aligning the old and new text, where a changed
|
||||
/// word takes the run before it. Find and replace sets it ([`TextBlock::replaced`]): there a
|
||||
/// replacement takes the run of the text it replaces, however many matches the paragraph has.
|
||||
pub sources: RunSources,
|
||||
/// For an invisible paragraph whose formatting nobody asked to change: cover and redraw only
|
||||
/// the words the draft changes, and leave the rest as the scan shows them (#3313). The session
|
||||
/// sets this; a caller that sets it should also give `cover`.
|
||||
|
||||
@@ -807,9 +807,8 @@ fn wrap_styled_rows(
|
||||
|
||||
fn apply_range_styles<'a>(
|
||||
first: &TextLine,
|
||||
block: &TextBlock,
|
||||
lines: &[TextLine],
|
||||
draft: &str,
|
||||
sources: Vec<(std::ops::Range<usize>, usize)>,
|
||||
ranges: &[&'a RangeStyle],
|
||||
style: &BlockStyle,
|
||||
) -> StyledLines<'a> {
|
||||
@@ -817,7 +816,7 @@ fn apply_range_styles<'a>(
|
||||
let mut styled_spans = Vec::new();
|
||||
let mut underlines = HashMap::new();
|
||||
let mut overrides = HashMap::new();
|
||||
for (span, source) in block.edited_spans(draft) {
|
||||
for (span, source) in sources {
|
||||
let mut edges = vec![span.start, span.end];
|
||||
for range in ranges {
|
||||
for edge in [range.range.start, range.range.end] {
|
||||
@@ -1591,6 +1590,25 @@ fn splice_with_marker(
|
||||
data
|
||||
}
|
||||
|
||||
/// The source run each part of the draft is styled from: the ones given, which must cover the draft
|
||||
/// in order, on character boundaries, from the block's runs, or else the edit's own.
|
||||
fn draft_sources(block: &TextBlock, draft: &str, style: &BlockStyle) -> Result<Vec<(std::ops::Range<usize>, usize)>, EditError> {
|
||||
if style.sources.is_empty() {
|
||||
return Ok(block.edited_spans(draft));
|
||||
}
|
||||
let mut at = 0;
|
||||
for (range, source) in &style.sources {
|
||||
if range.start != at || range.start >= range.end || draft.get(range.clone()).is_none() || !block.lines.contains(source) {
|
||||
return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into()));
|
||||
}
|
||||
at = range.end;
|
||||
}
|
||||
if at != draft.len() {
|
||||
return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into()));
|
||||
}
|
||||
Ok(style.sources.clone())
|
||||
}
|
||||
|
||||
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
|
||||
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
|
||||
pub(crate) fn rewrite_styled_block(
|
||||
@@ -1609,7 +1627,8 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
let ranges = validated_ranges(draft, style)?;
|
||||
let source_count = lines.len();
|
||||
let styled = apply_range_styles(first, block, lines, draft, &ranges, style);
|
||||
let sources = draft_sources(block, draft, style)?;
|
||||
let styled = apply_range_styles(first, lines, sources, &ranges, style);
|
||||
let lines = styled.lines.as_slice();
|
||||
let styled_spans = styled.spans;
|
||||
let underlines = styled.underlines;
|
||||
@@ -1711,7 +1730,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
// glyph widths stay exactly as they were instead of being rebuilt from the text state. A row
|
||||
// aligned on its own is reached; the rows around it are not.
|
||||
let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let plain = BlockStyle { ranges: Vec::new(), sources: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let mut kept = if plain {
|
||||
untouched_rows(block, draft, &row_ranges)
|
||||
.into_iter()
|
||||
|
||||
@@ -198,6 +198,9 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
|
||||
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
|
||||
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
|
||||
// Find and replace, in Edit text mode (the Find box's Replace row). Not ⌘H/Ctrl+H: macOS
|
||||
// takes ⌘H to hide the app, and Ctrl+H is Read mode on Windows and Linux (as in Acrobat).
|
||||
c("edit.replace", "Replace…", EDIT, Some(Shortcut::cmd_shift("H")), Modification, "replace"),
|
||||
c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"),
|
||||
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
|
||||
// Page display: View ▸ Page display and the rail's button list the base layouts as radios and
|
||||
|
||||
@@ -167,8 +167,16 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
/// The names of the batches built below L7, which an interface shows in the same places as
|
||||
/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives
|
||||
/// already worded in the interface's language.
|
||||
pub const BATCH_LABELS: &[&str] =
|
||||
&["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"];
|
||||
pub const BATCH_LABELS: &[&str] = &[
|
||||
"Set document title",
|
||||
"Mark differences",
|
||||
"Detect form fields",
|
||||
"Run JavaScript",
|
||||
"Recognize text",
|
||||
field_table::LABEL,
|
||||
"Style",
|
||||
crate::find_replace::REPLACE_ALL,
|
||||
];
|
||||
|
||||
impl Edit {
|
||||
/// Label for the Edit menu and history ("Undo Rotate pages").
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Find and replace in the page text (Edit text ▸ Replace, the `text_replace` tool).
|
||||
//!
|
||||
//! Each match is rewritten inside its own paragraph ([`crate::Document::text_blocks`]) through
|
||||
//! [`Edit::EditTextBlock`], so the paragraph keeps its fonts, sizes and colours and rewraps in its
|
||||
//! box. The paragraph's `editor_text` is matched with the find bar's own rules
|
||||
//! ([`pdfcraft_render::text::find_in_text`]) and the replacement takes the style of the run it
|
||||
//! lands in ([`pdfcraft_edit::TextBlock::replaced`]); every other run keeps its own.
|
||||
//!
|
||||
//! A longer replacement changes the paragraph only, measured with its own fonts' widths, and
|
||||
//! nothing is shrunk or hyphenated to fit. A paragraph of one row grows to the right, up to 36 pt
|
||||
//! from the crop box (a centred line stays in its own box), then wraps; a paragraph of several rows
|
||||
//! keeps its width and rewraps at spaces. New rows go below the last one at the paragraph's line
|
||||
//! spacing and nothing else on the page moves, so they can run over what is below, or off the page,
|
||||
//! without being reported. A word wider than the width stays whole, past the box.
|
||||
//!
|
||||
//! A match is left alone, and counted, when it runs from one paragraph into the next, lies in
|
||||
//! invisible text over a scan, would need another font (the paragraph's own can't draw the new
|
||||
//! text, see [`pdfcraft_edit::LineEdit::substituted`]) or is not page text the paragraphs hold
|
||||
//! (a form field, a comment, text drawn inside a form XObject). Each paragraph is rewritten on a
|
||||
//! copy first, so the edit returned is exactly what will apply: [`Edit::Batch`] for Replace all,
|
||||
//! one undo step.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::edit::check_permission;
|
||||
use crate::{Document, Edit, EditError, guard};
|
||||
|
||||
/// What to look for and what to put in its place, with the find bar's options.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Replace {
|
||||
pub find: String,
|
||||
/// May be empty: the match is deleted.
|
||||
pub with: String,
|
||||
pub case_sensitive: bool,
|
||||
pub whole_words: bool,
|
||||
}
|
||||
|
||||
/// Which matches to replace.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Which {
|
||||
/// Every match on the pages.
|
||||
All,
|
||||
/// The match the find bar highlights on `page`: the centres of its first and last glyphs, in
|
||||
/// view space (points from the top-left of the displayed page, y down: the text layer's).
|
||||
At { page: usize, start: [f32; 2], end: [f32; 2] },
|
||||
}
|
||||
|
||||
impl Which {
|
||||
/// The first match the find bar shows (page text order: pages in order, then reading order),
|
||||
/// as an [`Which::At`] on its first and last glyphs, or `None` when no page has one. `texts`
|
||||
/// are the pages' text, in page order. Replacing "the first match" is replacing this one: if
|
||||
/// no paragraph holds it, [`Document::plan_replace`] reports it as not editable and replaces
|
||||
/// nothing, rather than passing it for a later match that is.
|
||||
pub fn first_in<'a>(texts: impl IntoIterator<Item = (usize, &'a pdfcraft_render::PageText)>, query: &Replace) -> Option<Which> {
|
||||
texts.into_iter().find_map(|(page, text)| {
|
||||
let found = text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).into_iter().next()?;
|
||||
let centre = |g: usize| text.glyphs.get(g).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0]);
|
||||
Some(Which::At { page, start: centre(found.start)?, end: centre(found.end.saturating_sub(1))? })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a match was left as it is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Skip {
|
||||
/// It runs from one paragraph into the next.
|
||||
SpansParagraphs,
|
||||
/// It is invisible text over a scanned image (recognized text).
|
||||
ScannedText,
|
||||
/// The paragraph's font can't draw the new text; another font would have stepped in.
|
||||
MissingGlyphs,
|
||||
/// The paragraph couldn't be rewritten (see [`Replacement::refusals`]).
|
||||
Refused,
|
||||
/// It is not in the page's editable text: a form field, a comment, text inside a form XObject.
|
||||
NotEditable,
|
||||
}
|
||||
|
||||
impl Skip {
|
||||
/// How the reason is said after a count ("2 skipped: …"); an interface translates it.
|
||||
pub fn wording(self) -> &'static str {
|
||||
match self {
|
||||
Skip::SpansParagraphs => "spans two paragraphs",
|
||||
Skip::ScannedText => "scanned text",
|
||||
Skip::MissingGlyphs => "the font can't draw it",
|
||||
Skip::Refused => "the paragraph can't be rewritten",
|
||||
Skip::NotEditable => "not editable text",
|
||||
}
|
||||
}
|
||||
|
||||
/// A short name for tools.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Skip::SpansParagraphs => "spans_paragraphs",
|
||||
Skip::ScannedText => "scanned_text",
|
||||
Skip::MissingGlyphs => "missing_glyphs",
|
||||
Skip::Refused => "refused",
|
||||
Skip::NotEditable => "not_editable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every [`Skip::wording`], for an interface to translate.
|
||||
pub const SKIP_WORDINGS: &[&str] =
|
||||
&["spans two paragraphs", "scanned text", "the font can't draw it", "the paragraph can't be rewritten", "not editable text"];
|
||||
|
||||
/// What a replacement would do: the edit (apply it with [`crate::Session::apply`]) and the counts.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Replacement {
|
||||
/// `None` when nothing can be replaced.
|
||||
pub edit: Option<Edit>,
|
||||
/// Matches replaced.
|
||||
pub replaced: usize,
|
||||
/// The pages (0-based, ascending) where something was replaced.
|
||||
pub pages: Vec<usize>,
|
||||
/// Matches left alone, by reason.
|
||||
pub skipped: BTreeMap<Skip, usize>,
|
||||
/// What the engine said about the paragraphs it refused (at most [`MAX_REFUSALS`]).
|
||||
pub refusals: Vec<String>,
|
||||
}
|
||||
|
||||
/// Refusal messages kept in a [`Replacement`].
|
||||
pub const MAX_REFUSALS: usize = 10;
|
||||
|
||||
impl Replacement {
|
||||
pub fn skipped_total(&self) -> usize {
|
||||
self.skipped.values().fold(0, |a, b| a.saturating_add(*b))
|
||||
}
|
||||
|
||||
/// Say that `page`'s text could not be read for the find bar (`why`): its matches can't be
|
||||
/// counted, so one is counted as [`Skip::Refused`] and the page is named in the refusals.
|
||||
pub fn unreadable_page(&mut self, page: usize, why: &str) {
|
||||
refused(self, 1, page, format!("its text could not be read: {why}"));
|
||||
}
|
||||
|
||||
fn skip(&mut self, why: Skip, n: usize) {
|
||||
if n > 0 {
|
||||
let count = self.skipped.entry(why).or_insert(0);
|
||||
*count = count.saturating_add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The label of Replace all in the history (one of [`crate::BATCH_LABELS`]).
|
||||
pub const REPLACE_ALL: &str = "Replace all";
|
||||
|
||||
impl Document {
|
||||
/// The edit that replaces `query`'s matches on `pages` (0-based; others are ignored), and
|
||||
/// what it replaces and leaves. `found` is how many matches the find bar has on each page:
|
||||
/// those no paragraph holds are counted as [`Skip::NotEditable`] (`None`: not counted). The
|
||||
/// document is not changed; the same permission check as Edit text applies.
|
||||
pub fn plan_replace(
|
||||
&self,
|
||||
pages: &[usize],
|
||||
query: &Replace,
|
||||
which: Which,
|
||||
found: Option<&BTreeMap<usize, usize>>,
|
||||
) -> Result<Replacement, EditError> {
|
||||
let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly(self.read_only_reason.clone().unwrap_or_default()))?;
|
||||
// What Edit text checks: the document's permissions.
|
||||
let probe = Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() };
|
||||
if let Some(p) = editor.cos.permissions() {
|
||||
check_permission(&probe, &p)?;
|
||||
}
|
||||
if query.find.split_whitespace().next().is_none() {
|
||||
return Err(EditError::Invalid("there is nothing to find".into()));
|
||||
}
|
||||
let n = self.info.pages.len();
|
||||
let mut pages: Vec<usize> = pages.iter().copied().filter(|p| *p < n).collect();
|
||||
pages.sort_unstable();
|
||||
pages.dedup();
|
||||
if let Which::At { page, .. } = which {
|
||||
pages.retain(|p| *p == page);
|
||||
}
|
||||
let mut out = Replacement::default();
|
||||
let mut work = editor.cos.clone();
|
||||
let mut edits = Vec::new();
|
||||
for page in pages {
|
||||
// A page whose text can't be read is reported, and the others still replaced.
|
||||
let blocks = match pdfcraft_edit::text_blocks(&work, page) {
|
||||
Ok(blocks) => blocks,
|
||||
Err(e) => {
|
||||
refused(&mut out, found.and_then(|f| f.get(&page)).copied().unwrap_or(0), page, e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let matches: Vec<Vec<Range<usize>>> = blocks.iter().map(|b| find(&b.editor_text, query, usize::MAX)).collect();
|
||||
// Paragraph `k`'s matches to replace now.
|
||||
let mut chosen: BTreeMap<usize, Vec<Range<usize>>> = BTreeMap::new();
|
||||
match which {
|
||||
Which::All => {
|
||||
let held = matches.iter().fold(0usize, |a, m| a.saturating_add(m.len()));
|
||||
let spanning = spanning(&blocks, query);
|
||||
out.skip(Skip::SpansParagraphs, spanning);
|
||||
if let Some(f) = found.and_then(|f| f.get(&page)) {
|
||||
out.skip(Skip::NotEditable, f.saturating_sub(held.saturating_add(spanning)));
|
||||
}
|
||||
chosen.extend(matches.into_iter().enumerate().filter(|(_, m)| !m.is_empty()));
|
||||
}
|
||||
Which::At { start, end, .. } => match at(self, &work, page, &blocks, &matches, start, end) {
|
||||
Ok((k, m)) => {
|
||||
chosen.insert(k, vec![m]);
|
||||
}
|
||||
Err(why) => out.skip(why, 1),
|
||||
},
|
||||
}
|
||||
// Last paragraph first, which usually leaves those before it where they were. That is
|
||||
// checked, not assumed: once a paragraph on this page is rewritten, the page is read
|
||||
// again and a paragraph that is no longer what the match was found in is refused.
|
||||
let mut rewrote = false;
|
||||
for (k, ranges) in chosen.into_iter().rev() {
|
||||
let Some(block) = blocks.get(k) else { continue };
|
||||
let count = ranges.len();
|
||||
if rewrote {
|
||||
let same = pdfcraft_edit::text_blocks(&work, page)
|
||||
.ok()
|
||||
.is_some_and(|now| now.get(k).is_some_and(|b| b.editor_text == block.editor_text));
|
||||
if !same {
|
||||
refused(&mut out, count, page, "the page's paragraphs moved after an earlier replacement".into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if block.invisible {
|
||||
out.skip(Skip::ScannedText, count);
|
||||
continue;
|
||||
}
|
||||
let Some((text, sources)) = block.replaced(&ranges, &query.with) else {
|
||||
out.skip(Skip::Refused, count);
|
||||
continue;
|
||||
};
|
||||
if text == block.editor_text {
|
||||
// The replacement is what was there.
|
||||
out.replaced = out.replaced.saturating_add(count);
|
||||
continue;
|
||||
}
|
||||
let style = pdfcraft_edit::BlockStyle { sources, ..Default::default() };
|
||||
let mut trial = work.clone();
|
||||
match guard(|| pdfcraft_edit::rewrite_block(&mut trial, page, k, Some(&text), &style)) {
|
||||
Ok(Ok(pdfcraft_edit::LineEdit { substituted: None })) => {
|
||||
work = trial;
|
||||
rewrote = true;
|
||||
edits.push(Edit::EditTextBlock { page, block: k, text, style });
|
||||
out.replaced = out.replaced.saturating_add(count);
|
||||
if !out.pages.contains(&page) {
|
||||
out.pages.push(page);
|
||||
}
|
||||
}
|
||||
Ok(Ok(pdfcraft_edit::LineEdit { substituted: Some(_) })) => out.skip(Skip::MissingGlyphs, count),
|
||||
Ok(Err(e)) => refused(&mut out, count, page, e.to_string()),
|
||||
Err(panic) => refused(&mut out, count, page, panic),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.pages.sort_unstable();
|
||||
out.edit = match (which, edits.len()) {
|
||||
(_, 0) => None,
|
||||
(Which::All, _) => Some(Edit::Batch { label: REPLACE_ALL.into(), edits }),
|
||||
(Which::At { .. }, _) => edits.pop(),
|
||||
};
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn find(text: &str, query: &Replace, limit: usize) -> Vec<Range<usize>> {
|
||||
pdfcraft_render::text::find_in_text(text, &query.find, query.case_sensitive, query.whole_words, limit)
|
||||
}
|
||||
|
||||
fn refused(out: &mut Replacement, count: usize, page: usize, why: String) {
|
||||
out.skip(Skip::Refused, count);
|
||||
if out.refusals.len() < MAX_REFUSALS {
|
||||
out.refusals.push(format!("page {}: {why}", page.saturating_add(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches that start in one paragraph and end in a later one: the page's paragraphs read one
|
||||
/// after another (a line break between two), as the find bar reads the page.
|
||||
fn spanning(blocks: &[pdfcraft_edit::TextBlock], query: &Replace) -> usize {
|
||||
let mut joined = String::new();
|
||||
let mut ends = Vec::with_capacity(blocks.len());
|
||||
for b in blocks {
|
||||
if !joined.is_empty() {
|
||||
joined.push('\n');
|
||||
}
|
||||
joined.push_str(&b.editor_text);
|
||||
ends.push(joined.len());
|
||||
}
|
||||
// Which paragraph a byte of `joined` is in (a separator counts with the paragraph after it).
|
||||
let paragraph = |byte: usize| ends.partition_point(|end| *end <= byte);
|
||||
find(&joined, query, usize::MAX).into_iter().filter(|r| paragraph(r.start) != paragraph(r.end.saturating_sub(1))).count()
|
||||
}
|
||||
|
||||
/// The paragraph match the find bar's match at `start` … `end` (view space) is: the paragraph
|
||||
/// whose box holds both, and its match nearest `start`.
|
||||
fn at(
|
||||
doc: &Document,
|
||||
cos: &pdfcraft_cos::Document,
|
||||
page: usize,
|
||||
blocks: &[pdfcraft_edit::TextBlock],
|
||||
matches: &[Vec<Range<usize>>],
|
||||
start: [f32; 2],
|
||||
end: [f32; 2],
|
||||
) -> Result<(usize, Range<usize>), Skip> {
|
||||
let info = doc.info.pages.get(page).ok_or(Skip::NotEditable)?;
|
||||
let view = |x: f64, y: f64| info.user_to_view(x as f32, y as f32);
|
||||
// A paragraph's box in view space, a little larger so a glyph's centre on its edge is inside.
|
||||
let holds = |b: &pdfcraft_edit::TextBlock, p: [f32; 2]| {
|
||||
let corners = [view(b.rect[0], b.rect[1]), view(b.rect[2], b.rect[3]), view(b.rect[0], b.rect[3]), view(b.rect[2], b.rect[1])];
|
||||
let pad = (b.size as f32 * 0.3).clamp(1.0, 12.0);
|
||||
let (x0, x1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[0]), b.max(c[0])));
|
||||
let (y0, y1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[1]), b.max(c[1])));
|
||||
p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad
|
||||
};
|
||||
let lines = pdfcraft_edit::text_lines(cos, page).unwrap_or_default();
|
||||
// Where a match's first character is drawn, estimated along its run.
|
||||
let place = |b: &pdfcraft_edit::TextBlock, r: &Range<usize>| -> Option<[f32; 2]> {
|
||||
let (span, line) = b.spans.iter().find(|(s, _)| s.contains(&r.start)).or_else(|| b.spans.iter().find(|(s, _)| s.start >= r.start))?;
|
||||
let line = lines.get(*line)?;
|
||||
let run = b.editor_text.get(span.clone())?;
|
||||
let before = b.editor_text.get(span.start..r.start.max(span.start))?.chars().count() as f64;
|
||||
let total = run.chars().count().max(1) as f64;
|
||||
let along = ((before + 0.5) / total).clamp(0.0, 1.0);
|
||||
let x = line.rect[0] + along * (line.rect[2] - line.rect[0]);
|
||||
Some(view(x, (line.rect[1] + line.rect[3]) / 2.0))
|
||||
};
|
||||
let distance = |p: [f32; 2]| (p[0] - start[0]).hypot(p[1] - start[1]);
|
||||
let mut best: Option<(f32, usize, Range<usize>)> = None;
|
||||
for (k, b) in blocks.iter().enumerate().filter(|(_, b)| holds(b, start)) {
|
||||
for r in matches.get(k).into_iter().flatten() {
|
||||
let d = place(b, r).map_or(f32::MAX, distance);
|
||||
if best.as_ref().is_none_or(|(e, ..)| d < *e) {
|
||||
best = Some((d, k, r.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((_, k, r)) if blocks.get(k).is_some_and(|b| holds(b, end)) => Ok((k, r)),
|
||||
Some(_) => Err(Skip::SpansParagraphs),
|
||||
None if blocks.iter().any(|b| holds(b, start)) && blocks.iter().any(|b| holds(b, end) && !holds(b, start)) => Err(Skip::SpansParagraphs),
|
||||
None => Err(Skip::NotEditable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rewrite_order_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Replacing in several paragraphs of one page: each is checked against the page as it is after
|
||||
//! the earlier rewrites, and a paragraph that still reads as it was found is replaced.
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::{DocId, Session};
|
||||
|
||||
fn page_with(content: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn open(bytes: Vec<u8>) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("order.pdf", None, Arc::new(bytes), None).expect("opens");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_in_every_paragraph_of_a_page_still_replaces_each_one() {
|
||||
let (mut s, id) = open(page_with("BT /F1 12 Tf 72 700 Td (red apple) Tj 0 -60 Td (red pear) Tj 0 -60 Td (red plum) Tj ET"));
|
||||
let q = Replace { find: "red".into(), with: "green".into(), ..Default::default() };
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap();
|
||||
assert_eq!(plan.replaced, 3, "refusals: {:?}", plan.refusals);
|
||||
assert!(plan.refusals.is_empty(), "{:?}", plan.refusals);
|
||||
let edit = plan.edit.expect("an edit");
|
||||
s.apply(id, edit).unwrap();
|
||||
let texts: Vec<String> = s.get(id).unwrap().text_blocks(0).into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["green apple", "green pear", "green plum"]);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::{DocId, Session};
|
||||
|
||||
/// A one-page document with `content` and two fonts: `F1` Helvetica, `F2` Helvetica-Bold, and
|
||||
/// `F3` a subset of a face no computer has, with glyphs for `a` and `b` only.
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NoSuchFace /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
|
||||
.into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn open(bytes: Vec<u8>) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("replace.pdf", None, Arc::new(bytes), None).expect("opens");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn query(find: &str, with: &str) -> Replace {
|
||||
Replace { find: find.into(), with: with.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Plan and apply; the plan's counts.
|
||||
fn replace_all(s: &mut Session, id: DocId, q: &Replace) -> Replacement {
|
||||
let pages: Vec<usize> = (0..s.get(id).unwrap().info.pages.len()).collect();
|
||||
let plan = s.get(id).unwrap().plan_replace(&pages, q, Which::All, None).unwrap();
|
||||
if let Some(edit) = plan.edit.clone() {
|
||||
s.apply(id, edit).unwrap();
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
fn texts(s: &Session, id: DocId, page: usize) -> Vec<String> {
|
||||
s.get(id).unwrap().text_blocks(page).into_iter().map(|b| b.text).collect()
|
||||
}
|
||||
|
||||
/// The page text as the find bar reads it (the text layer's glyphs).
|
||||
fn page_text(s: &Session, id: DocId, page: usize) -> pdfcraft_render::PageText {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
out.text.map(|t| t.as_ref().clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_word_keeps_its_paragraphs_font_and_size() {
|
||||
let (mut s, id) = open(pdf("BT /F1 14 Tf 72 700 Td (The quick brown fox) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("quick", "slow"));
|
||||
assert_eq!((plan.replaced, plan.pages.clone(), plan.skipped_total()), (1, vec![0], 0));
|
||||
assert_eq!(texts(&s, id, 0), ["The slow brown fox"]);
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!((lines[0].font.as_str(), lines[0].base_font.as_str(), lines[0].size), ("F1", "Helvetica", 14.0));
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Replace all"));
|
||||
}
|
||||
|
||||
/// A red word and a bold name inside a paragraph keep their own style when words around them
|
||||
/// are replaced; a replacement inside the red word stays red.
|
||||
#[test]
|
||||
fn a_mixed_style_paragraph_keeps_the_other_runs_styles() {
|
||||
let content = "BT /F1 12 Tf 72 700 Td 0 g (Please pay the invoice ) Tj 1 0 0 rg (now) Tj 0 g (, before the end of the month,) Tj ET
|
||||
BT /F1 12 Tf 72 686 Td 0 g (as agreed with ) Tj /F2 12 Tf (Ada Lovelace) Tj /F1 12 Tf (.) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0).len(), 1, "one paragraph of five runs");
|
||||
let q = Replace { whole_words: true, ..query("the", "THE") };
|
||||
let plan = replace_all(&mut s, id, &q);
|
||||
assert_eq!(plan.replaced, 3);
|
||||
let plan = replace_all(&mut s, id, &query("now", "today"));
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert_eq!(texts(&s, id, 0), ["Please pay THE invoice today, before THE end of THE month, as agreed with Ada Lovelace."]);
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("no run with {word:?}: {lines:?}"));
|
||||
assert_eq!(run("today").color, [1.0, 0.0, 0.0], "the replacement takes the red run it lands in");
|
||||
assert_eq!(run("today").text.trim(), "today", "and only it is red");
|
||||
assert_eq!(run("Ada").base_font, "Helvetica-Bold");
|
||||
assert_eq!(run("Ada").text.trim(), "Ada Lovelace");
|
||||
assert_eq!(run("invoice").color, [0.0, 0.0, 0.0]);
|
||||
assert_eq!(run("invoice").base_font, "Helvetica");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_all_across_pages_is_one_undo_step() {
|
||||
let (mut s, id) = open(crate::tests::fixture(3));
|
||||
let before = s.get(id).unwrap().bytes.clone();
|
||||
let plan = replace_all(&mut s, id, &query("page", "Sheet"));
|
||||
assert_eq!((plan.replaced, plan.pages.clone()), (3, vec![0, 1, 2]));
|
||||
assert!(matches!(&plan.edit, Some(Edit::Batch { edits, .. }) if edits.len() == 3));
|
||||
for p in 0..3 {
|
||||
assert_eq!(texts(&s, id, p), [format!("Sheet {}", p + 1)]);
|
||||
}
|
||||
s.undo(id).unwrap();
|
||||
for p in 0..3 {
|
||||
assert_eq!(texts(&s, id, p), [format!("Page {}", p + 1)]);
|
||||
}
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), None, "one step undid all three pages");
|
||||
assert_eq!(s.get(id).unwrap().bytes, before, "the original bytes are back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_words_and_case_follow_the_find_options() {
|
||||
let content = "BT /F1 12 Tf 72 700 Td (cat Cats concat CAT) Tj ET";
|
||||
let count = |q: Replace| {
|
||||
let (s, id) = open(pdf(content));
|
||||
s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap().replaced
|
||||
};
|
||||
assert_eq!(count(query("cat", "dog")), 4);
|
||||
assert_eq!(count(Replace { whole_words: true, ..query("cat", "dog") }), 2);
|
||||
assert_eq!(count(Replace { case_sensitive: true, ..query("cat", "dog") }), 2);
|
||||
assert_eq!(count(Replace { case_sensitive: true, whole_words: true, ..query("CAT", "dog") }), 1);
|
||||
let (mut s, id) = open(pdf(content));
|
||||
replace_all(&mut s, id, &Replace { whole_words: true, ..query("cat", "dog") });
|
||||
assert_eq!(texts(&s, id, 0), ["dog Cats concat dog"]);
|
||||
// An empty replacement deletes the match.
|
||||
replace_all(&mut s, id, &query(" concat", ""));
|
||||
assert_eq!(texts(&s, id, 0), ["dog Cats dog"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_spanning_two_paragraphs_is_skipped_and_counted() {
|
||||
let content = "BT /F1 12 Tf 72 700 Td (The first ends here) Tj ET BT /F1 12 Tf 72 500 Td (second starts there) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0).len(), 2);
|
||||
let plan = replace_all(&mut s, id, &query("here second", "x"));
|
||||
assert_eq!(plan.replaced, 0);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::SpansParagraphs, 1)]));
|
||||
assert!(plan.edit.is_none());
|
||||
assert_eq!(texts(&s, id, 0), ["The first ends here", "second starts there"]);
|
||||
}
|
||||
|
||||
/// The subset in `F3` has glyphs for `a` and `b` only, from a face no computer has: a `c` would
|
||||
/// be drawn in Helvetica instead. That paragraph is left alone and counted, the other replaced.
|
||||
#[test]
|
||||
fn a_character_the_font_cannot_draw_is_skipped_and_counted() {
|
||||
let content = "BT /F3 12 Tf 72 700 Td (ab ba) Tj ET BT /F1 12 Tf 72 500 Td (ab in Helvetica) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
let before = texts(&s, id, 0);
|
||||
assert_eq!(before, ["ab ba", "ab in Helvetica"]);
|
||||
let plan = replace_all(&mut s, id, &query("ab", "cb"));
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)]));
|
||||
assert_eq!(texts(&s, id, 0), ["ab ba", "cb in Helvetica"]);
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert!(lines.iter().filter(|l| l.text.contains("ab ba")).all(|l| l.font == "F3"), "the subset paragraph keeps its font: {lines:?}");
|
||||
// What the subset can draw still goes in.
|
||||
let plan = replace_all(&mut s, id, &query("ab ba", "ba ab"));
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (1, 0));
|
||||
assert_eq!(texts(&s, id, 0)[0], "ba ab");
|
||||
}
|
||||
|
||||
/// The styled rewrite (a paragraph of several runs) reports the missing character as the
|
||||
/// one-style rewrite above does.
|
||||
#[test]
|
||||
fn a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs() {
|
||||
let (mut s, id) = open(pdf("BT /F3 12 Tf 72 700 Td (ab ) Tj /F1 12 Tf (and Helvetica) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("ab", "cb"));
|
||||
assert_eq!(plan.replaced, 0);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)]));
|
||||
assert!(plan.edit.is_none());
|
||||
}
|
||||
|
||||
/// A character Helvetica has no code for is never written as a box or a `?`: whether another font
|
||||
/// has it or none does, the match is left alone and reported.
|
||||
#[test]
|
||||
fn a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing() {
|
||||
for with in ["\u{65E5}\u{672C}", "\u{1F600}"] {
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("cat", with));
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (0, 1), "{with}: {plan:?}");
|
||||
assert!(plan.edit.is_none(), "{with}");
|
||||
assert_eq!(texts(&s, id, 0), ["one cat"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rects of the page's text lines holding `word`.
|
||||
fn line_rects(s: &Session, id: DocId, word: &str) -> Vec<[f64; 4]> {
|
||||
s.get(id).unwrap().text_lines(0).into_iter().filter(|l| l.text.contains(word)).map(|l| l.rect).collect()
|
||||
}
|
||||
|
||||
/// A paragraph of several rows keeps its width and gains rows below; what is under it stays where
|
||||
/// it was, so the new rows run over it.
|
||||
#[test]
|
||||
fn a_longer_replacement_adds_rows_and_moves_nothing_else() {
|
||||
let content =
|
||||
"BT /F1 12 Tf 14 TL 72 700 Td (alpha beta gamma delta) Tj T* (epsilon zeta eta theta) Tj ET BT /F1 12 Tf 72 650 Td (next paragraph) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0).len(), 2);
|
||||
let right = s.get(id).unwrap().text_blocks(0)[0].rect[2];
|
||||
let next = line_rects(&s, id, "next paragraph");
|
||||
let with = "a much longer replacement that takes several more rows than the paragraph has";
|
||||
let plan = replace_all(&mut s, id, &query("beta", with));
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (1, 0));
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
let rows: Vec<_> = lines.iter().filter(|l| !l.text.contains("next paragraph")).collect();
|
||||
assert!(rows.len() >= 5, "{rows:?}");
|
||||
assert!(rows.iter().all(|l| l.rect[2] <= right + 1.0), "the rows keep the width: {rows:?}");
|
||||
assert!(rows.iter().any(|l| l.rect[1] < next[0][1]), "the new rows go past what was below: {rows:?}");
|
||||
assert_eq!(line_rects(&s, id, "next paragraph"), next);
|
||||
}
|
||||
|
||||
/// One row grows to the right up to the margin, then wraps; a word wider than that stays whole.
|
||||
#[test]
|
||||
fn a_longer_replacement_grows_one_row_to_the_margin() {
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
|
||||
replace_all(&mut s, id, &query("short", "a line far wider than the word it took the place of"));
|
||||
assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1);
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
|
||||
replace_all(&mut s, id, &query("short", &"many words in a row ".repeat(8)));
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert!(lines.len() > 1 && lines.iter().all(|l| l.rect[2] <= 595.0 - 36.0 + 1.0), "{lines:?}");
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
|
||||
replace_all(&mut s, id, &query("short", &"w".repeat(80)));
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].rect[2] > 595.0, "the word runs past the page: {lines:?}");
|
||||
}
|
||||
|
||||
/// A match can run over a row's end hyphen, typed as the find bar reads it (the break is a space);
|
||||
/// Replace all then folds case, joins the rows and counts what the find bar counts. The word read
|
||||
/// unhyphenated is also a find-bar match, but no paragraph's text holds it: left alone and counted.
|
||||
#[test]
|
||||
fn case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end() {
|
||||
let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE and an Exam-) Tj T* (ple to end) Tj ET";
|
||||
let (mut s, id) = open(pdf(content));
|
||||
assert_eq!(texts(&s, id, 0), ["An EXAMPLE and an Example to end"]);
|
||||
let text = page_text(&s, id, 0);
|
||||
let whole = query("example", "sample");
|
||||
let found = BTreeMap::from([(0, text.find_opts(&whole.find, false, false).len())]);
|
||||
assert_eq!(found[&0], 2);
|
||||
let none = s.get(id).unwrap().plan_replace(&[0], &whole, Which::All, Some(&found)).unwrap();
|
||||
assert_eq!((none.replaced, none.skipped_total(), none.edit.is_none()), (0, 2, true));
|
||||
let q = query("exam- ple", "sample");
|
||||
let found = BTreeMap::from([(0, text.find_opts(&q.find, false, false).len())]);
|
||||
assert_eq!(found[&0], 2);
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, Some(&found)).unwrap();
|
||||
assert_eq!((plan.replaced, plan.skipped_total()), (2, 0));
|
||||
s.apply(id, plan.edit.unwrap()).unwrap();
|
||||
assert_eq!(texts(&s, id, 0), ["An sample and an sample to end"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_text_over_a_scan_is_skipped() {
|
||||
let (mut s, id) = open(pdf("BT 3 Tr /F1 12 Tf 72 700 Td (recognized words) Tj ET"));
|
||||
let plan = replace_all(&mut s, id, &query("words", "text"));
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::ScannedText, 1)]));
|
||||
assert!(plan.edit.is_none());
|
||||
}
|
||||
|
||||
/// What the find bar counts on a page beyond what the paragraphs hold is reported as not
|
||||
/// editable text (a form field, a comment), so the counts add up to what was highlighted.
|
||||
#[test]
|
||||
fn matches_the_paragraphs_dont_hold_are_counted_as_not_editable() {
|
||||
let (s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET"));
|
||||
let found = BTreeMap::from([(0, 3)]);
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), Which::All, Some(&found)).unwrap();
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 2)]));
|
||||
}
|
||||
|
||||
/// Replace (one match): the highlighted one, found by where the find bar's glyphs are, even when
|
||||
/// the paragraph holds the word twice.
|
||||
#[test]
|
||||
fn the_highlighted_match_is_the_one_replaced() {
|
||||
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (cat and cat) Tj ET BT /F1 12 Tf 72 500 Td (no match) Tj ET"));
|
||||
let text = page_text(&s, id, 0);
|
||||
let found = text.find("cat");
|
||||
assert_eq!(found.len(), 2);
|
||||
let centre = |g: usize| {
|
||||
let r = text.glyphs[g].rect;
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
};
|
||||
let second = &found[1];
|
||||
let which = Which::At { page: 0, start: centre(second.start), end: centre(second.end - 1) };
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), which, None).unwrap();
|
||||
assert_eq!(plan.replaced, 1);
|
||||
assert!(matches!(&plan.edit, Some(Edit::EditTextBlock { block: 0, .. })), "one paragraph edit, not a batch: {:?}", plan.edit);
|
||||
s.apply(id, plan.edit.unwrap()).unwrap();
|
||||
assert_eq!(texts(&s, id, 0), ["cat and dog", "no match"]);
|
||||
// A point in no paragraph is not editable text.
|
||||
let away = Which::At { page: 0, start: [500.0, 20.0], end: [510.0, 20.0] };
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), away, None).unwrap();
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_replaces_only_the_first_match_in_document_order() {
|
||||
let (mut s, id) = open(crate::tests::fixture(2));
|
||||
let q = query("page", "Sheet");
|
||||
let texts_of: Vec<_> = (0..2).map(|p| page_text(&s, id, p)).collect();
|
||||
let which = Which::first_in(texts_of.iter().enumerate(), &q).expect("the find bar has a match");
|
||||
assert!(matches!(which, Which::At { page: 0, .. }));
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0, 1], &q, which, None).unwrap();
|
||||
assert_eq!((plan.replaced, plan.pages.clone()), (1, vec![0]));
|
||||
s.apply(id, plan.edit.unwrap()).unwrap();
|
||||
assert_eq!((texts(&s, id, 0), texts(&s, id, 1)), (vec!["Sheet 1".to_string()], vec!["Page 2".to_string()]));
|
||||
assert!(Which::first_in(texts_of.iter().enumerate(), &query("absent", "x")).is_none());
|
||||
}
|
||||
|
||||
/// The first match is the find bar's, not the first one a paragraph holds: when it is a match no
|
||||
/// paragraph holds (a word split by a line-end hyphen), it is reported and nothing is replaced,
|
||||
/// not the later paragraph that has the word whole.
|
||||
#[test]
|
||||
fn first_does_not_skip_a_match_no_paragraph_holds_for_a_later_one() {
|
||||
let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE to end) Tj ET BT /F1 12 Tf 72 400 Td (a second example) Tj ET";
|
||||
let (s, id) = open(pdf(content));
|
||||
let q = query("example", "sample");
|
||||
let text = page_text(&s, id, 0);
|
||||
assert_eq!(text.find_opts("example", false, false).len(), 2, "the find bar has both");
|
||||
let which = Which::first_in([(0, &text)], &q).unwrap();
|
||||
let plan = s.get(id).unwrap().plan_replace(&[0], &q, which, None).unwrap();
|
||||
assert_eq!((plan.replaced, plan.edit.is_none()), (0, true), "{plan:?}");
|
||||
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)]));
|
||||
assert_eq!(texts(&s, id, 0).last().unwrap(), "a second example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_find_is_refused() {
|
||||
let (s, id) = open(crate::tests::fixture(1));
|
||||
assert!(matches!(s.get(id).unwrap().plan_replace(&[0], &query(" ", "x"), Which::All, None), Err(EditError::Invalid(_))));
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod error;
|
||||
pub mod export;
|
||||
pub mod field_table;
|
||||
mod fill_sign;
|
||||
pub mod find_replace;
|
||||
mod history;
|
||||
pub mod js;
|
||||
mod layers;
|
||||
@@ -55,6 +56,7 @@ mod session;
|
||||
pub mod signature_image;
|
||||
pub mod signature_scan;
|
||||
mod sources;
|
||||
pub mod spooler;
|
||||
mod stamp_preview;
|
||||
#[cfg(test)]
|
||||
mod test_hooks;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! The print-ready PDF as the system spooler gets it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_full};
|
||||
|
||||
use crate::guard;
|
||||
|
||||
/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler.
|
||||
/// On Windows, whose PDF renderer prints an image of more than 2²² pixels from a shrunk copy,
|
||||
/// large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131), so that a
|
||||
/// scan prints at the printer's resolution. Elsewhere, with no large image, and when the copy
|
||||
/// can't be read or written, the bytes go as they are: tiling never stops a print.
|
||||
pub fn spooler_copy(bytes: Vec<u8>) -> Vec<u8> {
|
||||
if !cfg!(windows) {
|
||||
return bytes;
|
||||
}
|
||||
let bytes = Arc::new(bytes);
|
||||
// Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy.
|
||||
tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone()))
|
||||
}
|
||||
|
||||
/// `bytes` with their large images drawn as tiles, or `None` when there is none, or the copy
|
||||
/// can't be read or written.
|
||||
pub fn tiled(bytes: Arc<Vec<u8>>) -> Option<Vec<u8>> {
|
||||
guard(|| {
|
||||
let mut doc = pdfcraft_cos::Document::open(bytes).ok()?;
|
||||
let report = pdfcraft_optimize::tile_large_images(&mut doc);
|
||||
(report.tiled > 0).then(|| write_full(&doc, &SaveOptions::default()).ok()).flatten()
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A one-page PDF drawing a `w` × `h` grey image over the page.
|
||||
fn page_with_image(w: i64, h: i64) -> Vec<u8> {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||||
let mut image = Dict::new();
|
||||
image.set("Type", Object::name("XObject"));
|
||||
image.set("Subtype", Object::name("Image"));
|
||||
image.set("Width", Object::Int(w));
|
||||
image.set("Height", Object::Int(h));
|
||||
image.set("ColorSpace", Object::name("DeviceGray"));
|
||||
image.set("BitsPerComponent", Object::Int(8));
|
||||
let samples: Vec<u8> = (0..w * h).map(|i| (i % 251) as u8).collect();
|
||||
let image = doc.add(Object::Stream(Stream::flate(image, &samples)));
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set("Im", Object::Ref(image));
|
||||
let mut resources = Dict::new();
|
||||
resources.set("XObject", Object::Dict(xobjects));
|
||||
let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 612 0 0 792 0 0 cm /Im Do Q".to_vec())));
|
||||
let mut page = Dict::new();
|
||||
page.set("Type", Object::name("Page"));
|
||||
page.set("Parent", Object::Ref(pages));
|
||||
page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()));
|
||||
page.set("Resources", Object::Dict(resources));
|
||||
page.set("Contents", Object::Ref(content));
|
||||
let page = doc.add(Object::Dict(page));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set("Count", Object::Int(1));
|
||||
d.set("Kids", Object::Array(vec![Object::Ref(page)]));
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_image_goes_to_the_spooler_as_tiles() {
|
||||
let tiled = tiled(Arc::new(page_with_image(2100, 2100))).expect("tiled");
|
||||
let doc = Document::open(Arc::new(tiled)).unwrap();
|
||||
let largest = doc
|
||||
.scan_objects()
|
||||
.filter_map(|(_, o)| match &*o {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"Width")? * s.dict.int(b"Height")?),
|
||||
_ => None,
|
||||
})
|
||||
.max();
|
||||
// The page's image is a form now; its tiles are the images it draws.
|
||||
assert_eq!(largest, Some(2001 * 2001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_with_nothing_to_tile_or_unreadable_goes_as_it_is() {
|
||||
assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None);
|
||||
assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None);
|
||||
assert_eq!(spooler_copy(b"not a PDF".to_vec()), b"not a PDF");
|
||||
let small = page_with_image(100, 100);
|
||||
assert_eq!(spooler_copy(small.clone()), small);
|
||||
}
|
||||
|
||||
/// On Windows the spooler gets the tiled copy ([`tiled`]); elsewhere the bytes as they are.
|
||||
#[test]
|
||||
fn the_spooler_gets_tiles_on_windows_only() {
|
||||
let large = page_with_image(2100, 2100);
|
||||
let sent = spooler_copy(large.clone());
|
||||
if cfg!(windows) {
|
||||
assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref());
|
||||
assert_ne!(sent, large);
|
||||
} else {
|
||||
assert_eq!(sent, large);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,16 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
action; a bookmark keeps its title and children. Links within the document and web links
|
||||
(`URI`) stay. An outline of more than 100,000 bookmarks stops the optimization with an error.
|
||||
|
||||
Apart from optimizing, `tile_large_images` redraws large pictures for Windows printing (#5131).
|
||||
Windows' PDF renderer draws an image of more than 2²² pixels from a copy shrunk to that size, so
|
||||
in the copy of a print that goes to the spooler (`pdfcraft_engine::spooler::spooler_copy`) each
|
||||
such image becomes a form XObject of the same object number drawing tiles, one every 2000 pixels.
|
||||
Each overlaps the next by a pixel, against seams on drivers that soften image edges, so a tile has
|
||||
at most 2001 pixels a side; except for an image with a soft mask, whose see-through pixels would
|
||||
be blended twice there. A soft or stencil
|
||||
mask of the image's size is cut the same way. JPEG 2000, JPEGs that aren't grey or RGB, masks of
|
||||
another size and images over 80 million pixels are left whole.
|
||||
|
||||
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
|
||||
then this, then merges identical objects and writes a full, compressed save
|
||||
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
//! runs alongside. Links that open another document, and bookmark actions that do, are discarded
|
||||
//! here (Discard User Data ▸ Discard external cross-references, see
|
||||
//! [`Settings::discard_external_refs`]).
|
||||
//!
|
||||
//! Apart from optimizing, [`tile_large_images`] redraws large pictures as tiles in the copy of a
|
||||
//! document printed on Windows, whose PDF renderer shrinks any image over 2²² pixels.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -40,6 +43,7 @@ mod images;
|
||||
mod links;
|
||||
mod pool;
|
||||
mod samples;
|
||||
mod tiles;
|
||||
mod unused;
|
||||
|
||||
pub use audit::{SpaceCategory, SpaceUse, audit_space};
|
||||
@@ -50,6 +54,7 @@ use pdfcraft_filters::{Filter, Params};
|
||||
pub use images::{ImageTarget, effective_resolutions, recolour_image};
|
||||
pub use pool::give_way_to_other_programs;
|
||||
pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
|
||||
pub use tiles::{DOWNSAMPLED_ABOVE, TileReport, tile_large_images};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OptimizeError {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Large pictures drawn as tiles, for Windows printing (#5131).
|
||||
//!
|
||||
//! Windows prints each page through its own PDF renderer (`Windows.Data.Pdf`), which draws an
|
||||
//! image of more than 2²² pixels from a copy shrunk to that size, however finely the page is
|
||||
//! printed: a 600 ppi scan of a page prints at about 200 ppi. Drawn as tiles under that size, the
|
||||
//! same picture keeps every pixel, and tiles that meet leave no seam (measured in #5131).
|
||||
//! [`tile_large_images`] does this to the copy of a document that goes to the spooler.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
|
||||
use pdfcraft_filters::Filter;
|
||||
|
||||
use crate::images::{self, MAX_PIXELS};
|
||||
|
||||
/// Images of more pixels than this are printed from a smaller copy on Windows: every larger image
|
||||
/// in #5131 came out at 4,194,304 pixels.
|
||||
pub const DOWNSAMPLED_ABOVE: u64 = 1 << 22;
|
||||
|
||||
/// A tile's side in pixels, before its overlap. With the overlap a tile has at most 2001 × 2001
|
||||
/// pixels, under [`DOWNSAMPLED_ABOVE`]. A multiple of 8, so that in every row a tile's samples
|
||||
/// start on a byte whatever the image's bits per pixel.
|
||||
const TILE: u32 = 2000;
|
||||
|
||||
/// Pixels each tile reaches into the next one, so that rounding in the tiles' matrices, or a
|
||||
/// printer driver that softens an image's edges, can't open a seam between them.
|
||||
/// `Windows.Data.Pdf` itself leaves none either way, turned and scaled too (#5131). Not for an image
|
||||
/// with a soft mask: where two tiles overlap, a pixel the mask makes see-through would be blended
|
||||
/// twice and come out darker. The same goes for an opaque image drawn see-through (`/ca` below 1),
|
||||
/// which this can't tell from the image: a faint line one pixel wide, against a seam on paper for
|
||||
/// every tiled image on such a driver.
|
||||
const OVERLAP: u32 = 1;
|
||||
|
||||
/// What [`tile_large_images`] did.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TileReport {
|
||||
/// Images now drawn as tiles.
|
||||
pub tiled: usize,
|
||||
/// Images over [`DOWNSAMPLED_ABOVE`] left whole: JPEG 2000, a JPEG that isn't grey or RGB or
|
||||
/// whose colour transform readers disagree about, a mask of another size than its image, more
|
||||
/// than 80 million pixels, data shorter than the image, or past the decode allowance.
|
||||
pub left_whole: usize,
|
||||
}
|
||||
|
||||
/// Replaces every image XObject of more than [`DOWNSAMPLED_ABOVE`] pixels with a form XObject of
|
||||
/// the same object number that draws it as tiles of at most [`TILE`] pixels a side, overlapping
|
||||
/// by [`OVERLAP`] unless it has a soft mask. Whatever draws the image (pages, forms, patterns, appearances) then draws the
|
||||
/// tiles, unchanged. The samples are copied as they are, colour space, decode array and
|
||||
/// interpolation included; a JPEG's are decoded and stored with Flate. A soft mask or stencil
|
||||
/// mask of the image's size is cut into the same tiles. An image used as another's mask goes with
|
||||
/// that image, and is otherwise left whole, as are the images [`TileReport::left_whole`] lists.
|
||||
pub fn tile_large_images(doc: &mut Document) -> TileReport {
|
||||
let mut budget = ImageBudget::new(doc);
|
||||
let mut large = Vec::new();
|
||||
let mut masks = HashSet::new();
|
||||
for (r, object) in doc.scan_objects() {
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
if s.dict.name(b"Subtype") != Some(b"Image") {
|
||||
continue;
|
||||
}
|
||||
masks.extend([b"SMask".as_slice(), b"Mask"].into_iter().filter_map(|key| s.dict.reference(key)));
|
||||
if size(s).is_some_and(|(w, h)| u64::from(w) * u64::from(h) > DOWNSAMPLED_ABOVE) {
|
||||
large.push(r);
|
||||
}
|
||||
}
|
||||
let mut report = TileReport::default();
|
||||
for r in large.into_iter().filter(|r| !masks.contains(r)) {
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(image) = &*object else { continue };
|
||||
match tiles(doc, image, &mut budget) {
|
||||
Some(tiles) => {
|
||||
let form = tiles.store(doc, image);
|
||||
doc.set(r, Object::Stream(form));
|
||||
report.tiled += 1;
|
||||
}
|
||||
None => report.left_whole += 1,
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// An image's `/Width` and `/Height`, when both are positive.
|
||||
fn size(s: &Stream) -> Option<(u32, u32)> {
|
||||
let side = |key: &[u8]| s.dict.int(key).and_then(|v| u32::try_from(v).ok()).filter(|v| *v > 0);
|
||||
Some((side(b"Width")?, side(b"Height")?))
|
||||
}
|
||||
|
||||
/// An image's samples, decoded: rows of `bits_per_pixel` × width bits each, padded to a byte.
|
||||
/// Kept as the image stores them, in its own colour space and depth (CMYK, indexed, 16-bit, a
|
||||
/// stencil mask), so each tile redraws exactly the picture it replaces. That is why this is not
|
||||
/// the crate's [`crate::Samples`], which turns an image into 8-bit grey or RGB for tools that
|
||||
/// repaint it and refuses the rest.
|
||||
struct StoredSamples {
|
||||
data: Vec<u8>,
|
||||
bits_per_pixel: usize,
|
||||
/// Bits per colour component as stored: 8 for a decoded JPEG.
|
||||
bits_per_component: i64,
|
||||
}
|
||||
|
||||
impl StoredSamples {
|
||||
/// Image `s`, `w` × `h`, decoded. A JPEG gives eight-bit grey or RGB ([`images::pixels`]),
|
||||
/// anything else its samples as they are stored ([`ImageBudget::decode`]). A soft mask is grey
|
||||
/// whether or not it says so: ISO 32000-2 requires `/ColorSpace /DeviceGray` of one.
|
||||
fn decode(doc: &Document, s: &Stream, (w, h): (u32, u32), soft_mask: bool, budget: &mut ImageBudget) -> Option<StoredSamples> {
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let stencil = matches!(s.dict.get(b"ImageMask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Bool(true)));
|
||||
let components = |colour_space: fn(&Document, &Object) -> Option<usize>| match s.dict.get(b"ColorSpace") {
|
||||
Some(cs) => colour_space(doc, cs),
|
||||
None => soft_mask.then_some(1),
|
||||
};
|
||||
match s.filters().last().map(|(f, _)| f.clone()) {
|
||||
Some(Filter::Jpx) => None,
|
||||
Some(Filter::Dct) if !stencil => {
|
||||
let components = components(images::components)?;
|
||||
let data = images::pixels(doc, s, components, budget)?;
|
||||
Some(StoredSamples { data, bits_per_pixel: components.checked_mul(8)?, bits_per_component: 8 })
|
||||
}
|
||||
Some(Filter::Dct) => None,
|
||||
_ => {
|
||||
let (components, bits_per_component) =
|
||||
if stencil { (1, 1) } else { (components(sample_components)?, s.dict.int(b"BitsPerComponent")?) };
|
||||
if !matches!(bits_per_component, 1 | 2 | 4 | 8 | 16) {
|
||||
return None;
|
||||
}
|
||||
let bits_per_pixel = components.checked_mul(usize::try_from(bits_per_component).ok()?)?;
|
||||
let len = row_bytes(w, bits_per_pixel)?.checked_mul(usize::try_from(h).ok()?)?;
|
||||
let mut data = budget.decode(doc, s, images::allowance(len, h)?).ok()?;
|
||||
// Fewer bytes would leave rows unpainted; readers ignore what follows the last row.
|
||||
if data.len() < len {
|
||||
return None;
|
||||
}
|
||||
data.truncate(len);
|
||||
Some(StoredSamples { data, bits_per_pixel, bits_per_component })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples of columns `x` and rows `y` (start and end), rows padded to a byte. `x.0` is a
|
||||
/// multiple of [`TILE`], so of 8: each row of the tile starts on a byte of the image's row.
|
||||
fn cut(&self, w: u32, x: (u32, u32), y: (u32, u32)) -> Option<Vec<u8>> {
|
||||
let stride = row_bytes(w, self.bits_per_pixel)?;
|
||||
let start = usize::try_from(x.0).ok()?.checked_mul(self.bits_per_pixel)? / 8;
|
||||
let len = row_bytes(x.1.checked_sub(x.0)?, self.bits_per_pixel)?;
|
||||
let mut out = Vec::with_capacity(len.checked_mul(usize::try_from(y.1.checked_sub(y.0)?).ok()?)?);
|
||||
for row in y.0..y.1 {
|
||||
let from = usize::try_from(row).ok()?.checked_mul(stride)?.checked_add(start)?;
|
||||
out.extend_from_slice(self.data.get(from..from.checked_add(len)?)?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes in a row of `w` pixels of `bits_per_pixel` bits, padded to a byte.
|
||||
fn row_bytes(w: u32, bits_per_pixel: usize) -> Option<usize> {
|
||||
Some(usize::try_from(w).ok()?.checked_mul(bits_per_pixel)?.div_ceil(8))
|
||||
}
|
||||
|
||||
/// Colour components in a sample of colour space `cs` (ISO 32000-2 §8.6): `None` for a space an
|
||||
/// image can't be in, or one that can't be read.
|
||||
fn sample_components(doc: &Document, cs: &Object) -> Option<usize> {
|
||||
match &*doc.resolve(cs) {
|
||||
Object::Name(n) => match n.as_slice() {
|
||||
b"DeviceGray" | b"G" => Some(1),
|
||||
b"DeviceRGB" | b"RGB" => Some(3),
|
||||
b"DeviceCMYK" | b"CMYK" => Some(4),
|
||||
_ => None,
|
||||
},
|
||||
Object::Array(a) => match doc.resolve(a.first()?).as_name()? {
|
||||
b"CalGray" | b"Indexed" | b"I" | b"Separation" => Some(1),
|
||||
b"CalRGB" | b"Lab" => Some(3),
|
||||
b"ICCBased" => match &*doc.resolve(a.get(1)?) {
|
||||
Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| (1..=32).contains(n)),
|
||||
_ => None,
|
||||
},
|
||||
b"DeviceN" => Some(doc.resolve(a.get(1)?).as_array()?.len()).filter(|n| (1..=32).contains(n)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The spans, start and end, an image side of `len` pixels is cut into: one every [`TILE`],
|
||||
/// each `overlap` longer where the side goes on. None lies wholly inside the one before's overlap.
|
||||
fn spans(len: u32, overlap: u32) -> impl Iterator<Item = (u32, u32)> {
|
||||
(0..len.saturating_sub(overlap).div_ceil(TILE).max(1)).map(move |i| {
|
||||
let start = i.saturating_mul(TILE);
|
||||
(start, start.saturating_add(TILE).saturating_add(overlap).min(len))
|
||||
})
|
||||
}
|
||||
|
||||
/// One tile of an image: the columns and rows it covers (start and end), its samples, and its
|
||||
/// mask's when the image has a mask stream.
|
||||
struct Tile {
|
||||
x: (u32, u32),
|
||||
y: (u32, u32),
|
||||
image: Stream,
|
||||
mask: Option<Stream>,
|
||||
}
|
||||
|
||||
/// An image cut into tiles, with its mask cut the same way, ready to be stored.
|
||||
struct Tiles {
|
||||
size: (u32, u32),
|
||||
tiles: Vec<Tile>,
|
||||
/// The key the image names its mask by: `SMask` or `Mask`.
|
||||
mask_key: &'static [u8],
|
||||
}
|
||||
|
||||
/// Image `image` cut into tiles, or `None` when it can't be read faithfully.
|
||||
fn tiles(doc: &Document, image: &Stream, budget: &mut ImageBudget) -> Option<Tiles> {
|
||||
let dims = size(image)?;
|
||||
let samples = StoredSamples::decode(doc, image, dims, false, budget)?;
|
||||
// A soft mask overrides `/Mask` (ISO 32000-2, the image dictionary's `/SMask`). A mask stream
|
||||
// goes into the same tiles, so it has to have the image's size.
|
||||
let soft = image.dict.contains(b"SMask");
|
||||
let mask_key: &'static [u8] = if soft { b"SMask" } else { b"Mask" };
|
||||
let mask_object = image.dict.get(mask_key).map(|m| doc.resolve(m));
|
||||
let mask = match mask_object.as_deref() {
|
||||
Some(Object::Stream(m)) if size(m) == Some(dims) => Some((m, StoredSamples::decode(doc, m, dims, soft, budget)?)),
|
||||
// A colour key mask goes on each tile as it is (`tile_dict`).
|
||||
Some(Object::Array(_)) if !soft => None,
|
||||
None | Some(Object::Null) => None,
|
||||
Some(_) => return None,
|
||||
};
|
||||
let mut tiles = Vec::new();
|
||||
// Overlapping tiles blend a see-through pixel twice ([`OVERLAP`]).
|
||||
let overlap = if soft { 0 } else { OVERLAP };
|
||||
for y in spans(dims.1, overlap) {
|
||||
for x in spans(dims.0, overlap) {
|
||||
let tile = |s: &Stream, samples: &StoredSamples| -> Option<Stream> {
|
||||
let dict = tile_dict(doc, &s.dict, (x.1.saturating_sub(x.0), y.1.saturating_sub(y.0)), samples.bits_per_component);
|
||||
Some(Stream::flate(dict, &samples.cut(dims.0, x, y)?))
|
||||
};
|
||||
let mask_tile = match &mask {
|
||||
Some((m, samples)) => Some(tile(m, samples)?),
|
||||
None => None,
|
||||
};
|
||||
tiles.push(Tile { x, y, image: tile(image, &samples)?, mask: mask_tile });
|
||||
}
|
||||
}
|
||||
Some(Tiles { size: dims, tiles, mask_key })
|
||||
}
|
||||
|
||||
/// The dictionary of a tile `w` × `h` of the image whose dictionary is `image`: what paints its
|
||||
/// samples, and nothing that belongs to the image as a whole (its mask goes on separately; its
|
||||
/// optional content, structure and metadata stay with the form that replaces it).
|
||||
fn tile_dict(doc: &Document, image: &Dict, (w, h): (u32, u32), bits_per_component: i64) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set("Type", Object::name("XObject"));
|
||||
d.set("Subtype", Object::name("Image"));
|
||||
d.set("Width", Object::Int(i64::from(w)));
|
||||
d.set("Height", Object::Int(i64::from(h)));
|
||||
d.set("BitsPerComponent", Object::Int(bits_per_component));
|
||||
for key in [b"ColorSpace".as_slice(), b"Decode", b"ImageMask", b"Interpolate", b"Intent", b"Matte"] {
|
||||
if let Some(v) = image.get(key) {
|
||||
d.set(key.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
// A colour key mask lists sample values, which the tiles keep.
|
||||
if let Some(Object::Array(key)) = image.get(b"Mask").map(|m| doc.resolve(m)).as_deref() {
|
||||
d.set("Mask", Object::Array(key.clone()));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
impl Tiles {
|
||||
/// Adds the tiles to `doc` and returns the form XObject that draws them where `image` was: the
|
||||
/// unit square, each tile in its share of it, the image's top row at the top.
|
||||
fn store(self, doc: &mut Document, image: &Stream) -> Stream {
|
||||
let (w, h) = (f64::from(self.size.0), f64::from(self.size.1));
|
||||
let mut names = Dict::new();
|
||||
let mut content = String::new();
|
||||
for (i, Tile { x, y, image: mut tile, mask }) in self.tiles.into_iter().enumerate() {
|
||||
if let Some(mask) = mask {
|
||||
tile.dict.set(self.mask_key.to_vec(), Object::Ref(doc.add(Object::Stream(mask))));
|
||||
}
|
||||
names.set(format!("T{i}").into_bytes(), Object::Ref(doc.add(Object::Stream(tile))));
|
||||
let [a, d] = [f64::from(x.1.saturating_sub(x.0)) / w, f64::from(y.1.saturating_sub(y.0)) / h];
|
||||
let [e, f] = [f64::from(x.0) / w, 1.0 - f64::from(y.1) / h];
|
||||
// Writing to a String can't fail.
|
||||
let _ = writeln!(content, "q {} 0 0 {} {} {} cm /T{i} Do Q", number(a), number(d), number(e), number(f));
|
||||
}
|
||||
let mut resources = Dict::new();
|
||||
resources.set("XObject", Object::Dict(names));
|
||||
let mut form = Dict::new();
|
||||
form.set("Type", Object::name("XObject"));
|
||||
form.set("Subtype", Object::name("Form"));
|
||||
form.set("BBox", Object::Array([0, 0, 1, 1].map(Object::Int).to_vec()));
|
||||
form.set("Resources", Object::Dict(resources));
|
||||
if let Some(oc) = image.dict.get(b"OC") {
|
||||
form.set("OC", oc.clone());
|
||||
}
|
||||
Stream::flate(form, content.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` in a content stream: ten decimals, enough for a pixel of an image 80 million wide, trailing
|
||||
/// zeros dropped.
|
||||
fn number(v: f64) -> String {
|
||||
let s = format!("{v:.10}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sides_are_cut_every_tile_with_an_overlap() {
|
||||
let spans = |len| spans(len, OVERLAP).collect::<Vec<_>>();
|
||||
assert_eq!(spans(1), [(0, 1)]);
|
||||
assert_eq!(spans(2000), [(0, 2000)]);
|
||||
// The 2001st pixel is the first tile's overlap: no tile of its own.
|
||||
assert_eq!(spans(2001), [(0, 2001)]);
|
||||
assert_eq!(spans(2002), [(0, 2001), (2000, 2002)]);
|
||||
assert_eq!(spans(6000), [(0, 2001), (2000, 4001), (4000, 6000)]);
|
||||
// Every pixel is in a tile, and no tile has more than its side and the overlap.
|
||||
for len in [1, 1999, 2000, 2001, 2002, 4001, 4002, 6000, 123_457] {
|
||||
let s = spans(len);
|
||||
let zero: Vec<_> = super::spans(len, 0).collect();
|
||||
assert!(zero.windows(2).all(|w| w[1].0 == w[0].1), "{len}: tiles without an overlap meet exactly");
|
||||
assert_eq!(s.first().map(|t| t.0), Some(0));
|
||||
assert_eq!(s.last().map(|t| t.1), Some(len));
|
||||
assert!(s.windows(2).all(|w| w[1].0 < w[0].1), "{len}: a gap");
|
||||
assert!(s.iter().all(|(a, b)| b - a <= TILE + OVERLAP && a % 8 == 0), "{len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_takes_whole_bytes_of_each_row_at_any_bit_depth() {
|
||||
// Two rows of 4004 one-bit pixels: 501 bytes each, the last holding 4 pixels.
|
||||
let row: Vec<u8> = (0..501).map(|i| i as u8).collect();
|
||||
let samples = StoredSamples { data: [row.clone(), row.iter().map(|b| !b).collect()].concat(), bits_per_pixel: 1, bits_per_component: 1 };
|
||||
let tile = samples.cut(4004, (4000, 4004), (0, 2)).unwrap();
|
||||
assert_eq!(tile, [row[500], !row[500]]);
|
||||
let first = samples.cut(4004, (0, 2001), (1, 2)).unwrap();
|
||||
assert_eq!(first.len(), 251);
|
||||
assert_eq!(first[0], !row[0]);
|
||||
// Sixteen-bit RGB: six bytes a pixel.
|
||||
let rgb16 = StoredSamples { data: (0..=255u8).cycle().take(6 * 2002).collect(), bits_per_pixel: 48, bits_per_component: 16 };
|
||||
assert_eq!(rgb16.cut(2002, (2000, 2002), (0, 1)).unwrap(), rgb16.data[12_000..12_012]);
|
||||
// Rows past the data are no tile, not a panic.
|
||||
assert_eq!(samples.cut(4004, (0, 8), (1, 3)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_written_plainly() {
|
||||
assert_eq!(number(0.0), "0");
|
||||
assert_eq!(number(1.0), "1");
|
||||
assert_eq!(number(0.5), "0.5");
|
||||
assert_eq!(number(2001.0 / 35_000.0), "0.0571714286");
|
||||
assert_eq!(number(1.0 - 4001.0 / 6000.0), "0.3331666667");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Large pictures drawn as tiles for Windows printing (#5131): the page looks the same, and no
|
||||
//! image it draws is over the size Windows' renderer shrinks.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
use pdfcraft_optimize::{DOWNSAMPLED_ABOVE, TileReport, tile_large_images};
|
||||
|
||||
/// 4200 × 1000: just over [`DOWNSAMPLED_ABOVE`], three tiles wide.
|
||||
const W: u32 = 4200;
|
||||
const H: u32 = 1000;
|
||||
|
||||
fn image_dict(w: u32, h: u32, entries: &[(&str, Object)]) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set("Type", Object::name("XObject"));
|
||||
d.set("Subtype", Object::name("Image"));
|
||||
d.set("Width", Object::Int(i64::from(w)));
|
||||
d.set("Height", Object::Int(i64::from(h)));
|
||||
for (k, v) in entries {
|
||||
d.set(*k, v.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Samples that change at every pixel, so a tile out of place by one shows: `n` bytes a pixel.
|
||||
fn texture(w: u32, h: u32, n: usize) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).flat_map(move |x| (0..n).map(move |c| (x.wrapping_mul(7) ^ y.wrapping_mul(13) ^ (c as u32 * 85)) as u8))).collect()
|
||||
}
|
||||
|
||||
/// A document with one page `w` × `h` points that draws image `im` (added by `add`) over it
|
||||
/// after `before` (a fill colour, say), so that at scale 1 each sample is a pixel.
|
||||
fn page_drawing(w: u32, h: u32, before: &str, add: impl FnOnce(&mut Document) -> ObjRef) -> (Document, ObjRef) {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||||
let im = add(&mut doc);
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set("Im", Object::Ref(im));
|
||||
let mut resources = Dict::new();
|
||||
resources.set("XObject", Object::Dict(xobjects));
|
||||
let content = format!("{before} q {w} 0 0 {h} 0 0 cm /Im Do Q");
|
||||
let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.into_bytes())));
|
||||
let mut p = Dict::new();
|
||||
p.set("Type", Object::name("Page"));
|
||||
p.set("Parent", Object::Ref(pages));
|
||||
p.set("MediaBox", Object::Array(vec![0.into(), 0.into(), Object::Int(i64::from(w)), Object::Int(i64::from(h))]));
|
||||
p.set("Resources", Object::Dict(resources));
|
||||
p.set("Contents", Object::Ref(c));
|
||||
let r = doc.add(Object::Dict(p));
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set("Count", Object::Int(1));
|
||||
d.set("Kids", Object::Array(vec![Object::Ref(r)]));
|
||||
})
|
||||
.unwrap();
|
||||
(doc, im)
|
||||
}
|
||||
|
||||
fn render(doc: &Document) -> Vec<u8> {
|
||||
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
out.rgba.to_vec()
|
||||
}
|
||||
|
||||
/// Tiles the image of `doc`, then checks that the image is now a form that draws `count` tiles under
|
||||
/// the cap, and that the page renders as before, within `tolerance` a channel.
|
||||
fn tiles_and_looks_the_same(mut doc: Document, im: ObjRef, count: usize, tolerance: u8) {
|
||||
let before = render(&doc);
|
||||
assert_eq!(tile_large_images(&mut doc), TileReport { tiled: 1, left_whole: 0 });
|
||||
let form = doc.get(im);
|
||||
let Object::Stream(form) = &*form else { panic!("the image isn't a stream") };
|
||||
assert_eq!(form.dict.name(b"Subtype"), Some(b"Form".as_slice()));
|
||||
let tiles = form.dict.get(b"Resources").and_then(Object::as_dict).and_then(|r| r.get(b"XObject")).and_then(Object::as_dict).unwrap();
|
||||
assert_eq!(tiles.len(), count);
|
||||
for (_, tile) in tiles.iter() {
|
||||
let tile = doc.get(tile.as_ref().unwrap());
|
||||
let Object::Stream(t) = &*tile else { panic!("a tile isn't a stream") };
|
||||
let pixels = t.dict.int(b"Width").unwrap() * t.dict.int(b"Height").unwrap();
|
||||
assert!(pixels as u64 <= DOWNSAMPLED_ABOVE, "a tile of {pixels} pixels");
|
||||
}
|
||||
let after = render(&doc);
|
||||
assert_eq!(before.len(), after.len());
|
||||
let worst = before.iter().zip(&after).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
assert!(worst <= tolerance, "the tiled page differs by {worst} in a channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_samples_are_cut_to_the_pixel() {
|
||||
let (doc, im) = page_drawing(W, H, "", |doc| {
|
||||
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bit_grey_and_four_bit_indexed_samples_are_cut_on_byte_boundaries() {
|
||||
let bits: Vec<u8> = texture(W.div_ceil(8), H, 1);
|
||||
let (doc, im) = page_drawing(W, H, "", |doc| {
|
||||
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceGray")), ("BitsPerComponent", Object::Int(1))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &bits)))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 3, 0);
|
||||
let palette: Vec<u8> = (0..48u8).map(|i| i.wrapping_mul(37)).collect();
|
||||
let nibbles: Vec<u8> = texture(W.div_ceil(2), H, 1);
|
||||
let (doc, im) = page_drawing(W, H, "", |doc| {
|
||||
let indexed = Object::Array(vec![
|
||||
Object::name("Indexed"),
|
||||
Object::name("DeviceRGB"),
|
||||
Object::Int(15),
|
||||
Object::String(pdfcraft_cos::PdfString::literal(palette.clone())),
|
||||
]);
|
||||
let d = image_dict(W, H, &[("ColorSpace", indexed), ("BitsPerComponent", Object::Int(4))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &nibbles)))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_image_with_a_decode_array_keeps_both() {
|
||||
let (doc, im) = page_drawing(W, H, "", |doc| {
|
||||
let decode = Object::Array([1, 0, 0, 1, 1, 0, 0, 1].map(Object::Int).to_vec());
|
||||
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceCMYK")), ("BitsPerComponent", Object::Int(8)), ("Decode", decode)]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 4))))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_mask_is_cut_into_the_same_tiles() {
|
||||
let (doc, im) = page_drawing(W, H, "0 0 1 rg 0 0 4200 1000 re f", |doc| {
|
||||
// Without `/ColorSpace`, as many writers leave a soft mask.
|
||||
let mask = image_dict(W, H, &[("BitsPerComponent", Object::Int(8))]);
|
||||
let mask = doc.add(Object::Stream(Stream::flate(mask, &texture(W, H, 1))));
|
||||
let d = image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8)), ("SMask", Object::Ref(mask))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stencil_mask_paints_the_fill_colour_through_its_tiles() {
|
||||
let bits: Vec<u8> = texture(W.div_ceil(8), H, 1);
|
||||
let (doc, im) = page_drawing(W, H, "1 0 0 rg", |doc| {
|
||||
let d = image_dict(W, H, &[("ImageMask", Object::Bool(true))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &bits)))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_is_decoded_into_its_tiles() {
|
||||
let px = texture(W, H, 3);
|
||||
let mut jpeg = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95).encode(&px, W, H, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let (doc, im) = page_drawing(W, H, "", |doc| {
|
||||
let d = image_dict(
|
||||
W,
|
||||
H,
|
||||
&[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8)), ("Filter", Object::name("DCTDecode"))],
|
||||
);
|
||||
doc.add(Object::Stream(Stream::from_raw(d, jpeg)))
|
||||
});
|
||||
// The renderer's JPEG decoder and the one tiling uses may round a sample differently.
|
||||
tiles_and_looks_the_same(doc, im, 3, 2);
|
||||
}
|
||||
|
||||
/// Adds an image to a document.
|
||||
type AddImage = Box<dyn Fn(&mut Document) -> ObjRef>;
|
||||
|
||||
#[test]
|
||||
fn images_it_cannot_cut_faithfully_are_left_whole() {
|
||||
fn rgb() -> Dict {
|
||||
image_dict(W, H, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))])
|
||||
}
|
||||
let cases: Vec<(&str, AddImage)> = vec![
|
||||
(
|
||||
"JPEG 2000",
|
||||
Box::new(|doc| {
|
||||
let mut d = rgb();
|
||||
d.set("Filter", Object::name("JPXDecode"));
|
||||
doc.add(Object::Stream(Stream::from_raw(d, b"not decoded here".to_vec())))
|
||||
}),
|
||||
),
|
||||
("data short of the image", Box::new(|doc| doc.add(Object::Stream(Stream::flate(rgb(), &texture(W, H - 1, 3)))))),
|
||||
(
|
||||
"a soft mask of another size",
|
||||
Box::new(|doc| {
|
||||
let mask = doc.add(Object::Stream(Stream::flate(image_dict(10, 10, &[("BitsPerComponent", Object::Int(8))]), &[0; 100])));
|
||||
let mut d = rgb();
|
||||
d.set("SMask", Object::Ref(mask));
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
|
||||
}),
|
||||
),
|
||||
(
|
||||
"an unknown colour space",
|
||||
Box::new(|doc| {
|
||||
let mut d = rgb();
|
||||
d.set("ColorSpace", Object::name("Fancy"));
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
|
||||
}),
|
||||
),
|
||||
(
|
||||
"12 bits a component",
|
||||
Box::new(|doc| {
|
||||
let mut d = rgb();
|
||||
d.set("BitsPerComponent", Object::Int(12));
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(W, H, 3))))
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (what, add) in cases {
|
||||
let (mut doc, im) = page_drawing(W, H, "", add);
|
||||
let before = doc.get(im);
|
||||
assert_eq!(tile_large_images(&mut doc), TileReport { tiled: 0, left_whole: 1 }, "{what}");
|
||||
assert!(Arc::ptr_eq(&before, &doc.get(im)) || *before == *doc.get(im), "{what}: changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_images_and_a_large_mask_of_a_small_image_are_not_touched() {
|
||||
let (mut doc, im) = page_drawing(W, H, "", |doc| {
|
||||
let mask = doc.add(Object::Stream(Stream::flate(image_dict(W, H, &[("BitsPerComponent", Object::Int(8))]), &texture(W, H, 1))));
|
||||
// Exactly the cap: drawn whole by Windows.
|
||||
let d =
|
||||
image_dict(2048, 2048, &[("ColorSpace", Object::name("DeviceGray")), ("BitsPerComponent", Object::Int(8)), ("SMask", Object::Ref(mask))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(2048, 2048, 1))))
|
||||
});
|
||||
let before = doc.get(im);
|
||||
assert_eq!(tile_large_images(&mut doc), TileReport::default());
|
||||
assert_eq!(*before, *doc.get(im));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_of_tiles_keeps_its_rows_and_columns_in_place() {
|
||||
// 2100 × 2100: two tiles a side, the top row first in the samples and at the top of the page.
|
||||
let (doc, im) = page_drawing(2100, 2100, "", |doc| {
|
||||
let d = image_dict(2100, 2100, &[("ColorSpace", Object::name("DeviceRGB")), ("BitsPerComponent", Object::Int(8))]);
|
||||
doc.add(Object::Stream(Stream::flate(d, &texture(2100, 2100, 3))))
|
||||
});
|
||||
tiles_and_looks_the_same(doc, im, 4, 0);
|
||||
}
|
||||
@@ -53,6 +53,19 @@ fn unavailable(dll: &str) -> crate::PrintError {
|
||||
crate::PrintError::Spool(format!("printing needs {dll}, which this Windows installation lacks; use Save as PDF instead"))
|
||||
}
|
||||
|
||||
/// Keeps two threads of this process out of the Windows PrintTicket provider (`PTOpenProvider`
|
||||
/// and the calls on its handle) at the same time. When two threads make the process's first calls
|
||||
/// together, winspool can keep a pointer to a device capabilities cache it has freed, and a later
|
||||
/// call crashes the process. All entry points in this process that query driver capabilities
|
||||
/// or submit jobs share this process-wide lock.
|
||||
#[cfg(windows)]
|
||||
static PRINT_TICKET_PROVIDER: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn print_ticket_provider() -> std::sync::MutexGuard<'static, ()> {
|
||||
PRINT_TICKET_PROVIDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The system's printers; none on a Windows installation without the print DLLs.
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
if missing_print_dll().is_some() {
|
||||
@@ -60,6 +73,8 @@ pub fn printers() -> Vec<Printer> {
|
||||
}
|
||||
#[cfg(all(windows, test))]
|
||||
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
#[cfg(windows)]
|
||||
let _provider = print_ticket_provider();
|
||||
craft_print::printers()
|
||||
}
|
||||
|
||||
@@ -83,6 +98,8 @@ pub fn two_sided(printer: &str) -> Option<bool> {
|
||||
}
|
||||
#[cfg(all(windows, test))]
|
||||
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
#[cfg(windows)]
|
||||
let _provider = print_ticket_provider();
|
||||
craft_print::two_sided(printer)
|
||||
}
|
||||
|
||||
@@ -392,6 +409,8 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
|
||||
return Err(unavailable(dll));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
let _provider = print_ticket_provider();
|
||||
#[cfg(windows)]
|
||||
let resolved = windows_duplex::job(job);
|
||||
#[cfg(windows)]
|
||||
let job = resolved.as_ref();
|
||||
@@ -406,6 +425,8 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz
|
||||
return Err(unavailable(dll));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
let _provider = print_ticket_provider();
|
||||
#[cfg(windows)]
|
||||
let resolved = windows_duplex::job(job);
|
||||
#[cfg(windows)]
|
||||
let job = resolved.as_ref();
|
||||
@@ -439,6 +460,12 @@ pub type Paper = (String, (f64, f64));
|
||||
/// Empty when the printer reports none or on platforms where driver capabilities are not queried.
|
||||
#[cfg(windows)]
|
||||
pub fn printer_papers(printer: &str) -> Vec<Paper> {
|
||||
let _provider = print_ticket_provider();
|
||||
printer_papers_internal(printer)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn printer_papers_internal(printer: &str) -> Vec<Paper> {
|
||||
use winprint::ticket::{FeatureOptionPack as _, FeatureOptionPackWithPredefined as _};
|
||||
let Ok(devices) = winprint::printer::PrinterDevice::all() else { return Vec::new() };
|
||||
let Some(device) = devices.into_iter().find(|d| d.name() == printer) else { return Vec::new() };
|
||||
@@ -490,6 +517,11 @@ pub fn printer_default_paper(printer: &str) -> Option<Paper> {
|
||||
|
||||
/// Reads the supported paper sizes and the default paper size configured for `printer`.
|
||||
pub fn printer_paper_info(printer: &str) -> (Vec<Paper>, Option<Paper>) {
|
||||
#[cfg(windows)]
|
||||
let _provider = print_ticket_provider();
|
||||
#[cfg(windows)]
|
||||
let papers = printer_papers_internal(printer);
|
||||
#[cfg(not(windows))]
|
||||
let papers = printer_papers(printer);
|
||||
#[cfg(windows)]
|
||||
let default_paper = {
|
||||
|
||||
@@ -1660,6 +1660,45 @@ fn printer_papers_and_default_paper_api() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping driver discovery, queue queries, and paper detection run concurrently across
|
||||
/// worker threads without deadlocking or racing the PrintTicket provider.
|
||||
#[test]
|
||||
fn overlapping_paper_discovery_two_sided_and_queue_queries() {
|
||||
let queues = crate::spool::printer_queues();
|
||||
let name = queues.first().map(|p| p.name.clone()).unwrap_or_else(|| "Microsoft Print to PDF".to_string());
|
||||
|
||||
let threads: Vec<_> = (0..8)
|
||||
.map(|i| {
|
||||
let printer = name.clone();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..5 {
|
||||
match i % 5 {
|
||||
0 => {
|
||||
let _ = crate::spool::printer_papers(&printer);
|
||||
}
|
||||
1 => {
|
||||
let _ = crate::spool::printer_default_paper(&printer);
|
||||
}
|
||||
2 => {
|
||||
let _ = crate::spool::two_sided(&printer);
|
||||
}
|
||||
3 => {
|
||||
let _ = crate::spool::printers();
|
||||
}
|
||||
_ => {
|
||||
let _ = crate::spool::printer_paper_info(&printer);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in threads {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_form_without_annotations_shares_the_pages_indirect_resources() {
|
||||
let mut doc = fixture(2);
|
||||
|
||||
@@ -93,6 +93,15 @@ pub fn fold_case(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte ranges in `text` where `needle` occurs, with the find bar's rules ([`PageText::find_opts`]):
|
||||
/// case folded unless `case_sensitive`, any run of whitespace matching one space, and with
|
||||
/// `whole_words` only where no letter or digit adjoins the match. At most `limit` ranges, in order
|
||||
/// and not overlapping. Find and replace matches a paragraph's text with it, so what the find bar
|
||||
/// highlights is what gets replaced.
|
||||
pub fn find_in_text(text: &str, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec<std::ops::Range<usize>> {
|
||||
search::find_in_text(text, needle, case_sensitive, whole_words, limit)
|
||||
}
|
||||
|
||||
/// The text of one page, in content order, with line structure.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PageText {
|
||||
|
||||
@@ -50,16 +50,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
let texts: Vec<String> = page.glyphs.iter().map(|g| fold(&g.text)).collect();
|
||||
let divided: Vec<bool> = (0..texts.len()).map(|i| divides(page, &texts, i)).collect();
|
||||
let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] };
|
||||
// KMP reuses a matched prefix instead of comparing a long query again at every position.
|
||||
// The table is bounded by the page's already materialized character count.
|
||||
let mut prefix = vec![0; needle.len()];
|
||||
let mut matched = 0;
|
||||
for (i, &c) in needle.iter().enumerate().skip(1) {
|
||||
matched = advance(&needle, &prefix, matched, c);
|
||||
if let Some(slot) = prefix.get_mut(i) {
|
||||
*slot = matched;
|
||||
}
|
||||
}
|
||||
let prefix = prefix_table(&needle);
|
||||
let mut hits: Vec<(Range<usize>, usize)> = Vec::new();
|
||||
for (r, &reading) in readings.iter().enumerate() {
|
||||
let (chars, zero_width_space) = characters(page, &texts, ÷d, reading);
|
||||
@@ -92,6 +83,58 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
kept
|
||||
}
|
||||
|
||||
/// [`super::find_in_text`]: the same search over a string (find and replace matches a paragraph's
|
||||
/// text with it), as the byte ranges of the characters each hit covers. Hyphenated line ends do
|
||||
/// not arise in a string, so there is the one reading.
|
||||
pub(super) fn find_in_text(text: &str, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec<Range<usize>> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let fold = |s: &str| if sensitive { ligature_letters(s.to_owned()) } else { fold_case(&ligature_letters(s.to_owned())) };
|
||||
let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect();
|
||||
let needle: Vec<char> = query.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||||
if needle.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Each character is owned by its index in the string, turned back into bytes at the end.
|
||||
let mut chars: Vec<(char, usize)> = Vec::new();
|
||||
let mut zero_width_space: Vec<bool> = Vec::new();
|
||||
let mut pending_zero_width_space = false;
|
||||
for (index, (_, c)) in text.char_indices().enumerate() {
|
||||
for c in fold(c.encode_utf8(&mut [0; 4])).chars() {
|
||||
if is_invisible(c) {
|
||||
pending_zero_width_space |= c == '\u{200B}';
|
||||
continue;
|
||||
}
|
||||
let c = if c.is_whitespace() { ' ' } else { c };
|
||||
if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') {
|
||||
chars.push((c, index));
|
||||
zero_width_space.push(pending_zero_width_space && c != ' ');
|
||||
pending_zero_width_space = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let starts: Vec<usize> = text.char_indices().map(|(at, _)| at).chain(std::iter::once(text.len())).collect();
|
||||
search(&chars, &zero_width_space, &needle, &prefix_table(&needle), whole_words, limit)
|
||||
.into_iter()
|
||||
.filter_map(|hit| Some(*starts.get(hit.start)?..*starts.get(hit.end)?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// KMP reuses a matched prefix instead of comparing a long query again at every position. The
|
||||
/// table is bounded by the page's already materialized character count.
|
||||
fn prefix_table(needle: &[char]) -> Vec<usize> {
|
||||
let mut prefix = vec![0; needle.len()];
|
||||
let mut matched = 0;
|
||||
for (i, &c) in needle.iter().enumerate().skip(1) {
|
||||
matched = advance(needle, &prefix, matched, c);
|
||||
if let Some(slot) = prefix.get_mut(i) {
|
||||
*slot = matched;
|
||||
}
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
/// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next
|
||||
/// line starts with a letter. `texts` are the glyphs' folded texts; the invisible characters find
|
||||
/// skips are passed over on both sides.
|
||||
|
||||
@@ -1123,6 +1123,33 @@ fn turkish_dotted_and_dotless_i_match_in_any_case() {
|
||||
assert_eq!(lower.find("KIŞ"), vec![13..16]);
|
||||
}
|
||||
|
||||
/// Find and replace matches a paragraph's text with the find bar's own rules: the same
|
||||
/// matches as the page search, as byte ranges of the text (multi-byte letters included).
|
||||
#[test]
|
||||
fn text_search_agrees_with_the_page_search() {
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0);
|
||||
word(&mut v, "cat", 80.0, 10.0, 7.0);
|
||||
word(&mut v, "Cats", 110.0, 10.0, 7.0);
|
||||
word(&mut v, "cat.", 150.0, 10.0, 7.0);
|
||||
let page = layout(v);
|
||||
let text = page.plain_text();
|
||||
assert_eq!(text, "İSTANBUL cat Cats cat.");
|
||||
for (needle, case, whole) in
|
||||
[("cat", false, false), ("cat", true, false), ("cat", false, true), ("istanbul cat", false, false), ("CATS", true, true)]
|
||||
{
|
||||
let glyphs: Vec<String> = page.find_opts(needle, case, whole).into_iter().map(|r| page.text_of(r)).collect();
|
||||
let bytes: Vec<&str> = find_in_text(&text, needle, case, whole, usize::MAX).into_iter().filter_map(|r| text.get(r)).collect();
|
||||
assert_eq!(glyphs, bytes, "{needle:?} case {case} whole {whole}");
|
||||
}
|
||||
assert_eq!(find_in_text(&text, "cat", false, true, usize::MAX), vec![10..13, 19..22]);
|
||||
assert_eq!(find_in_text(&text, "istanbul", false, false, usize::MAX), vec![0..9], "İ is two bytes");
|
||||
assert_eq!(find_in_text("a\n b", "a b", false, false, usize::MAX), vec![0..5], "any whitespace run is one space");
|
||||
assert_eq!(find_in_text("cat cat", "cat", false, false, 1), vec![0..3]);
|
||||
assert!(find_in_text("cat", " ", false, false, usize::MAX).is_empty());
|
||||
assert!(find_in_text("cat", "cat", false, false, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greek_sigma_forms_match_without_changing_exact_search_or_glyph_ranges() {
|
||||
let mut v = Vec::new();
|
||||
|
||||
@@ -177,6 +177,8 @@ impl PdfCraftApp {
|
||||
v["pinned_tools"] = serde_json::json!(self.pinned_tools);
|
||||
// Set outside json! above, which is at serde_json's macro recursion limit.
|
||||
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
|
||||
// The Print dialog's own paper sizes (print.page-setup).
|
||||
v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers);
|
||||
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
|
||||
// Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and
|
||||
// settings written then would turn this off unasked.
|
||||
@@ -186,14 +188,10 @@ impl PdfCraftApp {
|
||||
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
|
||||
v["text_links"] = serde_json::Value::Bool(self.text_links);
|
||||
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!(
|
||||
self.print_draft
|
||||
.papers
|
||||
.get(self.print_draft.paper)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.or_else(|| pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name))
|
||||
);
|
||||
// Canonical names survive changes to the order of PAPERS and do not depend on UI language;
|
||||
// a custom paper is stored by the name it was given.
|
||||
let d = &self.print_draft;
|
||||
v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name()));
|
||||
let size = self.print_draft.paper_size();
|
||||
v["print_paper_size"] = serde_json::json!([size.0, size.1]);
|
||||
v["print_paper_overridden"] = serde_json::json!(self.print_draft.paper_overridden);
|
||||
@@ -310,6 +308,80 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Print dialog's papers, each printer's setup and the choices it keeps (#2001).
|
||||
fn restore_print_prefs(&mut self, v: &serde_json::Value) {
|
||||
// The Print dialog's own paper sizes; settings are untrusted, so each custom paper is
|
||||
// checked as if it were typed.
|
||||
if v.get("print_custom_papers").is_some() {
|
||||
self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]);
|
||||
}
|
||||
// After the custom papers, so a custom paper chosen last time is found by its name.
|
||||
if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) {
|
||||
let saved_size = v.get("print_paper_size").and_then(|s| {
|
||||
let w = s.get(0)?.as_f64()?;
|
||||
let h = s.get(1)?.as_f64()?;
|
||||
if w >= 72.0 && h >= 72.0 && w.is_finite() && h.is_finite() { Some((w, h)) } else { None }
|
||||
});
|
||||
let matched_pos = self
|
||||
.print_draft
|
||||
.papers
|
||||
.iter()
|
||||
.position(|(n, s)| n == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0))
|
||||
.or_else(|| {
|
||||
pdfcraft_engine::print::PAPERS.iter().position(|(canonical, s)| {
|
||||
*canonical == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0)
|
||||
})
|
||||
})
|
||||
.or_else(|| self.print_draft.paper_index(name));
|
||||
if let Some(paper) = matched_pos {
|
||||
self.print_draft.paper = paper;
|
||||
self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
} else if let Some(size) = saved_size {
|
||||
self.print_draft.papers.push((name.to_string(), size));
|
||||
self.print_draft.paper = self.print_draft.papers.len() - 1;
|
||||
self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
}
|
||||
}
|
||||
if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) {
|
||||
self.print_draft.setups = setups;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches for reopening, window chrome and file watching.
|
||||
fn restore_behavior_prefs(&mut self, v: &serde_json::Value) {
|
||||
if let Some(show) = v["default_page_gaps"].as_bool() {
|
||||
@@ -412,73 +484,6 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print dialog settings, remembered per-printer setups, and driver paper selection.
|
||||
fn restore_print_prefs(&mut self, v: &serde_json::Value) {
|
||||
if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) {
|
||||
let saved_size = v.get("print_paper_size").and_then(|s| {
|
||||
let w = s.get(0)?.as_f64()?;
|
||||
let h = s.get(1)?.as_f64()?;
|
||||
if w >= 72.0 && h >= 72.0 && w.is_finite() && h.is_finite() { Some((w, h)) } else { None }
|
||||
});
|
||||
let matched_pos = self
|
||||
.print_draft
|
||||
.papers
|
||||
.iter()
|
||||
.position(|(n, s)| n == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0))
|
||||
.or_else(|| {
|
||||
pdfcraft_engine::print::PAPERS.iter().position(|(canonical, s)| {
|
||||
*canonical == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0)
|
||||
})
|
||||
});
|
||||
if let Some(paper) = matched_pos {
|
||||
self.print_draft.paper = paper;
|
||||
self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
} else if let Some(size) = saved_size {
|
||||
self.print_draft.papers.push((name.to_string(), size));
|
||||
self.print_draft.paper = self.print_draft.papers.len() - 1;
|
||||
self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
}
|
||||
}
|
||||
if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) {
|
||||
self.print_draft.setups = setups;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// The author and per-tool comment styles.
|
||||
fn restore_comment_prefs(&mut self, v: &serde_json::Value) {
|
||||
// Per-tool comment styles (#340): each field goes through its clamping setter, so a
|
||||
|
||||
@@ -855,6 +855,10 @@ pub enum ViewAction {
|
||||
CameraLevel,
|
||||
/// After Enhance page: leave the tool for this command (Crop pages, Organize pages).
|
||||
CameraNext(&'static str),
|
||||
/// The find bar's Replace (the highlighted match) or Replace all.
|
||||
Replace {
|
||||
all: bool,
|
||||
},
|
||||
/// Lock now, on the notice shown after a signature is placed (#2149).
|
||||
LockSignatures,
|
||||
}
|
||||
@@ -2809,7 +2813,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim
|
||||
});
|
||||
});
|
||||
}
|
||||
find_bar(view, info.pages.len(), avail, ui, t);
|
||||
find_bar(view, info.pages.len(), avail, tool == QuickTool::EditText && can_modify, ui, t);
|
||||
page_size_hint(view, info, units, avail, unobstructed, ui, t);
|
||||
if tool == QuickTool::EnhanceCamera {
|
||||
crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, icons, theme, vec2, widgets};
|
||||
use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, ViewAction, icons, theme, vec2, widgets};
|
||||
|
||||
/// The find bar (⌘F): query, matches across the document, current match.
|
||||
#[derive(Default)]
|
||||
@@ -26,6 +26,11 @@ pub struct Find {
|
||||
pub whole_words: bool,
|
||||
/// Shown in the Search panel (Advanced Search) instead of the find bar.
|
||||
pub in_panel: bool,
|
||||
/// Find and replace (in Edit text mode): the Replace row shows, with what replaces a match.
|
||||
pub replacing: bool,
|
||||
pub replacement: String,
|
||||
/// Focus the Replace field on the next frame.
|
||||
pub focus_replace: bool,
|
||||
}
|
||||
|
||||
/// What a text layer arriving after a search began may do with the automatic choice.
|
||||
@@ -80,6 +85,19 @@ impl DocView {
|
||||
f.focus = true;
|
||||
}
|
||||
|
||||
/// Open the find bar with its Replace row (Edit text mode shows it): the Find field takes the
|
||||
/// keyboard while it is empty, else the Replace field.
|
||||
pub fn open_replace(&mut self) {
|
||||
let f = self.find.get_or_insert_with(Find::default);
|
||||
f.in_panel = false;
|
||||
f.replacing = true;
|
||||
if f.query.trim().is_empty() {
|
||||
f.focus = true;
|
||||
} else {
|
||||
f.focus_replace = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run the search on all pages whose text is known (after the query changed).
|
||||
pub fn rerun_find(&mut self) {
|
||||
let Some(f) = self.find.as_mut() else { return };
|
||||
@@ -249,8 +267,10 @@ impl DocView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acrobat-style floating find bar at the top-right of the document area.
|
||||
pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) {
|
||||
/// Acrobat-style floating find bar at the top-right of the document area. In Edit text mode
|
||||
/// (`editing`) it offers a Replace row: Replace changes the highlighted match, Replace all every
|
||||
/// match, each inside its own paragraph.
|
||||
pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, editing: bool, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let Some(find) = view.find.as_mut() else { return };
|
||||
// The Search panel shows this search.
|
||||
if find.in_panel {
|
||||
@@ -258,6 +278,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg
|
||||
}
|
||||
let mut close = false;
|
||||
let mut step: Option<bool> = None;
|
||||
let mut replace: Option<bool> = None;
|
||||
let searched = view.texts.len() + view.text_failed.len();
|
||||
egui::Area::new(egui::Id::unique(("find-bar", view.pane.key)))
|
||||
.order(egui::Order::Middle)
|
||||
@@ -318,6 +339,13 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg
|
||||
if icons::button(ui, "chevron-down", 26.0, false, &tip).clicked() {
|
||||
step = Some(true);
|
||||
}
|
||||
if editing {
|
||||
let tip = crate::commands::command_tip(ui.ctx(), tl!("Replace ({key})"), "edit.replace");
|
||||
if icons::button(ui, "replace", 26.0, find.replacing, &tip).clicked() {
|
||||
find.replacing = !find.replacing;
|
||||
find.focus_replace = find.replacing;
|
||||
}
|
||||
}
|
||||
let opts = icons::button(ui, "settings-2", 26.0, find.case_sensitive || find.whole_words, tl!("Find options"));
|
||||
egui::Popup::menu(&opts).show(|ui| {
|
||||
let a = ui.add(widgets::checkbox(&mut find.whole_words, tl!("Whole words only"))).changed();
|
||||
@@ -331,12 +359,49 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
if editing && find.replacing {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("replace", 16.0, t.text_muted));
|
||||
let edit = egui::TextEdit::singleline(&mut find.replacement)
|
||||
.id(egui::Id::unique("replace-input"))
|
||||
.hint_text(tl!("Replace with"))
|
||||
.desired_width(220.0)
|
||||
.frame(egui::Frame::NONE);
|
||||
let r = ui.add(edit).accessible_name(tl!("Replace with"));
|
||||
if find.focus_replace {
|
||||
r.request_focus();
|
||||
find.focus_replace = false;
|
||||
}
|
||||
let searched = !find.case_query.trim().is_empty();
|
||||
// Enter in the field replaces the highlighted match, as the button does.
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && find.current.is_some() {
|
||||
replace = Some(false);
|
||||
r.request_focus();
|
||||
}
|
||||
if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
ui.add_enabled_ui(find.current.is_some(), |ui| {
|
||||
if crate::widgets::pill_button(ui, tl!("Replace"), false).clicked() {
|
||||
replace = Some(false);
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(searched, |ui| {
|
||||
if crate::widgets::pill_button(ui, tl!("Replace all"), true).clicked() {
|
||||
replace = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if close {
|
||||
view.find = None;
|
||||
return;
|
||||
}
|
||||
if let Some(all) = replace {
|
||||
view.pending_action = Some(ViewAction::Replace { all });
|
||||
}
|
||||
if find.query != find.case_query {
|
||||
view.rerun_find();
|
||||
}
|
||||
|
||||
@@ -275,6 +275,16 @@ impl PdfCraftApp {
|
||||
}
|
||||
"view.select_tool" => self.quick_tool = crate::QuickTool::Select,
|
||||
"view.hand_tool" => self.quick_tool = crate::QuickTool::Hand,
|
||||
// Find and replace changes page text, so it runs in Edit text mode (as Acrobat's
|
||||
// does): the command turns the mode on, and the find bar shows its Replace row there.
|
||||
"edit.replace" => {
|
||||
if let Some(i) = active {
|
||||
self.quick_tool = crate::QuickTool::EditText;
|
||||
self.left = crate::LeftPanel::Tool("edit");
|
||||
self.reveal_tools();
|
||||
self.views[i].open_replace();
|
||||
}
|
||||
}
|
||||
"view.side_panel" => self.toggle_side_panel(),
|
||||
"view.tools_pane" => self.left_open = !self.left_open,
|
||||
"view.focus_page_input" => {
|
||||
|
||||
@@ -568,6 +568,7 @@ impl PdfCraftApp {
|
||||
self.leave_camera(i);
|
||||
self.execute(command);
|
||||
}
|
||||
Some(crate::canvas::ViewAction::Replace { all }) => self.replace_found(i, all),
|
||||
None => {}
|
||||
}
|
||||
// That tab comes forward when its value was refused (its editor is open again with the
|
||||
|
||||
@@ -204,6 +204,7 @@ use workflows::ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use workflows::ocr_web;
|
||||
use workflows::optimize_ui;
|
||||
use workflows::print_paper;
|
||||
use workflows::print_ui;
|
||||
use workflows::scan_clean_ui;
|
||||
use workflows::web_print;
|
||||
|
||||
@@ -147,6 +147,7 @@ const EDIT: &[&str] = &[
|
||||
SELECT_ALL,
|
||||
SEP,
|
||||
"edit.find",
|
||||
"edit.replace",
|
||||
"edit.advanced_search",
|
||||
SEP,
|
||||
"edit.column_select",
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) mod measure_ui;
|
||||
pub(super) mod object_ui;
|
||||
pub mod prepare;
|
||||
pub(super) mod redact_ui;
|
||||
pub(super) mod replace_ui;
|
||||
pub(super) mod sign_ui;
|
||||
pub(super) mod stamps_ui;
|
||||
pub(super) mod tags_ui;
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Find and replace from the find bar (Edit text mode): the engine plans the change
|
||||
//! ([`pdfcraft_engine::Document::plan_replace`]) and it is applied as one edit, so Replace all
|
||||
//! is one undo step; what was left alone is said, never skipped silently.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use pdfcraft_engine::find_replace::{Replace, Replacement, Which};
|
||||
|
||||
use crate::PdfCraftApp;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Replace the find bar's highlighted match (`all` false) or every match in the document, in
|
||||
/// view `i`, with its Replace field's text.
|
||||
pub(crate) fn replace_found(&mut self, i: usize, all: bool) {
|
||||
let Some(view) = self.views.get(i) else { return };
|
||||
let Some(find) = view.find.as_ref() else { return };
|
||||
if find.case_query.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let query = Replace {
|
||||
find: find.case_query.clone(),
|
||||
with: find.replacement.clone(),
|
||||
case_sensitive: find.case_sensitive,
|
||||
whole_words: find.whole_words,
|
||||
};
|
||||
let Some(doc) = self.session.get(view.id) else { return };
|
||||
let (pages, which, found) = if all {
|
||||
// What the find bar holds on each page it has searched, so matches no paragraph
|
||||
// holds (a form field, a comment) are counted rather than lost.
|
||||
let mut found: BTreeMap<usize, usize> = view.texts.keys().map(|p| (*p, 0)).collect();
|
||||
for (p, _) in &find.matches {
|
||||
let n = found.entry(*p).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
((0..doc.info.pages.len()).collect::<Vec<_>>(), Which::All, Some(found))
|
||||
} else {
|
||||
// The highlighted match, by where its first and last glyphs are drawn.
|
||||
let Some((page, range)) = find.current.and_then(|c| find.matches.get(c)).cloned() else { return };
|
||||
let centre = |g: usize| {
|
||||
view.texts.get(&page).and_then(|t| t.glyphs.get(g)).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0])
|
||||
};
|
||||
let (Some(start), Some(end)) = (centre(range.start), centre(range.end.saturating_sub(1))) else { return };
|
||||
(vec![page], Which::At { page, start, end }, None)
|
||||
};
|
||||
let plan = match doc.plan_replace(&pages, &query, which, found.as_ref()) {
|
||||
Ok(plan) => plan,
|
||||
Err(e) => {
|
||||
self.notify_fmt("{label} failed: {e}", &[("label", tl!("Replace")), ("e", &e.to_string())]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(edit) = plan.edit.clone()
|
||||
&& !self.apply_edit(edit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(message) = outcome(&plan, all) {
|
||||
self.notify(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What to tell the user: the count and pages after Replace all, what was left and why. Nothing
|
||||
/// after one match replaced (the page shows it).
|
||||
fn outcome(plan: &Replacement, all: bool) -> Option<String> {
|
||||
let skipped = plan.skipped_total();
|
||||
if plan.replaced == 0 && skipped == 0 {
|
||||
return Some(tl!("No matches to replace").to_string());
|
||||
}
|
||||
let why = plan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|(reason, n)| crate::i18n::fmt(tl!("{why} ({n})"), &[("why", crate::i18n::t(reason.wording())), ("n", &n.to_string())]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if !all {
|
||||
return (skipped > 0).then(|| crate::i18n::fmt(tl!("Not replaced: {why}"), &[("why", &why)]));
|
||||
}
|
||||
let lang = crate::i18n::current();
|
||||
let pages = crate::i18n::trn(lang, plan.pages.len() as u64, "{n} page", "{n} pages");
|
||||
let mut message = if plan.replaced == 0 {
|
||||
tl!("Nothing replaced").to_string()
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("Replaced {n} on {pages}"), &[("n", &plan.replaced.to_string()), ("pages", &pages)])
|
||||
};
|
||||
if skipped > 0 {
|
||||
message.push_str(". ");
|
||||
message.push_str(&crate::i18n::fmt(tl!("{k} skipped: {why}"), &[("k", &skipped.to_string()), ("why", &why)]));
|
||||
}
|
||||
Some(message)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub(super) mod ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) mod ocr_web;
|
||||
pub(super) mod optimize_ui;
|
||||
pub(super) mod print_paper;
|
||||
pub(super) mod print_ui;
|
||||
pub(super) mod scan_clean_ui;
|
||||
pub(super) mod web_print;
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The Print dialog's paper list (print.page-setup): the standard sizes, then paper sizes of a
|
||||
//! person's own, added with Custom… beside Paper and kept with the settings. Which paper each
|
||||
//! printer prints on is remembered with the rest of its setup (`print_ui::setups`, #2001), by name,
|
||||
//! so a custom paper is found again however the list changes.
|
||||
|
||||
use pdfcraft_engine::print::PAPERS;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::print_ui::PrintDraft;
|
||||
|
||||
/// The largest side a custom paper can have, in points: PDF's own page limit (200 inches).
|
||||
pub const MAX_SIDE: f64 = 14_400.0;
|
||||
/// The smallest side, in points (one inch).
|
||||
pub const MIN_SIDE: f64 = 72.0;
|
||||
/// How many custom papers the settings keep.
|
||||
pub const MAX_CUSTOM: usize = 100;
|
||||
/// The longest name a custom paper keeps, in characters.
|
||||
const MAX_NAME: usize = 64;
|
||||
|
||||
/// A paper size added in the Print dialog (points, portrait).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CustomPaper {
|
||||
pub name: String,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl CustomPaper {
|
||||
/// A custom paper from a name and two sides in points, portrait; None when the name is empty
|
||||
/// or a side is outside [`MIN_SIDE`]..=[`MAX_SIDE`], or a standard paper already has the name
|
||||
/// (any case), which would otherwise be found first when the paper is looked up by name.
|
||||
pub fn new(name: &str, a: f64, b: f64) -> Option<Self> {
|
||||
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
let fits = |v: f64| v.is_finite() && (MIN_SIDE..=MAX_SIDE).contains(&v);
|
||||
let standard = PAPERS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&name));
|
||||
(!name.is_empty() && !standard && fits(a) && fits(b)).then(|| Self { name, width: a.min(b), height: a.max(b) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom papers read back from the settings, which are untrusted: each one is checked as if it
|
||||
/// were typed, a name already taken keeps its first size, and at most [`MAX_CUSTOM`] are kept.
|
||||
pub fn read_custom(value: &serde_json::Value) -> Vec<CustomPaper> {
|
||||
let Some(list) = value.as_array() else { return Vec::new() };
|
||||
let mut out: Vec<CustomPaper> = Vec::new();
|
||||
for item in list {
|
||||
let (Some(name), Some(w), Some(h)) = (item["name"].as_str(), item["width"].as_f64(), item["height"].as_f64()) else { continue };
|
||||
if let Some(paper) = CustomPaper::new(name, w, h)
|
||||
&& !out.iter().any(|p| p.name == paper.name)
|
||||
&& out.len() < MAX_CUSTOM
|
||||
{
|
||||
out.push(paper);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when `name` can name a paper: a standard size's own name, or one a custom paper could
|
||||
/// have been given ([`CustomPaper::new`] keeps it as it is).
|
||||
pub fn is_paper_name(name: &str) -> bool {
|
||||
PAPERS.iter().any(|(n, _)| *n == name) || CustomPaper::new(name, MIN_SIDE, MIN_SIDE).is_some_and(|p| p.name == name)
|
||||
}
|
||||
|
||||
impl PrintDraft {
|
||||
/// The paper list: the standard sizes, then the custom ones.
|
||||
pub fn paper_count(&self) -> usize {
|
||||
self.papers.len() + self.custom_papers.len()
|
||||
}
|
||||
|
||||
/// The chosen paper's size (points, portrait).
|
||||
pub fn paper_size(&self) -> (f64, f64) {
|
||||
match self.paper.checked_sub(self.papers.len()) {
|
||||
None => self.papers.get(self.paper).map_or(PAPERS[0].1, |p| p.1),
|
||||
Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen paper's name (a standard name, or the custom paper's).
|
||||
pub fn paper_name(&self) -> String {
|
||||
self.paper_name_at(self.paper)
|
||||
}
|
||||
|
||||
/// The name of the paper at `index` in the list; the first size's for a stale index.
|
||||
pub fn paper_name_at(&self, index: usize) -> String {
|
||||
match index.checked_sub(self.papers.len()) {
|
||||
None => self.papers.get(index).map_or_else(|| PAPERS[0].0.to_string(), |p| p.0.clone()),
|
||||
Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the paper called `name`, standard or custom.
|
||||
pub fn paper_index(&self, name: &str) -> Option<usize> {
|
||||
self.papers
|
||||
.iter()
|
||||
.position(|(n, _)| *n == name)
|
||||
.or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| self.papers.len() + i))
|
||||
}
|
||||
|
||||
/// True when the chosen paper is a custom one (which can be removed).
|
||||
pub fn paper_is_custom(&self) -> bool {
|
||||
self.paper >= self.papers.len()
|
||||
}
|
||||
|
||||
/// Add (or replace, by name) a custom paper and choose it.
|
||||
pub fn add_custom_paper(&mut self, paper: CustomPaper) {
|
||||
self.custom_papers.retain(|p| p.name != paper.name);
|
||||
if self.custom_papers.len() >= MAX_CUSTOM {
|
||||
self.custom_papers.remove(0);
|
||||
}
|
||||
self.custom_papers.push(paper);
|
||||
self.paper = self.papers.len() + self.custom_papers.len() - 1;
|
||||
}
|
||||
|
||||
/// Take the chosen custom paper off the list; the dialog goes back to the first size.
|
||||
pub fn remove_custom_paper(&mut self) {
|
||||
if let Some(i) = self.paper.checked_sub(self.papers.len())
|
||||
&& i < self.custom_papers.len()
|
||||
{
|
||||
self.custom_papers.remove(i);
|
||||
self.paper = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_custom_paper_is_portrait_named_and_within_pdfs_limits() {
|
||||
let p = CustomPaper::new(" Legal, tray 2 ", 1008.0, 612.0).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.width, p.height), ("Legal, tray 2", 612.0, 1008.0));
|
||||
assert!(CustomPaper::new("", 612.0, 792.0).is_none());
|
||||
assert!(CustomPaper::new("\u{1}", 612.0, 792.0).is_none());
|
||||
assert!(CustomPaper::new("Tiny", 10.0, 792.0).is_none());
|
||||
assert!(CustomPaper::new("Huge", 612.0, 20_000.0).is_none());
|
||||
assert!(CustomPaper::new("NaN", f64::NAN, 792.0).is_none());
|
||||
assert_eq!(CustomPaper::new(&"x".repeat(500), 612.0, 792.0).unwrap().name.chars().count(), MAX_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_read_as_untrusted() {
|
||||
let v = serde_json::json!([
|
||||
{ "name": "Label", "width": 288.0, "height": 432.0 },
|
||||
{ "name": "Label", "width": 300.0, "height": 400.0 },
|
||||
{ "name": "Bad", "width": -1.0, "height": 432.0 },
|
||||
{ "name": 5, "width": 288.0, "height": 432.0 },
|
||||
"nonsense",
|
||||
]);
|
||||
assert_eq!(read_custom(&v), vec![CustomPaper { name: "Label".into(), width: 288.0, height: 432.0 }]);
|
||||
assert!(read_custom(&serde_json::json!(5)).is_empty());
|
||||
let many: Vec<_> = (0..300).map(|i| serde_json::json!({ "name": format!("P{i}"), "width": 300.0, "height": 400.0 })).collect();
|
||||
assert_eq!(read_custom(&serde_json::Value::Array(many)).len(), MAX_CUSTOM);
|
||||
// A paper name read back is a standard name, or one a custom paper could have.
|
||||
assert!(is_paper_name("A4") && is_paper_name("Label 4×6"));
|
||||
assert!(!is_paper_name("a4") && !is_paper_name("") && !is_paper_name(" Label") && !is_paper_name(&"x".repeat(MAX_NAME + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paper_list_is_the_standard_sizes_then_the_custom_ones() {
|
||||
let mut d = PrintDraft::default();
|
||||
assert_eq!(d.paper_count(), PAPERS.len());
|
||||
d.add_custom_paper(CustomPaper::new("Label", 288.0, 432.0).unwrap());
|
||||
assert_eq!((d.paper, d.paper_name().as_str(), d.paper_size()), (PAPERS.len(), "Label", (288.0, 432.0)));
|
||||
assert_eq!(d.paper_index("A4"), PAPERS.iter().position(|(n, _)| *n == "A4"));
|
||||
assert_eq!(d.paper_index("Label"), Some(PAPERS.len()));
|
||||
// The same name replaces the size; removing goes back to the first standard size.
|
||||
d.add_custom_paper(CustomPaper::new("Label", 300.0, 400.0).unwrap());
|
||||
assert_eq!((d.paper_count(), d.paper_size()), (PAPERS.len() + 1, (300.0, 400.0)));
|
||||
d.remove_custom_paper();
|
||||
assert_eq!((d.paper_count(), d.paper), (PAPERS.len(), 0));
|
||||
// A stale index reads as the first size, never out of bounds.
|
||||
d.paper = PAPERS.len() + 7;
|
||||
assert_eq!(d.paper_size(), PAPERS[0].1);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::file_filter::AddFilterAnyCase as _;
|
||||
use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2};
|
||||
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
|
||||
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, spool};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
@@ -247,6 +247,7 @@ impl PdfCraftApp {
|
||||
sheet: 0,
|
||||
selected,
|
||||
which,
|
||||
custom_new: None,
|
||||
..keep
|
||||
};
|
||||
if changed {
|
||||
@@ -695,6 +696,9 @@ impl PdfCraftApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
};
|
||||
// Large pictures become tiles on Windows (#5131): work on the whole file, kept off the
|
||||
// UI thread with the rest.
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes);
|
||||
// The receiver may be gone (the app quit): nothing to report to then.
|
||||
let result = match &spooler {
|
||||
Some(submit) => submit(&bytes, &job),
|
||||
|
||||
@@ -226,12 +226,14 @@ pub(crate) fn body(
|
||||
ui.end_row();
|
||||
}
|
||||
let paper_label = ui.label(tl!("Paper:"));
|
||||
let paper_name = d.paper_name().to_string();
|
||||
d.paper = d.paper.min(d.paper_count().saturating_sub(1));
|
||||
let before = d.paper;
|
||||
let mut paper_clicked = false;
|
||||
let detecting = d.papers_loading();
|
||||
ui.horizontal(|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(&paper_name)).width(180.0).show_ui(ui, |ui| {
|
||||
// A standard size's name is translated; a custom paper keeps the name it was given.
|
||||
let shown = if d.paper_is_custom() { d.paper_name().to_string() } else { tl!(&d.paper_name()).to_string() };
|
||||
craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(180.0).show_ui(ui, |ui| {
|
||||
if detecting {
|
||||
ui.add_enabled(false, egui::Label::new(tl!("Detecting paper…")));
|
||||
}
|
||||
@@ -240,12 +242,31 @@ pub(crate) fn body(
|
||||
paper_clicked = true;
|
||||
}
|
||||
}
|
||||
if !d.custom_papers.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for (i, p) in d.custom_papers.iter().enumerate() {
|
||||
let label = format!("{} ({})", p.name, d.units.size(p.width, p.height));
|
||||
if ui.selectable_value(&mut d.paper, d.papers.len() + i, label).clicked() {
|
||||
paper_clicked = true;
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(paper_label.id);
|
||||
if detecting {
|
||||
ui.label(egui::RichText::new(tl!("Detecting…")).small().color(t.text_muted));
|
||||
}
|
||||
if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() {
|
||||
d.custom_new = match d.custom_new {
|
||||
Some(_) => None,
|
||||
None => Some((String::new(), String::new(), String::new())),
|
||||
};
|
||||
}
|
||||
if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() {
|
||||
d.remove_custom_paper();
|
||||
paper_clicked = true;
|
||||
}
|
||||
let page_setup_tip = if spool::HAS_PRINTER_PREFERENCES {
|
||||
tl!("Selects paper size and orientation from the printer driver. For paper tray, print quality or other driver defaults, use Properties…")
|
||||
} else {
|
||||
@@ -281,6 +302,11 @@ pub(crate) fn body(
|
||||
d.sheet = 0;
|
||||
}
|
||||
ui.end_row();
|
||||
if d.custom_new.is_some() {
|
||||
ui.label("");
|
||||
custom_paper_row(ui, d, t);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// Windows says which drivers can't print two-sided as they're asked; they keep their
|
||||
// own setting, even when Two-sided is Off.
|
||||
@@ -650,6 +676,33 @@ pub(crate) fn body(
|
||||
(go, cancel)
|
||||
}
|
||||
|
||||
/// The row that adds a custom paper: name, width and height in the preferences' unit.
|
||||
fn custom_paper_row(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) {
|
||||
let unit = d.units;
|
||||
let Some((name, width, height)) = d.custom_new.as_mut() else { return };
|
||||
let side = |s: &str| s.trim().replace(',', ".").parse::<f64>().ok().map(|v| v / unit.per_point());
|
||||
let paper = match (side(width), side(height)) {
|
||||
(Some(w), Some(h)) => crate::print_paper::CustomPaper::new(name, w, h),
|
||||
_ => None,
|
||||
};
|
||||
let (mut add, mut cancel) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(name).hint_text(tl!("Name")).desired_width(110.0)).accessible_name(tl!("Name"));
|
||||
ui.add(egui::TextEdit::singleline(width).hint_text(tl!("Width")).desired_width(52.0)).accessible_name(tl!("Width"));
|
||||
ui.label("×");
|
||||
ui.add(egui::TextEdit::singleline(height).hint_text(tl!("Height")).desired_width(52.0)).accessible_name(tl!("Height"));
|
||||
ui.label(egui::RichText::new(unit.label()).color(t.text_muted));
|
||||
add = ui.add_enabled(paper.is_some(), egui::Button::new(tl!("Add"))).clicked();
|
||||
cancel = ui.button(tl!("Cancel")).clicked();
|
||||
});
|
||||
if add && let Some(paper) = paper {
|
||||
d.add_custom_paper(paper);
|
||||
d.custom_new = None;
|
||||
} else if cancel {
|
||||
d.custom_new = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui_kittest::Harness;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::setups::{self, PrinterSetup};
|
||||
use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool};
|
||||
use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool};
|
||||
use pdfcraft_engine::print::PAPERS;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Which {
|
||||
@@ -71,7 +72,12 @@ pub struct PrintDraft {
|
||||
/// Print as image (Acrobat's Advanced Print Setup) at `dpi`.
|
||||
pub as_image: bool,
|
||||
pub dpi: f64,
|
||||
/// Index into the paper list: the standard sizes, then `custom_papers`.
|
||||
pub paper: usize,
|
||||
/// Paper sizes added with Custom…, kept with the settings.
|
||||
pub custom_papers: Vec<crate::print_paper::CustomPaper>,
|
||||
/// The custom paper being added: name, width and height as typed (the preferences' unit).
|
||||
pub custom_new: Option<(String, String, String)>,
|
||||
/// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564).
|
||||
pub paper_before_label: Option<usize>,
|
||||
/// Whether the user has explicitly chosen or overridden the paper size.
|
||||
@@ -150,6 +156,8 @@ impl Default for PrintDraft {
|
||||
as_image: false,
|
||||
dpi: 300.0,
|
||||
paper: 0,
|
||||
custom_papers: Vec::new(),
|
||||
custom_new: None,
|
||||
paper_before_label: None,
|
||||
paper_overridden: false,
|
||||
papers: default_papers(),
|
||||
@@ -178,16 +186,6 @@ pub fn default_papers() -> Vec<(String, (f64, f64))> {
|
||||
}
|
||||
|
||||
impl PrintDraft {
|
||||
/// The display name of the currently selected paper.
|
||||
pub fn paper_name(&self) -> &str {
|
||||
self.papers.get(self.paper).map(|p| p.0.as_str()).or_else(|| PAPERS.get(self.paper).map(|p| p.0)).unwrap_or(PAPERS[0].0)
|
||||
}
|
||||
|
||||
/// The dimensions in points `(width, height)` of the currently selected paper.
|
||||
pub fn paper_size(&self) -> (f64, f64) {
|
||||
self.papers.get(self.paper).map(|p| p.1).or_else(|| PAPERS.get(self.paper).map(|p| p.1)).unwrap_or(PAPERS[0].1)
|
||||
}
|
||||
|
||||
/// Find the index of a paper in `self.papers` matching `size`, allowing for orientation.
|
||||
pub fn matching_paper(&self, size: (f64, f64)) -> Option<usize> {
|
||||
print::matching_paper(size).or_else(|| {
|
||||
@@ -243,7 +241,11 @@ impl PrintDraft {
|
||||
|
||||
/// Apply driver-reported media sizes to `self.papers`, preserving the current selection.
|
||||
pub fn apply_printer_papers(&mut self, detected: Vec<(String, (f64, f64))>) {
|
||||
let before_label = self.paper_before_label.and_then(|idx| self.papers.get(idx).cloned());
|
||||
let custom_paper_idx = self.paper.checked_sub(self.papers.len());
|
||||
let before_label_custom = self.paper_before_label.and_then(|idx| idx.checked_sub(self.papers.len()));
|
||||
let before_label = if before_label_custom.is_some() { None } else { self.paper_before_label.and_then(|idx| self.papers.get(idx).cloned()) };
|
||||
let current_name = self.paper_name().to_string();
|
||||
let current_size = self.paper_size();
|
||||
let mut papers = default_papers();
|
||||
for (name, size) in detected {
|
||||
if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) {
|
||||
@@ -257,10 +259,14 @@ impl PrintDraft {
|
||||
papers.push((name, size));
|
||||
}
|
||||
}
|
||||
let current_name = self.paper_name().to_string();
|
||||
let current_size = self.paper_size();
|
||||
self.papers = papers;
|
||||
if let Some(pos) =
|
||||
if let Some(c_idx) = custom_paper_idx {
|
||||
if c_idx < self.custom_papers.len() {
|
||||
self.paper = self.papers.len() + c_idx;
|
||||
} else {
|
||||
self.paper = 0;
|
||||
}
|
||||
} else if let Some(pos) =
|
||||
self.papers.iter().position(|(n, s)| n == ¤t_name && (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5)
|
||||
{
|
||||
self.paper = pos;
|
||||
@@ -270,16 +276,24 @@ impl PrintDraft {
|
||||
self.papers.push((current_name, current_size));
|
||||
self.paper = self.papers.len() - 1;
|
||||
}
|
||||
self.paper_before_label = before_label.map(|(name, size)| {
|
||||
if let Some(pos) = self.papers.iter().position(|(n, s)| n == &name && (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) {
|
||||
pos
|
||||
} else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) {
|
||||
pos
|
||||
if let Some(c_idx) = before_label_custom {
|
||||
if c_idx < self.custom_papers.len() {
|
||||
self.paper_before_label = Some(self.papers.len() + c_idx);
|
||||
} else {
|
||||
self.papers.push((name, size));
|
||||
self.papers.len() - 1
|
||||
self.paper_before_label = None;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
self.paper_before_label = before_label.map(|(name, size)| {
|
||||
if let Some(pos) = self.papers.iter().position(|(n, s)| n == &name && (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) {
|
||||
pos
|
||||
} else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) {
|
||||
pos
|
||||
} else {
|
||||
self.papers.push((name, size));
|
||||
self.papers.len() - 1
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-select the printer's configured default paper (from DEVMODE / driver settings).
|
||||
@@ -380,24 +394,28 @@ impl PrintDraft {
|
||||
let Some(printer) = self.printer.clone() else { return };
|
||||
// Label's stock is that job's; the printer keeps the paper Label replaced.
|
||||
let paper = if self.handling == Handling::Label { self.paper_before_label.unwrap_or(self.paper) } else { self.paper };
|
||||
let setup = PrinterSetup { paper, size: self.size, custom_scale: self.custom_scale, duplex: self.duplex };
|
||||
// By name, so a custom paper is found again however the list changes.
|
||||
let setup =
|
||||
PrinterSetup { paper: self.paper_name_at(paper).to_string(), size: self.size, custom_scale: self.custom_scale, duplex: self.duplex };
|
||||
setups::record(&mut self.setups, &printer, setup);
|
||||
}
|
||||
|
||||
/// Bring back what the chosen printer was last printed with, when it has been printed to
|
||||
/// (#2001); a printer never printed to keeps the dialog's choices. While Label holds the
|
||||
/// label stock, the printer's paper is what leaving Label gives back.
|
||||
/// label stock, the printer's paper is what leaving Label gives back. A custom paper taken
|
||||
/// off the list since leaves the paper as it is.
|
||||
pub fn apply_setup(&mut self) {
|
||||
let Some(printer) = self.printer.as_deref() else { return };
|
||||
let Some(setup) = self.setups.iter().find(|(name, _)| name == printer).map(|(_, s)| *s) else { return };
|
||||
let Some(setup) = self.setups.iter().find(|(name, _)| name == printer).map(|(_, s)| s.clone()) else { return };
|
||||
self.size = setup.size;
|
||||
self.custom_scale = setup.custom_scale;
|
||||
self.duplex = setup.duplex;
|
||||
let paper = setup.paper.min(PAPERS.len() - 1);
|
||||
if self.handling == Handling::Label {
|
||||
self.paper_before_label = Some(paper);
|
||||
} else {
|
||||
self.paper = paper;
|
||||
if let Some(paper) = self.paper_index(&setup.paper) {
|
||||
if self.handling == Handling::Label {
|
||||
self.paper_before_label = Some(paper);
|
||||
} else {
|
||||
self.paper = paper;
|
||||
}
|
||||
}
|
||||
self.sheet = 0;
|
||||
}
|
||||
@@ -496,7 +514,7 @@ impl PrintDraft {
|
||||
}
|
||||
.into_iter()
|
||||
// The sheet the PDF was laid out on, not the queue's default paper.
|
||||
.chain(Some(print::paper_media_option(Some(self.paper_name()), self.paper_size())))
|
||||
.chain(Some(print::paper_media_option(Some(&self.paper_name()), self.paper_size())))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! Copies, the pages to print, orientation and the Multiple / Booklet / Poster layouts stay one
|
||||
//! job's choices, as before.
|
||||
|
||||
use super::{CUSTOM_SCALE, PAPERS, SizeMode, print::names, spool::Duplex};
|
||||
use super::{CUSTOM_SCALE, SizeMode, print::names, spool::Duplex};
|
||||
use crate::print_paper::is_paper_name;
|
||||
|
||||
/// At most this many printers are remembered, the most recently printed to first (settings are
|
||||
/// untrusted, and a long-gone queue shouldn't stay forever).
|
||||
@@ -15,10 +16,10 @@ pub const MAX_PRINTERS: usize = 32;
|
||||
const MAX_NAME_BYTES: usize = 1024;
|
||||
|
||||
/// The paper, sizing and two-sided choice a printer was last printed with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PrinterSetup {
|
||||
/// `PAPERS` index.
|
||||
pub paper: usize,
|
||||
/// The paper's name: a standard size's canonical name, or a custom paper's (print.page-setup).
|
||||
pub paper: String,
|
||||
pub size: SizeMode,
|
||||
/// The percent `SizeMode::Custom` prints at.
|
||||
pub custom_scale: f64,
|
||||
@@ -34,20 +35,19 @@ pub fn record(setups: &mut Vec<(String, PrinterSetup)>, printer: &str, setup: Pr
|
||||
setups.truncate(MAX_PRINTERS);
|
||||
}
|
||||
|
||||
/// The setups for the settings file: the paper by its canonical name (as `print_paper`), sizing
|
||||
/// and two-sided by the names `doc_print` and the `print` setting use ([`names`]).
|
||||
/// The setups for the settings file: the paper by its name (as `print_paper`), sizing and
|
||||
/// two-sided by the names `doc_print` and the `print` setting use ([`names`]).
|
||||
pub fn to_json(setups: &[(String, PrinterSetup)]) -> serde_json::Value {
|
||||
setups
|
||||
.iter()
|
||||
.filter_map(|(printer, s)| {
|
||||
let (paper, _) = PAPERS.get(s.paper)?;
|
||||
Some(serde_json::json!({
|
||||
.map(|(printer, s)| {
|
||||
serde_json::json!({
|
||||
"printer": printer,
|
||||
"paper": paper,
|
||||
"paper": s.paper,
|
||||
"size": names::size_mode(s.size),
|
||||
"custom_scale": s.custom_scale,
|
||||
"duplex": names::duplex(s.duplex),
|
||||
}))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -71,8 +71,8 @@ pub fn from_json(v: &serde_json::Value) -> Option<Vec<(String, PrinterSetup)>> {
|
||||
|
||||
fn read_entry(entry: &serde_json::Value) -> Option<(String, PrinterSetup)> {
|
||||
let printer = entry.get("printer")?.as_str().filter(|p| !p.is_empty() && p.len() <= MAX_NAME_BYTES)?;
|
||||
let paper_name = entry.get("paper")?.as_str()?;
|
||||
let paper = PAPERS.iter().position(|(name, _)| *name == paper_name)?;
|
||||
// A custom paper's name is kept even when the paper isn't on this list: it may be added again.
|
||||
let paper = entry.get("paper")?.as_str().filter(|name| is_paper_name(name))?.to_string();
|
||||
let custom_scale = entry.get("custom_scale")?.as_f64().filter(|s| CUSTOM_SCALE.contains(s))?;
|
||||
let size = names::parse_size_mode(entry.get("size")?.as_str()?, custom_scale)?;
|
||||
let duplex = names::parse_duplex(entry.get("duplex")?.as_str()?)?;
|
||||
@@ -84,7 +84,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup(paper: usize, size: SizeMode, duplex: Option<Duplex>) -> PrinterSetup {
|
||||
PrinterSetup { paper, size, custom_scale: 62.5, duplex }
|
||||
PrinterSetup { paper: pdfcraft_engine::print::PAPERS[paper].0.to_string(), size, custom_scale: 62.5, duplex }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -94,6 +94,8 @@ mod tests {
|
||||
record(&mut setups, "Label", setup(super::super::print::LABEL_PAPER, SizeMode::Actual, Some(Duplex::Off)));
|
||||
record(&mut setups, "Plotter", setup(0, SizeMode::Custom(62.5), None));
|
||||
record(&mut setups, "Shrinker", setup(1, SizeMode::Shrink, Some(Duplex::ShortEdge)));
|
||||
// A custom paper goes by the name it was given (print.page-setup).
|
||||
record(&mut setups, "Labeller", PrinterSetup { paper: "Label 4×6".into(), ..setup(0, SizeMode::Actual, None) });
|
||||
assert_eq!(from_json(&to_json(&setups)), Some(setups));
|
||||
}
|
||||
|
||||
@@ -121,6 +123,8 @@ mod tests {
|
||||
("printer", serde_json::json!(5)),
|
||||
("printer", serde_json::json!("x".repeat(MAX_NAME_BYTES + 1))),
|
||||
("paper", serde_json::json!("a4")),
|
||||
("paper", serde_json::json!("")),
|
||||
("paper", serde_json::json!("x".repeat(65))),
|
||||
("paper", serde_json::json!(4)),
|
||||
("size", serde_json::json!("fit-to-page")),
|
||||
("size", serde_json::Value::Null),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Find and replace (Edit text mode): ⇧⌘H turns Edit text on and shows the find bar's Replace
|
||||
//! row, Replace all rewrites every match inside its paragraph as one undo step, and outside Edit
|
||||
//! text the row is not offered.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
|
||||
|
||||
/// Two 300×200 pages of Helvetica text: "cat and cat" and "a cat".
|
||||
fn fixture() -> Vec<u8> {
|
||||
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>".into(),
|
||||
stream("BT /F1 14 Tf 20 150 Td (cat and cat) Tj ET"),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>".into(),
|
||||
stream("BT /F1 14 Tf 20 150 Td (a cat) Tj ET"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn texts(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
(0..2).flat_map(|p| doc.text_blocks(p).into_iter().map(|b| b.text)).collect()
|
||||
}
|
||||
|
||||
fn has_field(h: &Harness<'static, PdfCraftApp>, placeholder: &str) -> bool {
|
||||
h.query_all_by(|n| n.placeholder() == Some(placeholder)).next().is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_all_from_the_find_bar_is_one_undo_step() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("cats.pdf", None, fixture()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
// ⌘F with the Select tool: the find bar, without a Replace row.
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::F);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[0].find.is_some());
|
||||
assert!(has_field(&h, "Find text"));
|
||||
assert!(!has_field(&h, "Replace with"), "outside Edit text the find bar doesn't replace");
|
||||
// ⇧⌘H: Edit text, and the Replace row.
|
||||
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::H);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::EditText);
|
||||
assert!(h.state().views[0].find.as_ref().is_some_and(|f| f.replacing));
|
||||
assert!(has_field(&h, "Replace with"), "the Replace row shows in Edit text");
|
||||
h.query_by(|n| n.placeholder() == Some("Find text")).expect("find field").type_text("cat");
|
||||
// Both pages' text loads in the background before every match is listed.
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
if h.state().views[0].find.as_ref().is_some_and(|f| f.matches.len() == 3) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(h.state().views[0].find.as_ref().map(|f| f.matches.len()), Some(3));
|
||||
h.query_by(|n| n.placeholder() == Some("Replace with")).expect("replace field").click();
|
||||
h.run_steps(2);
|
||||
h.query_by(|n| n.placeholder() == Some("Replace with")).expect("replace field").type_text("dog");
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().views[0].find.as_ref().map(|f| (f.query.as_str(), f.replacement.as_str())), Some(("cat", "dog")));
|
||||
// The row may settle over a few frames once the status text changes its width.
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Replace all").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(texts(&h), ["dog and dog", "a dog"], "{:?}", h.state().toast);
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Replace all"));
|
||||
assert_eq!(s.toast.as_ref().map(|t| t.0.as_str()), Some("Replaced 3 on 2 pages"));
|
||||
// One Undo puts every page back.
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(texts(&h), ["cat and cat", "a cat"]);
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None);
|
||||
}
|
||||
|
||||
/// Replace changes the highlighted match only: here the second "cat" on the first page.
|
||||
#[test]
|
||||
fn replace_changes_the_highlighted_match() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("cats.pdf", None, fixture()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("edit.replace"));
|
||||
h.run_steps(3);
|
||||
h.query_by(|n| n.placeholder() == Some("Find text")).expect("find field").type_text("cat");
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
if h.state().views[0].find.as_ref().is_some_and(|f| f.matches.len() == 3) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.state_mut().views[0].find.as_mut().unwrap().replacement = "dog".into();
|
||||
h.state_mut().views[0].go_to_match(1);
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Replace").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(texts(&h), ["cat and dog", "a cat"]);
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit text"));
|
||||
}
|
||||
@@ -43,6 +43,7 @@ mod editing;
|
||||
mod editor_taps;
|
||||
mod export;
|
||||
mod fill_sign;
|
||||
mod find_replace;
|
||||
mod folders;
|
||||
mod fonts;
|
||||
mod forms;
|
||||
@@ -67,6 +68,8 @@ mod portfolio;
|
||||
mod preferences_layout;
|
||||
mod prepare;
|
||||
mod print;
|
||||
mod print_custom_paper_names;
|
||||
mod print_late_printer_list;
|
||||
mod protect;
|
||||
mod reading_colors;
|
||||
mod recent_files;
|
||||
|
||||
@@ -729,19 +729,25 @@ fn leaving_label_gives_back_the_paper_it_replaced() {
|
||||
let a4 = paper("A4");
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
h.run_steps(3);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading()) && std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.run_steps(2);
|
||||
for (mode, tab) in
|
||||
[(PrintHandling::Size, "Size"), (PrintHandling::Poster, "Poster"), (PrintHandling::Multiple, "Multiple"), (PrintHandling::Booklet, "Booklet")]
|
||||
{
|
||||
h.state_mut().print_draft.paper = a4;
|
||||
h.state_mut().print_draft.paper_overridden = true;
|
||||
h.state_mut().print_draft.handling = PrintHandling::Size;
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::Button, "Label").click();
|
||||
h.run_steps(2);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().print_draft.handling, PrintHandling::Label);
|
||||
assert_eq!(h.state().print_draft.paper, LABEL_PAPER, "Label puts label stock in");
|
||||
h.get_by_role_and_label(Role::Button, tab).click();
|
||||
h.run_steps(2);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().print_draft.handling, mode);
|
||||
assert_eq!(h.state().print_draft.paper, a4, "{tab} gives A4 back after Label");
|
||||
h.get_by_value("A4");
|
||||
@@ -808,21 +814,27 @@ fn a_paper_picked_from_the_menu_while_in_label_is_kept() {
|
||||
let (a4, letter) = (paper("A4"), paper("US Letter"));
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
h.run_steps(3);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading()) && std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.state_mut().print_draft.paper = a4;
|
||||
h.state_mut().print_draft.paper_overridden = true;
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::Button, "Label").click();
|
||||
h.run_steps(2);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().print_draft.paper, LABEL_PAPER);
|
||||
h.get_by_value(PAPERS[LABEL_PAPER].0).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("US Letter").scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("US Letter").click();
|
||||
h.run_steps(2);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().print_draft.paper, letter);
|
||||
h.get_by_role_and_label(Role::Button, "Size").click();
|
||||
h.run_steps(2);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().print_draft.handling, PrintHandling::Size);
|
||||
assert_eq!(h.state().print_draft.paper, letter, "the paper picked in Label is kept");
|
||||
h.get_by_value("US Letter");
|
||||
@@ -1509,6 +1521,103 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() {
|
||||
std::fs::remove_file(out).unwrap();
|
||||
}
|
||||
|
||||
/// After the Print dialog opens: frames until its printer list has loaded (its arrival changes
|
||||
/// the dialog's rows, even an empty list, which adds the "no printers" line), then a few more so
|
||||
/// the dialog has settled where it stays. A click before then can land where a control was
|
||||
/// (windows-latest CI).
|
||||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..500 {
|
||||
h.run_steps(1);
|
||||
if !h.state().print_draft.printers_loading && !h.state().print_draft.papers_loading() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
|
||||
assert!(!h.state().print_draft.papers_loading(), "the printer papers loaded");
|
||||
h.run_steps(8);
|
||||
}
|
||||
|
||||
/// Page setup: a paper size of one's own joins the Paper list (Custom…) and prints at its size.
|
||||
/// The printer it was printed on keeps it (#2001) by name, across a restart, and Remove takes it
|
||||
/// off the list; the printer's setup then leaves the paper as it is.
|
||||
#[test]
|
||||
fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() {
|
||||
use pdfcraft_engine::print::spool::{Job, Printer};
|
||||
use std::sync::{Arc, Mutex};
|
||||
let sent: Arc<Mutex<Vec<Vec<u8>>>> = Arc::default();
|
||||
let attach = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.state_mut().printer_source =
|
||||
Some(Arc::new(|| vec![Printer { name: "Labeller".into(), default: true, two_sided: None, description: String::new() }]));
|
||||
h.state_mut().printer_papers_source = Some(Arc::new(|_| Vec::new()));
|
||||
h.state_mut().default_paper_source = Some(Arc::new(|_| None));
|
||||
let sent = sent.clone();
|
||||
h.state_mut().spooler = Some(Arc::new(move |pdf: &[u8], _job: &Job| {
|
||||
sent.lock().unwrap().push(pdf.to_vec());
|
||||
Ok(String::new())
|
||||
}));
|
||||
};
|
||||
let mut h = harness();
|
||||
attach(&mut h);
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().print_draft.printer.as_deref(), Some("Labeller"));
|
||||
h.get_by_label("Custom…").click();
|
||||
h.run_steps(2);
|
||||
// Typed in the preferences' unit, landscape: 6 × 4 inches.
|
||||
let per_point = h.state().print_draft.units.per_point();
|
||||
h.state_mut().print_draft.custom_new = Some(("Label 4×6".into(), format!("{}", 432.0 * per_point), format!("{}", 288.0 * per_point)));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Add").click();
|
||||
h.run_steps(2);
|
||||
{
|
||||
let d = &h.state().print_draft;
|
||||
assert!(d.paper_is_custom() && d.paper_name() == "Label 4×6", "{}", d.paper_name());
|
||||
let (w, l) = d.paper_size();
|
||||
assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}");
|
||||
}
|
||||
// The custom row has closed: let the dialog settle at its new height before clicking.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.run_steps(8);
|
||||
h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click();
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
if !sent.lock().unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let pdf = sent.lock().unwrap().first().cloned().expect("the job reached the spooler");
|
||||
let printed = pdfcraft_cos::Document::open(Arc::new(pdf)).unwrap();
|
||||
let sheet = pdfcraft_model::pages(&printed)[0].display_size(&printed);
|
||||
assert!((sheet.0.min(sheet.1) - 288.0).abs() < 0.5 && (sheet.0.max(sheet.1) - 432.0).abs() < 0.5, "the label paper: {sheet:?}");
|
||||
assert_eq!(h.state().print_draft.setups.first().map(|(p, s)| (p.as_str(), s.paper.as_str())), Some(("Labeller", "Label 4×6")));
|
||||
|
||||
// After a restart the list has it, and the printer starts on it.
|
||||
let settings = h.state().persist();
|
||||
let mut h = harness_with_settings(Some(settings));
|
||||
attach(&mut h);
|
||||
assert_eq!(h.state().print_draft.paper_count(), pdfcraft_engine::print::PAPERS.len() + 1);
|
||||
h.state_mut().print_draft.paper = 0;
|
||||
h.state_mut().print_draft.printer = None;
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
settle(&mut h);
|
||||
assert_eq!((h.state().print_draft.printer.as_deref(), h.state().print_draft.paper_name().as_str()), (Some("Labeller"), "Label 4×6"));
|
||||
|
||||
// Remove takes it off the list; the printer's setup, still naming it, then changes nothing.
|
||||
h.get_by_label("Remove").click();
|
||||
h.run_steps(2);
|
||||
let d = &mut h.state_mut().print_draft;
|
||||
assert_eq!((d.paper_count(), d.paper), (pdfcraft_engine::print::PAPERS.len(), 0));
|
||||
d.paper = 3;
|
||||
d.apply_setup();
|
||||
assert_eq!(d.paper, 3);
|
||||
}
|
||||
|
||||
/// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided
|
||||
/// while Booklet is chosen): the job flips on the short edge for both sides and prints
|
||||
/// one-sided for the front or back only, and the Two-sided choice is kept for other layouts.
|
||||
@@ -1760,6 +1869,7 @@ fn each_printer_keeps_what_it_last_printed_with_across_a_restart() {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click();
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A custom paper cannot take the name of a standard one: papers are looked up by name, so the
|
||||
//! standard size would be found first and a printer's custom paper (#2001) would come back as it.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_paper_cannot_take_a_standard_name() {
|
||||
let mut h = harness();
|
||||
// No printers here: the list is empty as soon as it is asked for.
|
||||
h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new));
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
for _ in 0..500 {
|
||||
h.run_steps(1);
|
||||
if !h.state().print_draft.printers_loading {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Custom…").click();
|
||||
h.run_steps(2);
|
||||
let before = h.state().print_draft.paper_count();
|
||||
let per_point = h.state().print_draft.units.per_point();
|
||||
let (w, l) = (format!("{}", 288.0 * per_point), format!("{}", 432.0 * per_point));
|
||||
// The name of a standard paper, in either case, is refused: Add stays disabled.
|
||||
for name in ["A4", " a4 "] {
|
||||
h.state_mut().print_draft.custom_new = Some((name.into(), w.clone(), l.clone()));
|
||||
h.run_steps(3);
|
||||
assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "{name:?} is a standard paper's name");
|
||||
assert_eq!(h.state().print_draft.paper_count(), before);
|
||||
}
|
||||
// Any other name is accepted by the same row.
|
||||
h.state_mut().print_draft.custom_new = Some(("A4 label".into(), w.clone(), l.clone()));
|
||||
h.run_steps(3);
|
||||
assert!(!h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled());
|
||||
|
||||
// Settings read back are checked the same way: the custom "A4" is dropped, the rest kept.
|
||||
let paper = |name: &str| serde_json::json!({ "name": name, "width": 288.0, "height": 432.0 });
|
||||
let settings = serde_json::json!({
|
||||
"print_custom_papers": [paper("A4"), paper("Label"), paper("us letter")],
|
||||
});
|
||||
let mut fresh = PdfCraftApp::new();
|
||||
fresh.restore(&settings.to_string());
|
||||
let names: Vec<&str> = fresh.print_draft.custom_papers.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Label"]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! A printer list that arrives late must not undo what was picked while it was being read, when
|
||||
//! the printer it names is the one the dialog already has.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_engine::print::spool::Printer;
|
||||
use pdfcraft_engine::print::{PAPERS, SizeMode};
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, PrinterSetup};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn wait_for_list(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline {
|
||||
h.run_steps(1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_list_keeps_the_paper_picked_meanwhile() {
|
||||
let mut h = harness();
|
||||
// The source waits for the test to let it answer, once the first list is in.
|
||||
let gate = Arc::new(Mutex::new(()));
|
||||
let wait = gate.clone();
|
||||
h.state_mut().printer_source = Some(Arc::new(move || {
|
||||
let _open = wait.lock();
|
||||
vec![Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }]
|
||||
}));
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
wait_for_list(&mut h);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"));
|
||||
|
||||
// "Office" was last printed on Tabloid (#2001).
|
||||
let tabloid = PAPERS.iter().position(|(name, _)| *name == "Tabloid").unwrap();
|
||||
let setup = PrinterSetup { paper: "Tabloid".into(), size: SizeMode::Fit, custom_scale: 100.0, duplex: None };
|
||||
h.state_mut().print_draft.setups.push(("Office".into(), setup));
|
||||
h.state_mut().print_draft.apply_setup();
|
||||
assert_eq!(h.state().print_draft.paper, tabloid);
|
||||
h.state_mut().dialog = None;
|
||||
let hold = gate.lock().unwrap();
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().print_draft.printers_loading, "the fresh list is still being read");
|
||||
assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "last time's list shows at once");
|
||||
|
||||
// Meanwhile the person picks another paper.
|
||||
h.state_mut().print_draft.paper = 0;
|
||||
drop(hold);
|
||||
wait_for_list(&mut h);
|
||||
h.run_steps(2);
|
||||
let d = &h.state().print_draft;
|
||||
assert_eq!(d.printer.as_deref(), Some("Office"), "the same printer");
|
||||
assert_eq!(d.paper, 0, "the paper is as it was left, not the printer's setup again");
|
||||
}
|
||||
+4
-1
@@ -10,6 +10,7 @@
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (gap 3: #2517 a partial rewrite keeps a kept row inside the paragraph and leaves the next marked region closed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: find and replace in Edit text mode, partial; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -114,7 +115,8 @@ update the numbers.
|
||||
|
||||
- **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the
|
||||
needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping
|
||||
structure tags, find and replace in edit mode; vector and object editing; arrange and align.
|
||||
structure tags, find and replace beyond plain paragraphs (form fields, comments, matches across
|
||||
paragraphs); vector and object editing; arrange and align.
|
||||
- **Evidence:** `edit.text-edit` is partial (font reused or Helvetica substituted);
|
||||
`edit.rtl-cjk-editing`, `edit.rotated-text-editing`, `edit.keep-tags-on-edit`,
|
||||
`edit.vector-edit`, `edit.edit-object-tool` are planned. Users:
|
||||
@@ -429,6 +431,7 @@ update the numbers.
|
||||
| 2026-10-17 | minor | Gap 6: the redaction audit counts a cover over one word of a long line instead of a clean bill (#2662, fixed by #2698). Estimates unchanged. |
|
||||
| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | Gap 3: find and replace in Edit text mode (the Find box's Replace row, `text_replace`); each match is rewritten inside its own paragraph, and matches in form fields, comments or across paragraphs are left and counted. Estimates unchanged. |
|
||||
| 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) |
|
||||
| 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. |
|
||||
| 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. |
|
||||
|
||||
@@ -2068,11 +2068,11 @@ title = "Find and replace in edit mode"
|
||||
area = "C"
|
||||
tier = "P2"
|
||||
milestone = "M7"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["edit.replace"]
|
||||
tools = ["text_replace"]
|
||||
evidence = ["crates/engine/src/find_replace/tests.rs::a_replaced_word_keeps_its_paragraphs_font_and_size", "crates/engine/src/find_replace/tests.rs::a_mixed_style_paragraph_keeps_the_other_runs_styles", "crates/engine/src/find_replace/tests.rs::replace_all_across_pages_is_one_undo_step", "crates/engine/src/find_replace/tests.rs::whole_words_and_case_follow_the_find_options", "crates/engine/src/find_replace/tests.rs::a_match_spanning_two_paragraphs_is_skipped_and_counted", "crates/engine/src/find_replace/tests.rs::a_character_the_font_cannot_draw_is_skipped_and_counted", "crates/engine/src/find_replace/tests.rs::a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs", "crates/engine/src/find_replace/tests.rs::a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing", "crates/engine/src/find_replace/tests.rs::a_longer_replacement_adds_rows_and_moves_nothing_else", "crates/engine/src/find_replace/tests.rs::a_longer_replacement_grows_one_row_to_the_margin", "crates/engine/src/find_replace/tests.rs::case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end", "crates/engine/src/find_replace/tests.rs::invisible_text_over_a_scan_is_skipped", "crates/engine/src/find_replace/tests.rs::the_highlighted_match_is_the_one_replaced", "crates/engine/src/find_replace/tests.rs::matches_the_paragraphs_dont_hold_are_counted_as_not_editable", "crates/engine/src/find_replace/tests.rs::first_replaces_only_the_first_match_in_document_order", "crates/edit/src/tests.rs::replacing_words_apart_in_a_paragraph_keeps_every_run_between_them", "crates/edit/src/tests.rs::style_sources_must_cover_the_text_with_the_paragraphs_runs", "crates/render/src/text/tests.rs::text_search_agrees_with_the_page_search", "crates/ui-egui/tests/it/find_replace.rs::replace_all_from_the_find_bar_is_one_undo_step", "crates/ui-egui/tests/it/find_replace.rs::replace_changes_the_highlighted_match", "crates/automation/tests/it/automation.rs::replacing_text_through_tools"]
|
||||
notes = "in Edit text mode the floating Find box (⌘F) gains a Replace with row (⇧⌘H / Ctrl+Shift+H, Edit ▸ Replace…, which turns Edit text on; ⌘H is macOS's Hide and Ctrl+H Read mode): Replace changes the highlighted match, Replace all every match in the document as one undo step. Matching is the find bar's own (case-sensitive and whole-words options, whitespace runs as one space). Each match is rewritten inside its own paragraph through Edit text's paragraph rewrite, which keeps the paragraph's fonts, sizes and colours and rewraps it; the replacement takes the run it lands in and every other run keeps its own style. A longer replacement is not shrunk or hyphenated: one row grows to the page's margin and then wraps, several rows keep their width, and new rows go below without moving anything else, so they can overlap what follows and that is not reported. Left alone and counted in the notice (and text_replace's skipped_by): a match spanning two paragraphs, invisible OCR text, a paragraph whose own font can't draw the new text (no silent switch to another font), a paragraph the engine refuses, and matches the find bar sees outside editable paragraphs (form fields, comments, text inside form XObjects). Missing: replacing inside form fields, comments and form XObjects, matches across paragraphs, a word broken by a hyphen at a row's end found by its unhyphenated spelling (the find bar reads it with a space there), regular expressions and Acrobat's other search options (diacritics, bookmarks, comments), offering a substitute font for a skipped paragraph, and right-to-left (Hebrew) paragraphs of several styles (the paragraph rewrite rewrites them in one style). The highlighted match is mapped to its paragraph by position (paragraph box and an estimate along its run), so two overlapping paragraphs may pick the nearer one."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.rtl-cjk-editing"
|
||||
@@ -8201,8 +8201,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["printers", "printer_options", "doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer. The copy sent to the Windows spooler renames every embedded TrueType/OpenType program (name table, /BaseFont, /FontName), because Windows drew a subset's renumbered glyphs with the installed font of the same name and printed shifted letters (#3132); not yet checked on a physical Windows printer."
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing", "crates/optimize/tests/tiles.rs::a_grid_of_tiles_keeps_its_rows_and_columns_in_place", "crates/engine/src/spooler.rs::a_large_image_goes_to_the_spooler_as_tiles"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate; an image of more than 2²² pixels as tiles under that size, which Windows.Data.Pdf would draw from a shrunk copy, #5131), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer. The copy sent to the Windows spooler renames every embedded TrueType/OpenType program (name table, /BaseFont, /FontName), because Windows drew a subset's renumbered glyphs with the installed font of the same name and printed shifted letters (#3132); not yet checked on a physical Windows printer."
|
||||
|
||||
[[feature]]
|
||||
id = "print.copies-collate"
|
||||
@@ -8390,15 +8390,22 @@ title = "Page setup"
|
||||
area = "L"
|
||||
tier = "P1"
|
||||
milestone = "M10"
|
||||
status = "shipped"
|
||||
status = "partial"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["printer_papers"]
|
||||
tools = ["doc_print", "printer_papers"]
|
||||
evidence = [
|
||||
"crates/ui-egui/tests/it/print.rs::printer_papers_and_properties_selection",
|
||||
"crates/automation/tests/it/automation.rs::printer_papers_through_tools",
|
||||
"crates/print/src/tests.rs::printer_papers_and_default_paper_api",
|
||||
"crates/print/src/tests.rs::overlapping_paper_discovery_two_sided_and_queue_queries",
|
||||
"crates/ui-egui/tests/it/print.rs::a_custom_paper_prints_at_its_size_and_its_printer_keeps_it",
|
||||
"crates/ui-egui/src/workflows/print_paper.rs::the_paper_list_is_the_standard_sizes_then_the_custom_ones",
|
||||
"crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted",
|
||||
"crates/ui-egui/src/workflows/print_ui/setups.rs::setups_round_trip_through_the_settings_file",
|
||||
"crates/ui-egui/tests/it/print_custom_paper_names.rs::a_custom_paper_cannot_take_a_standard_name",
|
||||
"crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own",
|
||||
]
|
||||
notes = "The Print dialog's Page Setup button opens the driver's page setup to choose paper size and orientation. printer_papers enumerates driver-reported papers and default paper headlessly."
|
||||
notes = "In the Print dialog: Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time (#2001). The Page Setup button opens the driver's page setup to choose paper size and orientation. printer_papers enumerates driver-reported papers and default paper headlessly. doc_print takes sizes such as 8.5x14in or 210x297mm or driver papers. Missing: margins."
|
||||
|
||||
[[feature]]
|
||||
id = "print.print-ready-pdf"
|
||||
|
||||
Reference in new issue
Block a user